1986 lines
61 KiB
HTML
1986 lines
61 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Orca Mobile - Tasks Mock</title>
|
|
<style>
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
:root {
|
|
--bg-base: #111111;
|
|
--bg-panel: #1a1a1a;
|
|
--bg-raised: #242424;
|
|
--border-subtle: #2a2a2a;
|
|
--text-primary: #e0e0e0;
|
|
--text-secondary: #888888;
|
|
--text-muted: #555555;
|
|
--accent-blue: #3b82f6;
|
|
--status-green: #22c55e;
|
|
--status-amber: #f59e0b;
|
|
--status-red: #ef4444;
|
|
}
|
|
|
|
body {
|
|
min-height: 100vh;
|
|
background: #0b0b0b;
|
|
color: var(--text-primary);
|
|
font-family:
|
|
Geist,
|
|
ui-sans-serif,
|
|
system-ui,
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
'Segoe UI',
|
|
sans-serif;
|
|
}
|
|
|
|
button,
|
|
input,
|
|
textarea {
|
|
color: inherit;
|
|
font: inherit;
|
|
}
|
|
|
|
button {
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.stage {
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 28px;
|
|
}
|
|
|
|
.phone {
|
|
width: min(430px, 100vw);
|
|
height: min(844px, 100vh);
|
|
min-height: 720px;
|
|
background: var(--bg-base);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: 34px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
|
}
|
|
|
|
.screen {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--bg-base);
|
|
}
|
|
|
|
.top-chrome {
|
|
background: var(--bg-panel);
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
flex: none;
|
|
}
|
|
|
|
.status-bar {
|
|
min-height: 42px;
|
|
padding: 6px 16px 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.back-button,
|
|
.icon-button {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: grid;
|
|
place-items: center;
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.back-button {
|
|
color: var(--text-primary);
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.back-button:active,
|
|
.icon-button:active,
|
|
.segment-button:active,
|
|
.task-row:active,
|
|
.drawer-row:active,
|
|
.target-button:active {
|
|
background: var(--bg-raised);
|
|
}
|
|
|
|
.title-wrap {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
min-width: 0;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 999px;
|
|
background: var(--status-green);
|
|
margin-right: 8px;
|
|
flex: none;
|
|
}
|
|
|
|
.title {
|
|
font-size: 16px;
|
|
line-height: 20px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
padding: 6px 12px;
|
|
border-top: 1px solid var(--border-subtle);
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.toolbar::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.toolbar-inline {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: none;
|
|
}
|
|
|
|
.segment-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
min-height: 26px;
|
|
padding: 4px 10px;
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--text-primary);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.segment-button .primary {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.segment-button .secondary {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.search-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 12px;
|
|
border-top: 1px solid var(--border-subtle);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
border: 0;
|
|
outline: 0;
|
|
background: transparent;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
line-height: 18px;
|
|
padding: 2px 0;
|
|
}
|
|
|
|
.search-input::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.clear-search {
|
|
width: 24px;
|
|
height: 24px;
|
|
display: none;
|
|
place-items: center;
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.clear-search.visible {
|
|
display: grid;
|
|
}
|
|
|
|
.error-banner {
|
|
display: none;
|
|
padding: 8px 16px;
|
|
background: var(--bg-panel);
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
}
|
|
|
|
.error-banner.visible {
|
|
display: block;
|
|
}
|
|
|
|
.error-text {
|
|
color: var(--status-red);
|
|
font-size: 13px;
|
|
line-height: 17px;
|
|
}
|
|
|
|
.list {
|
|
flex: 1;
|
|
overflow: auto;
|
|
padding-top: 4px;
|
|
}
|
|
|
|
.task-row {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
padding: 10px 16px;
|
|
background: transparent;
|
|
text-align: left;
|
|
}
|
|
|
|
.task-row + .task-row {
|
|
border-top: 1px solid var(--border-subtle);
|
|
border-image: linear-gradient(
|
|
to right,
|
|
transparent 44px,
|
|
var(--border-subtle) 44px,
|
|
var(--border-subtle) calc(100% - 16px),
|
|
transparent calc(100% - 16px)
|
|
)
|
|
1;
|
|
}
|
|
|
|
.task-icon {
|
|
width: 20px;
|
|
padding-top: 3px;
|
|
color: var(--text-secondary);
|
|
flex: none;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
|
|
.task-main {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.task-title-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.task-title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 14px;
|
|
line-height: 18px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.updated-at {
|
|
padding-top: 2px;
|
|
font-size: 11px;
|
|
line-height: 14px;
|
|
color: var(--text-muted);
|
|
flex: none;
|
|
}
|
|
|
|
.meta-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-top: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.repo-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 999px;
|
|
flex: none;
|
|
}
|
|
|
|
.subtitle {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.status-pill {
|
|
max-width: 88px;
|
|
margin-top: 1px;
|
|
padding: 3px 8px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border-subtle);
|
|
background: var(--bg-raised);
|
|
flex: none;
|
|
}
|
|
|
|
.status-text {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 11px;
|
|
line-height: 14px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state,
|
|
.loading-state {
|
|
height: 100%;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-secondary);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.loading-dot {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 999px;
|
|
border: 2px solid var(--text-muted);
|
|
border-top-color: var(--text-secondary);
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 30;
|
|
display: none;
|
|
}
|
|
|
|
.overlay.visible {
|
|
display: block;
|
|
}
|
|
|
|
.backdrop {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.drawer {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
max-height: calc(100% - 24px);
|
|
overflow: auto;
|
|
background: var(--bg-base);
|
|
border-top-left-radius: 16px;
|
|
border-top-right-radius: 16px;
|
|
padding: 8px 12px 28px;
|
|
transform: translateY(100%);
|
|
animation: drawer-in 180ms ease forwards;
|
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.handle-hit-area {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 0 0 12px;
|
|
}
|
|
|
|
.handle {
|
|
width: 36px;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
background: var(--text-muted);
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.sheet-header {
|
|
padding: 0 4px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.sheet-title {
|
|
font-size: 15px;
|
|
line-height: 20px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.sheet-subtitle {
|
|
margin-top: 2px;
|
|
font-size: 12px;
|
|
line-height: 16px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.drawer-group,
|
|
.action-group,
|
|
.detail-group {
|
|
background: var(--bg-panel);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.detail-group {
|
|
padding: 12px;
|
|
margin-bottom: 12px;
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.detail-meta-grid {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.detail-meta-item {
|
|
min-width: 96px;
|
|
flex: 1;
|
|
}
|
|
|
|
.detail-meta-label {
|
|
display: block;
|
|
margin-bottom: 2px;
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.detail-meta-value {
|
|
display: block;
|
|
font-size: 13px;
|
|
line-height: 17px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.chip-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
}
|
|
|
|
.detail-chip {
|
|
border: 1px solid var(--border-subtle);
|
|
background: var(--bg-raised);
|
|
border-radius: 999px;
|
|
padding: 2px 8px;
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.detail-section {
|
|
display: grid;
|
|
gap: 4px;
|
|
}
|
|
|
|
.detail-section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.detail-section-title {
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.detail-section-meta {
|
|
flex: none;
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.detail-body,
|
|
.comment-body {
|
|
font-size: 13px;
|
|
line-height: 18px;
|
|
color: var(--text-primary);
|
|
white-space: pre-line;
|
|
}
|
|
|
|
.detail-line,
|
|
.detail-muted {
|
|
font-size: 12px;
|
|
line-height: 17px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.comment-block {
|
|
border-top: 1px solid var(--border-subtle);
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.comment-source {
|
|
margin-bottom: 2px;
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: var(--text-secondary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.comment-meta {
|
|
margin-bottom: 2px;
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.drawer-row,
|
|
.action-row {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px 14px;
|
|
background: transparent;
|
|
text-align: left;
|
|
}
|
|
|
|
.drawer-row + .drawer-row,
|
|
.action-row + .action-row,
|
|
.linear-state-row + .linear-state-row {
|
|
border-top: 1px solid var(--border-subtle);
|
|
}
|
|
|
|
.drawer-row-main,
|
|
.action-text {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.drawer-row-label,
|
|
.action-text {
|
|
font-size: 14px;
|
|
line-height: 18px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.drawer-row-label.selected {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.drawer-row-subtitle {
|
|
margin-top: 1px;
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: var(--text-muted);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.create-form {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.field-label {
|
|
font-size: 12px;
|
|
line-height: 16px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.target-button,
|
|
.field-input,
|
|
.field-textarea {
|
|
width: 100%;
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: 6px;
|
|
background: var(--bg-panel);
|
|
color: var(--text-primary);
|
|
font-size: 14px;
|
|
line-height: 18px;
|
|
padding: 10px 12px;
|
|
outline: 0;
|
|
}
|
|
|
|
.target-button {
|
|
text-align: left;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.field-input::placeholder,
|
|
.field-textarea::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.field-textarea {
|
|
min-height: 88px;
|
|
resize: none;
|
|
}
|
|
|
|
.create-button {
|
|
margin-top: 8px;
|
|
width: 100%;
|
|
border-radius: 6px;
|
|
padding: 10px 12px;
|
|
background: var(--text-primary);
|
|
color: var(--bg-base);
|
|
font-size: 14px;
|
|
line-height: 18px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.create-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
|
|
.linear-states-block {
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.linear-states-title {
|
|
padding: 0 14px 4px;
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.toast {
|
|
position: absolute;
|
|
left: 16px;
|
|
right: 16px;
|
|
bottom: 18px;
|
|
z-index: 50;
|
|
display: none;
|
|
padding: 10px 12px;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--border-subtle);
|
|
background: var(--bg-raised);
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
line-height: 17px;
|
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
|
}
|
|
|
|
.toast.visible {
|
|
display: block;
|
|
animation: toast-in 140ms ease;
|
|
}
|
|
|
|
.icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
stroke: currentColor;
|
|
stroke-width: 2;
|
|
fill: none;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
flex: none;
|
|
}
|
|
|
|
.icon.sm {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
.icon.xs {
|
|
width: 12px;
|
|
height: 12px;
|
|
}
|
|
|
|
@keyframes drawer-in {
|
|
to {
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
@keyframes toast-in {
|
|
from {
|
|
transform: translateY(8px);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.stage {
|
|
padding: 0;
|
|
}
|
|
|
|
.phone {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
min-height: 100vh;
|
|
border: 0;
|
|
border-radius: 0;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="stage">
|
|
<main class="phone" aria-label="Orca mobile Tasks mock">
|
|
<section class="screen">
|
|
<header class="top-chrome">
|
|
<div class="status-bar">
|
|
<button
|
|
class="back-button"
|
|
type="button"
|
|
aria-label="Back"
|
|
data-action="toast"
|
|
data-message="Back"
|
|
>
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6" /></svg>
|
|
</button>
|
|
<div class="title-wrap">
|
|
<span class="status-dot" aria-hidden="true"></span>
|
|
<h1 class="title">Tasks</h1>
|
|
</div>
|
|
<button class="icon-button" type="button" aria-label="Refresh" data-action="refresh">
|
|
<svg class="icon" viewBox="0 0 24 24">
|
|
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
<path d="M3 3v5h5" />
|
|
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
|
<path d="M16 16h5v5" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="icon-button"
|
|
type="button"
|
|
aria-label="Create task"
|
|
data-action="open-create"
|
|
>
|
|
<svg class="icon" viewBox="0 0 24 24">
|
|
<path d="M5 12h14" />
|
|
<path d="M12 5v14" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<button class="segment-button" type="button" data-action="open-provider">
|
|
<span id="providerIcon"></span>
|
|
<span class="primary" id="providerLabel">GitHub</span>
|
|
</button>
|
|
<span id="filterControls" class="toolbar-inline"></span>
|
|
</div>
|
|
|
|
<label class="search-bar">
|
|
<svg class="icon sm" viewBox="0 0 24 24">
|
|
<path d="m21 21-4.34-4.34" />
|
|
<circle cx="11" cy="11" r="8" />
|
|
</svg>
|
|
<input
|
|
class="search-input"
|
|
id="searchInput"
|
|
type="search"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder="Search GitHub tasks..."
|
|
/>
|
|
<button
|
|
class="clear-search"
|
|
id="clearSearch"
|
|
type="button"
|
|
aria-label="Clear search"
|
|
data-action="clear-search"
|
|
>
|
|
<svg class="icon sm" viewBox="0 0 24 24">
|
|
<path d="M18 6 6 18" />
|
|
<path d="m6 6 12 12" />
|
|
</svg>
|
|
</button>
|
|
</label>
|
|
</header>
|
|
|
|
<div class="error-banner" id="errorBanner">
|
|
<p class="error-text" id="errorText"></p>
|
|
</div>
|
|
|
|
<div class="list" id="taskList"></div>
|
|
<div class="empty-state" id="emptyState">No GitHub tasks</div>
|
|
<div class="loading-state" id="loadingState"><span class="loading-dot"></span></div>
|
|
</section>
|
|
|
|
<div class="overlay" id="overlay" aria-hidden="true">
|
|
<div class="backdrop" data-action="close-drawer"></div>
|
|
<div class="drawer" id="drawer" role="dialog" aria-modal="true">
|
|
<div class="handle-hit-area" data-action="close-drawer">
|
|
<span class="handle"></span>
|
|
</div>
|
|
<div id="drawerContent"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
const repoPalette = [
|
|
'#f97316',
|
|
'#8b5cf6',
|
|
'#06b6d4',
|
|
'#ec4899',
|
|
'#84cc16',
|
|
'#f59e0b',
|
|
'#6366f1'
|
|
]
|
|
|
|
const repos = [
|
|
{ id: 'repo-orca', displayName: 'orca', path: '/Users/jinwoo/orca' },
|
|
{ id: 'repo-sdk', displayName: 'stably-sdk', path: '/Users/jinwoo/stably-sdk' },
|
|
{ id: 'repo-ssh', displayName: 'prod-remote', path: 'ssh://desk/orca' },
|
|
{
|
|
id: 'repo-folder',
|
|
displayName: 'notes-folder',
|
|
path: '/Users/jinwoo/notes',
|
|
unsupported: true
|
|
}
|
|
]
|
|
|
|
const teams = [
|
|
{ id: 'team-mobile', name: 'Mobile', workspaceName: 'Orca' },
|
|
{ id: 'team-runtime', name: 'Runtime', workspaceName: 'Orca' }
|
|
]
|
|
|
|
const linearStates = [
|
|
{ id: 'triage', name: 'Triage' },
|
|
{ id: 'todo', name: 'Todo' },
|
|
{ id: 'in-progress', name: 'In Progress' },
|
|
{ id: 'done', name: 'Done' }
|
|
]
|
|
|
|
const tasks = {
|
|
github: [
|
|
{
|
|
key: 'gh-pr-1284',
|
|
type: 'pr',
|
|
number: 1284,
|
|
title: 'Add mobile task drawer with repo-aware workspace creation',
|
|
repoId: 'repo-orca',
|
|
repoName: 'orca',
|
|
status: 'Open',
|
|
state: 'open',
|
|
updatedAt: '18m',
|
|
labels: ['mobile', 'tasks'],
|
|
assignees: ['jinwoo'],
|
|
body: 'Mobile Tasks should match the desktop workflow: inspect the issue, understand the recent discussion, then create a workspace without bouncing to a browser.',
|
|
comments: [
|
|
{
|
|
id: 1,
|
|
author: 'mira',
|
|
createdAt: '2026-05-19',
|
|
body: 'The action sheet needs the same context as desktop, especially comments and labels.'
|
|
},
|
|
{
|
|
id: 2,
|
|
author: 'alex',
|
|
createdAt: '2026-05-19',
|
|
body: 'Can we keep this below the search row so refresh stays reachable?',
|
|
path: 'mobile/app/h/[hostId]/tasks.tsx',
|
|
line: 1312,
|
|
threadId: 'thread-1',
|
|
isResolved: false
|
|
},
|
|
{
|
|
id: 3,
|
|
author: 'sam',
|
|
createdAt: '2026-05-20',
|
|
body: 'Verified the drawer works on iPhone SE width.',
|
|
path: 'mobile/app/h/[hostId]/tasks.tsx',
|
|
startLine: 1722,
|
|
line: 1730,
|
|
threadId: 'thread-2',
|
|
isResolved: true
|
|
}
|
|
],
|
|
checks: [
|
|
{ name: 'typecheck:mobile', status: 'completed', conclusion: 'success' },
|
|
{ name: 'runtime RPC tests', status: 'completed', conclusion: 'success' }
|
|
],
|
|
files: [{ path: 'mobile/app/h/[hostId]/tasks.tsx' }]
|
|
},
|
|
{
|
|
key: 'gh-issue-1197',
|
|
type: 'issue',
|
|
number: 1197,
|
|
title: 'Investigate remote client selector_ambiguous errors when listing work items',
|
|
repoId: 'repo-orca',
|
|
repoName: 'orca',
|
|
status: 'Open',
|
|
state: 'open',
|
|
updatedAt: '2h',
|
|
labels: ['rpc'],
|
|
assignees: ['alex'],
|
|
body: 'The web client occasionally passes repo names that collide. Prefer stable repo ids for remote RPC selectors.',
|
|
comments: [
|
|
{
|
|
id: 2,
|
|
author: 'alex',
|
|
createdAt: '2026-05-18',
|
|
body: 'Confirmed this happens when two repos share a display path suffix.'
|
|
}
|
|
],
|
|
checks: [],
|
|
files: []
|
|
},
|
|
{
|
|
key: 'gh-pr-812',
|
|
type: 'pr',
|
|
number: 812,
|
|
title: 'Persist linked issue metadata after remote worktree creation',
|
|
repoId: 'repo-sdk',
|
|
repoName: 'stably-sdk',
|
|
status: 'Draft',
|
|
state: 'draft',
|
|
updatedAt: '6h',
|
|
labels: ['worktree'],
|
|
assignees: [],
|
|
body: 'Remote-created worktrees need to carry linked task metadata so desktop and mobile show the same task affordances.',
|
|
comments: [],
|
|
checks: [{ name: 'unit', status: 'queued', conclusion: null }],
|
|
files: [{ path: 'src/main/ipc/worktree-remote.ts' }]
|
|
},
|
|
{
|
|
key: 'gh-issue-1088',
|
|
type: 'issue',
|
|
number: 1088,
|
|
title: 'Clean up stale task results after switching hosts',
|
|
repoId: 'repo-orca',
|
|
repoName: 'orca',
|
|
status: 'Closed',
|
|
state: 'closed',
|
|
updatedAt: '1d',
|
|
labels: ['bug'],
|
|
assignees: ['sam'],
|
|
body: 'Switching hosts should not let slow responses from the previous host overwrite the current list.',
|
|
comments: [],
|
|
checks: [],
|
|
files: []
|
|
}
|
|
],
|
|
gitlab: [
|
|
{
|
|
key: 'gl-mr-73',
|
|
type: 'mr',
|
|
number: 73,
|
|
title: 'Resolve merge request source branch from preferred GitLab remote',
|
|
repoId: 'repo-orca',
|
|
repoName: 'orca',
|
|
status: 'Open',
|
|
state: 'opened',
|
|
updatedAt: '12m',
|
|
labels: ['gitlab'],
|
|
assignees: ['jinwoo'],
|
|
body: 'MR workspaces should start from the source branch fetched from the same remote selected for GitLab issue-source preference.',
|
|
comments: [
|
|
{
|
|
id: 5,
|
|
author: 'nora',
|
|
createdAt: '2026-05-19',
|
|
body: 'Please verify forks fail with a clear message on mobile.'
|
|
},
|
|
{
|
|
id: 6,
|
|
author: 'lee',
|
|
createdAt: '2026-05-20',
|
|
body: 'This should mention which remote was selected.',
|
|
path: 'src/main/runtime/orca-runtime.ts',
|
|
line: 5210,
|
|
threadId: 'gl-discussion-1',
|
|
isResolved: false
|
|
}
|
|
],
|
|
pipelineJobs: [
|
|
{ stage: 'test', name: 'node', status: 'success' },
|
|
{ stage: 'lint', name: 'oxlint', status: 'success' }
|
|
]
|
|
},
|
|
{
|
|
key: 'gl-issue-64',
|
|
type: 'issue',
|
|
number: 64,
|
|
title: 'Support search query passthrough for GitLab work items',
|
|
repoId: 'repo-orca',
|
|
repoName: 'orca',
|
|
status: 'Open',
|
|
state: 'opened',
|
|
updatedAt: '3h',
|
|
labels: ['api'],
|
|
assignees: [],
|
|
body: 'GitLab search should be sent to the API instead of only filtering after fetching a page.',
|
|
comments: [],
|
|
pipelineJobs: []
|
|
},
|
|
{
|
|
key: 'gl-mr-58',
|
|
type: 'mr',
|
|
number: 58,
|
|
title: 'Forward push target for mobile MR workspaces',
|
|
repoId: 'repo-sdk',
|
|
repoName: 'stably-sdk',
|
|
status: 'Merged',
|
|
state: 'merged',
|
|
updatedAt: '2d',
|
|
labels: ['workspace'],
|
|
assignees: ['lee'],
|
|
body: 'The created workspace needs the MR source branch push target so follow-up changes go to the right branch.',
|
|
comments: [],
|
|
pipelineJobs: [{ stage: 'build', name: 'bundle', status: 'success' }]
|
|
},
|
|
{
|
|
key: 'gl-issue-49',
|
|
type: 'issue',
|
|
number: 49,
|
|
title: 'Keep folder-only repositories out of hosted task queries',
|
|
repoId: 'repo-orca',
|
|
repoName: 'orca',
|
|
status: 'Closed',
|
|
state: 'closed',
|
|
updatedAt: '4d',
|
|
labels: ['mobile'],
|
|
assignees: [],
|
|
body: 'Hosted task APIs require Git remotes. SSH repos should load; folder-only repos should be explicit skips.',
|
|
comments: [],
|
|
pipelineJobs: []
|
|
}
|
|
],
|
|
gitlabTodos: [
|
|
{
|
|
key: 'gl-todo-1',
|
|
targetType: 'MergeRequest',
|
|
targetIid: 73,
|
|
title: 'Resolve merge request source branch from preferred GitLab remote',
|
|
projectPath: 'stably/orca',
|
|
status: 'review requested',
|
|
updatedAt: '18m'
|
|
},
|
|
{
|
|
key: 'gl-todo-2',
|
|
targetType: 'Issue',
|
|
targetIid: 64,
|
|
title: 'Support search query passthrough for GitLab work items',
|
|
projectPath: 'stably/orca',
|
|
status: 'mentioned',
|
|
updatedAt: '3h'
|
|
}
|
|
],
|
|
linear: [
|
|
{
|
|
key: 'lin-412',
|
|
identifier: 'MOB-412',
|
|
title: 'Review mobile Tasks page action sheet density',
|
|
team: 'Mobile',
|
|
status: 'In Progress',
|
|
state: 'in-progress',
|
|
updatedAt: '25m',
|
|
labels: ['ux'],
|
|
assignee: 'Jinwoo',
|
|
project: 'Mobile parity',
|
|
description:
|
|
'Audit whether the mobile task sheet gives enough context to act without opening Linear on desktop.',
|
|
comments: [
|
|
{
|
|
id: 'c1',
|
|
user: { displayName: 'Priya' },
|
|
createdAt: '2026-05-19',
|
|
body: 'The detail view should expose recent comments and child tasks.'
|
|
}
|
|
],
|
|
children: [{ identifier: 'MOB-413', title: 'Add comment preview' }]
|
|
},
|
|
{
|
|
key: 'lin-405',
|
|
identifier: 'MOB-405',
|
|
title: 'Add Linear team picker when creating issues from mobile',
|
|
team: 'Mobile',
|
|
status: 'Todo',
|
|
state: 'todo',
|
|
updatedAt: '4h',
|
|
labels: ['tasks'],
|
|
assignee: 'Mira',
|
|
project: 'Tasks',
|
|
description:
|
|
'When creating a Linear issue, mobile should let the user pick a team from the connected workspace.',
|
|
comments: [],
|
|
children: []
|
|
},
|
|
{
|
|
key: 'lin-384',
|
|
identifier: 'RUN-384',
|
|
title: 'Keep RPC allowlist aligned with remote client task methods',
|
|
team: 'Runtime',
|
|
status: 'Triage',
|
|
state: 'triage',
|
|
updatedAt: '1d',
|
|
labels: ['rpc'],
|
|
assignee: 'Alex',
|
|
project: 'Runtime',
|
|
description:
|
|
'Mobile and web remote clients should expose the same read paths required by Tasks.',
|
|
comments: [],
|
|
children: []
|
|
},
|
|
{
|
|
key: 'lin-351',
|
|
identifier: 'MOB-351',
|
|
title: 'Completed task fixture for filter review',
|
|
team: 'Mobile',
|
|
status: 'Done',
|
|
state: 'done',
|
|
updatedAt: '6d',
|
|
labels: ['done'],
|
|
assignee: 'Sam',
|
|
project: 'Fixtures',
|
|
description: 'A completed fixture used to test the filter drawer.',
|
|
comments: [],
|
|
children: []
|
|
}
|
|
]
|
|
}
|
|
|
|
const state = {
|
|
provider: 'github',
|
|
githubKind: 'issues',
|
|
githubPreset: 'issues',
|
|
gitlabView: 'project',
|
|
gitlabFilter: 'opened',
|
|
linearFilter: 'all',
|
|
query: 'is:issue is:open',
|
|
drawer: null,
|
|
selectedItem: null,
|
|
createTitle: '',
|
|
createBody: '',
|
|
createTargetId: 'repo-orca',
|
|
loading: false
|
|
}
|
|
|
|
const providerOptions = [
|
|
{ value: 'github', label: 'GitHub', subtitle: 'Issues and pull requests' },
|
|
{ value: 'gitlab', label: 'GitLab', subtitle: 'Issues and merge requests' },
|
|
{ value: 'linear', label: 'Linear', subtitle: 'Assigned and team issues' }
|
|
]
|
|
|
|
const gitlabFilters = [
|
|
{ value: 'opened', label: 'Open', subtitle: 'Open issues and merge requests' },
|
|
{ value: 'merged', label: 'Merged', subtitle: 'Merged merge requests' },
|
|
{ value: 'closed', label: 'Closed', subtitle: 'Closed issues and merge requests' },
|
|
{ value: 'all', label: 'All', subtitle: 'Any GitLab state' }
|
|
]
|
|
|
|
const linearFilters = [
|
|
{ value: 'all', label: 'All', subtitle: 'Open issues across connected workspaces' },
|
|
{ value: 'assigned', label: 'My Issues', subtitle: 'Issues assigned to you' },
|
|
{ value: 'created', label: 'Created', subtitle: 'Issues created by you' },
|
|
{ value: 'completed', label: 'Completed', subtitle: 'Recently completed issues' }
|
|
]
|
|
|
|
const issuePresets = [
|
|
{ value: 'issues', label: 'Open', query: 'is:issue is:open' },
|
|
{ value: 'my-issues', label: 'Assigned to me', query: 'assignee:@me is:issue is:open' }
|
|
]
|
|
|
|
const prPresets = [
|
|
{ value: 'prs', label: 'Open', query: 'is:pr is:open' },
|
|
{ value: 'my-prs', label: 'Mine', query: 'author:@me is:pr is:open' },
|
|
{ value: 'review', label: 'Needs review', query: 'review-requested:@me is:pr is:open' }
|
|
]
|
|
|
|
const githubKindOptions = [
|
|
{ value: 'issues', label: 'Issues', subtitle: 'GitHub issues' },
|
|
{ value: 'prs', label: 'PRs', subtitle: 'GitHub pull requests' }
|
|
]
|
|
|
|
const gitlabViewOptions = [
|
|
{
|
|
value: 'project',
|
|
label: 'Project MRs',
|
|
subtitle: 'Merge requests and issues by repository'
|
|
},
|
|
{ value: 'todos', label: 'My Todos', subtitle: 'Pending GitLab todos' }
|
|
]
|
|
|
|
const icons = {
|
|
github:
|
|
'<svg class="icon sm" viewBox="0 0 24 24"><path d="M18 18h.01"/><path d="M6 6h.01"/><path d="M13 6h3a2 2 0 0 1 2 2v10"/><path d="M6 6v12"/><path d="M6 18h12"/></svg>',
|
|
gitlab:
|
|
'<svg class="icon sm" viewBox="0 0 24 24"><path d="M12 3v18"/><path d="M6 8h12"/><path d="M6 16h12"/></svg>',
|
|
linear:
|
|
'<svg class="icon sm" viewBox="0 0 24 24"><path d="M11 4h10"/><path d="M11 12h10"/><path d="M11 20h10"/><path d="M4 4h1"/><path d="M4 12h1"/><path d="M4 20h1"/></svg>',
|
|
check: '<svg class="icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>',
|
|
plus: '<svg class="icon" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></svg>',
|
|
branch:
|
|
'<svg class="icon" viewBox="0 0 24 24"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>'
|
|
}
|
|
|
|
const taskList = document.getElementById('taskList')
|
|
const emptyState = document.getElementById('emptyState')
|
|
const loadingState = document.getElementById('loadingState')
|
|
const overlay = document.getElementById('overlay')
|
|
const drawerContent = document.getElementById('drawerContent')
|
|
const searchInput = document.getElementById('searchInput')
|
|
const clearSearch = document.getElementById('clearSearch')
|
|
const toast = document.getElementById('toast')
|
|
|
|
function providerLabel() {
|
|
return state.provider === 'github'
|
|
? 'GitHub'
|
|
: state.provider === 'gitlab'
|
|
? 'GitLab'
|
|
: 'Linear'
|
|
}
|
|
|
|
function repoColor(name) {
|
|
let hash = 0
|
|
for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) | 0
|
|
return repoPalette[Math.abs(hash) % repoPalette.length]
|
|
}
|
|
|
|
function subtitleFor(item) {
|
|
if (state.provider === 'gitlab' && state.gitlabView === 'todos')
|
|
return `${item.projectPath}${item.targetIid ? ` !${item.targetIid}` : ''}`
|
|
if (state.provider === 'github') return `${item.repoName} #${item.number}`
|
|
if (state.provider === 'gitlab')
|
|
return `${item.repoName} ${item.type === 'mr' ? '!' : '#'}${item.number}`
|
|
return `${item.identifier} · ${item.team}`
|
|
}
|
|
|
|
function toolbarButton(label, action) {
|
|
return `<button class="segment-button" type="button" data-action="${action}"><span class="secondary">${escapeHtml(label)}</span></button>`
|
|
}
|
|
|
|
function renderFilterControls() {
|
|
const controls = document.getElementById('filterControls')
|
|
if (state.provider === 'github') {
|
|
const presets = state.githubKind === 'prs' ? prPresets : issuePresets
|
|
const preset = presets.find((entry) => entry.value === state.githubPreset) || presets[0]
|
|
controls.innerHTML = [
|
|
toolbarButton(state.githubKind === 'prs' ? 'PRs' : 'Issues', 'open-github-kind'),
|
|
toolbarButton(preset.label, 'open-github-preset')
|
|
].join('')
|
|
return
|
|
}
|
|
if (state.provider === 'gitlab') {
|
|
controls.innerHTML = [
|
|
toolbarButton(
|
|
state.gitlabView === 'project' ? 'Project MRs' : 'My Todos',
|
|
'open-gitlab-view'
|
|
),
|
|
...(state.gitlabView === 'project'
|
|
? [
|
|
toolbarButton(
|
|
(
|
|
gitlabFilters.find((filter) => filter.value === state.gitlabFilter) ||
|
|
gitlabFilters[0]
|
|
).label,
|
|
'open-gitlab-filter'
|
|
)
|
|
]
|
|
: [])
|
|
].join('')
|
|
return
|
|
}
|
|
controls.innerHTML = toolbarButton(
|
|
(linearFilters.find((filter) => filter.value === state.linearFilter) || linearFilters[0])
|
|
.label,
|
|
'open-linear-filter'
|
|
)
|
|
}
|
|
|
|
function itemKindLabel(item) {
|
|
if (state.provider === 'gitlab' && state.gitlabView === 'todos') return 'GitLab todo'
|
|
if (state.provider === 'github') return item.type === 'pr' ? 'Pull request' : 'Issue'
|
|
if (state.provider === 'gitlab') return item.type === 'mr' ? 'Merge request' : 'Issue'
|
|
return 'Linear ticket'
|
|
}
|
|
|
|
function statusActionLabel(item) {
|
|
const verb = item.state === 'closed' ? 'Reopen' : 'Close'
|
|
return `${verb} ${itemKindLabel(item).toLowerCase()}`
|
|
}
|
|
|
|
function visibleItems() {
|
|
const query = state.query.trim().toLowerCase()
|
|
let items =
|
|
state.provider === 'gitlab' && state.gitlabView === 'todos'
|
|
? tasks.gitlabTodos.slice()
|
|
: tasks[state.provider].slice()
|
|
if (state.provider === 'github') {
|
|
items = items.filter((item) =>
|
|
state.githubKind === 'prs' ? item.type === 'pr' : item.type === 'issue'
|
|
)
|
|
}
|
|
if (
|
|
state.provider === 'gitlab' &&
|
|
state.gitlabView === 'project' &&
|
|
state.gitlabFilter !== 'all'
|
|
) {
|
|
items = items.filter((item) => item.state === state.gitlabFilter)
|
|
}
|
|
if (state.provider === 'linear') {
|
|
if (state.linearFilter === 'completed')
|
|
items = items.filter((item) => item.state === 'done')
|
|
else if (state.linearFilter === 'assigned')
|
|
items = items.filter((item) => item.assignee === 'Jinwoo')
|
|
else if (state.linearFilter === 'created')
|
|
items = items.filter((item) => item.key === 'lin-405')
|
|
}
|
|
if (query && state.provider !== 'github') {
|
|
items = items.filter((item) => {
|
|
const haystack = [item.title, item.repoName, item.identifier, item.team, item.status]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase()
|
|
return haystack.includes(query)
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
function render() {
|
|
document.getElementById('providerIcon').innerHTML = icons[state.provider]
|
|
document.getElementById('providerLabel').textContent = providerLabel()
|
|
searchInput.placeholder = `Search ${providerLabel()} tasks...`
|
|
if (document.activeElement !== searchInput && searchInput.value !== state.query) {
|
|
searchInput.value = state.query
|
|
}
|
|
clearSearch.classList.toggle('visible', Boolean(state.query))
|
|
renderFilterControls()
|
|
|
|
const errorBanner = document.getElementById('errorBanner')
|
|
const errorText = document.getElementById('errorText')
|
|
const showUnsupported = false
|
|
errorBanner.classList.toggle('visible', showUnsupported)
|
|
errorText.textContent = ''
|
|
|
|
const items = visibleItems()
|
|
taskList.innerHTML = ''
|
|
loadingState.style.display = state.loading ? 'flex' : 'none'
|
|
taskList.style.display = state.loading ? 'none' : 'block'
|
|
emptyState.style.display = !state.loading && items.length === 0 ? 'flex' : 'none'
|
|
emptyState.textContent = state.query ? 'No matching tasks' : `No ${providerLabel()} tasks`
|
|
|
|
if (!state.loading) {
|
|
for (const item of items) {
|
|
const provider = state.provider
|
|
const dot =
|
|
provider === 'linear' || (provider === 'gitlab' && state.gitlabView === 'todos')
|
|
? 'var(--accent-blue)'
|
|
: repoColor(item.repoName)
|
|
const row = document.createElement('button')
|
|
row.className = 'task-row'
|
|
row.type = 'button'
|
|
row.dataset.action = 'open-action'
|
|
row.dataset.key = item.key
|
|
row.innerHTML = `
|
|
<span class="task-icon">${icons[provider]}</span>
|
|
<span class="task-main">
|
|
<span class="task-title-row">
|
|
<span class="task-title">${escapeHtml(item.title)}</span>
|
|
<span class="updated-at">${item.updatedAt}</span>
|
|
</span>
|
|
<span class="meta-row">
|
|
<span class="repo-dot" style="background:${dot}"></span>
|
|
<span class="subtitle">${escapeHtml(itemKindLabel(item))} · ${escapeHtml(subtitleFor(item))}</span>
|
|
</span>
|
|
</span>
|
|
<span class="status-pill"><span class="status-text">${escapeHtml(item.status)}</span></span>
|
|
`
|
|
taskList.appendChild(row)
|
|
}
|
|
}
|
|
|
|
if (state.drawer) renderDrawer()
|
|
}
|
|
|
|
function openDrawer(type, item) {
|
|
state.drawer = type
|
|
state.selectedItem = item || null
|
|
overlay.classList.add('visible')
|
|
overlay.setAttribute('aria-hidden', 'false')
|
|
renderDrawer()
|
|
}
|
|
|
|
function closeDrawer() {
|
|
state.drawer = null
|
|
state.selectedItem = null
|
|
overlay.classList.remove('visible')
|
|
overlay.setAttribute('aria-hidden', 'true')
|
|
drawerContent.innerHTML = ''
|
|
}
|
|
|
|
function renderDrawer() {
|
|
if (state.drawer === 'provider') {
|
|
renderPicker('Task Source', providerOptions, state.provider, 'select-provider')
|
|
return
|
|
}
|
|
if (state.drawer === 'github-kind') {
|
|
renderPicker('GitHub View', githubKindOptions, state.githubKind, 'select-github-kind')
|
|
return
|
|
}
|
|
if (state.drawer === 'github-preset') {
|
|
const presets = state.githubKind === 'prs' ? prPresets : issuePresets
|
|
renderPicker(
|
|
state.githubKind === 'prs' ? 'Pull Requests' : 'Issues',
|
|
presets,
|
|
state.githubPreset,
|
|
'select-github-preset'
|
|
)
|
|
return
|
|
}
|
|
if (state.drawer === 'gitlab-view') {
|
|
renderPicker('GitLab View', gitlabViewOptions, state.gitlabView, 'select-gitlab-view')
|
|
return
|
|
}
|
|
if (state.drawer === 'gitlab-filter') {
|
|
renderPicker('GitLab Filter', gitlabFilters, state.gitlabFilter, 'select-gitlab-filter')
|
|
return
|
|
}
|
|
if (state.drawer === 'linear-filter') {
|
|
renderPicker('Linear Filter', linearFilters, state.linearFilter, 'select-linear-filter')
|
|
return
|
|
}
|
|
if (state.drawer === 'create-target') {
|
|
const options =
|
|
state.provider === 'linear'
|
|
? teams.map((team) => ({
|
|
value: team.id,
|
|
label: team.name,
|
|
subtitle: team.workspaceName
|
|
}))
|
|
: repos
|
|
.filter((repo) => !repo.unsupported)
|
|
.map((repo) => ({ value: repo.id, label: repo.displayName, subtitle: repo.path }))
|
|
renderPicker(
|
|
state.provider === 'linear' ? 'Linear Team' : 'Repository',
|
|
options,
|
|
state.createTargetId,
|
|
'select-create-target'
|
|
)
|
|
return
|
|
}
|
|
if (state.drawer === 'workspace-target') {
|
|
const options = repos
|
|
.filter((repo) => !repo.unsupported)
|
|
.map((repo) => ({ value: repo.id, label: repo.displayName, subtitle: repo.path }))
|
|
renderPicker('Create Workspace In', options, repos[0].id, 'select-workspace-target')
|
|
return
|
|
}
|
|
if (state.drawer === 'create') {
|
|
const target =
|
|
state.provider === 'linear'
|
|
? teams.find((team) => team.id === state.createTargetId) || teams[0]
|
|
: repos.find((repo) => repo.id === state.createTargetId) || repos[0]
|
|
drawerContent.innerHTML = `
|
|
<div class="sheet-header">
|
|
<div class="sheet-title">New ${providerLabel()} Issue</div>
|
|
<div class="sheet-subtitle">${state.provider === 'linear' ? 'Create an issue in the selected Linear team.' : 'Create an issue in the selected repository.'}</div>
|
|
</div>
|
|
<div class="create-form">
|
|
<label class="field-label">${state.provider === 'linear' ? 'Team' : 'Repository'}</label>
|
|
<button class="target-button" type="button" data-action="open-create-target">${escapeHtml(target.displayName || target.name)}</button>
|
|
<label class="field-label" for="createTitle">Title</label>
|
|
<input class="field-input" id="createTitle" placeholder="Task title" value="${escapeAttr(state.createTitle)}">
|
|
<label class="field-label" for="createBody">Description</label>
|
|
<textarea class="field-textarea" id="createBody" placeholder="Add context">${escapeHtml(state.createBody)}</textarea>
|
|
<button class="create-button" type="button" data-action="create-issue" ${state.createTitle.trim() ? '' : 'disabled'}>Create Issue</button>
|
|
</div>
|
|
`
|
|
return
|
|
}
|
|
if (state.drawer === 'action' && state.selectedItem) {
|
|
const item = state.selectedItem
|
|
const canToggle =
|
|
state.provider === 'github'
|
|
? item.state !== 'merged'
|
|
: state.provider === 'gitlab'
|
|
? item.state !== 'merged' && item.state !== 'locked'
|
|
: false
|
|
drawerContent.innerHTML = `
|
|
<div class="sheet-header">
|
|
<div class="sheet-title">${escapeHtml(item.title)}</div>
|
|
<div class="sheet-subtitle">${escapeHtml(itemKindLabel(item))} · ${escapeHtml(subtitleFor(item))}</div>
|
|
</div>
|
|
${detailMarkup(item)}
|
|
<div class="action-group">
|
|
<button class="action-row" type="button" data-action="create-workspace">
|
|
${icons.plus}
|
|
<span class="action-text">Create Workspace</span>
|
|
</button>
|
|
<button class="action-row" type="button" data-action="open-browser">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
|
|
<span class="action-text">Open in Browser</span>
|
|
</button>
|
|
${
|
|
canToggle
|
|
? `
|
|
<button class="action-row" type="button" data-action="toggle-status">
|
|
${icons.check}
|
|
<span class="action-text">${statusActionLabel(item)}</span>
|
|
</button>`
|
|
: ''
|
|
}
|
|
${state.provider === 'linear' ? linearStatesMarkup(item) : ''}
|
|
</div>
|
|
`
|
|
}
|
|
}
|
|
|
|
function detailMarkup(item) {
|
|
const comments = item.comments || []
|
|
const labels = item.labels || []
|
|
const assignees = item.assignees || (item.assignee ? [item.assignee] : [])
|
|
return `
|
|
<div class="detail-group">
|
|
<div class="detail-meta-grid">
|
|
<div class="detail-meta-item">
|
|
<span class="detail-meta-label">Type</span>
|
|
<span class="detail-meta-value">${escapeHtml(itemKindLabel(item))}</span>
|
|
</div>
|
|
<div class="detail-meta-item">
|
|
<span class="detail-meta-label">Status</span>
|
|
<span class="detail-meta-value">${escapeHtml(item.status)}</span>
|
|
</div>
|
|
${
|
|
assignees.length
|
|
? `
|
|
<div class="detail-meta-item">
|
|
<span class="detail-meta-label">${state.provider === 'linear' ? 'Assignee' : 'Assignees'}</span>
|
|
<span class="detail-meta-value">${escapeHtml(assignees.slice(0, 3).join(', '))}</span>
|
|
</div>`
|
|
: ''
|
|
}
|
|
${
|
|
item.project
|
|
? `
|
|
<div class="detail-meta-item">
|
|
<span class="detail-meta-label">Project</span>
|
|
<span class="detail-meta-value">${escapeHtml(item.project)}</span>
|
|
</div>`
|
|
: ''
|
|
}
|
|
</div>
|
|
${
|
|
labels.length
|
|
? `<div class="chip-row">${labels
|
|
.slice(0, 6)
|
|
.map((label) => `<span class="detail-chip">${escapeHtml(label)}</span>`)
|
|
.join('')}</div>`
|
|
: ''
|
|
}
|
|
<div class="detail-section">
|
|
<div class="detail-section-title">Description</div>
|
|
<div class="detail-body">${escapeHtml(compact(item.description || item.body || 'No description.'))}</div>
|
|
</div>
|
|
${detailSupplementMarkup(item)}
|
|
<div class="detail-section">
|
|
<div class="detail-section-header">
|
|
<div class="detail-section-title">Recent discussion</div>
|
|
<div class="detail-section-meta">${discussionSummary(comments.length)}</div>
|
|
</div>
|
|
${
|
|
comments.length
|
|
? comments
|
|
.slice(-3)
|
|
.map(
|
|
(comment) => `
|
|
<div class="comment-block">
|
|
<div class="comment-source">${escapeHtml(commentSourceLabel(comment))}</div>
|
|
<div class="comment-meta">${escapeHtml(comment.author || comment.user?.displayName || 'unknown')}${comment.createdAt ? ` · ${escapeHtml(comment.createdAt)}` : ''}</div>
|
|
<div class="comment-body">${escapeHtml(compact(comment.body))}</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join('')
|
|
: `<div class="detail-muted">No comments.</div>`
|
|
}
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function detailSupplementMarkup(item) {
|
|
if (state.provider === 'github' && item.checks?.length) {
|
|
return `
|
|
<div class="detail-section">
|
|
<div class="detail-section-title">Checks</div>
|
|
${item.checks
|
|
.slice(0, 3)
|
|
.map(
|
|
(check) =>
|
|
`<div class="detail-line">${escapeHtml(check.name)} · ${escapeHtml(check.conclusion || check.status)}</div>`
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
}
|
|
if (state.provider === 'gitlab' && item.pipelineJobs?.length) {
|
|
return `
|
|
<div class="detail-section">
|
|
<div class="detail-section-title">Pipeline</div>
|
|
${item.pipelineJobs
|
|
.slice(0, 3)
|
|
.map(
|
|
(job) =>
|
|
`<div class="detail-line">${job.stage ? `${escapeHtml(job.stage)} · ` : ''}${escapeHtml(job.name)} · ${escapeHtml(job.status)}</div>`
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
}
|
|
if (state.provider === 'linear' && item.children?.length) {
|
|
return `
|
|
<div class="detail-section">
|
|
<div class="detail-section-title">Sub-issues</div>
|
|
${item.children
|
|
.slice(0, 3)
|
|
.map(
|
|
(child) =>
|
|
`<div class="detail-line">${escapeHtml(child.identifier)} · ${escapeHtml(child.title)}</div>`
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function compact(value) {
|
|
const text = String(value || '').trim()
|
|
return text.length > 600 ? `${text.slice(0, 600).trimEnd()}...` : text
|
|
}
|
|
|
|
function commentSourceLabel(comment) {
|
|
if (comment.path) {
|
|
const line =
|
|
typeof comment.line === 'number'
|
|
? typeof comment.startLine === 'number' && comment.startLine !== comment.line
|
|
? `${comment.startLine}-${comment.line}`
|
|
: String(comment.line)
|
|
: ''
|
|
const location = line ? `${comment.path}:${line}` : comment.path
|
|
return `${comment.isResolved ? 'Resolved review' : 'Review'} · ${location}`
|
|
}
|
|
if (comment.threadId) return comment.isResolved ? 'Resolved review thread' : 'Review thread'
|
|
return 'Top-level comment'
|
|
}
|
|
|
|
function discussionSummary(count) {
|
|
if (count === 0) return 'No comments yet'
|
|
if (count <= 3) return `${count} ${count === 1 ? 'comment' : 'comments'}`
|
|
return `Latest 3 of ${count}`
|
|
}
|
|
|
|
function renderPicker(title, options, selected, action) {
|
|
drawerContent.innerHTML = `
|
|
<div class="sheet-header"><div class="sheet-title">${title}</div></div>
|
|
<div class="drawer-group">
|
|
${options
|
|
.map(
|
|
(option) => `
|
|
<button class="drawer-row" type="button" data-action="${action}" data-value="${escapeAttr(option.value)}">
|
|
<span class="drawer-row-main">
|
|
<span class="drawer-row-label ${option.value === selected ? 'selected' : ''}">${escapeHtml(option.label)}</span>
|
|
${option.subtitle ? `<span class="drawer-row-subtitle">${escapeHtml(option.subtitle)}</span>` : ''}
|
|
</span>
|
|
${option.value === selected ? icons.check : ''}
|
|
</button>
|
|
`
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function linearStatesMarkup(item) {
|
|
return `
|
|
<div class="linear-states-block">
|
|
<div class="linear-states-title">Status</div>
|
|
${linearStates
|
|
.map(
|
|
(linearState) => `
|
|
<button class="action-row linear-state-row" type="button" data-action="set-linear-status" data-value="${linearState.id}">
|
|
${icons.branch}
|
|
<span class="action-text">${escapeHtml(linearState.name)}</span>
|
|
${linearState.name === item.status ? icons.check : ''}
|
|
</button>
|
|
`
|
|
)
|
|
.join('')}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function setProvider(provider) {
|
|
state.provider = provider
|
|
state.query = provider === 'github' ? 'is:issue is:open' : ''
|
|
state.githubKind = 'issues'
|
|
state.githubPreset = 'issues'
|
|
state.createTargetId = provider === 'linear' ? teams[0].id : repos[0].id
|
|
searchInput.value = state.query
|
|
closeDrawer()
|
|
simulateLoad()
|
|
}
|
|
|
|
function simulateLoad() {
|
|
state.loading = true
|
|
render()
|
|
window.setTimeout(() => {
|
|
state.loading = false
|
|
render()
|
|
}, 240)
|
|
}
|
|
|
|
function showToast(message) {
|
|
toast.textContent = message
|
|
toast.classList.add('visible')
|
|
window.clearTimeout(showToast.timer)
|
|
showToast.timer = window.setTimeout(() => toast.classList.remove('visible'), 1500)
|
|
}
|
|
|
|
function mutateSelectedStatus() {
|
|
const item = state.selectedItem
|
|
if (!item) return
|
|
if (state.provider === 'linear') return
|
|
if (item.state === 'closed') {
|
|
item.state = state.provider === 'gitlab' ? 'opened' : 'open'
|
|
item.status = 'Open'
|
|
} else {
|
|
item.state = 'closed'
|
|
item.status = 'Closed'
|
|
}
|
|
closeDrawer()
|
|
render()
|
|
showToast('Status updated')
|
|
}
|
|
|
|
function createIssueFromForm() {
|
|
const title = state.createTitle.trim()
|
|
if (!title) return
|
|
const target =
|
|
state.provider === 'linear'
|
|
? teams.find((team) => team.id === state.createTargetId) || teams[0]
|
|
: repos.find((repo) => repo.id === state.createTargetId) || repos[0]
|
|
const newItem =
|
|
state.provider === 'linear'
|
|
? {
|
|
key: `lin-${Date.now()}`,
|
|
identifier: 'MOB-500',
|
|
title,
|
|
team: target.name,
|
|
status: 'Todo',
|
|
state: 'todo',
|
|
updatedAt: 'now',
|
|
labels: []
|
|
}
|
|
: {
|
|
key: `${state.provider}-${Date.now()}`,
|
|
type: 'issue',
|
|
number: state.provider === 'github' ? 1291 : 75,
|
|
title,
|
|
repoId: target.id,
|
|
repoName: target.displayName,
|
|
status: 'Open',
|
|
state: state.provider === 'github' ? 'open' : 'opened',
|
|
updatedAt: 'now',
|
|
labels: []
|
|
}
|
|
tasks[state.provider].unshift(newItem)
|
|
state.createTitle = ''
|
|
state.createBody = ''
|
|
closeDrawer()
|
|
render()
|
|
showToast('Issue created')
|
|
}
|
|
|
|
function findCurrentItem(key) {
|
|
if (state.provider === 'gitlab' && state.gitlabView === 'todos')
|
|
return tasks.gitlabTodos.find((item) => item.key === key)
|
|
return tasks[state.provider].find((item) => item.key === key)
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
function escapeAttr(value) {
|
|
return escapeHtml(value).replace(/`/g, '`')
|
|
}
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const trigger = event.target.closest('[data-action]')
|
|
if (!trigger) return
|
|
const action = trigger.dataset.action
|
|
|
|
if (action === 'open-provider') openDrawer('provider')
|
|
if (action === 'open-github-kind') openDrawer('github-kind')
|
|
if (action === 'open-github-preset') openDrawer('github-preset')
|
|
if (action === 'open-gitlab-view') openDrawer('gitlab-view')
|
|
if (action === 'open-gitlab-filter') openDrawer('gitlab-filter')
|
|
if (action === 'open-linear-filter') openDrawer('linear-filter')
|
|
if (action === 'close-drawer') closeDrawer()
|
|
if (action === 'open-create') {
|
|
state.createTitle = ''
|
|
state.createBody = ''
|
|
state.createTargetId = state.provider === 'linear' ? teams[0].id : repos[0].id
|
|
openDrawer('create')
|
|
}
|
|
if (action === 'open-create-target') openDrawer('create-target')
|
|
if (action === 'clear-search') {
|
|
state.query = ''
|
|
searchInput.value = ''
|
|
render()
|
|
}
|
|
if (action === 'refresh') {
|
|
simulateLoad()
|
|
showToast('Tasks refreshed')
|
|
}
|
|
if (action === 'toast') showToast(trigger.dataset.message || 'Action')
|
|
if (action === 'open-action') {
|
|
const item = findCurrentItem(trigger.dataset.key)
|
|
if (state.provider === 'gitlab' && state.gitlabView === 'todos') {
|
|
showToast('Would open GitLab todo URL')
|
|
return
|
|
}
|
|
if (item) openDrawer('action', item)
|
|
}
|
|
if (action === 'select-provider') setProvider(trigger.dataset.value)
|
|
if (action === 'select-github-kind') {
|
|
state.githubKind = trigger.dataset.value
|
|
const preset = state.githubKind === 'prs' ? prPresets[0] : issuePresets[0]
|
|
state.githubPreset = preset.value
|
|
state.query = preset.query
|
|
searchInput.value = state.query
|
|
closeDrawer()
|
|
render()
|
|
}
|
|
if (action === 'select-github-preset') {
|
|
const preset = [...issuePresets, ...prPresets].find(
|
|
(entry) => entry.value === trigger.dataset.value
|
|
)
|
|
if (preset) {
|
|
state.githubPreset = preset.value
|
|
state.githubKind =
|
|
preset.value === 'issues' || preset.value === 'my-issues' ? 'issues' : 'prs'
|
|
state.query = preset.query
|
|
searchInput.value = state.query
|
|
}
|
|
closeDrawer()
|
|
render()
|
|
}
|
|
if (action === 'select-gitlab-view') {
|
|
state.gitlabView = trigger.dataset.value
|
|
closeDrawer()
|
|
render()
|
|
}
|
|
if (action === 'select-gitlab-filter') {
|
|
state.gitlabFilter = trigger.dataset.value
|
|
closeDrawer()
|
|
render()
|
|
}
|
|
if (action === 'select-linear-filter') {
|
|
state.linearFilter = trigger.dataset.value
|
|
closeDrawer()
|
|
render()
|
|
}
|
|
if (action === 'select-create-target') {
|
|
state.createTargetId = trigger.dataset.value
|
|
openDrawer('create')
|
|
}
|
|
if (action === 'select-workspace-target') {
|
|
closeDrawer()
|
|
showToast('Workspace created')
|
|
}
|
|
if (action === 'create-workspace') {
|
|
if (state.provider === 'linear' && repos.filter((repo) => !repo.unsupported).length > 1)
|
|
openDrawer('workspace-target', state.selectedItem)
|
|
else {
|
|
closeDrawer()
|
|
showToast('Workspace created')
|
|
}
|
|
}
|
|
if (action === 'open-browser') showToast('Would open task URL')
|
|
if (action === 'toggle-status') mutateSelectedStatus()
|
|
if (action === 'set-linear-status') {
|
|
const selected = linearStates.find((entry) => entry.id === trigger.dataset.value)
|
|
if (state.selectedItem && selected) {
|
|
state.selectedItem.state = selected.id
|
|
state.selectedItem.status = selected.name
|
|
}
|
|
closeDrawer()
|
|
render()
|
|
showToast('Linear status updated')
|
|
}
|
|
if (action === 'create-issue') createIssueFromForm()
|
|
})
|
|
|
|
document.addEventListener('input', (event) => {
|
|
if (event.target === searchInput) {
|
|
state.query = searchInput.value
|
|
render()
|
|
return
|
|
}
|
|
if (event.target.id === 'createTitle') {
|
|
state.createTitle = event.target.value
|
|
renderDrawer()
|
|
const input = document.getElementById('createTitle')
|
|
input.focus()
|
|
input.setSelectionRange(input.value.length, input.value.length)
|
|
}
|
|
if (event.target.id === 'createBody') {
|
|
state.createBody = event.target.value
|
|
}
|
|
})
|
|
|
|
render()
|
|
</script>
|
|
</body>
|
|
</html>
|