@pat-lewczuk/cezar 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/config.d.ts +55 -0
  2. package/dist/config.js +44 -0
  3. package/dist/config.js.map +1 -0
  4. package/dist/core/agent-runner.d.ts +13 -0
  5. package/dist/core/claude-cli-runner.js +23 -2
  6. package/dist/core/claude-cli-runner.js.map +1 -1
  7. package/dist/git-worktree.d.ts +49 -0
  8. package/dist/git-worktree.js +129 -0
  9. package/dist/git-worktree.js.map +1 -0
  10. package/dist/handoff.d.ts +42 -0
  11. package/dist/handoff.js +120 -0
  12. package/dist/handoff.js.map +1 -0
  13. package/dist/index.js +27 -6
  14. package/dist/index.js.map +1 -1
  15. package/dist/planner.d.ts +17 -0
  16. package/dist/planner.js +244 -0
  17. package/dist/planner.js.map +1 -0
  18. package/dist/runs/store.d.ts +40 -13
  19. package/dist/runs/store.js +33 -3
  20. package/dist/runs/store.js.map +1 -1
  21. package/dist/server/github.d.ts +28 -0
  22. package/dist/server/github.js +133 -0
  23. package/dist/server/github.js.map +1 -0
  24. package/dist/server/launch-key.d.ts +7 -0
  25. package/dist/server/launch-key.js +33 -0
  26. package/dist/server/launch-key.js.map +1 -0
  27. package/dist/server/pr.d.ts +22 -0
  28. package/dist/server/pr.js +92 -0
  29. package/dist/server/pr.js.map +1 -0
  30. package/dist/server/server.js +370 -15
  31. package/dist/server/server.js.map +1 -1
  32. package/dist/skills-remote.d.ts +35 -0
  33. package/dist/skills-remote.js +266 -0
  34. package/dist/skills-remote.js.map +1 -0
  35. package/dist/skills.d.ts +18 -6
  36. package/dist/skills.js +8 -4
  37. package/dist/skills.js.map +1 -1
  38. package/dist/todos.d.ts +60 -0
  39. package/dist/todos.js +166 -0
  40. package/dist/todos.js.map +1 -0
  41. package/dist/workflows/load.js +5 -9
  42. package/dist/workflows/load.js.map +1 -1
  43. package/dist/workflows/run.d.ts +62 -3
  44. package/dist/workflows/run.js +332 -43
  45. package/dist/workflows/run.js.map +1 -1
  46. package/dist/workflows/types.d.ts +27 -20
  47. package/dist/workflows/types.js +21 -0
  48. package/dist/workflows/types.js.map +1 -1
  49. package/package.json +1 -1
  50. package/scripts/mock-claude.mjs +118 -0
  51. package/web/app.js +1851 -149
  52. package/web/index.html +73 -22
  53. package/web/style.css +1271 -223
package/web/app.js CHANGED
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
- /* cez cockpit — vanilla JS, no build step. Talks to the local server via
4
- fetch + Server-Sent Events. */
3
+ /* cez cockpit v2 — vanilla JS, no build step. Talks to the local server via
4
+ fetch + Server-Sent Events. Layout: a persistent sidebar (new task, nav,
5
+ run list, env footer) + one main view per tab. */
5
6
 
6
7
  const $ = (sel, el = document) => el.querySelector(sel);
7
8
 
@@ -12,8 +13,37 @@ const state = {
12
13
  lastSeq: 0, // dedup across SSE reconnect replays
13
14
  autoScroll: true,
14
15
  workflows: [],
16
+ taskSource: 'workflow', // 'workflow' (a chain) | 'skill' — what taskRef names
17
+ taskRef: null, // selected chain/skill name (the "≡ fix-and-verify" pill)
18
+ taskModel: '', // model override ('' = auto; the "⊞ auto" pill)
19
+ modelsAvailable: false, // claude CLI detected → model menu lists Claude models
20
+ srcMenuTab: 'workflow', // Workflows | Skills tab inside the source pill menu
21
+ srcMenuQuery: '', // search box inside the source pill menu
15
22
  pendingImages: [], // [{mediaType, data, preview}] queued for the next message
23
+ taskImages: [], // screenshots pasted into the new-task form
16
24
  listView: 'active', // 'active' | 'archived'
25
+ todos: [], // global inbox entries (spec 007)
26
+ plan: null, // {task, steps, rationale, fallback} — proposed chain (spec 008)
27
+ planDragIdx: null, // index of the plan step being dragged
28
+ variants: 1, // ×1/×2/×3 switch — parallel variants (spec 010)
29
+ expandedGroups: new Set(), // groupIds expanded in the run list
30
+ selectedGroupId: null, // group shown in the compare view (instead of a run)
31
+ launchKey: null, // bookmarklet auto-start secret (spec 011), lazy-fetched
32
+ bmAuto: true, // "One-click launch (auto-submit)" checkbox
33
+ bmFilter: '', // per-skill bookmarklet filter text
34
+ theme: document.documentElement.dataset.theme || 'dark',
35
+ // GitHub tab
36
+ gh: null, // /api/github payload
37
+ ghView: 'issues', // 'issues' | 'prs'
38
+ ghSel: null, // selected item url
39
+ ghWorkflow: null, // workflow chip
40
+ ghSkills: new Set(), // skill names toggled on
41
+ ghQueued: new Map(), // item url -> run id (client-side "queued" marker)
42
+ lastGhRun: null,
43
+ // Skills tab
44
+ skillsList: null, // /api/skills payload (shared with the GitHub tab chips)
45
+ skillSel: null, // selected skill name, or '__bm' for the bookmarklet panel
46
+ skillQuery: '',
17
47
  };
18
48
 
19
49
  const STATUS_ORDER = { waiting: 0, review: 1, running: 2, queued: 3 };
@@ -37,6 +67,16 @@ function timeAgo(iso) {
37
67
  return `${Math.floor(s / 86400)}d ago`;
38
68
  }
39
69
 
70
+ /* Compact form for the run list — "2m", "1h", "3d". */
71
+ function shortAgo(iso) {
72
+ if (!iso) return '';
73
+ const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
74
+ if (s < 60) return `${Math.floor(s)}s`;
75
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
76
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
77
+ return `${Math.floor(s / 86400)}d`;
78
+ }
79
+
40
80
  function fmtTokens(n) {
41
81
  if (!n) return '0 tok';
42
82
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`;
@@ -49,14 +89,49 @@ function fmtCost(usd) {
49
89
  return `$${usd >= 10 ? usd.toFixed(0) : usd.toFixed(2)}`;
50
90
  }
51
91
 
52
- function prBadge(run) {
92
+ /* Queue position among queued runs (FIFO = createdAt order) — spec 006. */
93
+ function queuePosition(run) {
94
+ const queued = [...state.runs.values()]
95
+ .filter((r) => !r.archived && r.status === 'queued')
96
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
97
+ const idx = queued.findIndex((r) => r.id === run.id);
98
+ return idx >= 0 ? idx + 1 : null;
99
+ }
100
+
101
+ const STATUS_LABEL = {
102
+ waiting: 'needs you',
103
+ review: 'review',
104
+ running: 'running',
105
+ queued: 'queued',
106
+ done: 'done',
107
+ failed: 'failed',
108
+ cancelled: 'cancelled',
109
+ };
110
+
111
+ function statusPill(run) {
112
+ const status = run.status;
113
+ const pulse = status === 'waiting' || status === 'running' || status === 'review';
114
+ let label = STATUS_LABEL[status] ?? status;
115
+ if (status === 'queued') {
116
+ const pos = queuePosition(run);
117
+ if (pos) label += ` #${pos}`;
118
+ }
119
+ return `<span class="pill ${esc(status)}">${pulse ? '<span class="pulse-dot"></span>' : ''}${esc(label)}</span>`;
120
+ }
121
+
122
+ function prLink(run) {
53
123
  if (!run.pullRequestUrl) return '';
54
124
  const num = run.pullRequestUrl.split('/').pop();
55
- return `<a class="pr-badge" href="${esc(run.pullRequestUrl)}" target="_blank" rel="noopener">PR #${esc(num)}</a>`;
125
+ return `<a href="${esc(run.pullRequestUrl)}" target="_blank" rel="noopener">PR #${esc(num)}</a>`;
56
126
  }
57
127
 
58
- function badge(status) {
59
- return `<span class="badge ${esc(status)}">${esc(status)}</span>`;
128
+ /* ---- parallel variants (spec 010) ---- */
129
+
130
+ const TERMINAL_STATUSES = ['done', 'failed', 'review', 'cancelled'];
131
+
132
+ /* Group title = any variant's title without its " (A)" suffix. */
133
+ function groupTitle(run) {
134
+ return run.title.replace(/ \([A-C]\)$/, '');
60
135
  }
61
136
 
62
137
  async function getJson(url) {
@@ -65,39 +140,282 @@ async function getJson(url) {
65
140
  return res.json();
66
141
  }
67
142
 
143
+ /* Minimal unified-diff renderer (spec 009) — shared by the "± Diff" panel and
144
+ the review gate. Text parsing only: `diff --git` starts a collapsible
145
+ per-file <details>, +/− lines get colored, @@ hunks and file meta dim.
146
+ Non-diff text (degradation notes) renders as a plain <pre>. ZERO libs. */
147
+ function renderDiff(text) {
148
+ const raw = String(text ?? '').trimEnd();
149
+ if (!raw.trim()) return '<div class="dim">(no changes)</div>';
150
+ if (!raw.includes('diff --git ')) return `<pre>${esc(raw)}</pre>`;
151
+ const out = [];
152
+ let file = null; // { name, lines }
153
+ const flush = () => {
154
+ if (!file) return;
155
+ out.push(
156
+ `<details class="diff-file" open><summary>${esc(file.name)}</summary><pre>${file.lines.join('\n')}</pre></details>`,
157
+ );
158
+ file = null;
159
+ };
160
+ for (const line of raw.split('\n')) {
161
+ if (line.startsWith('diff --git ')) {
162
+ flush();
163
+ const m = / b\/(.+)$/.exec(line);
164
+ file = { name: m ? m[1] : line.slice(11), lines: [] };
165
+ continue;
166
+ }
167
+ if (!file) continue; // preamble before the first file header
168
+ let cls = '';
169
+ if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
170
+ else if (line.startsWith('@@')) cls = 'diff-hunk';
171
+ else if (line.startsWith('+')) cls = 'diff-add';
172
+ else if (line.startsWith('-')) cls = 'diff-del';
173
+ else if (/^(index |new file|deleted file|old mode|new mode|similarity|rename |copy |Binary files)/.test(line)) cls = 'diff-meta';
174
+ file.lines.push(cls ? `<span class="${cls}">${esc(line)}</span>` : esc(line));
175
+ }
176
+ flush();
177
+ return out.join('');
178
+ }
179
+
68
180
  // ---- boot ------------------------------------------------------------------
69
181
 
70
182
  async function init() {
71
183
  bindUi();
72
- const [health, workflowsRes, runs] = await Promise.all([
184
+ applyTheme(state.theme);
185
+ const [health, workflowsRes, runs, todos, skills] = await Promise.all([
73
186
  getJson('/api/health'),
74
187
  getJson('/api/workflows'),
75
188
  getJson('/api/runs'),
189
+ getJson('/api/todos').catch(() => []),
190
+ getJson('/api/skills').catch(() => []), // the form's skill mode + GitHub chips
76
191
  ]);
77
- renderHeader(health);
78
- state.workflows = workflowsRes.workflows;
79
- const select = $('#new-task select[name=workflow]');
80
- select.innerHTML = state.workflows
81
- .map((w) => `<option value="${esc(w.name)}" title="${esc(w.description ?? '')}">${esc(w.name)}</option>`)
82
- .join('');
192
+ state.todos = todos;
193
+ state.skillsList = skills;
194
+ renderInboxBadge();
195
+ renderChrome(health);
196
+ setWorkflowOptions(workflowsRes.workflows);
83
197
  for (const run of runs) state.runs.set(run.id, run);
84
198
  renderRunList();
85
199
  connectGlobal();
86
- const latest = sortedRuns()[0];
87
- if (latest) selectRun(latest.id);
200
+ const deepLinked = await handleDeepLink();
201
+ if (!deepLinked) {
202
+ const latest = sortedRuns()[0];
203
+ if (latest) selectRun(latest.id);
204
+ }
88
205
  }
89
206
 
90
- function renderHeader(health) {
91
- const repoChip = $('#repo-chip');
92
- if (health.repo) {
93
- repoChip.hidden = false;
94
- repoChip.textContent = `${health.repo.root.split('/').pop()} · ${health.repo.branch}`;
207
+ function applyTheme(theme) {
208
+ state.theme = theme;
209
+ document.documentElement.dataset.theme = theme;
210
+ try {
211
+ localStorage.setItem('cez-theme', theme);
212
+ } catch {
213
+ // private mode — the toggle still works for this page
214
+ }
215
+ const btn = $('#theme-toggle');
216
+ if (btn) btn.textContent = theme === 'dark' ? 'LIGHT ☼' : 'DARK ☾';
217
+ }
218
+
219
+ // ---- bookmarklet deep-link (spec 011) ----------------------------------------
220
+
221
+ /* `/new?skill=<name>&ref=<github-url>&auto=1&key=<launchKey>` — opened by a
222
+ bookmarklet clicked on a GitHub PR/issue. `auto=1` + the correct launch-key
223
+ starts the run immediately; anything else (no auto, bad key, a drive-by
224
+ page guessing the URL) only prefills the form — the user presses Run.
225
+ Returns true when a run was started (and selected). */
226
+ async function handleDeepLink() {
227
+ const params = new URLSearchParams(location.search);
228
+ if (location.pathname !== '/new' && !params.has('skill') && !params.has('ref')) return false;
229
+ const skill = (params.get('skill') ?? '').trim();
230
+ const ref = (params.get('ref') ?? params.get('task') ?? '').trim();
231
+ const auto = params.get('auto') === '1';
232
+ const key = params.get('key') ?? '';
233
+ history.replaceState({}, '', '/'); // never re-trigger on reload
234
+ if (!ref) return false;
235
+
236
+ if (auto) {
237
+ let launchKey = '';
238
+ try {
239
+ launchKey = (await getJson('/api/launch-key')).key;
240
+ } catch {
241
+ // fall through to the blocked path
242
+ }
243
+ if (launchKey && key === launchKey) {
244
+ // Per-skill: one inline step (the spec-008 API); no skill: quick-task.
245
+ const body = skill
246
+ ? { steps: [{ id: 'task', name: skill, skill, prompt: ref }], task: ref }
247
+ : { workflow: 'quick-task', task: ref };
248
+ try {
249
+ const res = await fetch('/api/runs', {
250
+ method: 'POST',
251
+ headers: { 'content-type': 'application/json' },
252
+ body: JSON.stringify(body),
253
+ });
254
+ const data = await res.json();
255
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
256
+ handleStarted(data);
257
+ alertBar(skill ? `Started "${skill}" on ${oneLine(ref)}` : `Started task on ${oneLine(ref)}`);
258
+ return true;
259
+ } catch (err) {
260
+ alertBar(`Auto-start failed: ${err.message} — review the form and press Run`);
261
+ }
262
+ } else {
263
+ alertBar('auto-launch blocked (bad key) — press Run');
264
+ }
95
265
  }
266
+
267
+ // Prefill path: the form carries everything; quick-task (or the planner)
268
+ // resolves a named skill from the task text — zero extra UI.
269
+ const form = $('#new-task');
270
+ form.task.value = skill ? `Use the "${skill}" skill on: ${ref}` : ref;
271
+ if (state.workflows.some((w) => w.name === 'quick-task')) {
272
+ state.taskSource = 'workflow';
273
+ state.taskRef = 'quick-task';
274
+ renderTaskPills();
275
+ }
276
+ if (!auto) alertBar('Form prefilled from GitHub — review & press Run');
277
+ $('#run-btn').focus();
278
+ return false;
279
+ }
280
+
281
+ function setWorkflowOptions(workflows) {
282
+ state.workflows = workflows;
283
+ if (
284
+ !state.taskRef ||
285
+ (state.taskSource === 'workflow' && !state.workflows.some((w) => w.name === state.taskRef))
286
+ ) {
287
+ state.taskSource = 'workflow';
288
+ state.taskRef = state.workflows[0]?.name ?? 'quick-task';
289
+ }
290
+ renderTaskPills();
291
+ if (!state.ghWorkflow || !state.workflows.some((w) => w.name === state.ghWorkflow)) {
292
+ state.ghWorkflow = state.workflows[0]?.name ?? 'quick-task';
293
+ }
294
+ }
295
+
296
+ /* ---- the form's pill dropdowns — "≡ fix-and-verify ⌄" and "⊞ auto ⌄" ---- */
297
+
298
+ const PILL_ICONS = {
299
+ chain: 'M4 6h16M4 12h16M4 18h10', // ≡ list
300
+ skill: 'M4 19.5A2.5 2.5 0 016.5 17H20M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z', // book
301
+ model: 'M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3M6 6h12v12H6z', // chip
302
+ };
303
+
304
+ function pillHtml(iconPath, label, menuHtml) {
305
+ return `
306
+ <button type="button" class="pill-btn">
307
+ <svg class="pic" viewBox="0 0 24 24"><path d="${iconPath}"/></svg>
308
+ <span class="pl">${esc(label)}</span>
309
+ <svg class="chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
310
+ </button>
311
+ <div class="pill-menu" hidden>${menuHtml}</div>`;
312
+ }
313
+
314
+ function menuItemHtml(data, title, desc, selected) {
315
+ return `
316
+ <div class="menu-item ${selected ? 'on' : ''}" ${data}>
317
+ <div class="mi-title"><span class="chk">${selected ? '✓' : ''}</span>${esc(title)}</div>
318
+ ${desc ? `<div class="mi-desc">${esc(desc)}</div>` : ''}
319
+ </div>`;
320
+ }
321
+
322
+ /* One pill covers chains AND skills: a search box + a Workflows|Skills tab
323
+ inside the menu ("zero new concepts": you pick what runs, the kind is
324
+ implicit in where you found it). */
325
+ function srcMenuItemsHtml() {
326
+ const q = state.srcMenuQuery.trim().toLowerCase();
327
+ const items =
328
+ state.srcMenuTab === 'skill'
329
+ ? (state.skillsList ?? []).map((s) => ({ name: s.name, desc: s.description, source: 'skill' }))
330
+ : state.workflows.map((w) => ({ name: w.name, desc: w.description, source: 'workflow' }));
331
+ const filtered = items.filter(
332
+ (i) => !q || i.name.toLowerCase().includes(q) || (i.desc ?? '').toLowerCase().includes(q),
333
+ );
334
+ if (!filtered.length) {
335
+ return `<div class="menu-empty">${
336
+ state.srcMenuTab === 'skill' && !(state.skillsList ?? []).length
337
+ ? 'No skills yet — drop Markdown files into .ai/skills/'
338
+ : '(nothing matches)'
339
+ }</div>`;
340
+ }
341
+ return filtered
342
+ .map((i) =>
343
+ menuItemHtml(
344
+ `data-mi="src" data-source="${i.source}" data-value="${esc(i.name)}"`,
345
+ i.name,
346
+ i.desc,
347
+ state.taskSource === i.source && state.taskRef === i.name,
348
+ ),
349
+ )
350
+ .join('');
351
+ }
352
+
353
+ function renderTaskPills() {
354
+ const srcMenu = `
355
+ <div class="menu-search">
356
+ <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/></svg>
357
+ <input id="src-search" type="text" placeholder="Search…" autocomplete="off">
358
+ </div>
359
+ <div class="menu-tabs">
360
+ <button type="button" data-mtab="workflow" class="${state.srcMenuTab === 'workflow' ? 'active' : ''}">Workflows</button>
361
+ <button type="button" data-mtab="skill" class="${state.srcMenuTab === 'skill' ? 'active' : ''}">Skills</button>
362
+ </div>
363
+ <div id="src-menu-items">${srcMenuItemsHtml()}</div>`;
364
+ $('#src-pill').innerHTML = pillHtml(
365
+ state.taskSource === 'skill' ? PILL_ICONS.skill : PILL_ICONS.chain,
366
+ state.taskRef ?? 'quick-task',
367
+ srcMenu,
368
+ );
369
+
370
+ const models = state.modelsAvailable ? CLAUDE_MODELS : CLAUDE_MODELS.slice(0, 1);
371
+ const selectedModel = models.find((m) => m.id === state.taskModel) ?? models[0];
372
+ const modelMenu = models
373
+ .map((m) => menuItemHtml(`data-mi="model" data-value="${esc(m.id)}"`, m.label, m.desc, m.id === (selectedModel?.id ?? '')))
374
+ .join('');
375
+ $('#model-pill').innerHTML = pillHtml(PILL_ICONS.model, selectedModel?.label ?? 'auto', modelMenu);
376
+ }
377
+
378
+ function closePillMenus() {
379
+ for (const el of document.querySelectorAll('.pill-select.open')) {
380
+ el.classList.remove('open');
381
+ const menu = el.querySelector('.pill-menu');
382
+ if (menu) menu.hidden = true;
383
+ }
384
+ }
385
+
386
+ function renderChrome(health) {
387
+ const repoChip = $('#repo-chip');
388
+ repoChip.hidden = false;
389
+ repoChip.textContent = health.repo
390
+ ? `${health.repo.root.split('/').pop()} / ${health.repo.branch}`
391
+ : 'no git — tasks run in place';
392
+ repoChip.title = health.repo ? `${health.repo.root} · ${health.repo.branch}` : 'not a git repo — tasks run in place, one at a time';
96
393
  $('#env-chips').innerHTML = health.checks
97
- .map((c) => `<span class="chip ${c.available ? 'ok' : 'bad'}" title="${esc(c.hint ?? c.version ?? '')}">${c.available ? '✓' : '✗'} ${esc(c.name)}</span>`)
394
+ .map(
395
+ (c) =>
396
+ `<span class="env-chip ${c.available ? 'ok' : 'bad'}" title="${esc(c.hint ?? c.version ?? '')}"><span class="led"></span>${esc(c.name)}</span>`,
397
+ )
98
398
  .join('');
399
+
400
+ // Model pill fed by what's actually connected: claude CLI present → the
401
+ // Claude catalog; otherwise only "auto".
402
+ state.modelsAvailable = health.checks.some((c) => c.name === 'claude' && c.available);
403
+ renderTaskPills();
99
404
  }
100
405
 
406
+ /* What `claude --model` accepts: "auto" (no flag — each step decides), the
407
+ stable tier aliases (they track the newest release), then current pinned
408
+ versions for reproducible runs. */
409
+ const CLAUDE_MODELS = [
410
+ { id: '', label: 'auto', desc: 'Pick the best model per step' },
411
+ { id: 'opus', label: 'opus', desc: 'Deep reasoning for hard tasks' },
412
+ { id: 'sonnet', label: 'sonnet', desc: 'Fast and cheap' },
413
+ { id: 'haiku', label: 'haiku', desc: 'Fastest — simple, scoped tasks' },
414
+ { id: 'claude-opus-4-8', label: 'Opus 4.8', desc: 'Pinned version' },
415
+ { id: 'claude-sonnet-5', label: 'Sonnet 5', desc: 'Pinned version' },
416
+ { id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Pinned version' },
417
+ ];
418
+
101
419
  function connectGlobal() {
102
420
  const es = new EventSource('/api/events');
103
421
  es.addEventListener('run', (e) => {
@@ -115,11 +433,20 @@ function connectGlobal() {
115
433
  $('#detail').innerHTML = '<div class="empty">Select a run — or start one.</div>';
116
434
  }
117
435
  });
436
+ es.addEventListener('todos', (e) => {
437
+ state.todos = JSON.parse(e.data);
438
+ renderInboxBadge();
439
+ if (!$('#view-inbox').hidden) renderInbox();
440
+ });
118
441
  }
119
442
 
120
443
  // ---- UI events -------------------------------------------------------------
121
444
 
122
445
  function bindUi() {
446
+ $('#theme-toggle').addEventListener('click', () => {
447
+ applyTheme(state.theme === 'dark' ? 'light' : 'dark');
448
+ });
449
+
123
450
  $('#tabs').addEventListener('click', (e) => {
124
451
  const btn = e.target.closest('button[data-view]');
125
452
  if (!btn) return;
@@ -129,20 +456,208 @@ function bindUi() {
129
456
  view.hidden = false;
130
457
  if (btn.dataset.view === 'repo') loadRepo();
131
458
  if (btn.dataset.view === 'skills') loadSkills();
459
+ if (btn.dataset.view === 'inbox') renderInbox();
460
+ if (btn.dataset.view === 'github') loadGithub();
461
+ });
462
+
463
+ $('#view-inbox').addEventListener('click', async (e) => {
464
+ const goto = e.target.closest('[data-goto-run]');
465
+ if (goto) {
466
+ e.preventDefault();
467
+ showRunsView();
468
+ selectRun(goto.dataset.gotoRun);
469
+ return;
470
+ }
471
+ const btn = e.target.closest('button[data-todo-action]');
472
+ if (!btn) return;
473
+ const id = btn.closest('.todo-card')?.dataset.id;
474
+ if (!id) return;
475
+ if (btn.dataset.todoAction === 'remove') {
476
+ await fetch(`/api/todos/${id}`, { method: 'DELETE' });
477
+ state.todos = state.todos.filter((t) => t.id !== id);
478
+ renderInboxBadge();
479
+ renderInbox();
480
+ } else if (btn.dataset.todoAction === 'start') {
481
+ btn.disabled = true;
482
+ try {
483
+ const res = await fetch(`/api/todos/${id}/start`, { method: 'POST' });
484
+ const data = await res.json().catch(() => ({}));
485
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
486
+ const todo = state.todos.find((t) => t.id === id);
487
+ if (todo) todo.startedTaskId = data.run.id;
488
+ renderInboxBadge();
489
+ renderInbox();
490
+ state.runs.set(data.run.id, data.run);
491
+ showRunsView();
492
+ renderRunList();
493
+ selectRun(data.run.id);
494
+ } catch (err) {
495
+ alertBar(err.message);
496
+ btn.disabled = false;
497
+ }
498
+ }
499
+ });
500
+
501
+ bindGithubView();
502
+ bindSkillsView();
503
+
504
+ const taskForm = $('#new-task');
505
+
506
+ // Pill dropdowns: toggle on the pill, select on a menu item, close on any
507
+ // outside click (one document listener; menus are re-rendered per change).
508
+ document.addEventListener('click', (e) => {
509
+ const pill = e.target.closest('.pill-select');
510
+ if (!pill) {
511
+ closePillMenus();
512
+ return;
513
+ }
514
+ const item = e.target.closest('.menu-item[data-mi]');
515
+ if (item) {
516
+ if (item.dataset.mi === 'src') {
517
+ state.taskSource = item.dataset.source;
518
+ state.taskRef = item.dataset.value;
519
+ } else if (item.dataset.mi === 'model') {
520
+ state.taskModel = item.dataset.value;
521
+ }
522
+ closePillMenus();
523
+ renderTaskPills();
524
+ return;
525
+ }
526
+ // Workflows | Skills switch inside the source menu.
527
+ const mtab = e.target.closest('button[data-mtab]');
528
+ if (mtab) {
529
+ state.srcMenuTab = mtab.dataset.mtab;
530
+ for (const b of mtab.parentElement.children) b.classList.toggle('active', b === mtab);
531
+ const box = $('#src-menu-items');
532
+ if (box) box.innerHTML = srcMenuItemsHtml();
533
+ $('#src-search')?.focus();
534
+ return;
535
+ }
536
+ if (e.target.closest('.menu-search')) return; // typing, not toggling
537
+ if (e.target.closest('.pill-btn')) {
538
+ const menu = pill.querySelector('.pill-menu');
539
+ const wasOpen = pill.classList.contains('open');
540
+ closePillMenus();
541
+ if (!wasOpen && menu) {
542
+ pill.classList.add('open');
543
+ menu.hidden = false;
544
+ // Fresh view on open: current kind's tab, empty query, focused search.
545
+ if (pill.id === 'src-pill') {
546
+ state.srcMenuTab = state.taskSource;
547
+ state.srcMenuQuery = '';
548
+ const search = $('#src-search');
549
+ if (search) search.value = '';
550
+ for (const b of menu.querySelectorAll('button[data-mtab]')) {
551
+ b.classList.toggle('active', b.dataset.mtab === state.srcMenuTab);
552
+ }
553
+ const box = $('#src-menu-items');
554
+ if (box) box.innerHTML = srcMenuItemsHtml();
555
+ search?.focus();
556
+ }
557
+ }
558
+ }
132
559
  });
133
560
 
134
- $('#new-task').addEventListener('submit', async (e) => {
561
+ // Live filter — re-render only the item list so the input keeps focus.
562
+ document.addEventListener('input', (e) => {
563
+ if (e.target.id !== 'src-search') return;
564
+ state.srcMenuQuery = e.target.value;
565
+ const box = $('#src-menu-items');
566
+ if (box) box.innerHTML = srcMenuItemsHtml();
567
+ });
568
+ // Enter in the search picks the first match (and never submits the form).
569
+ document.addEventListener('keydown', (e) => {
570
+ if (e.target.id !== 'src-search' || e.key !== 'Enter') return;
571
+ e.preventDefault();
572
+ $('#src-menu-items .menu-item')?.click();
573
+ });
574
+
575
+ // 📎 — attach images to the task (same pipeline as ⌘V paste).
576
+ const taskFile = $('#task-file');
577
+ $('#task-attach').addEventListener('click', () => taskFile.click());
578
+ taskFile.addEventListener('change', () => {
579
+ for (const file of taskFile.files ?? []) {
580
+ void readImageFile(file).then((img) => {
581
+ if (!img || state.taskImages.length >= 4) return;
582
+ state.taskImages.push(img);
583
+ renderTaskThumbs();
584
+ });
585
+ }
586
+ taskFile.value = '';
587
+ });
588
+
589
+ // ⌘↵ / Ctrl+↵ submits from inside the textarea.
590
+ taskForm.task.addEventListener('keydown', (e) => {
591
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
592
+ e.preventDefault();
593
+ taskForm.requestSubmit();
594
+ }
595
+ });
596
+
597
+ // The task box grows on focus and with content, springs back when left
598
+ // empty. Explicit px heights so the CSS height transition animates.
599
+ const taskBox = taskForm.task;
600
+ const autosizeTask = () => {
601
+ const engaged = document.activeElement === taskBox || taskBox.value.trim();
602
+ const floor = engaged ? 92 : 40;
603
+ const prev = taskBox.style.height || '40px';
604
+ taskBox.style.height = 'auto';
605
+ const target = Math.max(floor, Math.min(taskBox.scrollHeight, 220));
606
+ taskBox.style.height = prev; // restore the start point…
607
+ void taskBox.offsetHeight; // …force a reflow…
608
+ taskBox.style.height = `${target}px`; // …then animate to the target
609
+ };
610
+ taskBox.addEventListener('focus', autosizeTask);
611
+ taskBox.addEventListener('input', autosizeTask);
612
+ taskBox.addEventListener('blur', autosizeTask);
613
+
614
+ // Screenshots pasted into the task box travel with POST /api/runs and reach
615
+ // the first agent step's opening message.
616
+ taskBox.addEventListener('paste', (e) => {
617
+ const items = [...(e.clipboardData?.items ?? [])].filter((i) => i.type.startsWith('image/'));
618
+ if (!items.length) return;
619
+ e.preventDefault();
620
+ for (const item of items) {
621
+ const file = item.getAsFile();
622
+ if (file) {
623
+ void readImageFile(file).then((img) => {
624
+ if (!img || state.taskImages.length >= 4) return;
625
+ state.taskImages.push(img);
626
+ renderTaskThumbs();
627
+ });
628
+ }
629
+ }
630
+ });
631
+ $('#task-thumbs').addEventListener('click', (e) => {
632
+ const thumb = e.target.closest('[data-idx]');
633
+ if (!thumb) return;
634
+ state.taskImages.splice(Number(thumb.dataset.idx), 1);
635
+ renderTaskThumbs();
636
+ });
637
+
638
+ taskForm.addEventListener('submit', async (e) => {
135
639
  e.preventDefault();
136
640
  const form = e.target;
137
641
  const errorBox = $('#form-error');
138
642
  errorBox.hidden = true;
139
643
  const body = {
140
644
  task: form.task.value.trim(),
141
- workflow: form.workflow.value,
142
- model: form.model.value.trim() || undefined,
645
+ model: state.taskModel || undefined,
646
+ variants: state.variants > 1 ? state.variants : undefined,
647
+ images: state.taskImages.length
648
+ ? state.taskImages.map((i) => ({ mediaType: i.mediaType, data: i.data }))
649
+ : undefined,
143
650
  };
144
651
  if (!body.task) return;
145
- form.querySelector('button').disabled = true;
652
+ if (state.taskSource === 'skill' && state.taskRef) {
653
+ // A skill runs as a one-step inline chain — same shape the inbox and
654
+ // the bookmarklet auto-start use (spec 008's API).
655
+ body.steps = [{ id: 'task', name: state.taskRef, skill: state.taskRef, prompt: '{{task}}' }];
656
+ } else {
657
+ body.workflow = state.taskRef ?? 'quick-task';
658
+ }
659
+ discardPlan(); // a plain Run supersedes any pending plan (spec 008)
660
+ $('#run-btn').disabled = true;
146
661
  try {
147
662
  const res = await fetch('/api/runs', {
148
663
  method: 'POST',
@@ -152,21 +667,43 @@ function bindUi() {
152
667
  const data = await res.json();
153
668
  if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
154
669
  form.task.value = '';
155
- state.runs.set(data.id, data);
156
- renderRunList();
157
- selectRun(data.id);
670
+ state.taskImages = [];
671
+ renderTaskThumbs();
672
+ form.task.dispatchEvent(new Event('blur')); // shrink the box back
673
+ handleStarted(data);
158
674
  } catch (err) {
159
675
  errorBox.textContent = err.message;
160
676
  errorBox.hidden = false;
161
677
  } finally {
162
- form.querySelector('button').disabled = false;
678
+ $('#run-btn').disabled = false;
163
679
  }
164
680
  });
165
681
 
682
+ bindPlanPanel();
683
+
684
+ // Parallel variants (spec 010) have no form control right now — the ×1/×2/×3
685
+ // switch was retired from the UI. The backend path stays: state.variants is
686
+ // still sent with POST /api/runs whenever something sets it above 1.
687
+
166
688
  $('#run-list').addEventListener('click', (e) => {
167
- if (e.target.closest('a')) return; // PR links navigate, not select
689
+ if (e.target.closest('a')) return; // links navigate, not select
690
+ const compare = e.target.closest('button[data-compare]');
691
+ if (compare) {
692
+ void selectGroup(compare.dataset.compare);
693
+ return;
694
+ }
168
695
  const item = e.target.closest('.run-item');
169
- if (item) selectRun(item.dataset.id);
696
+ if (!item) return;
697
+ if (item.dataset.id) {
698
+ showRunsView();
699
+ selectRun(item.dataset.id);
700
+ } else if (item.dataset.group) {
701
+ // Group tile: toggle the variant list under it (in-memory UI state).
702
+ const gid = item.dataset.group;
703
+ if (state.expandedGroups.has(gid)) state.expandedGroups.delete(gid);
704
+ else state.expandedGroups.add(gid);
705
+ renderRunList();
706
+ }
170
707
  });
171
708
 
172
709
  $('#list-tabs').addEventListener('click', async (e) => {
@@ -181,6 +718,19 @@ function bindUi() {
181
718
  });
182
719
 
183
720
  $('#detail').addEventListener('click', async (e) => {
721
+ // Agent screenshots zoom into a lightbox.
722
+ const shot = e.target.closest('[data-lightbox]');
723
+ if (shot) {
724
+ openLightbox(shot.dataset.lightbox, shot.dataset.name);
725
+ return;
726
+ }
727
+ // Compare view (spec 010): "Pick this one" lives in #detail without a
728
+ // selected run — handle it before the per-run actions.
729
+ const pick = e.target.closest('button[data-pick]');
730
+ if (pick) {
731
+ void pickVariant(pick);
732
+ return;
733
+ }
184
734
  const btn = e.target.closest('button[data-action]');
185
735
  if (!btn) return;
186
736
  const id = state.selectedId;
@@ -224,6 +774,82 @@ function bindUi() {
224
774
  alertBar(data.error ?? 'cannot open terminal');
225
775
  }
226
776
  }
777
+ } else if (btn.dataset.action === 'diff') {
778
+ const panel = $('#diff-panel');
779
+ if (!panel) return;
780
+ if (!panel.hidden) {
781
+ panel.hidden = true;
782
+ return;
783
+ }
784
+ panel.hidden = false;
785
+ $('#diff-body').innerHTML = '<div class="dim">Loading…</div>';
786
+ try {
787
+ const res = await fetch(`/api/runs/${id}/diff`);
788
+ const text = await res.text();
789
+ $('#diff-body').innerHTML = text.trim() ? renderDiff(text) : '<div class="dim">(no changes yet)</div>';
790
+ } catch (err) {
791
+ $('#diff-body').textContent = `✗ ${err.message}`;
792
+ }
793
+ } else if (btn.dataset.action === 'send-back') {
794
+ // Review gate (spec 009): the notes go back into the same session via
795
+ // "Continue" — the run leaves `review`, works, and gates again.
796
+ const notes = $('#review-notes')?.value.trim();
797
+ if (!notes) {
798
+ alertBar('Write what to change first.');
799
+ $('#review-notes')?.focus();
800
+ return;
801
+ }
802
+ btn.disabled = true;
803
+ const res = await fetch(`/api/runs/${id}/continue`, {
804
+ method: 'POST',
805
+ headers: { 'content-type': 'application/json' },
806
+ body: JSON.stringify({ text: `Review feedback:\n${notes}` }),
807
+ });
808
+ if (!res.ok) {
809
+ const data = await res.json().catch(() => ({}));
810
+ alertBar(data.error ?? 'cannot send back');
811
+ btn.disabled = false;
812
+ }
813
+ // on success the SSE run update flips the status and hides the panel
814
+ } else if (btn.dataset.action === 'draft-pr') {
815
+ btn.disabled = true;
816
+ const res = await fetch(`/api/runs/${id}/pr`, { method: 'POST' });
817
+ const data = await res.json().catch(() => ({}));
818
+ if (res.ok) {
819
+ alertBar(`Draft PR created — ${data.url}`);
820
+ } else {
821
+ alertBar(data.error ?? 'PR creation failed');
822
+ const manual = $('#review-manual');
823
+ if (manual && data.manual) {
824
+ manual.hidden = false;
825
+ manual.textContent = `manual path: ${data.manual}`;
826
+ }
827
+ btn.disabled = false;
828
+ }
829
+ } else if (btn.dataset.action === 'notes') {
830
+ const panel = $('#notes-panel');
831
+ if (!panel) return;
832
+ if (!panel.hidden) {
833
+ panel.hidden = true;
834
+ return;
835
+ }
836
+ panel.hidden = false;
837
+ $('#notes-pre').textContent = 'Loading…';
838
+ try {
839
+ const res = await fetch(`/api/runs/${id}/handoff`);
840
+ const text = await res.text();
841
+ $('#notes-pre').textContent = text.trim() || '(no notes yet — the handoff file is seeded when the task starts)';
842
+ } catch (err) {
843
+ $('#notes-pre').textContent = `✗ ${err.message}`;
844
+ }
845
+ } else if (btn.dataset.action === 'remove-worktree') {
846
+ const res = await fetch(`/api/runs/${id}/remove-worktree`, { method: 'POST' });
847
+ if (!res.ok) {
848
+ const data = await res.json().catch(() => ({}));
849
+ alertBar(data.error ?? 'cannot remove worktree');
850
+ } else {
851
+ alertBar('Worktree removed.');
852
+ }
227
853
  } else if (btn.dataset.action === 'jump-bottom') {
228
854
  state.autoScroll = true;
229
855
  const log = $('#log');
@@ -240,6 +866,193 @@ function bindUi() {
240
866
  });
241
867
  }
242
868
 
869
+ // ---- chain-from-prompt (spec 008) --------------------------------------------
870
+
871
+ function bindPlanPanel() {
872
+ $('#plan-btn').addEventListener('click', () => void planTask());
873
+
874
+ const panel = $('#plan-panel');
875
+ panel.addEventListener('click', (e) => {
876
+ if (!state.plan) return;
877
+ const rm = e.target.closest('[data-plan-remove]');
878
+ if (rm) {
879
+ state.plan.steps.splice(Number(rm.dataset.planRemove), 1);
880
+ renderPlan();
881
+ return;
882
+ }
883
+ const btn = e.target.closest('button[data-plan-action]');
884
+ if (!btn) return;
885
+ if (btn.dataset.planAction === 'discard') discardPlan();
886
+ else if (btn.dataset.planAction === 'start') void startPlannedRun(btn);
887
+ else if (btn.dataset.planAction === 'save') void savePlanAsChain(btn);
888
+ });
889
+
890
+ // Reorder by drag (HTML5 draggable — no libraries).
891
+ panel.addEventListener('dragstart', (e) => {
892
+ const step = e.target.closest('.plan-step');
893
+ if (!step) return;
894
+ state.planDragIdx = Number(step.dataset.idx);
895
+ e.dataTransfer.effectAllowed = 'move';
896
+ });
897
+ panel.addEventListener('dragover', (e) => {
898
+ const step = e.target.closest('.plan-step');
899
+ if (!step || state.planDragIdx === null) return;
900
+ e.preventDefault();
901
+ e.dataTransfer.dropEffect = 'move';
902
+ step.classList.add('drag-over');
903
+ });
904
+ panel.addEventListener('dragleave', (e) => {
905
+ e.target.closest('.plan-step')?.classList.remove('drag-over');
906
+ });
907
+ panel.addEventListener('drop', (e) => {
908
+ const step = e.target.closest('.plan-step');
909
+ if (!step || state.planDragIdx === null || !state.plan) return;
910
+ e.preventDefault();
911
+ const from = state.planDragIdx;
912
+ const to = Number(step.dataset.idx);
913
+ state.planDragIdx = null;
914
+ if (from === to) return;
915
+ const [moved] = state.plan.steps.splice(from, 1);
916
+ state.plan.steps.splice(to, 0, moved);
917
+ renderPlan();
918
+ });
919
+ panel.addEventListener('dragend', () => {
920
+ state.planDragIdx = null;
921
+ for (const el of panel.querySelectorAll('.drag-over')) el.classList.remove('drag-over');
922
+ });
923
+ }
924
+
925
+ async function planTask() {
926
+ const form = $('#new-task');
927
+ const task = form.task.value.trim();
928
+ const errorBox = $('#form-error');
929
+ errorBox.hidden = true;
930
+ if (!task) {
931
+ form.task.focus();
932
+ return;
933
+ }
934
+ const btn = $('#plan-btn');
935
+ const label = btn.textContent;
936
+ btn.disabled = true;
937
+ btn.textContent = '⏳ Planning…';
938
+ try {
939
+ const res = await fetch('/api/plan', {
940
+ method: 'POST',
941
+ headers: { 'content-type': 'application/json' },
942
+ body: JSON.stringify({ task }),
943
+ });
944
+ const data = await res.json();
945
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
946
+ state.plan = { task, steps: data.steps, rationale: data.rationale, fallback: data.fallback };
947
+ renderPlan();
948
+ } catch (err) {
949
+ errorBox.textContent = err.message;
950
+ errorBox.hidden = false;
951
+ } finally {
952
+ btn.disabled = false;
953
+ btn.textContent = label;
954
+ }
955
+ }
956
+
957
+ function oneLine(s, max = 70) {
958
+ const first = String(s ?? '').split('\n')[0] ?? '';
959
+ return first.length > max ? `${first.slice(0, max - 1)}…` : first;
960
+ }
961
+
962
+ function renderPlan() {
963
+ const panel = $('#plan-panel');
964
+ if (!state.plan) {
965
+ panel.hidden = true;
966
+ panel.innerHTML = '';
967
+ return;
968
+ }
969
+ const { steps, rationale, fallback } = state.plan;
970
+ panel.hidden = false;
971
+ panel.innerHTML = `
972
+ ${fallback ? '<div class="plan-note">planner unavailable — single-step plan</div>' : ''}
973
+ ${rationale ? `<div class="plan-rationale dim" title="${esc(rationale)}">${esc(rationale)}</div>` : ''}
974
+ <div class="plan-steps">
975
+ ${steps
976
+ .map(
977
+ (s, i) => `
978
+ <div class="plan-step" draggable="true" data-idx="${i}">
979
+ <span class="grip" title="Drag to reorder">≡</span>
980
+ <span class="num">${i + 1}.</span>
981
+ <span class="name">${esc(s.name ?? s.id)}</span>
982
+ ${s.skill ? `<span class="plan-badge skill" title="skill">${esc(s.skill)}</span>` : ''}
983
+ ${s.command ? '<span class="plan-badge check">check</span>' : ''}
984
+ <span class="hint dim" title="${esc(s.command ?? s.prompt ?? '')}">${esc(oneLine(s.command ?? s.prompt ?? ''))}</span>
985
+ <button type="button" class="plan-remove" data-plan-remove="${i}" title="Remove step">✕</button>
986
+ </div>`,
987
+ )
988
+ .join('') || '<div class="dim">(no steps left — discard and plan again)</div>'}
989
+ </div>
990
+ <div class="plan-actions">
991
+ <button type="button" class="btn-dark" data-plan-action="start" ${steps.length ? '' : 'disabled'}>▶ Start</button>
992
+ <button type="button" class="btn-ghost" data-plan-action="save" ${steps.length ? '' : 'disabled'}>Save as chain</button>
993
+ <button type="button" class="btn-ghost" data-plan-action="discard">Discard</button>
994
+ </div>`;
995
+ }
996
+
997
+ function discardPlan() {
998
+ state.plan = null;
999
+ state.planDragIdx = null;
1000
+ renderPlan();
1001
+ }
1002
+
1003
+ async function startPlannedRun(btn) {
1004
+ const form = $('#new-task');
1005
+ const task = form.task.value.trim() || state.plan.task;
1006
+ btn.disabled = true;
1007
+ try {
1008
+ const res = await fetch('/api/runs', {
1009
+ method: 'POST',
1010
+ headers: { 'content-type': 'application/json' },
1011
+ body: JSON.stringify({
1012
+ task,
1013
+ steps: state.plan.steps,
1014
+ model: state.taskModel || undefined,
1015
+ variants: state.variants > 1 ? state.variants : undefined,
1016
+ images: state.taskImages.length
1017
+ ? state.taskImages.map((i) => ({ mediaType: i.mediaType, data: i.data }))
1018
+ : undefined,
1019
+ }),
1020
+ });
1021
+ const data = await res.json();
1022
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
1023
+ form.task.value = '';
1024
+ state.taskImages = [];
1025
+ renderTaskThumbs();
1026
+ discardPlan();
1027
+ handleStarted(data);
1028
+ } catch (err) {
1029
+ alertBar(err.message);
1030
+ btn.disabled = false;
1031
+ }
1032
+ }
1033
+
1034
+ async function savePlanAsChain(btn) {
1035
+ const name = prompt('Chain name:');
1036
+ if (!name || !name.trim()) return;
1037
+ btn.disabled = true;
1038
+ try {
1039
+ const res = await fetch('/api/workflows', {
1040
+ method: 'POST',
1041
+ headers: { 'content-type': 'application/json' },
1042
+ body: JSON.stringify({ name: name.trim(), steps: state.plan.steps }),
1043
+ });
1044
+ const data = await res.json();
1045
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
1046
+ alertBar(`Saved — ${String(data.path).split('/').pop()}`);
1047
+ const workflowsRes = await getJson('/api/workflows');
1048
+ setWorkflowOptions(workflowsRes.workflows);
1049
+ } catch (err) {
1050
+ alertBar(err.message);
1051
+ } finally {
1052
+ btn.disabled = false;
1053
+ }
1054
+ }
1055
+
243
1056
  // ---- live-session message bar ------------------------------------------------
244
1057
 
245
1058
  function bindMessageBar(runId) {
@@ -291,11 +1104,12 @@ function bindMessageBar(runId) {
291
1104
  });
292
1105
  }
293
1106
 
294
- async function addImage(file) {
295
- if (state.pendingImages.length >= 4) return;
1107
+ /* File {mediaType, data, preview}, or null when oversized. Shared by the
1108
+ live-session bar and the new-task form. */
1109
+ async function readImageFile(file) {
296
1110
  if (file.size > 5 * 1024 * 1024) {
297
1111
  alertBar('Image too large (max 5 MB)');
298
- return;
1112
+ return null;
299
1113
  }
300
1114
  const buf = await file.arrayBuffer();
301
1115
  let binary = '';
@@ -304,10 +1118,26 @@ async function addImage(file) {
304
1118
  binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
305
1119
  }
306
1120
  const data = btoa(binary);
307
- state.pendingImages.push({ mediaType: file.type, data, preview: `data:${file.type};base64,${data}` });
1121
+ return { mediaType: file.type, data, preview: `data:${file.type};base64,${data}` };
1122
+ }
1123
+
1124
+ async function addImage(file) {
1125
+ if (state.pendingImages.length >= 4) return;
1126
+ const img = await readImageFile(file);
1127
+ if (!img) return;
1128
+ state.pendingImages.push(img);
308
1129
  renderThumbs();
309
1130
  }
310
1131
 
1132
+ function renderTaskThumbs() {
1133
+ const box = $('#task-thumbs');
1134
+ if (!box) return;
1135
+ box.hidden = state.taskImages.length === 0;
1136
+ box.innerHTML = state.taskImages
1137
+ .map((img, idx) => `<span class="thumb" data-idx="${idx}" title="Click to remove"><img src="${img.preview}"></span>`)
1138
+ .join('');
1139
+ }
1140
+
311
1141
  function renderThumbs() {
312
1142
  const box = $('#msg-thumbs');
313
1143
  if (!box) return;
@@ -340,6 +1170,23 @@ async function sendMessage(runId, text, images) {
340
1170
  }
341
1171
  }
342
1172
 
1173
+ function openLightbox(url, name) {
1174
+ closeLightbox();
1175
+ const el = document.createElement('div');
1176
+ el.id = 'lightbox';
1177
+ el.innerHTML = `
1178
+ <div class="lb-inner">
1179
+ <img src="${esc(url)}" alt="${esc(name ?? '')}">
1180
+ <div class="lb-name">${esc(name ?? '')} — click anywhere to close</div>
1181
+ </div>`;
1182
+ el.addEventListener('click', closeLightbox);
1183
+ document.body.appendChild(el);
1184
+ }
1185
+
1186
+ function closeLightbox() {
1187
+ $('#lightbox')?.remove();
1188
+ }
1189
+
343
1190
  function alertBar(message) {
344
1191
  const existing = $('#toast');
345
1192
  if (existing) existing.remove();
@@ -352,6 +1199,16 @@ function alertBar(message) {
352
1199
 
353
1200
  // ---- run list --------------------------------------------------------------
354
1201
 
1202
+ /* POST /api/runs answers with one run (×1) or {runs: [...]} (variants). */
1203
+ function handleStarted(data) {
1204
+ const runs = Array.isArray(data.runs) ? data.runs : [data];
1205
+ for (const run of runs) state.runs.set(run.id, run);
1206
+ const first = runs[0];
1207
+ if (first?.groupId) state.expandedGroups.add(first.groupId);
1208
+ renderRunList();
1209
+ if (first) selectRun(first.id);
1210
+ }
1211
+
355
1212
  function sortedRuns() {
356
1213
  // Needs-you-first: waiting/review, then running/queued, then by recency.
357
1214
  return [...state.runs.values()]
@@ -364,6 +1221,14 @@ function sortedRuns() {
364
1221
  });
365
1222
  }
366
1223
 
1224
+ /* Which sidebar group a run (or a variant group's best member) belongs to. */
1225
+ function bucketOf(status) {
1226
+ if (state.listView === 'archived') return 'Archived';
1227
+ if (status === 'waiting' || status === 'review') return 'Needs you';
1228
+ if (status === 'running' || status === 'queued') return 'Working';
1229
+ return 'Recent';
1230
+ }
1231
+
367
1232
  function renderRunList() {
368
1233
  const all = [...state.runs.values()];
369
1234
  const activeCount = all.filter((r) => !r.archived).length;
@@ -371,26 +1236,81 @@ function renderRunList() {
371
1236
  const finishedCount = all.filter(
372
1237
  (r) => !r.archived && ['done', 'failed', 'cancelled'].includes(r.status),
373
1238
  ).length;
374
- const waitingCount = all.filter((r) => !r.archived && r.status === 'waiting').length;
1239
+ const waitingCount = all.filter(
1240
+ (r) => !r.archived && (r.status === 'waiting' || r.status === 'review'),
1241
+ ).length;
375
1242
 
376
1243
  $('#list-tabs').innerHTML = `
377
1244
  <button data-list="active" class="${state.listView === 'active' ? 'active' : ''}">
378
- Active ${activeCount ? `(${activeCount})` : ''}${waitingCount ? ' <span class="dot"></span>' : ''}
1245
+ Active${activeCount ? ` ${activeCount}` : ''}${waitingCount ? ' <span class="dot"></span>' : ''}
379
1246
  </button>
380
1247
  <button data-list="archived" class="${state.listView === 'archived' ? 'active' : ''}">
381
- Archived ${archivedCount ? `(${archivedCount})` : ''}
1248
+ Archived${archivedCount ? ` ${archivedCount}` : ''}
382
1249
  </button>
383
1250
  ${state.listView === 'active' && finishedCount ? `<button data-list="archive-finished" title="Archive all finished runs">📥 ${finishedCount}</button>` : ''}`;
384
1251
 
385
- $('#run-list').innerHTML = sortedRuns()
386
- .map(
387
- (r) => `
388
- <div class="run-item ${r.id === state.selectedId ? 'selected' : ''}" data-id="${r.id}">
389
- <div class="title" title="${esc(r.task)}">${esc(r.title)}</div>
390
- <div class="meta">${badge(r.status)} ${prBadge(r)} <span>${esc(r.workflow)}</span> <span>${fmtTokens(r.tokensUsed)}</span> ${r.costUsd ? `<span>${fmtCost(r.costUsd)}</span>` : ''} <span>${timeAgo(r.createdAt)}</span></div>
391
- </div>`,
392
- )
393
- .join('');
1252
+ // Variant groups (spec 010): runs sharing a groupId collapse into one
1253
+ // group tile at the position of their best-ranked member; click expands.
1254
+ const runs = sortedRuns();
1255
+ const seenGroups = new Set();
1256
+ const buckets = new Map(); // label -> html[]
1257
+ const push = (label, html) => {
1258
+ if (!buckets.has(label)) buckets.set(label, []);
1259
+ buckets.get(label).push(html);
1260
+ };
1261
+ for (const r of runs) {
1262
+ if (r.groupId) {
1263
+ if (seenGroups.has(r.groupId)) continue;
1264
+ seenGroups.add(r.groupId);
1265
+ const members = runs
1266
+ .filter((m) => m.groupId === r.groupId)
1267
+ .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
1268
+ if (members.length > 1) {
1269
+ push(bucketOf(r.status), groupTileHtml(r.groupId, members));
1270
+ continue;
1271
+ }
1272
+ // A lone survivor (the picked winner) renders like any other run.
1273
+ }
1274
+ push(bucketOf(r.status), runItemHtml(r));
1275
+ }
1276
+ const order = ['Needs you', 'Working', 'Recent', 'Archived'];
1277
+ $('#run-list').innerHTML =
1278
+ order
1279
+ .filter((label) => buckets.has(label))
1280
+ .map((label) => `<div class="group-label">${label}</div>${buckets.get(label).join('')}`)
1281
+ .join('') ||
1282
+ `<div class="dim" style="padding:14px 12px;font-size:12px">${
1283
+ state.listView === 'archived' ? 'Nothing archived yet.' : 'No runs yet — describe a task above.'
1284
+ }</div>`;
1285
+ }
1286
+
1287
+ function runItemHtml(r, variantRow = false) {
1288
+ const timeText =
1289
+ r.status === 'queued' ? `#${queuePosition(r) ?? '·'}` : shortAgo(r.finishedAt ?? r.createdAt);
1290
+ return `
1291
+ <div class="run-item ${variantRow ? 'variant-row' : ''} ${r.id === state.selectedId ? 'selected' : ''}" data-id="${r.id}" title="${esc(r.task)}">
1292
+ <span class="dot ${esc(r.status)}"></span>
1293
+ ${r.variant ? `<span class="time">${esc(r.variant)}</span>` : ''}
1294
+ <span class="title">${esc(variantRow ? groupTitle(r) : r.title)}</span>
1295
+ ${r.pullRequestUrl ? `<a class="time" href="${esc(r.pullRequestUrl)}" target="_blank" rel="noopener" title="open the PR">PR↗</a>` : ''}
1296
+ <span class="time">${esc(timeText)}</span>
1297
+ </div>`;
1298
+ }
1299
+
1300
+ function groupTileHtml(groupId, members) {
1301
+ const first = members[0];
1302
+ const expanded = state.expandedGroups.has(groupId);
1303
+ const allTerminal = members.every((m) => TERMINAL_STATUSES.includes(m.status));
1304
+ return `
1305
+ <div class="run-item group-item ${state.selectedGroupId === groupId ? 'selected' : ''}" data-group="${esc(groupId)}" title="${esc(first.task)}">
1306
+ <span class="time">${expanded ? '▾' : '▸'}</span>
1307
+ <span class="title">${esc(groupTitle(first))}</span>
1308
+ <span class="dots">${members
1309
+ .map((m) => `<span class="dot ${esc(m.status)}" title="${esc(`${m.variant ?? '?'} · ${m.status}`)}"></span>`)
1310
+ .join('')}</span>
1311
+ ${allTerminal ? `<button type="button" class="compare-btn" data-compare="${esc(groupId)}" title="Compare the variants' diffs side by side">⚖</button>` : `<span class="time">${members.length}×</span>`}
1312
+ </div>
1313
+ ${expanded ? members.map((m) => runItemHtml(m, true)).join('') : ''}`;
394
1314
  }
395
1315
 
396
1316
  // ---- run detail ------------------------------------------------------------
@@ -399,6 +1319,7 @@ function selectRun(id) {
399
1319
  const run = state.runs.get(id);
400
1320
  if (!run) return;
401
1321
  state.selectedId = id;
1322
+ state.selectedGroupId = null;
402
1323
  state.lastSeq = 0;
403
1324
  state.autoScroll = true;
404
1325
  if (state.runEs) {
@@ -422,21 +1343,43 @@ function selectRun(id) {
422
1343
  function renderDetailShell(run) {
423
1344
  state.pendingImages = [];
424
1345
  $('#detail').innerHTML = `
425
- <div class="detail-header" id="detail-header"></div>
426
- <div class="steps-rail" id="steps-rail"></div>
1346
+ <div class="detail-head">
1347
+ <div class="meta-line" id="d-meta"></div>
1348
+ <div class="title-row">
1349
+ <h1 id="d-title"></h1>
1350
+ <span id="d-badge"></span>
1351
+ </div>
1352
+ <div class="head-bar">
1353
+ <span class="steps-line" id="d-steps"></span>
1354
+ <div class="spacer"></div>
1355
+ <span id="d-actions" style="display:flex;align-items:center;gap:4px;flex-wrap:wrap"></span>
1356
+ </div>
1357
+ <div id="d-error" class="run-error" hidden></div>
1358
+ <div id="d-resume" class="resume-hint" hidden></div>
1359
+ </div>
1360
+ <div id="review-panel" class="detail-panel" hidden></div>
1361
+ <div id="diff-panel" class="detail-panel" hidden><div class="inner"><div class="panel-label">What this task changed</div><div id="diff-body"></div></div></div>
1362
+ <div id="notes-panel" class="detail-panel" hidden><div class="inner"><div class="panel-label">Handoff notes</div><pre id="notes-pre"></pre></div></div>
427
1363
  <div class="log-wrap">
428
- <div id="log"></div>
1364
+ <div id="log"><div class="log-inner" id="log-inner"></div></div>
429
1365
  <button id="jump-bottom" data-action="jump-bottom" hidden>↓ jump to bottom</button>
430
1366
  </div>
431
- <form id="msg-form">
432
- <div id="msg-thumbs" hidden></div>
433
- <div class="msg-row">
434
- <button type="button" id="msg-attach" title="Attach an image">📎</button>
435
- <textarea id="msg-text" rows="1" placeholder="Message the agent… (Enter to send, ⌘V to paste a screenshot)"></textarea>
436
- <button type="submit" id="msg-send" class="primary">➤</button>
437
- </div>
438
- <input type="file" id="msg-file" accept="image/*" multiple hidden>
439
- </form>`;
1367
+ <div class="composer-wrap">
1368
+ <div id="waiting-note" hidden><span class="pulse-dot"></span>The agent is paused, waiting for your reply</div>
1369
+ <form id="msg-form">
1370
+ <div id="msg-thumbs" hidden></div>
1371
+ <div class="msg-row">
1372
+ <textarea id="msg-text" rows="1" placeholder="Message the agent…"></textarea>
1373
+ <button type="button" id="msg-attach" title="Attach an image (or paste a screenshot)">
1374
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.4 3.4 0 015 5l-8 8a1.7 1.7 0 01-2.4-2.4l7-7"/></svg>
1375
+ </button>
1376
+ <button type="submit" id="msg-send" title="Send (Enter)">
1377
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 19V5M6 11l6-6 6 6"/></svg>
1378
+ </button>
1379
+ </div>
1380
+ <input type="file" id="msg-file" accept="image/*" multiple hidden>
1381
+ </form>
1382
+ </div>`;
440
1383
  bindMessageBar(run.id);
441
1384
  updateDetail(run);
442
1385
  $('#log').addEventListener('scroll', () => {
@@ -447,24 +1390,76 @@ function renderDetailShell(run) {
447
1390
  });
448
1391
  }
449
1392
 
1393
+ const STEP_MARK = { done: '✓', running: '●', waiting: '●', review: '●', failed: '✗' };
1394
+
450
1395
  function updateDetail(run) {
451
1396
  state.runs.set(run.id, run);
452
1397
  const active = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
453
1398
  const lastSession = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
454
- $('#detail-header').innerHTML = `
455
- ${badge(run.status)}
456
- <h2 title="${esc(run.task)}">${esc(run.title)}</h2>
457
- ${run.status === 'waiting' ? '<button data-action="finish">✓ Finish</button>' : ''}
458
- ${!active && lastSession ? '<button data-action="continue">▶ Continue</button><button data-action="open-cli">⌨ Open in terminal</button>' : ''}
459
- ${!active ? `<button data-action="archive" title="${run.archived ? 'Unarchive' : 'Archive'}">${run.archived ? '📤' : '📥'}</button>` : ''}
1399
+ const resumeCmd = run.worktreePath
1400
+ ? `cd ${run.worktreePath} && claude --resume ${lastSession}`
1401
+ : `claude --resume ${lastSession}`;
1402
+
1403
+ const metaParts = [
1404
+ esc(run.workflow),
1405
+ esc(fmtTokens(run.tokensUsed)),
1406
+ run.costUsd ? esc(fmtCost(run.costUsd)) : null,
1407
+ `started ${esc(timeAgo(run.startedAt ?? run.createdAt))}`,
1408
+ run.finishedAt ? `finished ${esc(timeAgo(run.finishedAt))}` : null,
1409
+ run.model ? `model ${esc(run.model)}` : null,
1410
+ run.branch ? esc(run.branch) : null,
1411
+ prLink(run) || null,
1412
+ ].filter(Boolean);
1413
+ $('#d-meta').innerHTML = metaParts.map((p) => `<span>${p}</span>`).join('<span>·</span>');
1414
+
1415
+ $('#d-title').textContent = run.title;
1416
+ $('#d-title').title = run.task;
1417
+ $('#d-badge').innerHTML = statusPill(run);
1418
+
1419
+ $('#d-steps').innerHTML = run.steps
1420
+ .map((s) => {
1421
+ const mark = STEP_MARK[s.status] ?? '○';
1422
+ const label = `${mark} ${esc(s.name)}${s.iterations > 1 ? ` ×${s.iterations}` : ''}`;
1423
+ const tip = `${s.kind} · ${s.status} · ${fmtTokens(s.tokensUsed)}${s.error ? `\n${s.error}` : ''}${s.sessionId ? `\nsession: ${s.sessionId}` : ''}`;
1424
+ return `<span title="${esc(tip)}">${label}</span>`;
1425
+ })
1426
+ .join('<span style="opacity:.5">&nbsp;&nbsp;→&nbsp;&nbsp;</span>');
1427
+
1428
+ $('#d-actions').innerHTML = `
1429
+ ${run.status === 'waiting' || run.status === 'review' ? `<button class="btn-dark" data-action="finish" title="${run.status === 'review' ? 'Accept the changes without a PR' : 'Close the session'}">✓ Finish</button>` : ''}
1430
+ ${!active && lastSession ? '<button class="btn-text" data-action="continue">▶ Continue</button><button class="btn-text" data-action="open-cli" title="Take over the session in a real terminal">Terminal</button>' : ''}
1431
+ ${run.worktreePath ? '<button class="btn-text" data-action="diff" title="What this task changed (worktree vs base)">± Diff</button>' : ''}
1432
+ <button class="btn-text" data-action="notes" title="Handoff notes — what the agent did and what's left">Notes</button>
1433
+ ${run.archived && run.worktreePath ? '<button class="btn-text" data-action="remove-worktree" title="Remove the task worktree and its branch">Remove worktree</button>' : ''}
1434
+ ${!active ? `<button class="btn-text" data-action="archive" title="${run.archived ? 'Unarchive' : 'Archive'}">${run.archived ? 'Unarchive' : 'Archive'}</button>` : ''}
460
1435
  ${active
461
- ? '<button data-action="cancel" class="danger">■ Cancel</button>'
462
- : '<button data-action="delete" class="danger">🗑 Delete</button>'}
463
- <div class="sub">
464
- ${esc(run.workflow)} ${prBadge(run)} · ${fmtTokens(run.tokensUsed)}${run.costUsd ? ` · ${fmtCost(run.costUsd)}` : ''} · started ${timeAgo(run.startedAt ?? run.createdAt)}${run.finishedAt ? ` · finished ${timeAgo(run.finishedAt)}` : ''}${run.model ? ` · model ${esc(run.model)}` : ''}
465
- ${run.error ? `<br>✗ ${esc(run.error)}` : ''}
466
- ${!active && lastSession ? `<br>take over interactively: claude --resume ${esc(lastSession)}` : ''}
467
- </div>`;
1436
+ ? '<button class="btn-text danger" data-action="cancel">■ Cancel</button>'
1437
+ : '<button class="btn-text danger" data-action="delete">Delete</button>'}`;
1438
+
1439
+ const errBox = $('#d-error');
1440
+ errBox.hidden = !run.error;
1441
+ if (run.error) errBox.textContent = `✗ ${run.error}`;
1442
+ const resumeBox = $('#d-resume');
1443
+ const showResume = !active && lastSession;
1444
+ resumeBox.hidden = !showResume;
1445
+ if (showResume) resumeBox.textContent = `take over interactively: ${resumeCmd}`;
1446
+
1447
+ // Review gate (spec 009): the panel lives while the run rests at `review`;
1448
+ // it (re)loads the diff on each entry into review and clears on exit, so a
1449
+ // send-back round always comes back with a fresh diff.
1450
+ const reviewPanel = $('#review-panel');
1451
+ if (reviewPanel) {
1452
+ const inReview = run.status === 'review';
1453
+ reviewPanel.hidden = !inReview;
1454
+ if (inReview && !reviewPanel.dataset.loaded) {
1455
+ reviewPanel.dataset.loaded = '1';
1456
+ void renderReviewPanel(run.id);
1457
+ } else if (!inReview && reviewPanel.dataset.loaded) {
1458
+ delete reviewPanel.dataset.loaded;
1459
+ reviewPanel.innerHTML = '';
1460
+ }
1461
+ }
1462
+
468
1463
  const msgForm = $('#msg-form');
469
1464
  if (msgForm) {
470
1465
  const sessionOpen = run.status === 'running' || run.status === 'waiting';
@@ -474,56 +1469,212 @@ function updateDetail(run) {
474
1469
  $('#msg-attach').disabled = !sessionOpen;
475
1470
  $('#msg-text').placeholder = sessionOpen
476
1471
  ? run.status === 'waiting'
477
- ? 'The agent is waiting for you… (Enter to send)'
478
- : 'Message the agent… (Enter to send, ⌘V to paste a screenshot)'
479
- : 'Session closed.';
1472
+ ? 'Reply to the agent… (Enter to send, ⌘V pastes a screenshot)'
1473
+ : 'Message the agent… (Enter to send, ⌘V pastes a screenshot)'
1474
+ : 'Session closed — Continue to reopen.';
1475
+ $('#waiting-note').hidden = run.status !== 'waiting';
1476
+ }
1477
+ }
1478
+
1479
+ /* Review gate panel (spec 009): the full diff on top, then a notes field with
1480
+ exactly two buttons — "Send back" (feedback into the same session) and
1481
+ "Draft PR". The third exit, "✓ Finish" (accept without a PR), sits in the
1482
+ detail header. */
1483
+ async function renderReviewPanel(runId) {
1484
+ const panel = $('#review-panel');
1485
+ if (!panel) return;
1486
+ panel.innerHTML = `
1487
+ <div class="inner">
1488
+ <div class="panel-label">Changes ready for review</div>
1489
+ <div id="review-diff"><div class="dim">Loading diff…</div></div>
1490
+ <textarea id="review-notes" rows="2" placeholder="Notes for the agent — what should change?"></textarea>
1491
+ <div class="review-buttons">
1492
+ <button type="button" class="btn-ghost" data-action="send-back" title="Send the notes back into the agent's session">↩ Send back</button>
1493
+ <button type="button" class="btn-dark" data-action="draft-pr" title="Push the branch and open a draft PR">Draft PR</button>
1494
+ </div>
1495
+ <div id="review-manual" class="dim mono" hidden></div>
1496
+ </div>`;
1497
+ try {
1498
+ const res = await fetch(`/api/runs/${runId}/diff`);
1499
+ const text = await res.text();
1500
+ const box = $('#review-diff');
1501
+ if (box) box.innerHTML = renderDiff(text);
1502
+ } catch (err) {
1503
+ const box = $('#review-diff');
1504
+ if (box) box.textContent = `✗ ${err.message}`;
1505
+ }
1506
+ }
1507
+
1508
+ // ---- variant compare view (spec 010) ----------------------------------------
1509
+
1510
+ /* "⚖ Compare": columns per variant — status, cost, `git diff --stat`, the
1511
+ first Progress-log lines — each with a "Pick this one" button; full diffs
1512
+ collapse under the columns (renderDiff from spec 009, reused). */
1513
+ async function selectGroup(groupId) {
1514
+ state.selectedGroupId = groupId;
1515
+ state.selectedId = null;
1516
+ if (state.runEs) {
1517
+ state.runEs.close();
1518
+ state.runEs = null;
1519
+ }
1520
+ showRunsView();
1521
+ renderRunList();
1522
+ const detail = $('#detail');
1523
+ detail.innerHTML = '<div class="empty">Loading variants…</div>';
1524
+ let data;
1525
+ try {
1526
+ data = await getJson(`/api/groups/${groupId}`);
1527
+ } catch (err) {
1528
+ detail.innerHTML = `<div class="empty">✗ ${esc(err.message)}</div>`;
1529
+ return;
1530
+ }
1531
+ if (state.selectedGroupId !== groupId) return; // user moved on meanwhile
1532
+ renderCompareView(data.runs);
1533
+ }
1534
+
1535
+ function renderCompareView(variants) {
1536
+ const title = variants.length ? groupTitle(variants[0]) : '';
1537
+ $('#detail').innerHTML = `
1538
+ <div class="compare-view"><div class="inner">
1539
+ <div class="compare-head">
1540
+ <h1 title="${esc(title)}">⚖ ${esc(title)}</h1>
1541
+ <span class="dim">${variants.length} variants — pick the diff you want to keep; the others are archived and their worktrees removed</span>
1542
+ </div>
1543
+ <div class="compare-cols">
1544
+ ${variants
1545
+ .map(
1546
+ (v) => `
1547
+ <div class="compare-col">
1548
+ <div class="col-head"><span class="variant-letter">${esc(v.variant)}</span> ${statusPill(v)}</div>
1549
+ <div class="col-meta">${fmtTokens(v.tokensUsed)}${v.costUsd ? ` · ${fmtCost(v.costUsd)}` : ''}</div>
1550
+ <pre class="col-stat">${esc(v.diffStat || '(no changes)')}</pre>
1551
+ <pre class="col-handoff">${esc(v.handoffExcerpt || '(no progress notes)')}</pre>
1552
+ <button type="button" class="btn-dark" data-pick="${esc(v.id)}">✔ Pick this one</button>
1553
+ </div>`,
1554
+ )
1555
+ .join('')}
1556
+ </div>
1557
+ <div class="compare-diffs">
1558
+ ${variants
1559
+ .map(
1560
+ (v) => `
1561
+ <details class="compare-diff">
1562
+ <summary>Variant ${esc(v.variant)} — full diff</summary>
1563
+ <div class="diff-body" data-group-diff="${esc(v.id)}"><div class="dim">Loading…</div></div>
1564
+ </details>`,
1565
+ )
1566
+ .join('')}
1567
+ </div>
1568
+ </div></div>`;
1569
+ // Full diffs (max 3) via the existing per-run endpoint.
1570
+ for (const v of variants) {
1571
+ void fetch(`/api/runs/${v.id}/diff`)
1572
+ .then((r) => r.text())
1573
+ .then((text) => {
1574
+ const box = document.querySelector(`[data-group-diff="${v.id}"]`);
1575
+ if (box) box.innerHTML = renderDiff(text);
1576
+ })
1577
+ .catch(() => undefined);
480
1578
  }
481
- $('#steps-rail').innerHTML = run.steps
482
- .map(
483
- (s) => `
484
- <div class="step-card" title="${esc(s.error ?? '')}${s.sessionId ? `\nsession: ${esc(s.sessionId)}` : ''}">
485
- ${badge(s.status)}
486
- <span class="name">${esc(s.name)}</span>
487
- <span class="dim">${s.kind}${s.iterations > 1 ? ` ×${s.iterations}` : ''}</span>
488
- <span class="tok">${fmtTokens(s.tokensUsed)}</span>
489
- </div>`,
490
- )
491
- .join('');
1579
+ }
1580
+
1581
+ /* "Pick this one" → the winner rests at `review` (the spec-009 gate takes it
1582
+ from there); the losers are archived and their worktrees removed. */
1583
+ async function pickVariant(btn) {
1584
+ const groupId = state.selectedGroupId;
1585
+ if (!groupId) return;
1586
+ btn.disabled = true;
1587
+ try {
1588
+ const res = await fetch(`/api/groups/${groupId}/pick`, {
1589
+ method: 'POST',
1590
+ headers: { 'content-type': 'application/json' },
1591
+ body: JSON.stringify({ runId: btn.dataset.pick }),
1592
+ });
1593
+ const data = await res.json();
1594
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
1595
+ if (data.winner) state.runs.set(data.winner.id, data.winner);
1596
+ state.expandedGroups.delete(groupId);
1597
+ renderRunList();
1598
+ if (data.winner) selectRun(data.winner.id); // lands on the review gate
1599
+ } catch (err) {
1600
+ alertBar(err.message);
1601
+ btn.disabled = false;
1602
+ }
1603
+ }
1604
+
1605
+ // ---- transcript ------------------------------------------------------------
1606
+
1607
+ /* The most telling single argument of a tool call — a command, a path, a
1608
+ pattern — shown inside the tool chip. */
1609
+ function toolArg(input) {
1610
+ if (input == null) return null;
1611
+ if (typeof input === 'string') return input.trim() || null;
1612
+ if (typeof input !== 'object') return String(input);
1613
+ for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt']) {
1614
+ const v = input[key];
1615
+ if (typeof v === 'string' && v.trim()) return v.trim();
1616
+ }
1617
+ const s = JSON.stringify(input);
1618
+ return s === '{}' ? null : s;
492
1619
  }
493
1620
 
494
1621
  function appendLog(evt) {
1622
+ const inner = $('#log-inner');
495
1623
  const log = $('#log');
496
- if (!log) return;
1624
+ if (!inner || !log) return;
497
1625
  const el = document.createElement('div');
498
- el.className = `ev ${evt.type}${evt.isError ? ' error' : ''}`;
499
1626
 
500
1627
  switch (evt.type) {
501
1628
  case 'text':
1629
+ el.className = 'ev text';
502
1630
  el.textContent = evt.text ?? '';
503
1631
  break;
504
1632
  case 'tool-call': {
505
- const input = JSON.stringify(evt.input ?? {}, null, 2);
506
- const preview = input.length > 100 ? `${input.slice(0, 97).replaceAll('\n', ' ')}…` : input.replaceAll('\n', ' ');
507
- el.innerHTML =
508
- input.length > 100
509
- ? `→ ${esc(evt.tool)} <details><summary>${esc(preview)}</summary><pre>${esc(input)}</pre></details>`
510
- : `→ ${esc(evt.tool)} ${esc(preview)}`;
1633
+ el.className = 'ev tool';
1634
+ const arg = toolArg(evt.input);
1635
+ el.innerHTML = `<span class="ok">✓</span><span>${esc(evt.tool)}</span>${
1636
+ arg ? `<span class="arg">${esc(oneLine(arg, 64))}</span>` : ''
1637
+ }`;
1638
+ if (arg && arg.length > 64) el.title = arg.slice(0, 1000);
511
1639
  break;
512
1640
  }
513
1641
  case 'tool-result': {
514
1642
  const result = String(evt.result ?? '');
515
- const first = result.split('\n')[0] ?? '';
516
- el.innerHTML =
517
- result.length > first.length
518
- ? `← <details><summary>${esc(first.slice(0, 140))}${result.length > 140 ? '…' : ''}</summary><pre>${esc(result.slice(0, 10_000))}</pre></details>`
519
- : `← ${esc(first)}`;
1643
+ const first = (result.split('\n')[0] ?? '').slice(0, 140);
1644
+ const hasMore = result.length > first.length;
1645
+ el.className = `ev result${evt.isError ? ' error' : ''}`;
1646
+ el.innerHTML = hasMore
1647
+ ? `<details><summary><span class="lead-in">↳</span><span class="head">${esc(first)}${result.length > 140 ? '…' : ''}</span><span class="show">show</span></summary><pre>${esc(result.slice(0, 10_000))}</pre></details>`
1648
+ : `<span class="ev tool" style="margin:0"><span class="lead-in" style="color:var(--text3);font-size:11px">↳</span><span>${esc(first)}</span></span>`;
520
1649
  break;
521
1650
  }
522
- case 'check-output':
523
- el.textContent = `${evt.text ?? ''}\n(exit ${evt.exitCode})`;
1651
+ case 'check-output': {
1652
+ el.className = 'ev check';
1653
+ const ok = evt.exitCode === 0;
1654
+ el.innerHTML = `
1655
+ <div class="check-head">
1656
+ <span class="lbl">Command</span>
1657
+ <code>${esc(evt.stepId ?? 'check')}</code>
1658
+ <span class="check-pill ${ok ? 'pass' : 'fail'}">${ok ? 'passed' : `failed (exit ${esc(String(evt.exitCode))})`}</span>
1659
+ </div>
1660
+ <pre>${esc(String(evt.text ?? ''))}</pre>`;
1661
+ break;
1662
+ }
1663
+ case 'image':
1664
+ el.className = 'ev image-ev';
1665
+ el.innerHTML = `
1666
+ <div class="img-card" data-lightbox="${esc(evt.url)}" data-name="${esc(evt.name)}" title="Click to zoom">
1667
+ <img src="${esc(evt.url)}" alt="${esc(evt.name)}" loading="lazy">
1668
+ <div class="img-foot">
1669
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text3)" stroke-width="1.8" stroke-linejoin="round"><path d="M4 5h16v14H4z"/><path d="M4 15l5-5 4 4 3-3 4 4"/><circle cx="9.5" cy="9.5" r="1.1"/></svg>
1670
+ <span class="nm">${esc(evt.name)}</span>
1671
+ <span class="zm">click to zoom</span>
1672
+ </div>
1673
+ </div>`;
524
1674
  break;
525
1675
  case 'step-start':
526
- el.textContent = `▸ ${evt.name}${evt.iteration > 1 ? ` (attempt ${evt.iteration})` : ''}`;
1676
+ el.className = 'ev step';
1677
+ el.innerHTML = `<span class="step-label">${esc(evt.name ?? evt.stepId ?? 'step')}${evt.iteration > 1 ? ` · attempt ${esc(String(evt.iteration))}` : ''}</span><div class="rule"></div>`;
527
1678
  break;
528
1679
  case 'step-end':
529
1680
  el.className = 'ev note';
@@ -531,14 +1682,17 @@ function appendLog(evt) {
531
1682
  break;
532
1683
  case 'note':
533
1684
  case 'lifecycle':
1685
+ el.className = 'ev note';
534
1686
  el.textContent = `· ${evt.message ?? ''}`;
535
1687
  break;
536
1688
  case 'user-message': {
1689
+ el.className = 'ev user';
537
1690
  const imgs = evt.imageCount > 0 ? ` [${evt.imageCount} image${evt.imageCount > 1 ? 's' : ''}]` : '';
538
- el.textContent = `you: ${evt.text ?? ''}${imgs}`;
1691
+ el.innerHTML = `<div class="bubble">${esc(`${evt.text ?? ''}${imgs}`)}</div>`;
539
1692
  break;
540
1693
  }
541
1694
  case 'error':
1695
+ el.className = 'ev err';
542
1696
  el.textContent = `✗ ${evt.message ?? ''}`;
543
1697
  break;
544
1698
  case 'token-usage':
@@ -548,82 +1702,630 @@ function appendLog(evt) {
548
1702
  case 'done':
549
1703
  return;
550
1704
  default:
1705
+ el.className = 'ev note';
551
1706
  el.textContent = JSON.stringify(evt);
552
1707
  }
553
1708
 
554
- log.appendChild(el);
1709
+ inner.appendChild(el);
555
1710
  if (state.autoScroll) log.scrollTop = log.scrollHeight;
556
1711
  }
557
1712
 
1713
+ // ---- inbox view (spec 007) -----------------------------------------------------
1714
+
1715
+ /* Entries already turned into a task (startedTaskId) are hidden — they stay
1716
+ in todos.json as an audit trail. */
1717
+ function visibleTodos() {
1718
+ return state.todos.filter((t) => !t.startedTaskId);
1719
+ }
1720
+
1721
+ function renderInboxBadge() {
1722
+ const badgeEl = $('#inbox-badge');
1723
+ if (!badgeEl) return;
1724
+ const count = visibleTodos().length;
1725
+ badgeEl.textContent = count;
1726
+ badgeEl.hidden = count === 0;
1727
+ }
1728
+
1729
+ function showRunsView() {
1730
+ const btn = $('#tabs button[data-view="runs"]');
1731
+ if (btn && !btn.classList.contains('active')) btn.click();
1732
+ }
1733
+
1734
+ function renderInbox() {
1735
+ const view = $('#view-inbox');
1736
+ const todos = visibleTodos();
1737
+ view.innerHTML = `
1738
+ <div class="page">
1739
+ <h1>Inbox</h1>
1740
+ <p class="lead">Follow-ups agents suggested when they finished a task.</p>
1741
+ ${
1742
+ todos.length
1743
+ ? todos
1744
+ .map((t) => {
1745
+ const source = t.taskId
1746
+ ? state.runs.has(t.taskId)
1747
+ ? `<a href="#" data-goto-run="${esc(t.taskId)}">source task</a>`
1748
+ : '<span class="gone">source task deleted</span>'
1749
+ : '';
1750
+ const pr = t.prUrl
1751
+ ? `<a href="${esc(t.prUrl)}" target="_blank" rel="noopener">PR</a>`
1752
+ : '';
1753
+ return `
1754
+ <div class="todo-card" data-id="${esc(t.id)}">
1755
+ <div class="todo-main">
1756
+ <div class="summary">${esc(t.summary)}</div>
1757
+ <div class="meta">
1758
+ ${t.ts ? `<span>${esc(timeAgo(t.ts))}</span>` : ''}
1759
+ ${t.action ? `<span>${esc(t.action)}</span>` : ''}
1760
+ ${source} ${pr}
1761
+ ${t.suggestedSkill ? `<span>skill: ${esc(t.suggestedSkill)}</span>` : ''}
1762
+ </div>
1763
+ </div>
1764
+ <button class="btn-dark" data-todo-action="start" title="Start a task from this follow-up">▶ Run</button>
1765
+ <button class="btn-text" data-todo-action="remove" title="Check off (remove)">Dismiss</button>
1766
+ </div>`;
1767
+ })
1768
+ .join('')
1769
+ : '<div class="dim" style="padding:16px 0">Inbox empty — agents drop follow-up suggestions here when they finish a task.</div>'
1770
+ }
1771
+ </div>`;
1772
+ }
1773
+
1774
+ // ---- GitHub view -------------------------------------------------------------
1775
+
1776
+ const GH_ISSUE_ICON = 'M12 3a9 9 0 100 18 9 9 0 000-18zM12 10.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z';
1777
+ const GH_PR_ICON =
1778
+ 'M7 8.5v7M7 3.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5zM7 15.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5zM17 15.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5zM17 15v-4a3 3 0 00-3-3h-2.5';
1779
+
1780
+ function bindGithubView() {
1781
+ const view = $('#view-github');
1782
+ view.addEventListener('click', async (e) => {
1783
+ const refresh = e.target.closest('[data-gh-refresh]');
1784
+ if (refresh) {
1785
+ await loadGithub(true);
1786
+ return;
1787
+ }
1788
+ const tab = e.target.closest('button[data-gh-view]');
1789
+ if (tab) {
1790
+ state.ghView = tab.dataset.ghView;
1791
+ state.ghSel = null;
1792
+ renderGithub();
1793
+ return;
1794
+ }
1795
+ const row = e.target.closest('.gh-row');
1796
+ if (row) {
1797
+ state.ghSel = row.dataset.url;
1798
+ renderGithub();
1799
+ return;
1800
+ }
1801
+ const wf = e.target.closest('button[data-gh-workflow]');
1802
+ if (wf) {
1803
+ state.ghWorkflow = wf.dataset.ghWorkflow;
1804
+ renderGithub();
1805
+ return;
1806
+ }
1807
+ const sk = e.target.closest('button[data-gh-skill]');
1808
+ if (sk) {
1809
+ const name = sk.dataset.ghSkill;
1810
+ if (state.ghSkills.has(name)) state.ghSkills.delete(name);
1811
+ else state.ghSkills.add(name);
1812
+ renderGithub();
1813
+ return;
1814
+ }
1815
+ const viewRun = e.target.closest('button[data-gh-view-run]');
1816
+ if (viewRun) {
1817
+ showRunsView();
1818
+ selectRun(viewRun.dataset.ghViewRun);
1819
+ return;
1820
+ }
1821
+ const runBtn = e.target.closest('button[data-gh-run]');
1822
+ if (runBtn) {
1823
+ await runOnGithub(runBtn);
1824
+ return;
1825
+ }
1826
+ });
1827
+ }
1828
+
1829
+ async function loadGithub(refresh = false) {
1830
+ const view = $('#view-github');
1831
+ if (!state.gh || refresh) {
1832
+ if (!state.gh) view.innerHTML = '<div class="gh-unavailable">Loading GitHub…</div>';
1833
+ try {
1834
+ // Skill chips ride along — same catalog as the Skills tab.
1835
+ const [gh, skills] = await Promise.all([
1836
+ getJson(`/api/github${refresh ? '?refresh=1' : ''}`),
1837
+ state.skillsList ? Promise.resolve(state.skillsList) : getJson('/api/skills').catch(() => []),
1838
+ ]);
1839
+ state.gh = gh;
1840
+ state.skillsList = skills;
1841
+ } catch (err) {
1842
+ view.innerHTML = `<div class="gh-unavailable">✗ ${esc(err.message)}</div>`;
1843
+ return;
1844
+ }
1845
+ }
1846
+ renderGithub();
1847
+ }
1848
+
1849
+ function ghItems() {
1850
+ if (!state.gh) return [];
1851
+ return state.ghView === 'issues' ? state.gh.issues : state.gh.prs;
1852
+ }
1853
+
1854
+ function renderGithub() {
1855
+ const view = $('#view-github');
1856
+ const gh = state.gh;
1857
+ if (!gh) return;
1858
+ if (!gh.available) {
1859
+ view.innerHTML = `
1860
+ <div class="gh-unavailable">
1861
+ <div style="font-family:var(--serif);font-size:21px;color:var(--text);margin-bottom:10px">GitHub</div>
1862
+ GitHub is unavailable here — ${esc(gh.reason ?? 'unknown reason')}.<br><br>
1863
+ The tab needs the <span class="mono">gh</span> CLI, logged in (<span class="mono">gh auth login</span>),
1864
+ and a repo with a GitHub remote. Everything else in cezar works without it.
1865
+ <div style="margin-top:14px"><button class="btn-ghost" data-gh-refresh>⟳ Try again</button></div>
1866
+ </div>`;
1867
+ return;
1868
+ }
1869
+
1870
+ const items = ghItems();
1871
+ let sel = items.find((i) => i.url === state.ghSel) ?? items[0] ?? null;
1872
+ if (sel) state.ghSel = sel.url;
1873
+
1874
+ const rows = items.length
1875
+ ? items
1876
+ .map((i) => {
1877
+ const queuedRun = state.ghQueued.get(i.url);
1878
+ return `
1879
+ <div class="gh-row ${sel && i.url === sel.url ? 'selected' : ''}" data-url="${esc(i.url)}">
1880
+ <div class="row1">
1881
+ <svg viewBox="0 0 24 24" style="stroke:${i.kind === 'issue' ? 'var(--green)' : 'var(--accent)'}"><path d="${i.kind === 'issue' ? GH_ISSUE_ICON : GH_PR_ICON}"/></svg>
1882
+ <span class="t">${esc(i.title)}</span>
1883
+ </div>
1884
+ <div class="row2">
1885
+ <span>#${i.number}</span><span>${esc(i.author)}</span><span>${esc(shortAgo(i.createdAt))}</span>
1886
+ ${queuedRun ? '<span class="queued-flag">↗ run queued</span>' : ''}
1887
+ </div>
1888
+ </div>`;
1889
+ })
1890
+ .join('')
1891
+ : `<div class="dim" style="padding:12px">No open ${state.ghView === 'issues' ? 'issues' : 'pull requests'}.</div>`;
1892
+
1893
+ const detail = sel ? ghDetailHtml(sel) : '<div class="empty">Nothing selected.</div>';
1894
+
1895
+ view.innerHTML = `
1896
+ <div class="split">
1897
+ <div class="split-list">
1898
+ <div class="split-head">
1899
+ <div class="head-row">
1900
+ <h1>GitHub</h1>
1901
+ <span class="mono dim" style="font-size:10.5px">${esc(gh.repo ?? '')}</span>
1902
+ <button class="head-note" data-gh-refresh style="border:none;background:transparent;cursor:pointer" title="Refresh from GitHub">
1903
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M20 11a8 8 0 10-2.3 5.7M20 11V5m0 6h-6"/></svg>
1904
+ synced ${esc(gh.syncedAt ? shortAgo(gh.syncedAt) : '?')} ago
1905
+ </button>
1906
+ </div>
1907
+ <div class="sub-tabs">
1908
+ <button data-gh-view="issues" class="${state.ghView === 'issues' ? 'active' : ''}">Issues · ${gh.issues.length}</button>
1909
+ <button data-gh-view="prs" class="${state.ghView === 'prs' ? 'active' : ''}">Pull requests · ${gh.prs.length}</button>
1910
+ </div>
1911
+ </div>
1912
+ <div class="split-rows">${rows}</div>
1913
+ </div>
1914
+ <div class="split-detail">${detail}</div>
1915
+ </div>`;
1916
+ }
1917
+
1918
+ function ghDetailHtml(item) {
1919
+ const kindWord = item.kind === 'pr' ? 'pull request' : 'issue';
1920
+ const diffStat =
1921
+ item.kind === 'pr' && (item.additions || item.deletions) ? ` · +${item.additions} −${item.deletions}` : '';
1922
+ const checks = item.checks
1923
+ ? `<span class="gh-checks ${esc(item.checks)}">${
1924
+ item.checks === 'passing' ? '✓ checks passing' : item.checks === 'failing' ? '✗ checks failing' : '○ checks pending'
1925
+ }</span>`
1926
+ : '';
1927
+ const skills = (state.skillsList ?? []).map((s) => s.name);
1928
+ const queuedRun = state.ghQueued.get(item.url);
1929
+ return `
1930
+ <div class="inner">
1931
+ <div class="mono dim" style="font-size:10.5px">#${item.number} · ${kindWord} · opened by ${esc(item.author)} · ${esc(shortAgo(item.createdAt))} ago${item.comments ? ` · ${item.comments} comments` : ''}${diffStat} · <a href="${esc(item.url)}" target="_blank" rel="noopener">open on GitHub ↗</a></div>
1932
+ <h1 style="margin-top:8px">${esc(item.title)}</h1>
1933
+ <div style="display:flex;align-items:center;gap:7px;margin-top:12px;flex-wrap:wrap">
1934
+ ${item.labels.map((l) => `<span class="gh-label">${esc(l)}</span>`).join('')}
1935
+ ${checks}
1936
+ </div>
1937
+ <div class="gh-body">${esc(item.body || '(no description)')}</div>
1938
+ <div class="gh-hand">
1939
+ <div class="hand-label">
1940
+ <svg viewBox="0 0 24 24"><path d="M13 2L4.5 13.5h5.5L9 22l8.5-11.5H12L13 2z"/></svg>
1941
+ Hand this to the agent
1942
+ </div>
1943
+ <div class="hand-row">
1944
+ <span class="k" style="padding-top:0;align-self:center">workflow</span>
1945
+ <div class="chips">
1946
+ ${state.workflows
1947
+ .map(
1948
+ (w) =>
1949
+ `<button class="chip-toggle ${state.ghWorkflow === w.name ? 'on' : ''}" data-gh-workflow="${esc(w.name)}" title="${esc(w.description ?? '')}">${esc(w.name)}</button>`,
1950
+ )
1951
+ .join('')}
1952
+ </div>
1953
+ </div>
1954
+ ${
1955
+ skills.length
1956
+ ? `<div class="hand-row">
1957
+ <span class="k">skills</span>
1958
+ <div class="chips">
1959
+ ${skills
1960
+ .map(
1961
+ (name) =>
1962
+ `<button class="chip-toggle ${state.ghSkills.has(name) ? 'on' : ''}" data-gh-skill="${esc(name)}">${esc(name)}</button>`,
1963
+ )
1964
+ .join('')}
1965
+ </div>
1966
+ </div>`
1967
+ : ''
1968
+ }
1969
+ <div class="go-row">
1970
+ <button class="btn-dark" data-gh-run="${esc(item.url)}">▶ Run agent on this ${item.kind === 'pr' ? 'PR' : 'issue'}</button>
1971
+ ${
1972
+ queuedRun
1973
+ ? `<span class="queued-ok">✓ queued</span><button class="btn-text" data-gh-view-run="${esc(queuedRun)}">View in Runs →</button>`
1974
+ : ''
1975
+ }
1976
+ </div>
1977
+ </div>
1978
+ </div>`;
1979
+ }
1980
+
1981
+ async function runOnGithub(btn) {
1982
+ const item = ghItems().find((i) => i.url === btn.dataset.ghRun);
1983
+ if (!item) return;
1984
+ const skills = [...state.ghSkills].filter((name) => (state.skillsList ?? []).some((s) => s.name === name));
1985
+ let task = `${item.kind === 'pr' ? 'Address GitHub pull request' : 'Fix GitHub issue'} #${item.number}: ${item.title}\n\n${item.url}`;
1986
+ if (item.body?.trim()) task += `\n\n---\n\n${item.body.trim()}`;
1987
+ if (skills.length) task += `\n\nUse these skills where relevant: ${skills.join(', ')}.`;
1988
+ btn.disabled = true;
1989
+ try {
1990
+ const res = await fetch('/api/runs', {
1991
+ method: 'POST',
1992
+ headers: { 'content-type': 'application/json' },
1993
+ body: JSON.stringify({ workflow: state.ghWorkflow, task }),
1994
+ });
1995
+ const data = await res.json();
1996
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
1997
+ state.runs.set(data.id, data);
1998
+ state.ghQueued.set(item.url, data.id);
1999
+ state.lastGhRun = data.id;
2000
+ renderRunList();
2001
+ renderGithub();
2002
+ } catch (err) {
2003
+ alertBar(err.message);
2004
+ btn.disabled = false;
2005
+ }
2006
+ }
2007
+
558
2008
  // ---- repo view ---------------------------------------------------------------
559
2009
 
2010
+ function statusClass(status) {
2011
+ if (/A|\?/.test(status)) return 'added';
2012
+ if (/D/.test(status)) return 'deleted';
2013
+ return '';
2014
+ }
2015
+
560
2016
  async function loadRepo() {
561
2017
  const view = $('#view-repo');
562
- view.innerHTML = '<div class="dim">Loading…</div>';
2018
+ view.innerHTML = '<div class="page dim">Loading…</div>';
563
2019
  try {
564
2020
  const [repo, diff] = await Promise.all([
565
2021
  getJson('/api/repo'),
566
2022
  fetch('/api/repo/diff').then((r) => r.text()),
567
2023
  ]);
568
2024
  if (!repo.info) {
569
- view.innerHTML = '<div class="panel">Not a git repository.</div>';
2025
+ view.innerHTML = '<div class="page"><h1>Repository</h1><p class="lead">Not a git repository — tasks run in place, one at a time.</p></div>';
570
2026
  return;
571
2027
  }
572
2028
  view.innerHTML = `
573
- <div class="panel">
574
- <h3>Repository</h3>
575
- <div class="mono">${esc(repo.info.root)} · <b>${esc(repo.info.branch)}</b>${repo.info.remote ? ` · ${esc(repo.info.remote)}` : ''}</div>
576
- </div>
577
- <div class="panel">
578
- <h3>Working tree (${repo.status.length} changed)</h3>
579
- ${repo.status.length
580
- ? `<table>${repo.status.map((s) => `<tr><td class="mono dim">${esc(s.status)}</td><td class="mono">${esc(s.path)}</td></tr>`).join('')}</table>`
581
- : '<div class="dim">clean</div>'}
582
- </div>
583
- <div class="panel">
584
- <h3>Diff vs HEAD</h3>
585
- ${diff.trim() ? `<pre>${esc(diff)}</pre>` : '<div class="dim">no changes</div>'}
586
- </div>
587
- <div class="panel">
588
- <h3>Recent commits</h3>
589
- <table>${repo.log.map((l) => `<tr><td class="mono dim">${esc(l.hash)}</td><td>${esc(l.subject)}</td><td class="dim">${esc(l.author)}</td><td class="dim">${esc(l.when)}</td></tr>`).join('')}</table>
2029
+ <div class="page">
2030
+ <h1>Repository</h1>
2031
+ <p class="lead mono" style="font-size:12px">${esc(repo.info.root)} · ${esc(repo.info.branch)}${repo.info.remote ? ` · ${esc(repo.info.remote)}` : ''}</p>
2032
+
2033
+ <div class="section-label">Working tree · ${repo.status.length ? `${repo.status.length} changed` : 'clean'}</div>
2034
+ ${
2035
+ repo.status.length
2036
+ ? repo.status
2037
+ .map(
2038
+ (s) =>
2039
+ `<div class="repo-file"><span class="st ${statusClass(s.status)}">${esc(s.status)}</span><span class="p">${esc(s.path)}</span></div>`,
2040
+ )
2041
+ .join('')
2042
+ : '<div class="dim" style="font-size:12.5px">Nothing modified.</div>'
2043
+ }
2044
+ ${
2045
+ diff.trim()
2046
+ ? `<details class="repo-diff"><summary>Diff vs HEAD</summary><div class="diff-wrap">${renderDiff(diff)}</div></details>`
2047
+ : ''
2048
+ }
2049
+
2050
+ <div class="section-label">Recent commits</div>
2051
+ ${repo.log
2052
+ .map(
2053
+ (l) =>
2054
+ `<div class="commit-row"><span class="hash">${esc(l.hash)}</span><span class="subj">${esc(l.subject)}</span><span class="when" title="${esc(l.author)}">${esc(l.when)}</span></div>`,
2055
+ )
2056
+ .join('')}
590
2057
  </div>`;
591
2058
  } catch (err) {
592
- view.innerHTML = `<div class="panel">✗ ${esc(err.message)}</div>`;
2059
+ view.innerHTML = `<div class="page">✗ ${esc(err.message)}</div>`;
593
2060
  }
594
2061
  }
595
2062
 
596
2063
  // ---- skills view ---------------------------------------------------------------
597
2064
 
598
- async function loadSkills() {
2065
+ const SKILL_ICON = 'M12 3l2 5.5L19.5 10 14 12l-2 5.5L10 12l-5.5-2L10 8.5 12 3z';
2066
+
2067
+ function bindSkillsView() {
599
2068
  const view = $('#view-skills');
600
- view.innerHTML = '<div class="dim">Loading…</div>';
601
- try {
602
- const skills = await getJson('/api/skills');
603
- if (!skills.length) {
604
- view.innerHTML = `
605
- <div class="panel">
606
- <h3>Skills</h3>
607
- <div>No skills yet. Drop Markdown files into <span class="mono">.ai/skills/</span> or <span class="mono">.ai/cezar/skills/</span> —
608
- optional frontmatter: <span class="mono">name</span>, <span class="mono">description</span>.
609
- Reference one from a workflow step via <span class="mono">skill: &lt;name&gt;</span>.</div>
610
- </div>`;
2069
+ view.addEventListener('click', (e) => {
2070
+ if (e.target.closest('#skills-refresh')) {
2071
+ void loadSkills(true);
611
2072
  return;
612
2073
  }
613
- view.innerHTML = skills
614
- .map(
615
- (s) => `
616
- <div class="panel skill-card">
617
- <h3>${esc(s.name)} <span class="dim">(${esc(s.source)})</span></h3>
618
- ${s.description ? `<div>${esc(s.description)}</div>` : ''}
619
- <div class="path mono dim">${esc(s.path)}</div>
620
- <details><summary class="dim">show body</summary><pre>${esc(s.body)}</pre></details>
621
- </div>`,
622
- )
623
- .join('');
624
- } catch (err) {
625
- view.innerHTML = `<div class="panel">✗ ${esc(err.message)}</div>`;
2074
+ const row = e.target.closest('.skill-row');
2075
+ if (row) {
2076
+ state.skillSel = row.dataset.skill;
2077
+ renderSkills();
2078
+ return;
2079
+ }
2080
+ });
2081
+ // Filter without re-rendering the input (it would lose focus).
2082
+ view.addEventListener('input', (e) => {
2083
+ if (e.target.id !== 'skill-filter') return;
2084
+ state.skillQuery = e.target.value;
2085
+ const rows = $('#skill-rows');
2086
+ if (rows) rows.innerHTML = skillRowsHtml();
2087
+ });
2088
+ }
2089
+
2090
+ async function loadSkills(refresh = false) {
2091
+ const view = $('#view-skills');
2092
+ if (!state.skillsList || refresh) {
2093
+ if (!state.skillsList) view.innerHTML = '<div class="gh-unavailable">Loading…</div>';
2094
+ try {
2095
+ state.skillsList = refresh
2096
+ ? await fetch('/api/skills/refresh', { method: 'POST' }).then((r) => {
2097
+ if (!r.ok) throw new Error(`refresh → ${r.status}`);
2098
+ return r.json();
2099
+ })
2100
+ : await getJson('/api/skills');
2101
+ if (refresh) alertBar('Team skills refreshed.');
2102
+ renderTaskPills(); // the form's source pill lists the same catalog
2103
+ } catch (err) {
2104
+ view.innerHTML = `<div class="gh-unavailable">✗ ${esc(err.message)}</div>`;
2105
+ return;
2106
+ }
2107
+ }
2108
+ renderSkills();
2109
+ }
2110
+
2111
+ function filteredSkills() {
2112
+ const q = state.skillQuery.trim().toLowerCase();
2113
+ return (state.skillsList ?? []).filter(
2114
+ (s) => !q || s.name.toLowerCase().includes(q) || (s.description ?? '').toLowerCase().includes(q),
2115
+ );
2116
+ }
2117
+
2118
+ function skillRowsHtml() {
2119
+ const skills = filteredSkills();
2120
+ const rows = skills
2121
+ .map(
2122
+ (s) => `
2123
+ <div class="skill-row ${state.skillSel === s.name ? 'selected' : ''}" data-skill="${esc(s.name)}">
2124
+ <div class="row1">
2125
+ <svg viewBox="0 0 24 24"><path d="${SKILL_ICON}"/></svg>
2126
+ <span class="name">${esc(s.name)}</span>
2127
+ <span class="skill-tag ${esc(s.source)}">${esc(s.source)}</span>
2128
+ </div>
2129
+ ${s.description ? `<div class="desc">${esc(s.description)}</div>` : ''}
2130
+ </div>`,
2131
+ )
2132
+ .join('');
2133
+ const empty = state.skillsList?.length
2134
+ ? '<div class="dim" style="padding:12px">(no skills match)</div>'
2135
+ : `<div class="dim" style="padding:12px;font-size:12px;line-height:1.6">No skills yet. Drop Markdown files into <span class="mono">.ai/skills/</span> or <span class="mono">.ai/cezar/skills/</span> — optional frontmatter: <span class="mono">name</span>, <span class="mono">description</span>. Team skills from your skills repo appear here too — try ⟳ Refresh.</div>`;
2136
+ return rows || empty;
2137
+ }
2138
+
2139
+ /* Always-visible entry below the (scrollable) skill list — spec 011 must not
2140
+ drown under a long team catalog. */
2141
+ function bookmarkletRowHtml() {
2142
+ return `
2143
+ <div class="skill-row pinned ${state.skillSel === '__bm' ? 'selected' : ''}" data-skill="__bm">
2144
+ <div class="row1">
2145
+ <svg viewBox="0 0 24 24" style="stroke:var(--accent)"><path d="M13 2L4.5 13.5h5.5L9 22l8.5-11.5H12L13 2z"/></svg>
2146
+ <span class="name">Run from GitHub</span>
2147
+ <span class="skill-tag">bookmarklets</span>
2148
+ </div>
2149
+ <div class="desc">One-click skill launch from any GitHub PR or issue.</div>
2150
+ </div>`;
2151
+ }
2152
+
2153
+ /* Workflows referencing this skill — "fix-and-verify › Verify". */
2154
+ function skillUsedBy(name) {
2155
+ const out = [];
2156
+ for (const w of state.workflows) {
2157
+ for (const s of w.steps ?? []) {
2158
+ if (s.skill === name) out.push(`${w.name} › ${s.name ?? s.id}`);
2159
+ }
2160
+ }
2161
+ return out;
2162
+ }
2163
+
2164
+ function renderSkills() {
2165
+ const view = $('#view-skills');
2166
+ const skills = state.skillsList ?? [];
2167
+ if (state.skillSel !== '__bm' && !skills.some((s) => s.name === state.skillSel)) {
2168
+ state.skillSel = skills[0]?.name ?? '__bm';
2169
+ }
2170
+
2171
+ view.innerHTML = `
2172
+ <div class="split">
2173
+ <div class="split-list">
2174
+ <div class="split-head">
2175
+ <div class="head-row">
2176
+ <h1>Skills</h1>
2177
+ <button id="skills-refresh" class="btn-ghost" style="margin-left:auto;height:27px;font-size:12px" title="git fetch the team skills repos">⟳ Refresh</button>
2178
+ </div>
2179
+ </div>
2180
+ <input id="skill-filter" class="filter-input" placeholder="Filter skills…" value="${esc(state.skillQuery)}">
2181
+ <div class="split-rows" id="skill-rows">${skillRowsHtml()}</div>
2182
+ <div class="list-foot">${bookmarkletRowHtml()}</div>
2183
+ </div>
2184
+ <div class="split-detail" id="skill-detail">${state.skillSel === '__bm' ? bookmarkletShellHtml() : skillDetailHtml()}</div>
2185
+ </div>`;
2186
+
2187
+ if (state.skillSel === '__bm') void bindBookmarklets(skills);
2188
+ }
2189
+
2190
+ function skillDetailHtml() {
2191
+ const skill = (state.skillsList ?? []).find((s) => s.name === state.skillSel);
2192
+ if (!skill) return '<div class="empty">No skill selected.</div>';
2193
+ const usedBy = skillUsedBy(skill.name);
2194
+ return `
2195
+ <div class="inner skill-detail">
2196
+ <div class="title-row">
2197
+ <h1>${esc(skill.name)}</h1>
2198
+ <span class="skill-tag ${esc(skill.source)}">${esc(skill.source)}</span>
2199
+ </div>
2200
+ <div class="path-line">${esc(skill.path)}${skill.team ? ` · from ${esc(skill.team.repo)}` : ''}</div>
2201
+ ${skill.description ? `<div class="desc">${esc(skill.description)}</div>` : ''}
2202
+ <div class="section-label">Used by</div>
2203
+ ${
2204
+ usedBy.length
2205
+ ? usedBy
2206
+ .map(
2207
+ (u) =>
2208
+ `<div class="used-row"><svg viewBox="0 0 24 24"><path d="M4 12h12M12 6l6 6-6 6"/></svg>${esc(u)}</div>`,
2209
+ )
2210
+ .join('')
2211
+ : '<div class="dim" style="font-size:12.5px;padding:6px 0">Not referenced by any workflow yet — quick-task picks it up when the task mentions it.</div>'
2212
+ }
2213
+ <div class="content-head">
2214
+ <span class="section-label">Content</span>
2215
+ <span class="spacer"></span>
2216
+ </div>
2217
+ <pre class="content">${esc(skill.body)}</pre>
2218
+ </div>`;
2219
+ }
2220
+
2221
+ // ---- bookmarklet generator (spec 011) ------------------------------------------
2222
+
2223
+ /* The javascript: URL dragged to the bookmarks bar. On a GitHub PR/issue it
2224
+ scans localhost:4321–4330 for cockpits (GET /api/health, 800 ms timeout,
2225
+ CORS-enabled server-side), picks the one whose repo.remote matches the
2226
+ page's owner/repo (fallback: first alive) and opens /new there. The whole
2227
+ program is one URI-encoded expression — no external state. */
2228
+ function bookmarkletUrl(skillName, auto, key) {
2229
+ const enc = (s) => encodeURIComponent(s).replaceAll("'", '%27'); // ' survives encodeURIComponent — would break the JS string
2230
+ const query = `${skillName ? `skill=${enc(skillName)}&` : ''}auto=${auto ? '1' : '0'}&key=${enc(key)}&ref=`;
2231
+ const code =
2232
+ `(async()=>{const m=location.href.match(/^https:\\/\\/github\\.com\\/([^\\/]+)\\/([^\\/]+)\\/(pull|issues)\\/\\d+/);` +
2233
+ `if(!m){alert('Open a GitHub PR or issue first');return;}` +
2234
+ `const q='${query}'+encodeURIComponent(location.href);` +
2235
+ `const f=async p=>{try{const c=new AbortController();setTimeout(()=>c.abort(),800);` +
2236
+ `const r=await fetch('http://localhost:'+p+'/api/health',{signal:c.signal});const h=await r.json();` +
2237
+ `return{p,remote:(h.repo&&h.repo.remote)||''}}catch(e){return null}};` +
2238
+ `const rs=(await Promise.all([4321,4322,4323,4324,4325,4326,4327,4328,4329,4330].map(f))).filter(Boolean);` +
2239
+ `if(!rs.length){alert('cezar cockpit is not running - start it with: npx cezar');return;}` +
2240
+ `const t=rs.find(r=>r.remote.includes(m[1]+'/'+m[2]))||rs[0];` +
2241
+ `open('http://localhost:'+t.p+'/new?'+q,'_blank');})();`;
2242
+ return `javascript:${encodeURIComponent(code)}`;
2243
+ }
2244
+
2245
+ function bookmarkletShellHtml() {
2246
+ return `
2247
+ <div class="inner" id="bm-panel">
2248
+ <h1>Run from GitHub</h1>
2249
+ <div class="bm-help">Drag a button below to your browser's bookmarks bar.
2250
+ On any GitHub PR or issue, click it — it finds your running cockpit on
2251
+ localhost (ports 4321–4330) and picks the one serving that repo.
2252
+ The cockpit must be running: <span class="mono">npx cezar</span>.</div>
2253
+ <label class="bm-auto"><input type="checkbox" id="bm-auto">
2254
+ One-click launch (auto-submit) <span class="dim">— re-drag the buttons after changing this</span></label>
2255
+ <div id="bm-generic"></div>
2256
+ <input id="bm-filter" class="bm-filter" placeholder="Filter skills…">
2257
+ <div id="bm-list"></div>
2258
+ </div>`;
2259
+ }
2260
+
2261
+ function bmRowHtml(label, url, hint) {
2262
+ return `
2263
+ <div class="bm-row">
2264
+ <a class="bm" draggable="true" href="${esc(url)}" title="Drag me to your bookmarks bar">⚡ ${esc(label)}</a>
2265
+ <button type="button" class="bm-copy" data-copy="${esc(url)}" title="Copy the bookmarklet URL">Copy</button>
2266
+ ${hint ? `<span class="dim bm-hint">${esc(hint)}</span>` : ''}
2267
+ </div>`;
2268
+ }
2269
+
2270
+ function renderBmLinks(skills) {
2271
+ const key = state.launchKey ?? '';
2272
+ // Generic launcher: no skill, auto forced off — it only prefills the form.
2273
+ $('#bm-generic').innerHTML = bmRowHtml(
2274
+ 'cezar: this PR/issue',
2275
+ bookmarkletUrl('', false, key),
2276
+ 'prefills the form — nothing starts by itself',
2277
+ );
2278
+ const filter = state.bmFilter.trim().toLowerCase();
2279
+ const filtered = skills.filter((s) => s.name.toLowerCase().includes(filter));
2280
+ $('#bm-list').innerHTML = filtered.length
2281
+ ? filtered
2282
+ .map((s) => bmRowHtml(`/${s.name}`, bookmarkletUrl(s.name, state.bmAuto, key), s.source))
2283
+ .join('')
2284
+ : `<div class="dim">${skills.length ? '(no skills match)' : '(no skills yet — the generic launcher above still works)'}</div>`;
2285
+ }
2286
+
2287
+ async function bindBookmarklets(skills) {
2288
+ if (state.launchKey === null) {
2289
+ try {
2290
+ state.launchKey = (await getJson('/api/launch-key')).key;
2291
+ } catch {
2292
+ state.launchKey = ''; // degraded: bookmarklets still work, auto-start won't
2293
+ }
626
2294
  }
2295
+ const panel = $('#bm-panel');
2296
+ if (!panel) return; // skills view re-rendered meanwhile
2297
+ const autoBox = $('#bm-auto');
2298
+ autoBox.checked = state.bmAuto;
2299
+ autoBox.addEventListener('change', () => {
2300
+ state.bmAuto = autoBox.checked;
2301
+ renderBmLinks(skills);
2302
+ });
2303
+ const filterBox = $('#bm-filter');
2304
+ filterBox.value = state.bmFilter;
2305
+ filterBox.addEventListener('input', () => {
2306
+ state.bmFilter = filterBox.value;
2307
+ renderBmLinks(skills);
2308
+ });
2309
+ panel.addEventListener('click', async (e) => {
2310
+ const link = e.target.closest('a.bm');
2311
+ if (link) {
2312
+ // The cockpit page never executes the javascript: URL itself — the
2313
+ // links are only a drag source (spec 011 §5).
2314
+ e.preventDefault();
2315
+ alertBar('Drag me to your bookmarks bar');
2316
+ return;
2317
+ }
2318
+ const copy = e.target.closest('button[data-copy]');
2319
+ if (copy) {
2320
+ try {
2321
+ await navigator.clipboard.writeText(copy.dataset.copy);
2322
+ alertBar('Bookmarklet URL copied.');
2323
+ } catch {
2324
+ alertBar('Copy failed — drag the button instead.');
2325
+ }
2326
+ }
2327
+ });
2328
+ renderBmLinks(skills);
627
2329
  }
628
2330
 
629
2331
  init().catch((err) => {