orca/mobile/mock-tasks.html

1493 lines
51 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#096;");
}
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>