taskplane 0.28.4 → 0.28.6

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 (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,2573 +1,2573 @@
1
- /**
2
- * Orchestrator Web Dashboard — Frontend
3
- *
4
- * Connects to SSE endpoint for live state updates.
5
- * Zero dependencies, vanilla JS.
6
- */
7
-
8
- // ─── Helpers ────────────────────────────────────────────────────────────────
9
-
10
- function formatDuration(ms) {
11
- if (!ms || ms <= 0) return "—";
12
- const totalSec = Math.floor(ms / 1000);
13
- const h = Math.floor(totalSec / 3600);
14
- const m = Math.floor((totalSec % 3600) / 60);
15
- const s = totalSec % 60;
16
- if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
17
- return `${m}m ${String(s).padStart(2, "0")}s`;
18
- }
19
-
20
- function relativeTime(epochOrIso) {
21
- if (!epochOrIso) return "";
22
- const ts = typeof epochOrIso === "string" ? new Date(epochOrIso).getTime() : epochOrIso;
23
- if (isNaN(ts)) return "";
24
- const diff = Date.now() - ts;
25
- if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
26
- if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
27
- return `${Math.floor(diff / 3600000)}h ago`;
28
- }
29
-
30
- function pctClass(pct) {
31
- if (pct >= 100) return "pct-hi";
32
- if (pct >= 50) return "pct-mid";
33
- if (pct > 0) return "pct-low";
34
- return "pct-0";
35
- }
36
-
37
- function escapeHtml(str) {
38
- const div = document.createElement("div");
39
- div.textContent = str;
40
- return div.innerHTML;
41
- }
42
-
43
- /** Format token count as human-readable (e.g., 1.2k, 45k, 1.2M). */
44
- function formatTokens(n) {
45
- if (!n || n === 0) return "0";
46
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
47
- if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
48
- return String(n);
49
- }
50
-
51
- function formatCost(usd) {
52
- if (!usd || usd === 0) return "";
53
- if (usd < 0.01) return `$${usd.toFixed(4)}`;
54
- if (usd < 1) return `$${usd.toFixed(3)}`;
55
- return `$${usd.toFixed(2)}`;
56
- }
57
-
58
- /**
59
- * TP-107: Check if a lane has a live agent via the Runtime V2 registry.
60
- * Returns true/false if registry data is available, null if no V2 data.
61
- */
62
- function isLaneAliveV2(laneNumber) {
63
- if (!currentData || !currentData.runtimeRegistry || !currentData.runtimeRegistry.agents) return null;
64
- const agents = Object.values(currentData.runtimeRegistry.agents);
65
- const laneAgents = agents.filter(a => a.laneNumber === laneNumber);
66
- if (laneAgents.length === 0) return null;
67
- return laneAgents.some(a => a.status === 'running' || a.status === 'spawning');
68
- }
69
-
70
- /**
71
- * TP-107: Merge Runtime V2 lane snapshot data onto legacy lane state.
72
- * V2 fields take precedence when present; legacy fields are preserved as fallback.
73
- */
74
- function mergeV2LaneSnapshot(legacyLs, v2snap) {
75
- const base = legacyLs ? { ...legacyLs } : {};
76
- // Overlay V2 fields from nested worker snapshot onto flat legacy shape.
77
- // RuntimeLaneSnapshot has worker: { status, elapsedMs, toolCalls, contextPct, ... }
78
- const w = v2snap.worker;
79
- if (w) {
80
- // Map V2 agent status to legacy dashboard status strings
81
- if (w.status) {
82
- const statusMap = { running: 'running', spawning: 'running', exited: 'done', crashed: 'error', killed: 'done', timed_out: 'error', wrapping_up: 'running' };
83
- base.workerStatus = statusMap[w.status] || w.status;
84
- }
85
- if (w.elapsedMs != null) base.workerElapsed = w.elapsedMs;
86
- if (w.contextPct != null) base.workerContextPct = w.contextPct;
87
- if (w.toolCalls != null) base.workerToolCount = w.toolCalls;
88
- if (w.lastTool) base.workerLastTool = w.lastTool;
89
- if (w.costUsd != null) base.workerCostUsd = w.costUsd;
90
- if (w.inputTokens != null) base.workerInputTokens = w.inputTokens;
91
- if (w.outputTokens != null) base.workerOutputTokens = w.outputTokens;
92
- if (w.cacheReadTokens != null) base.workerCacheReadTokens = w.cacheReadTokens;
93
- if (w.cacheWriteTokens != null) base.workerCacheWriteTokens = w.cacheWriteTokens;
94
- }
95
- if (v2snap.taskId) base.taskId = v2snap.taskId;
96
- if (v2snap.batchId) base.batchId = v2snap.batchId;
97
- // Enrich progress display from V2 snapshot
98
- if (v2snap.progress) {
99
- base._v2Progress = v2snap.progress;
100
- }
101
- return base;
102
- }
103
-
104
- function isReviewerActiveForTask(ls, task) {
105
- if (!ls || !task) return false;
106
- return !!(ls.reviewerStatus === "running" && task.status === "running" && (!ls.taskId || ls.taskId === task.taskId));
107
- }
108
-
109
- /** Build a compact token summary string from lane state sidecar data.
110
- * Display: ↑total_input ↓output (cost)
111
- * Anthropic splits input into: uncached `input` + `cacheRead`.
112
- * Both represent tokens the model processed as input.
113
- * We show the combined figure as ↑ for clarity.
114
- */
115
- function tokenSummaryFromLaneState(ls) {
116
- if (!ls) return "";
117
- const inp = ls.workerInputTokens || 0;
118
- const out = ls.workerOutputTokens || 0;
119
- const cr = ls.workerCacheReadTokens || 0;
120
- const cw = ls.workerCacheWriteTokens || 0;
121
- const cost = ls.workerCostUsd || 0;
122
- const totalIn = inp + cr; // uncached + cached = total input processed
123
- if (totalIn === 0 && out === 0) return "";
124
- let s = `↑${formatTokens(totalIn)} ↓${formatTokens(out)}`;
125
- if (cost > 0) s += ` ${formatCost(cost)}`;
126
- return s;
127
- }
128
-
129
- function tokenSummaryFromReviewerLaneState(ls) {
130
- if (!ls) return "";
131
- const inp = ls.reviewerInputTokens || 0;
132
- const out = ls.reviewerOutputTokens || 0;
133
- const cr = ls.reviewerCacheReadTokens || 0;
134
- const cw = ls.reviewerCacheWriteTokens || 0;
135
- const cost = ls.reviewerCostUsd || 0;
136
- const totalIn = inp + cr; // uncached + cached = total input processed
137
- if (totalIn === 0 && out === 0) return "";
138
- let s = `↑${formatTokens(totalIn)} ↓${formatTokens(out)}`;
139
- if (cost > 0) s += ` ${formatCost(cost)}`;
140
- return s;
141
- }
142
-
143
- /** Build compact telemetry badge HTML for retry/compaction indicators.
144
- * Only shows badges when telemetry data has meaningful values.
145
- * @param {object|null} tel - Telemetry data for a lane (from currentData.telemetry[prefix])
146
- * @param {boolean} [suppressRetry=false] - When true, hide the retrying badge
147
- * (used when reviewer is active — long tool calls trigger false retry signals)
148
- * @returns {string} HTML string with badges, or "" if nothing to show
149
- */
150
- function telemetryBadgesHtml(tel, suppressRetry) {
151
- if (!tel) return "";
152
- let badges = "";
153
- if (tel.retryActive && !suppressRetry) {
154
- const err = tel.lastRetryError ? ` — ${tel.lastRetryError}` : "";
155
- badges += `<span class="telem-badge telem-retry-active" title="Retry in progress${escapeHtml(err)}">🔄 retrying</span>`;
156
- } else if (tel.retries > 0 && !suppressRetry) {
157
- badges += `<span class="telem-badge telem-retry" title="${tel.retries} auto-retry event(s)">🔄 ${tel.retries}</span>`;
158
- }
159
- if (tel.compactions > 0) {
160
- badges += `<span class="telem-badge telem-compaction" title="${tel.compactions} context compaction(s)">🗜 ${tel.compactions}</span>`;
161
- }
162
- return badges;
163
- }
164
-
165
- // ─── Copy to Clipboard ──────────────────────────────────────────────────────
166
-
167
- let toastEl = null;
168
- let toastTimer = null;
169
-
170
- function showCopyToast(text) {
171
- if (!toastEl) {
172
- toastEl = document.createElement("div");
173
- toastEl.className = "copy-toast";
174
- document.body.appendChild(toastEl);
175
- }
176
- toastEl.textContent = `Copied: ${text}`;
177
- toastEl.classList.add("visible");
178
- clearTimeout(toastTimer);
179
- toastTimer = setTimeout(() => toastEl.classList.remove("visible"), 2000);
180
- }
181
-
182
- function copySessionId(sessionName) {
183
- // Retained for potential future use but no longer rendered in the UI.
184
- navigator.clipboard.writeText(sessionName).then(() => {
185
- showCopyToast(`session ${sessionName}`);
186
- const btn = document.querySelector(`[data-session="${sessionName}"]`);
187
- if (btn) {
188
- btn.classList.add("copied");
189
- setTimeout(() => btn.classList.remove("copied"), 1500);
190
- }
191
- }).catch(() => {
192
- // Fallback: select the text
193
- const btn = document.querySelector(`[data-session="${sessionName}"]`);
194
- if (btn) {
195
- const range = document.createRange();
196
- range.selectNodeContents(btn);
197
- window.getSelection().removeAllRanges();
198
- window.getSelection().addRange(range);
199
- }
200
- });
201
- }
202
-
203
-
204
-
205
- // ─── DOM References ─────────────────────────────────────────────────────────
206
-
207
- const $ = (id) => document.getElementById(id);
208
-
209
- const $batchId = $("batch-id");
210
- const $batchPhase = $("batch-phase");
211
- const $connDot = $("conn-dot");
212
- const $lastUpdate = $("last-update");
213
- const $progressBarBg = $("progress-bar-bg");
214
- const $overallPct = $("overall-pct");
215
- const $summaryCounts = $("summary-counts");
216
- const $summaryElapsed = $("summary-elapsed");
217
- const $summaryWaves = $("summary-waves");
218
- const $lanesTasksBody = $("lanes-tasks-body");
219
- const $mergeBody = $("merge-body");
220
- const $errorsPanel = $("errors-panel");
221
- const $errorsBody = $("errors-body");
222
- const $footerInfo = $("footer-info");
223
- const $content = $("content");
224
- const $historySelect = $("history-select");
225
- const $historyPanel = $("history-panel");
226
- const $historyBody = $("history-body");
227
-
228
- // ─── Repo Filter State ──────────────────────────────────────────────────────
229
-
230
- const $repoFilter = $("repo-filter");
231
- let selectedRepo = ""; // "" means "All repos"
232
- let knownRepos = []; // sorted list of known repo IDs
233
- let repoFilterVisible = false;
234
-
235
- // ─── History State ──────────────────────────────────────────────────────────
236
-
237
- let historyList = []; // compact batch summaries
238
- let viewingHistoryId = null; // batchId if viewing history, null if live
239
-
240
- // ─── Viewer State ───────────────────────────────────────────────────────────
241
-
242
- let viewerMode = null; // "conversation" | "status-md" | null
243
- let viewerTarget = null; // session name (conversation) or taskId (status-md)
244
- let lastBatchId = null; // TP-178: track batchId for stale viewer detection (#487)
245
-
246
- // ─── Repo Helpers ───────────────────────────────────────────────────────────
247
-
248
- /**
249
- * Build a sorted, deduplicated list of repo IDs from the batch payload.
250
- * Returns empty array when mode !== "workspace" or when fewer than 2 repos.
251
- */
252
- function buildRepoSet(batch) {
253
- if (!batch || batch.mode !== "workspace") return [];
254
-
255
- const repos = new Set();
256
- for (const lane of (batch.lanes || [])) {
257
- if (lane.repoId) repos.add(lane.repoId);
258
- }
259
- for (const task of (batch.tasks || [])) {
260
- const rid = task.resolvedRepoId || task.repoId;
261
- if (rid) repos.add(rid);
262
- }
263
- for (const mr of (batch.mergeResults || [])) {
264
- for (const rr of (mr.repoResults || [])) {
265
- if (rr.repoId) repos.add(rr.repoId);
266
- }
267
- }
268
- const sorted = Array.from(repos).sort();
269
- return sorted.length >= 2 ? sorted : [];
270
- }
271
-
272
- /**
273
- * Update the repo filter dropdown options and visibility.
274
- * Resets selection to "All repos" if the previously selected repo disappeared.
275
- */
276
- function updateRepoFilter(repos) {
277
- knownRepos = repos;
278
- const shouldShow = repos.length >= 2;
279
-
280
- if (shouldShow !== repoFilterVisible) {
281
- $repoFilter.style.display = shouldShow ? "" : "none";
282
- repoFilterVisible = shouldShow;
283
- }
284
-
285
- if (!shouldShow) {
286
- selectedRepo = "";
287
- return;
288
- }
289
-
290
- // If selected repo disappeared, reset to "All"
291
- if (selectedRepo && !repos.includes(selectedRepo)) {
292
- selectedRepo = "";
293
- }
294
-
295
- // Rebuild options only if repo set changed
296
- const currentOpts = Array.from($repoFilter.options).slice(1).map(o => o.value);
297
- const changed = currentOpts.length !== repos.length || currentOpts.some((v, i) => v !== repos[i]);
298
- if (changed) {
299
- // Preserve selection
300
- const prev = selectedRepo;
301
- $repoFilter.innerHTML = '<option value="">All repos</option>';
302
- for (const r of repos) {
303
- const opt = document.createElement("option");
304
- opt.value = r;
305
- opt.textContent = r;
306
- $repoFilter.appendChild(opt);
307
- }
308
- $repoFilter.value = prev;
309
- }
310
- }
311
-
312
- /** Get the effective repo ID for a task (prefer resolvedRepoId, fallback repoId). */
313
- function taskRepoId(task) {
314
- return task.resolvedRepoId || task.repoId || undefined;
315
- }
316
-
317
- /** Render a repo badge span. Returns "" if repoId is falsy or repos not active. */
318
- function repoBadgeHtml(repoId, extraClass) {
319
- if (!repoId || knownRepos.length < 2) return "";
320
- return `<span class="repo-badge ${extraClass || ""}" title="Repo: ${escapeHtml(repoId)}">${escapeHtml(repoId)}</span>`;
321
- }
322
-
323
- function parseSegmentId(segmentId) {
324
- if (!segmentId || typeof segmentId !== "string") return null;
325
- const sep = segmentId.indexOf("::");
326
- if (sep <= 0 || sep >= segmentId.length - 2) return null;
327
- return {
328
- taskId: segmentId.slice(0, sep),
329
- repoId: segmentId.slice(sep + 2),
330
- };
331
- }
332
-
333
- function segmentProgressText(segmentInfo) {
334
- if (!segmentInfo) return "";
335
- const repo = segmentInfo.repoId || "unknown";
336
- if (segmentInfo.index && segmentInfo.total) {
337
- return `Segment ${segmentInfo.index}/${segmentInfo.total}: ${repo}`;
338
- }
339
- return `Segment: ${repo}`;
340
- }
341
-
342
- function buildSegmentStatusMap(batch) {
343
- const map = new Map();
344
- for (const seg of (batch?.segments || [])) {
345
- if (seg && typeof seg.segmentId === "string") {
346
- map.set(seg.segmentId, seg.status || "pending");
347
- }
348
- }
349
- return map;
350
- }
351
-
352
- function taskSegmentProgress(task, segmentStatusMap, forcedActiveSegmentId) {
353
- const segmentIds = Array.isArray(task?.segmentIds)
354
- ? task.segmentIds.filter(id => typeof id === "string")
355
- : [];
356
- // Repo-singleton (or repo-mode) tasks should stay visually clean.
357
- if (segmentIds.length <= 1) return null;
358
-
359
- const activeSegmentId = forcedActiveSegmentId || task.activeSegmentId;
360
- let currentSegmentId = activeSegmentId && segmentIds.includes(activeSegmentId)
361
- ? activeSegmentId
362
- : null;
363
-
364
- if (!currentSegmentId) {
365
- if (task.status === "pending" || task.status === "running") {
366
- currentSegmentId = segmentIds.find((id) => {
367
- const status = segmentStatusMap.get(id);
368
- return !["succeeded", "failed", "stalled", "skipped"].includes(status);
369
- }) || segmentIds[segmentIds.length - 1];
370
- } else {
371
- currentSegmentId = segmentIds[segmentIds.length - 1];
372
- }
373
- }
374
-
375
- const idx = Math.max(0, segmentIds.indexOf(currentSegmentId));
376
- const parsed = parseSegmentId(currentSegmentId);
377
- return {
378
- index: idx + 1,
379
- total: segmentIds.length,
380
- repoId: parsed?.repoId || taskRepoId(task) || undefined,
381
- segmentId: currentSegmentId,
382
- };
383
- }
384
-
385
- function laneActiveSegmentInfo(v2snap, laneTasks, segmentStatusMap) {
386
- if (!v2snap || !v2snap.segmentId) return null;
387
- const parsed = parseSegmentId(v2snap.segmentId);
388
- if (!parsed) return null;
389
-
390
- const ownerTaskId = v2snap.taskId || parsed.taskId;
391
- const ownerTask = (laneTasks || []).find(t => t.taskId === ownerTaskId) || null;
392
- if (ownerTask) {
393
- const byTask = taskSegmentProgress(ownerTask, segmentStatusMap, v2snap.segmentId);
394
- if (byTask) return byTask;
395
- return null;
396
- }
397
-
398
- return {
399
- index: null,
400
- total: null,
401
- repoId: parsed.repoId,
402
- segmentId: v2snap.segmentId,
403
- };
404
- }
405
-
406
- // Repo filter change handler
407
- $repoFilter.addEventListener("change", (e) => {
408
- selectedRepo = e.target.value;
409
- // Re-render with current data
410
- if (currentData) {
411
- const batch = currentData.batch;
412
- const sessions = currentData.sessions ?? currentData.tmuxSessions ?? [];
413
- if (batch) {
414
- renderLanesTasks(batch, sessions);
415
- renderMergeAgents(batch, sessions);
416
- }
417
- }
418
- });
419
-
420
- // ─── Render: Header ─────────────────────────────────────────────────────────
421
-
422
- function renderHeader(batch) {
423
- if (!batch) {
424
- $batchId.textContent = "—";
425
- $batchPhase.textContent = "No batch";
426
- $batchPhase.className = "header-badge badge-phase";
427
- return;
428
- }
429
- $batchId.textContent = batch.batchId;
430
- $batchPhase.textContent = batch.phase;
431
- $batchPhase.className = `header-badge badge-phase phase-${batch.phase}`;
432
- }
433
-
434
- // ─── Render: Summary ────────────────────────────────────────────────────────
435
-
436
- function renderSummary(batch) {
437
- if (!batch) {
438
- $progressBarBg.innerHTML = "";
439
- $overallPct.textContent = "0%";
440
- $summaryCounts.innerHTML = "";
441
- $summaryElapsed.textContent = "—";
442
- $summaryWaves.innerHTML = "";
443
- return;
444
- }
445
-
446
- const tasks = batch.tasks || [];
447
- const total = tasks.length;
448
- const succeeded = tasks.filter(t => t.status === "succeeded").length;
449
- const running = tasks.filter(t => t.status === "running").length;
450
- const failed = tasks.filter(t => t.status === "failed").length;
451
- const stalled = tasks.filter(t => t.status === "stalled").length;
452
- const pending = tasks.filter(t => t.status === "pending").length;
453
-
454
- // ── Checkbox-based progress by wave ──────────────────────────
455
- const taskMap = new Map(tasks.map(t => [t.taskId, t]));
456
- const wavePlan = batch.wavePlan || [tasks.map(t => t.taskId)]; // fallback: single wave
457
- const currentWaveIdx = batch.currentWaveIndex || 0;
458
-
459
- // TP-148: Build wave segment context — for each task appearing in multiple waves,
460
- // determine which segment corresponds to each wave appearance.
461
- const taskWaveAppearance = new Map(); // taskId → count of appearances so far
462
- const waveSegmentLabels = wavePlan.map((taskIds) => {
463
- const labels = new Map(); // taskId → label string
464
- for (const tid of taskIds) {
465
- const task = taskMap.get(tid);
466
- const segmentIds = task?.segmentIds;
467
- if (!segmentIds || segmentIds.length <= 1) continue;
468
- const count = (taskWaveAppearance.get(tid) || 0);
469
- taskWaveAppearance.set(tid, count + 1);
470
- const segId = segmentIds[count];
471
- if (segId) {
472
- const parsed = parseSegmentId(segId);
473
- const repo = parsed ? parsed.repoId : "";
474
- labels.set(tid, `${tid} (segment ${count + 1}/${segmentIds.length}: ${repo})`);
475
- }
476
- }
477
- return labels;
478
- });
479
-
480
- // Compute per-wave and overall checkbox totals
481
- let batchChecked = 0, batchTotal = 0;
482
- const waveStats = wavePlan.map((taskIds, waveIdx) => {
483
- let wChecked = 0, wTotal = 0;
484
- let allSucceeded = taskIds.length > 0;
485
- for (const tid of taskIds) {
486
- const t = taskMap.get(tid);
487
- if (!t || t.status !== "succeeded") allSucceeded = false;
488
- if (t && t.status === "succeeded" && t.statusData) {
489
- // Succeeded task with statusData: count as fully done even if
490
- // STATUS.md checkboxes weren't all ticked before .DONE was created
491
- const total = t.statusData.total || 1;
492
- wChecked += total;
493
- wTotal += total;
494
- } else if (t && t.statusData) {
495
- wChecked += t.statusData.checked || 0;
496
- wTotal += t.statusData.total || 0;
497
- } else if (t && t.status === "succeeded") {
498
- // Succeeded tasks may not have statusData if STATUS.md was cleaned up
499
- // Count as fully done — use a small placeholder if no data
500
- wChecked += 1;
501
- wTotal += 1;
502
- }
503
- }
504
- batchChecked += wChecked;
505
- batchTotal += wTotal;
506
- return { waveIdx, taskIds, checked: wChecked, total: wTotal, allSucceeded };
507
- });
508
-
509
- const overallPct = batchTotal > 0 ? Math.round((batchChecked / batchTotal) * 100) : 0;
510
- $overallPct.textContent = `${overallPct}%`;
511
-
512
- // Build segmented progress bar — each wave gets a proportional segment
513
- let barHtml = "";
514
- for (const ws of waveStats) {
515
- const segWidthPct = batchTotal > 0 ? (ws.total / batchTotal) * 100 : (100 / waveStats.length);
516
- const fillPct = ws.total > 0 ? (ws.checked / ws.total) * 100 : 0;
517
- const checkboxDone = ws.checked === ws.total && ws.total > 0;
518
- const pastWave = ws.waveIdx < currentWaveIdx;
519
- const batchDone = batch.phase === "completed";
520
- // TP-178: During merging, only past waves are truly done. The current wave's
521
- // checkboxDone/allSucceeded can be true (tasks finished) but the wave itself
522
- // isn't done until the merge completes. (#493)
523
- const isMerging = batch.phase === "merging";
524
- const isDone = batchDone || pastWave || (!isMerging && (checkboxDone || ws.allSucceeded));
525
- const isMergingWave = isMerging && ws.waveIdx === currentWaveIdx;
526
- const isCurrent = ws.waveIdx === currentWaveIdx && (batch.phase === "executing" || isMerging);
527
- const isFuture = ws.waveIdx > currentWaveIdx && (batch.phase === "executing" || isMerging);
528
-
529
- const fillClass = isDone ? "pct-hi" : fillPct > 50 ? "pct-mid" : fillPct > 0 ? "pct-low" : "pct-0";
530
- const fillWidth = isDone ? 100 : fillPct;
531
- // TP-178: Add merging visual state for the wave currently being merged (#493)
532
- const segClass = isMergingWave ? "wave-seg-current wave-seg-merging" : isCurrent ? "wave-seg-current" : isFuture ? "wave-seg-future" : "";
533
-
534
- // TP-148: Use segment-aware labels in tooltip when available
535
- const segLabels = waveSegmentLabels[ws.waveIdx] || new Map();
536
- const tooltipTasks = ws.taskIds.map(tid => segLabels.get(tid) || tid).join(', ');
537
- barHtml += `<div class="wave-seg ${segClass}" style="width:${segWidthPct.toFixed(1)}%" title="W${ws.waveIdx + 1}: ${ws.checked}/${ws.total} checkboxes (${tooltipTasks})">`;
538
- barHtml += ` <div class="wave-seg-fill ${fillClass}" style="width:${fillWidth.toFixed(1)}%"></div>`;
539
- barHtml += ` <span class="wave-seg-label">W${ws.waveIdx + 1}</span>`;
540
- barHtml += `</div>`;
541
- }
542
- $progressBarBg.innerHTML = barHtml;
543
-
544
- let countsHtml = "";
545
- if (succeeded > 0) countsHtml += `<span class="count-chip count-succeeded"><span class="count-num">${succeeded}</span><span class="count-icon">✓</span></span>`;
546
- if (running > 0) countsHtml += `<span class="count-chip count-running"><span class="count-num">${running}</span><span class="count-icon">▶</span></span>`;
547
- if (failed > 0) countsHtml += `<span class="count-chip count-failed"><span class="count-num">${failed}</span><span class="count-icon">✗</span></span>`;
548
- if (stalled > 0) countsHtml += `<span class="count-chip count-stalled"><span class="count-num">${stalled}</span><span class="count-icon">⏸</span></span>`;
549
- if (pending > 0) countsHtml += `<span class="count-chip count-pending"><span class="count-num">${pending}</span><span class="count-icon">◌</span></span>`;
550
- countsHtml += `<span class="count-total">/ ${total}</span>`;
551
- $summaryCounts.innerHTML = countsHtml;
552
-
553
- const elapsed = batch.startedAt ? Date.now() - batch.startedAt : 0;
554
- let elapsedStr = `elapsed: ${formatDuration(elapsed)}`;
555
- if (batch.updatedAt) elapsedStr += ` · updated: ${relativeTime(batch.updatedAt)}`;
556
-
557
- // Aggregate tokens/cost for summary.
558
- // Runtime V2 snapshots are authoritative when present; legacy lane-state sidecars are fallback.
559
- const laneStates = currentData?.laneStates || {};
560
- const runtimeLaneSnapshots = currentData?.runtimeLaneSnapshots || {};
561
- const v2Snaps = Object.values(runtimeLaneSnapshots);
562
-
563
- let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCostFromSnapshots = 0;
564
-
565
- if (v2Snaps.length > 0) {
566
- for (const snap of v2Snaps) {
567
- const w = snap?.worker || {};
568
- batchInput += w.inputTokens || 0;
569
- batchOutput += w.outputTokens || 0;
570
- batchCacheRead += w.cacheReadTokens || 0;
571
- batchCacheWrite += w.cacheWriteTokens || 0;
572
- batchCostFromSnapshots += w.costUsd || 0;
573
-
574
- const r = snap?.reviewer || null;
575
- if (r) {
576
- batchInput += r.inputTokens || 0;
577
- batchOutput += r.outputTokens || 0;
578
- batchCacheRead += r.cacheReadTokens || 0;
579
- batchCacheWrite += r.cacheWriteTokens || 0;
580
- batchCostFromSnapshots += r.costUsd || 0;
581
- }
582
- }
583
- } else {
584
- // Legacy fallback
585
- for (const ls of Object.values(laneStates)) {
586
- batchInput += ls.workerInputTokens || 0;
587
- batchOutput += ls.workerOutputTokens || 0;
588
- batchCacheRead += ls.workerCacheReadTokens || 0;
589
- batchCacheWrite += ls.workerCacheWriteTokens || 0;
590
- batchCostFromSnapshots += ls.workerCostUsd || 0;
591
- }
592
- }
593
-
594
- // Keep server-computed cost as fallback for uncovered early-start lanes.
595
- const batchCost = batchCostFromSnapshots > 0
596
- ? batchCostFromSnapshots
597
- : ((currentData?.batchTotalCost != null && currentData.batchTotalCost > 0)
598
- ? currentData.batchTotalCost
599
- : 0);
600
- const batchTotalIn = batchInput + batchCacheRead;
601
- if (batchTotalIn > 0 || batchOutput > 0) {
602
- let tokenStr = ` · tokens: ↑${formatTokens(batchTotalIn)} ↓${formatTokens(batchOutput)}`;
603
- if (batchCost > 0) tokenStr += ` · cost: ${formatCost(batchCost)}`;
604
- elapsedStr += tokenStr;
605
- }
606
-
607
- $summaryElapsed.textContent = elapsedStr;
608
-
609
- // Waves
610
- if (batch.wavePlan && batch.wavePlan.length > 0) {
611
- const waveIdx = batch.currentWaveIndex || 0;
612
- let wavesHtml = '<span style="color:var(--text-muted); font-weight:600; margin-right:4px;">Waves</span>';
613
- batch.wavePlan.forEach((taskIds, i) => {
614
- // TP-178: During merging, only past waves are done; current wave shows merging state (#493)
615
- const isDone = i < waveIdx || batch.phase === "completed";
616
- const isCurrent = i === waveIdx && (batch.phase === "executing" || batch.phase === "merging");
617
- const isMergingChip = i === waveIdx && batch.phase === "merging";
618
- const cls = isDone ? "done" : isMergingChip ? "current merging" : isCurrent ? "current" : "";
619
- wavesHtml += `<span class="wave-chip ${cls}">W${i + 1} [${taskIds.join(", ")}]</span>`;
620
- });
621
- $summaryWaves.innerHTML = wavesHtml;
622
- } else {
623
- $summaryWaves.innerHTML = "";
624
- }
625
- }
626
-
627
- // ─── Render: Lanes + Tasks (integrated) ─────────────────────────────────────
628
-
629
- function renderLanesTasks(batch, sessions) {
630
- if (!batch || !batch.lanes || batch.lanes.length === 0) {
631
- $lanesTasksBody.innerHTML = '<div class="empty-state">No lanes</div>';
632
- return;
633
- }
634
-
635
- const tasks = batch.tasks || [];
636
- const sessionSet = new Set(sessions || []);
637
- const laneStates = currentData?.laneStates || {};
638
- const telemetry = currentData?.telemetry || {};
639
- // TP-107: V2 lane snapshots take precedence over legacy lane states when present
640
- const v2Snapshots = currentData?.runtimeLaneSnapshots || {};
641
- const showRepos = knownRepos.length >= 2;
642
- const segmentStatusMap = buildSegmentStatusMap(batch);
643
- let html = "";
644
-
645
- for (const lane of batch.lanes) {
646
- const laneTasks = (lane.taskIds || []).map(tid => tasks.find(t => t.taskId === tid)).filter(Boolean);
647
- const v2snap = v2Snapshots[lane.laneNumber] || null;
648
- const laneActiveSegment = laneActiveSegmentInfo(v2snap, laneTasks, segmentStatusMap);
649
-
650
- // Repo filtering: if a repo is selected, skip lanes that don't match
651
- if (selectedRepo && showRepos) {
652
- const laneMatchesRepo = (lane.repoId === selectedRepo) ||
653
- laneTasks.some(t => (taskRepoId(t) || lane.repoId) === selectedRepo);
654
- if (!laneMatchesRepo) continue;
655
- }
656
-
657
- // TP-107: check Runtime V2 registry for liveness first, fall back to session list
658
- const laneSessionId = lane.laneSessionId;
659
- const v2Alive = isLaneAliveV2(lane.laneNumber);
660
- const alive = v2Alive !== null ? v2Alive : sessionSet.has(laneSessionId);
661
-
662
-
663
- // Lane header
664
- html += `<div class="lane-group">`;
665
- html += `<div class="lane-header">`;
666
- html += ` <span class="lane-num">${lane.laneNumber}</span>`;
667
- html += ` <div class="lane-meta">`;
668
- html += ` <span class="lane-session">${escapeHtml(laneSessionId || "—")}</span>`;
669
- html += ` <span class="lane-branch">${escapeHtml(lane.branch || "—")}</span>`;
670
- if (showRepos && lane.repoId) {
671
- html += ` ${repoBadgeHtml(lane.repoId, "repo-badge-lane")}`;
672
- }
673
- if (laneActiveSegment) {
674
- html += ` <span class="lane-segment" title="${escapeHtml(laneActiveSegment.segmentId || segmentProgressText(laneActiveSegment))}">${escapeHtml(segmentProgressText(laneActiveSegment))}</span>`;
675
- }
676
- html += ` </div>`;
677
- html += ` <div class="lane-right">`;
678
- html += ` <span class="session-dot ${alive ? "alive" : "dead"}" title="${alive ? "session alive" : "session not active"}"></span>`;
679
- // View button: shows conversation stream when available
680
- const isViewingConv = viewerMode === 'conversation' && viewerTarget === laneSessionId;
681
- html += ` <button class="session-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(laneSessionId)}')" title="View worker conversation">👁 View</button>`;
682
-
683
- html += ` </div>`;
684
- html += `</div>`;
685
-
686
- // Task rows for this lane
687
- if (laneTasks.length === 0) {
688
- html += `<div class="task-row"><span class="task-icon"></span><span style="color:var(--text-faint);grid-column:2/-1;">No tasks assigned</span></div>`;
689
- }
690
-
691
- // Get lane state and telemetry for worker stats
692
- // TP-107: V2 lane snapshots take precedence when present
693
- const legacyLs = laneStates[laneSessionId] || null;
694
- const ls = v2snap ? mergeV2LaneSnapshot(legacyLs, v2snap) : legacyLs;
695
- const tel = telemetry[laneSessionId] || null;
696
-
697
- for (const task of laneTasks) {
698
- // Repo filtering at task level
699
- const tRepo = taskRepoId(task) || lane.repoId;
700
- if (selectedRepo && showRepos && tRepo !== selectedRepo) continue;
701
-
702
- const sd = task.statusData;
703
- const dur = task.startedAt
704
- ? formatDuration((task.endedAt || Date.now()) - task.startedAt)
705
- : "—";
706
- const segmentInfo = taskSegmentProgress(task, segmentStatusMap, null);
707
- const packetHomeRepo = typeof task.packetRepoId === "string" ? task.packetRepoId : "";
708
- const showPacketHome = !!packetHomeRepo && packetHomeRepo !== (tRepo || lane.repoId || "");
709
-
710
- // Progress cell
711
- // TP-174: Prefer V2 snapshot progress (segment-scoped when available)
712
- // over full STATUS.md counts when the task is actively running on this lane.
713
- // TP-176: Succeeded tasks always show 100% regardless of sidecar/statusData (#491).
714
- let progressHtml = "";
715
- const v2p = ls && ls._v2Progress;
716
- const taskMatch = v2p && ls.taskId === task.taskId;
717
- // Split V2 usage: progress needs totals > 0, but step/iter can be used whenever present
718
- const useV2Progress = taskMatch && v2p.total > 0;
719
- const useV2Step = taskMatch && !!v2p.currentStep;
720
- if (task.status === "succeeded") {
721
- // #491 fix: succeeded tasks always show 100%
722
- progressHtml = `
723
- <div class="task-progress">
724
- <div class="task-progress-bar"><div class="task-progress-fill pct-hi" style="width:100%"></div></div>
725
- <span class="task-progress-text">100%</span>
726
- </div>`;
727
- } else if (useV2Progress || (sd && sd.total > 0)) {
728
- const displayChecked = useV2Progress ? v2p.checked : sd.checked;
729
- const displayTotal = useV2Progress ? v2p.total : sd.total;
730
- const displayProgress = displayTotal > 0 ? Math.round((displayChecked / displayTotal) * 100) : 0;
731
- const fillClass = pctClass(displayProgress);
732
- progressHtml = `
733
- <div class="task-progress">
734
- <div class="task-progress-bar">
735
- <div class="task-progress-fill ${fillClass}" style="width:${displayProgress}%"></div>
736
- </div>
737
- <span class="task-progress-text">${displayProgress}% ${displayChecked}/${displayTotal}</span>
738
- </div>`;
739
- } else if (task.status === "running") {
740
- // #494 fix: running tasks without meaningful totals show executing indicator
741
- // This covers non-final segments, early execution before sidecar captures, and stale 0/0 data
742
- progressHtml = `
743
- <div class="task-progress">
744
- <div class="task-progress-bar"><div class="task-progress-fill pct-low task-progress-executing" style="width:100%"></div></div>
745
- <span class="task-progress-text">executing…</span>
746
- </div>`;
747
- } else if (task.status === "pending") {
748
- progressHtml = `
749
- <div class="task-progress">
750
- <div class="task-progress-bar"><div class="task-progress-fill pct-0" style="width:0%"></div></div>
751
- <span class="task-progress-text">0%</span>
752
- </div>`;
753
- } else {
754
- progressHtml = '<span style="color:var(--text-faint)">—</span>';
755
- }
756
-
757
- // Step cell
758
- // TP-178: Prefer V2 snapshot currentStep (refreshed every sidecar poll) over
759
- // server-parsed statusData which can lag behind (#488).
760
- let stepHtml = "";
761
- if (task.status === "succeeded") {
762
- // TP-178: Succeeded tasks always show "Complete" regardless of sidecar data (#491)
763
- stepHtml = '<span style="color:var(--green)">Complete</span>';
764
- } else if (sd || useV2Step) {
765
- // #488 fix: prefer V2 step name whenever present (even if totals are 0)
766
- const stepName = useV2Step ? v2p.currentStep : (sd ? sd.currentStep : "Unknown");
767
- const iter = (useV2Step && v2p.iteration != null) ? v2p.iteration : (sd ? sd.iteration : 0);
768
- const revs = (useV2Step && v2p.reviews != null) ? v2p.reviews : (sd ? sd.reviews : 0);
769
- stepHtml = escapeHtml(stepName);
770
- if (iter > 0) stepHtml += `<span class="task-iter">i${iter}</span>`;
771
- if (revs > 0) stepHtml += `<span class="task-iter">r${revs}</span>`;
772
- } else if (task.status === "pending") {
773
- stepHtml = '<span style="color:var(--text-faint)">Waiting</span>';
774
- } else {
775
- stepHtml = `<span style="color:var(--text-faint)">${escapeHtml(task.exitReason || "—")}</span>`;
776
- }
777
-
778
- const detailBits = [];
779
- if (segmentInfo) {
780
- detailBits.push(`<span class="task-segment-progress" title="${escapeHtml(segmentInfo.segmentId || segmentProgressText(segmentInfo))}">${escapeHtml(segmentProgressText(segmentInfo))}</span>`);
781
- }
782
- if (showPacketHome) {
783
- detailBits.push(`<span class="task-packet-home" title="Task packet home repo">packet: ${escapeHtml(packetHomeRepo)}</span>`);
784
- }
785
- if (detailBits.length > 0) {
786
- stepHtml = `${detailBits.join('<span class="task-detail-sep"> · </span>')}<span class="task-detail-sep"> · </span><span class="task-step-main">${stepHtml}</span>`;
787
- }
788
-
789
- // Worker stats from lane state sidecar + telemetry badges
790
- let workerHtml = "";
791
- // Reviewer sub-row should only appear under the active running task in this lane.
792
- // Runtime V2 snapshots provide taskId; during early startup it can be briefly unset,
793
- // so allow a task-status fallback while still avoiding duplicate rows.
794
- const reviewerActive = isReviewerActiveForTask(ls, task);
795
- const telemBadges = task.status !== "pending" ? telemetryBadgesHtml(tel, reviewerActive) : "";
796
- if (ls && ls.workerStatus === "running" && task.status === "running") {
797
- const elapsed = ls.workerElapsed ? `${Math.round(ls.workerElapsed / 1000)}s` : "";
798
- const tools = ls.workerToolCount || 0;
799
- const ctx = ls.workerContextPct ? `${Math.round(ls.workerContextPct)}%` : "";
800
- const lastTool = reviewerActive ? "[awaiting review]" : (ls.workerLastTool || "");
801
- const tokenStr = tokenSummaryFromLaneState(ls);
802
- workerHtml = `<div class="worker-stats">`;
803
- workerHtml += `<span class="worker-stat" title="Worker elapsed">⏱ ${elapsed}</span>`;
804
- workerHtml += `<span class="worker-stat" title="Tool calls">🔧 ${tools}</span>`;
805
- if (ctx) workerHtml += `<span class="worker-stat" title="Context window used">📊 ${ctx}</span>`;
806
- if (tokenStr) workerHtml += `<span class="worker-stat" title="Tokens: input↑ output↓ cacheRead(R) cacheWrite(W)">🪙 ${tokenStr}</span>`;
807
- if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="${reviewerActive ? 'Waiting for reviewer' : 'Last tool call'}">${reviewerActive ? '<span style="color:var(--yellow)">' + escapeHtml(lastTool) + '</span>' : escapeHtml(lastTool)}</span>`;
808
- workerHtml += telemBadges;
809
- workerHtml += `</div>`;
810
- } else if (!ls && tel && task.status === "running") {
811
- // Running task with telemetry but no lane-state yet (early startup)
812
- const lastTool = tel.lastTool || "";
813
- workerHtml = `<div class="worker-stats">`;
814
- if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="Last tool call">${escapeHtml(lastTool)}</span>`;
815
- workerHtml += telemBadges;
816
- workerHtml += `</div>`;
817
- } else if (ls && ls.workerStatus === "done" && task.status !== "pending") {
818
- workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--green)">✓ Worker done</span>${telemBadges}</div>`;
819
- } else if (ls && ls.workerStatus === "error" && task.status !== "pending") {
820
- workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--red)">✗ Worker error</span>${telemBadges}</div>`;
821
- } else if (telemBadges && task.status !== "pending") {
822
- // No lane-state but telemetry exists (done/error lane without sidecar)
823
- workerHtml = `<div class="worker-stats">${telemBadges}</div>`;
824
- }
825
-
826
- // Reviewer sub-row: shown when reviewer is actively running
827
- let reviewerRowHtml = "";
828
- if (reviewerActive) {
829
- const rElapsed = ls.reviewerElapsed ? `${Math.round(ls.reviewerElapsed / 1000)}s` : "";
830
- const rTools = ls.reviewerToolCount || 0;
831
- const rCtx = ls.reviewerContextPct ? `${Math.round(ls.reviewerContextPct)}%` : "";
832
- const rLastTool = ls.reviewerLastTool || "";
833
- const rTokenStr = tokenSummaryFromReviewerLaneState(ls);
834
- const rType = ls.reviewerType || "review";
835
- const rStep = ls.reviewerStep || "?";
836
- reviewerRowHtml = `
837
- <div class="task-row reviewer-sub-row">
838
- <span class="task-icon"></span>
839
- <span class="task-actions"></span>
840
- <span class="reviewer-label">📋 Reviewer</span>
841
- <span class="reviewer-type">${escapeHtml(rType)} · Step ${rStep}</span>
842
- <span class="task-duration"></span>
843
- <span></span>
844
- <span class="task-step">
845
- <div class="worker-stats reviewer-stats">
846
- <span class="worker-stat" title="Reviewer elapsed">⏱ ${rElapsed}</span>
847
- <span class="worker-stat" title="Reviewer tool calls">🔧 ${rTools}</span>
848
- ${rCtx ? `<span class="worker-stat" title="Reviewer context used">📊 ${rCtx}</span>` : ""}
849
- ${rTokenStr ? `<span class="worker-stat" title="Reviewer tokens: input↑ output↓ cacheRead(R) cacheWrite(W)">🪙 ${rTokenStr}</span>` : ""}
850
- ${rLastTool ? `<span class="worker-stat worker-last-tool" title="Reviewer last tool">${escapeHtml(rLastTool)}</span>` : ""}
851
- </div>
852
- </span>
853
- </div>`;
854
- }
855
-
856
- const isViewingStatus = viewerMode === 'status-md' && viewerTarget === task.taskId;
857
- const eyeHtml = task.status !== 'pending'
858
- ? `<button class="viewer-eye-btn${isViewingStatus ? ' active' : ''}" onclick="viewStatusMd('${escapeHtml(task.taskId)}')" title="View STATUS.md">👁</button>`
859
- : '';
860
-
861
- html += `
862
- <div class="task-row">
863
- <span class="task-icon"><span class="status-dot ${task.status}"></span></span>
864
- <span class="task-actions">${eyeHtml}</span>
865
- <span class="task-id status-${task.status}">${escapeHtml(task.taskId)}${showRepos ? repoBadgeHtml(tRepo, "repo-badge-task") : ""}</span>
866
- <span><span class="status-badge status-${task.status}"><span class="status-dot ${task.status}"></span> ${task.status}</span></span>
867
- <span class="task-duration">${dur}</span>
868
- <span>${progressHtml}</span>
869
- <span class="task-step">${stepHtml}${workerHtml}</span>
870
- </div>`;
871
- html += reviewerRowHtml;
872
- }
873
-
874
- html += `</div>`; // close lane-group
875
- }
876
-
877
- $lanesTasksBody.innerHTML = html;
878
- }
879
-
880
- // ─── Render: Merge Agents ───────────────────────────────────────────────────
881
-
882
- /** Build full telemetry HTML for a merge agent (parity with worker stats).
883
- * Shows: elapsed, tool count, context %, cost, current tool, retry/compaction badges.
884
- * Returns empty string if no meaningful telemetry exists.
885
- */
886
- function mergeTelemetryHtml(tel, alive) {
887
- if (!tel) return '<span class="merge-no-data">—</span>';
888
- const hasData = (tel.inputTokens || 0) > 0 || (tel.outputTokens || 0) > 0 ||
889
- (tel.toolCalls || 0) > 0 || (tel.cost || 0) > 0;
890
- if (!hasData) return '<span class="merge-no-data">—</span>';
891
-
892
- let html = '<div class="merge-stats">';
893
-
894
- // Elapsed time
895
- if (tel.startedAt) {
896
- const elapsed = Date.now() - tel.startedAt;
897
- html += `<span class="worker-stat" title="Merge elapsed">⏱ ${formatDuration(elapsed)}</span>`;
898
- }
899
-
900
- // Tool calls
901
- if (tel.toolCalls > 0) {
902
- html += `<span class="worker-stat" title="Tool calls">🔧 ${tel.toolCalls}</span>`;
903
- }
904
-
905
- // Context %
906
- if (tel.contextPct > 0) {
907
- html += `<span class="worker-stat" title="Context window used">📊 ${Math.round(tel.contextPct)}%</span>`;
908
- }
909
-
910
- // Tokens + cost
911
- const inp = (tel.inputTokens || 0) + (tel.cacheReadTokens || 0);
912
- const out = tel.outputTokens || 0;
913
- const cost = tel.cost || 0;
914
- if (inp > 0 || out > 0) {
915
- let tokenStr = `↑${formatTokens(inp)} ↓${formatTokens(out)}`;
916
- if (cost > 0) tokenStr += ` ${formatCost(cost)}`;
917
- html += `<span class="worker-stat" title="Tokens">🪙 ${tokenStr}</span>`;
918
- }
919
-
920
- // Current tool (if alive/active) or last tool (completed merges)
921
- if (alive && tel.currentTool) {
922
- html += `<span class="worker-stat worker-last-tool" title="Current tool">${escapeHtml(tel.currentTool)}</span>`;
923
- } else if (!alive && tel.lastTool) {
924
- html += `<span class="worker-stat worker-last-tool" title="Last tool">${escapeHtml(tel.lastTool)}</span>`;
925
- }
926
-
927
- // Retry/compaction badges (reuse shared helper)
928
- html += telemetryBadgesHtml(tel);
929
-
930
- html += '</div>';
931
- return html;
932
- }
933
-
934
- function renderMergeAgents(batch, sessions) {
935
- const mergeResults = batch?.mergeResults || [];
936
- const sessionSet = new Set(sessions || []);
937
- const showRepos = knownRepos.length >= 2;
938
- const telemetry = currentData?.telemetry || {};
939
-
940
- // Check for active merge sessions (convention: {prefix}-{opId}-merge-{N})
941
- const mergeSessions = (sessions || []).filter(s => s.includes("-merge-"));
942
-
943
- // Derive merge session name from lane session naming pattern.
944
- // Lane sessions: "{prefix}-{opId}-lane-{N}", merge sessions: "{prefix}-{opId}-merge-{N}".
945
- // Extract the prefix-opId part from the first lane and use it to construct merge names.
946
- const lanes = batch?.lanes || [];
947
- let mergePrefix = "orch-merge"; // fallback for legacy/unknown patterns
948
- if (lanes.length > 0 && lanes[0].laneSessionId) {
949
- const laneName = lanes[0].laneSessionId;
950
- const laneMatch = laneName.match(/^(.+)-lane-\d+$/);
951
- if (laneMatch) {
952
- mergePrefix = laneMatch[1] + "-merge";
953
- }
954
- }
955
- // Helper: get merge session name for a merge number
956
- const getMergeSessionName = (mergeNum) => `${mergePrefix}-${mergeNum}`;
957
-
958
- if (mergeResults.length === 0 && mergeSessions.length === 0) {
959
- $mergeBody.innerHTML = '<div class="empty-state">No merge agents active</div>';
960
- return;
961
- }
962
-
963
- let html = '<table class="merge-table"><thead><tr>';
964
- html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th><th>Session ID</th><th>Details</th>';
965
- html += '</tr></thead><tbody>';
966
-
967
- // Track sessions shown in wave result rows so we don't duplicate them below
968
- const shownSessions = new Set();
969
-
970
- // Show merge results
971
- for (const mr of mergeResults) {
972
- // Repo filtering: if a repo is selected and this merge has repoResults,
973
- // check if the selected repo is among them
974
- const repoResults = mr.repoResults || [];
975
- if (selectedRepo && showRepos && repoResults.length >= 1) {
976
- const hasSelectedRepo = repoResults.some(rr => rr.repoId === selectedRepo);
977
- if (!hasSelectedRepo) continue;
978
- }
979
-
980
- const statusCls = mr.status === "succeeded" ? "status-succeeded"
981
- : mr.status === "partial" ? "status-stalled"
982
- : "status-failed";
983
-
984
- // Merge session mapping: derive from lane numbers involved in this wave.
985
- // Merge sessions are named by lane number (e.g., ...-merge-1), not wave index.
986
- // Extract lane numbers from repoResults or from batch tasks for this wave.
987
- const waveLaneNums = new Set();
988
- const repoResults2 = mr.repoResults || [];
989
- for (const rr of repoResults2) {
990
- for (const ln of (rr.laneNumbers || [])) waveLaneNums.add(ln);
991
- }
992
- // Fallback: find lane numbers from tasks assigned to this wave
993
- if (waveLaneNums.size === 0 && batch.wavePlan && batch.wavePlan[mr.waveIndex]) {
994
- const waveTaskIds = new Set(batch.wavePlan[mr.waveIndex]);
995
- for (const t of (batch.tasks || [])) {
996
- if (waveTaskIds.has(t.taskId) && t.laneNumber != null) {
997
- waveLaneNums.add(t.laneNumber);
998
- }
999
- }
1000
- }
1001
- // Find alive merge sessions matching the wave's lane numbers
1002
- let effectiveSession = null;
1003
- for (const ln of waveLaneNums) {
1004
- const candidate = getMergeSessionName(ln);
1005
- if (sessionSet.has(candidate) && !shownSessions.has(candidate)) {
1006
- effectiveSession = candidate;
1007
- break;
1008
- }
1009
- }
1010
- // Fallback: any unshown alive merge session
1011
- if (!effectiveSession) {
1012
- effectiveSession = mergeSessions.find(s => sessionSet.has(s) && !shownSessions.has(s)) || null;
1013
- }
1014
- const effectiveAlive = !!effectiveSession;
1015
- if (effectiveSession) shownSessions.add(effectiveSession);
1016
-
1017
- // TP-178: Find merge telemetry precisely using waveIndex (#498).
1018
- // First try matching by waveIndex from the telemetry entries (injected from merge snapshots).
1019
- // Then fall back to session-based matching (lane numbers), but never use a
1020
- // catch-all fallback that grabs any merge session's telemetry.
1021
- let mergeTel = null;
1022
- // Priority 1: Match by waveIndex in telemetry entries
1023
- for (const [telKey, tel] of Object.entries(telemetry)) {
1024
- if (tel._source === "merge-snapshot" && tel.waveIndex === mr.waveIndex) {
1025
- mergeTel = tel;
1026
- break;
1027
- }
1028
- }
1029
- // Priority 2: Match by lane number session
1030
- if (!mergeTel) {
1031
- for (const ln of waveLaneNums) {
1032
- const candidate = getMergeSessionName(ln);
1033
- if (telemetry[candidate]) { mergeTel = telemetry[candidate]; break; }
1034
- }
1035
- }
1036
- // Priority 3: Effective session telemetry only (no catch-all fallback)
1037
- if (!mergeTel && effectiveSession) mergeTel = telemetry[effectiveSession] || null;
1038
-
1039
- html += `<tr>`;
1040
- html += `<td class="merge-wave-cell">Wave ${mr.waveIndex + 1}</td>`;
1041
- html += `<td><span class="status-badge ${statusCls}">${mr.status}</span></td>`;
1042
- html += `<td class="merge-session-cell">${effectiveAlive ? escapeHtml(effectiveSession) : "—"}</td>`;
1043
- // Full telemetry cell
1044
- html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(mergeTel, effectiveAlive)}</td>`;
1045
- html += `<td>`;
1046
- html += '<span class="merge-no-data">—</span>';
1047
- html += `</td>`;
1048
- html += `<td class="merge-detail-cell">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
1049
- html += `</tr>`;
1050
-
1051
- // Per-repo sub-rows: show when workspace mode has repo results
1052
- if (showRepos && repoResults.length >= 1) {
1053
- const displayRepos = selectedRepo
1054
- ? repoResults.filter(rr => rr.repoId === selectedRepo)
1055
- : repoResults;
1056
-
1057
- for (const rr of displayRepos) {
1058
- const rrStatusCls = rr.status === "succeeded" ? "status-succeeded"
1059
- : rr.status === "partial" ? "status-stalled"
1060
- : "status-failed";
1061
- const rrLanes = (rr.laneNumbers || []).map(n => `L${n}`).join(", ") || "—";
1062
- const rrDetail = rr.failureReason ? escapeHtml(rr.failureReason) : "—";
1063
-
1064
- html += `<tr class="merge-repo-row">`;
1065
- html += `<td>${repoBadgeHtml(rr.repoId)}</td>`;
1066
- html += `<td><span class="status-badge ${rrStatusCls}">${rr.status}</span></td>`;
1067
- html += `<td class="merge-session-cell">${rrLanes}</td>`;
1068
- html += `<td></td>`; /* telemetry placeholder */
1069
- html += `<td></td>`; /* attach placeholder */
1070
- html += `<td class="merge-detail-cell">${rrDetail}</td>`;
1071
- html += `</tr>`;
1072
- }
1073
- }
1074
- }
1075
-
1076
- // Show active merge sessions not yet in results
1077
- for (const sess of mergeSessions) {
1078
- if (shownSessions.has(sess)) continue;
1079
-
1080
- const sessTel = telemetry[sess] || null;
1081
- html += `<tr>`;
1082
- html += `<td class="merge-wave-cell">—</td>`;
1083
- html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
1084
- html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
1085
- // Full telemetry cell for active merge session
1086
- html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
1087
- html += `<td>—</td>`;
1088
- html += `<td>—</td>`;
1089
- html += `</tr>`;
1090
- }
1091
-
1092
- html += '</tbody></table>';
1093
- $mergeBody.innerHTML = html;
1094
- }
1095
-
1096
- // ─── Render: Runtime V2 Agents (TP-107) ─────────────────────────────────────
1097
-
1098
- function renderAgentsPanel(registry) {
1099
- const $panel = document.getElementById('agents-panel');
1100
- const $body = document.getElementById('agents-body');
1101
- if (!$panel || !$body) return;
1102
-
1103
- if (!registry || !registry.agents || Object.keys(registry.agents).length === 0) {
1104
- $panel.style.display = 'none';
1105
- return;
1106
- }
1107
-
1108
- $panel.style.display = '';
1109
- const agents = Object.values(registry.agents);
1110
- let html = '<div class="agents-grid">';
1111
-
1112
- for (const agent of agents) {
1113
- const isCrash = ['crashed', 'timed_out'].includes(agent.status);
1114
- const isTerminal = ['exited', 'crashed', 'timed_out', 'killed'].includes(agent.status);
1115
- const statusClass = isTerminal ? (isCrash ? 'agent-terminal agent-crashed' : 'agent-terminal') : 'agent-live';
1116
- const icon = isCrash ? '\u{1F534}' : (isTerminal ? '\u26AA' : '\u{1F7E2}');
1117
- // Display label: exited and killed both show as 'shutdown' — the mechanism is an
1118
- // implementation detail. Only crashed/timed_out warrant a different label.
1119
- const displayStatus = (agent.status === 'exited' || agent.status === 'killed') ? 'shutdown'
1120
- : agent.status === 'timed_out' ? 'timed out'
1121
- : agent.status;
1122
- const elapsed = agent.startedAt ? Math.round((Date.now() - agent.startedAt) / 1000) : 0;
1123
- const elapsedStr = elapsed > 0 ? formatDuration(elapsed * 1000) : '';
1124
-
1125
- html += `<div class="agent-card ${statusClass}">`;
1126
- html += `<div class="agent-header">${icon} <strong>${escapeHtml(agent.agentId)}</strong></div>`;
1127
- html += `<div class="agent-meta">`;
1128
- html += `<span class="agent-badge">${escapeHtml(agent.role)}</span>`;
1129
- if (agent.laneNumber != null) html += `<span class="agent-badge">lane ${agent.laneNumber}</span>`;
1130
- if (agent.taskId) html += `<span class="agent-badge">${escapeHtml(agent.taskId)}</span>`;
1131
- html += `<span class="agent-badge agent-status-${agent.status}">${escapeHtml(displayStatus)}</span>`;
1132
- if (elapsedStr && !isTerminal) html += `<span class="agent-badge">${elapsedStr}</span>`;
1133
- html += `</div>`;
1134
- html += `</div>`;
1135
- }
1136
-
1137
- html += '</div>';
1138
- $body.innerHTML = html;
1139
- }
1140
-
1141
- // ─── Render: Mailbox Messages (TP-107) ──────────────────────────────────────
1142
-
1143
- function renderMessagesPanel(mailbox) {
1144
- const $panel = document.getElementById('messages-panel');
1145
- const $body = document.getElementById('messages-body');
1146
- if (!$panel || !$body) return;
1147
-
1148
- // TP-093: event-authoritative model — prefer audit events, fallback to directory scan
1149
- const auditEvents = mailbox?.auditEvents || [];
1150
- const dirMessages = mailbox?.messages || [];
1151
- const hasData = auditEvents.length > 0 || dirMessages.length > 0;
1152
-
1153
- if (!mailbox || !hasData) {
1154
- $panel.style.display = 'none';
1155
- return;
1156
- }
1157
-
1158
- $panel.style.display = '';
1159
- let html = '<div class="messages-list">';
1160
-
1161
- if (auditEvents.length > 0) {
1162
- // Primary: render from audit event stream (authoritative, durable)
1163
- for (const evt of auditEvents) {
1164
- html += renderMailboxAuditEvent(evt);
1165
- }
1166
- } else {
1167
- // Fallback: render from directory scan (legacy compatibility)
1168
- for (const msg of dirMessages) {
1169
- html += renderMailboxDirMessage(msg);
1170
- }
1171
- }
1172
-
1173
- html += '</div>';
1174
- $body.innerHTML = html;
1175
- }
1176
-
1177
- /** Render a single mailbox audit event (events.jsonl row). */
1178
- function renderMailboxAuditEvent(evt) {
1179
- const ts = evt.ts ? new Date(evt.ts).toLocaleTimeString() : '';
1180
- const type = evt.type || '';
1181
-
1182
- let direction = '';
1183
- let statusBadge = '';
1184
- let typeBadge = '';
1185
- let preview = '';
1186
-
1187
- if (type === 'message_sent') {
1188
- const isBroadcast = evt.broadcast;
1189
- direction = isBroadcast ? '\u2192 all (broadcast)' : `\u2192 ${escapeHtml(evt.to || '')}`;
1190
- statusBadge = '<span class="msg-badge msg-delivered">sent</span>';
1191
- typeBadge = `<span class="msg-badge msg-type">${escapeHtml(evt.messageType || '')}</span>`;
1192
- preview = evt.contentPreview || '';
1193
- } else if (type === 'message_delivered') {
1194
- direction = `\u2192 ${escapeHtml(evt.to || '')}`;
1195
- statusBadge = evt.broadcast
1196
- ? '<span class="msg-badge msg-delivered">broadcast delivered</span>'
1197
- : '<span class="msg-badge msg-delivered">delivered</span>';
1198
- typeBadge = evt.messageType ? `<span class="msg-badge msg-type">${escapeHtml(evt.messageType)}</span>` : '';
1199
- preview = evt.contentPreview || '';
1200
- } else if (type === 'message_replied' || type === 'message_escalated') {
1201
- direction = `\u2190 ${escapeHtml(evt.from || '')}`;
1202
- statusBadge = type === 'message_escalated'
1203
- ? '<span class="msg-badge msg-reply">escalation</span>'
1204
- : '<span class="msg-badge msg-reply">reply</span>';
1205
- typeBadge = evt.messageType ? `<span class="msg-badge msg-type">${escapeHtml(evt.messageType)}</span>` : '';
1206
- preview = evt.contentPreview || '';
1207
- } else if (type === 'message_rate_limited') {
1208
- direction = `\u2192 ${escapeHtml(evt.to || '')}`;
1209
- statusBadge = '<span class="msg-badge msg-rate-limited">rate limited</span>';
1210
- const waitSec = evt.retryAfterMs ? Math.ceil(evt.retryAfterMs / 1000) : '?';
1211
- preview = `${evt.reason || 'Rate limited'} (retry in ${waitSec}s)`;
1212
- } else {
1213
- // Unknown event type — render generically
1214
- direction = evt.from ? `${escapeHtml(evt.from)}` : '';
1215
- preview = JSON.stringify(evt);
1216
- }
1217
-
1218
- return `<div class="message-row">`
1219
- + `<span class="msg-time">${escapeHtml(ts)}</span>`
1220
- + `<span class="msg-direction">${direction}</span>`
1221
- + typeBadge
1222
- + statusBadge
1223
- + `<span class="msg-preview">${escapeHtml(preview)}</span>`
1224
- + `</div>`;
1225
- }
1226
-
1227
- /** Render a single directory-scanned message (legacy fallback). */
1228
- function renderMailboxDirMessage(msg) {
1229
- const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
1230
- // TP-093: for broadcast per-agent ack markers, show recipient identity instead of "_broadcast"
1231
- let direction;
1232
- if (msg.to === 'supervisor') {
1233
- direction = '\u2190 supervisor';
1234
- } else if (msg._isBroadcast && msg._agentDir && msg._agentDir !== '_broadcast') {
1235
- direction = `\u2192 ${escapeHtml(msg._agentDir)} (broadcast)`;
1236
- } else {
1237
- direction = `\u2192 ${escapeHtml(msg.to || msg._agentDir || '')}`;
1238
- }
1239
- let statusBadge;
1240
- if (msg._status === 'pending') statusBadge = '<span class="msg-badge msg-pending">pending</span>';
1241
- else if (msg._status === 'delivered') statusBadge = '<span class="msg-badge msg-delivered">delivered</span>';
1242
- else if (msg._status === 'reply') statusBadge = '<span class="msg-badge msg-reply">reply</span>';
1243
- else if (msg._status === 'reply-acked') statusBadge = '<span class="msg-badge msg-delivered">reply (acked)</span>';
1244
- else statusBadge = '';
1245
- const typeBadge = `<span class="msg-badge msg-type">${escapeHtml(msg.type || '')}</span>`;
1246
- const preview = msg.content || '';
1247
- const broadcastTag = msg._isBroadcast ? ' <span class="msg-badge msg-type">broadcast</span>' : '';
1248
-
1249
- return `<div class="message-row">`
1250
- + `<span class="msg-time">${escapeHtml(ts)}</span>`
1251
- + `<span class="msg-direction">${direction}</span>`
1252
- + typeBadge
1253
- + statusBadge
1254
- + broadcastTag
1255
- + `<span class="msg-preview">${escapeHtml(preview)}</span>`
1256
- + `</div>`;
1257
- }
1258
-
1259
-
1260
- // ─── Render: Errors ─────────────────────────────────────────────────────────
1261
-
1262
- function renderErrors(batch) {
1263
- const errors = batch?.errors || [];
1264
- if (errors.length === 0) {
1265
- $errorsPanel.style.display = "none";
1266
- return;
1267
- }
1268
- $errorsPanel.style.display = "";
1269
- let html = "";
1270
- for (const err of errors.slice(-10)) {
1271
- const msg = typeof err === "string" ? err : err.message || JSON.stringify(err);
1272
- html += `<div class="error-item"><span class="error-bullet">●</span><span class="error-text">${escapeHtml(msg)}</span></div>`;
1273
- }
1274
- $errorsBody.innerHTML = html;
1275
- }
1276
-
1277
- // ─── Render: No Batch ───────────────────────────────────────────────────────
1278
-
1279
- let noBatchRendered = false;
1280
-
1281
- function renderNoBatch() {
1282
- if (noBatchRendered) return;
1283
- noBatchRendered = true;
1284
-
1285
- // Hide repo filter when no batch
1286
- updateRepoFilter([]);
1287
-
1288
- // Hide live panels, show history panel
1289
- const $lanesPanel = document.getElementById("lanes-tasks-panel");
1290
- const $mergePanel = document.getElementById("merge-panel");
1291
- if ($lanesPanel) $lanesPanel.style.display = "none";
1292
- if ($mergePanel) $mergePanel.style.display = "none";
1293
- if ($errorsPanel) $errorsPanel.style.display = "none";
1294
-
1295
- // Show a placeholder while history loads. loadHistoryList() (called in
1296
- // render() just before this) is async — the fresh list may not be
1297
- // available yet. The loadHistoryList callback will replace this with
1298
- // the actual latest entry once it resolves.
1299
- if (!viewingHistoryId) {
1300
- $historyBody.innerHTML = `
1301
- <div class="no-batch">
1302
- <div class="no-batch-icon">⏳</div>
1303
- <div class="no-batch-title">Batch complete</div>
1304
- <div class="no-batch-hint">Loading history…</div>
1305
- </div>`;
1306
- $historyPanel.style.display = "";
1307
- }
1308
- }
1309
-
1310
- function ensureContentPanels() {
1311
- if (noBatchRendered) {
1312
- // A live batch started — restore panels without full page reload.
1313
- // Reset the no-batch state and re-show content panels.
1314
- noBatchRendered = false;
1315
- const $lanesPanel = document.getElementById("lanes-tasks-panel");
1316
- const $mergePanel = document.getElementById("merge-panel");
1317
- if ($lanesPanel) $lanesPanel.style.display = "";
1318
- if ($mergePanel) $mergePanel.style.display = "";
1319
- if ($errorsPanel) $errorsPanel.style.display = "";
1320
- $historyPanel.style.display = "none";
1321
- viewingHistoryId = null;
1322
- // Re-render with current data
1323
- if (currentData) render(currentData);
1324
- }
1325
- }
1326
-
1327
- // ─── Supervisor Panel ───────────────────────────────────────────────────────
1328
-
1329
- const $supervisorPanel = $("supervisor-panel");
1330
- const $supervisorStatusBadge = $("supervisor-status-badge");
1331
- const $supervisorCollapseBtn = $("supervisor-collapse-btn");
1332
- const $supervisorPanelBody = $("supervisor-panel-body");
1333
- const $supervisorStatusSection = $("supervisor-status-section");
1334
- const $supervisorConversationSection = $("supervisor-conversation-section");
1335
- const $supervisorActionsSection = $("supervisor-actions-section");
1336
- const $supervisorSummarySection = $("supervisor-summary-section");
1337
-
1338
- let supervisorCollapsed = false;
1339
-
1340
- // Toggle collapse on header click
1341
- $("supervisor-panel-toggle").addEventListener("click", (e) => {
1342
- // Don't toggle when clicking the collapse button itself (it has its own handler)
1343
- if (e.target.id === "supervisor-collapse-btn") return;
1344
- toggleSupervisorPanel();
1345
- });
1346
-
1347
- $supervisorCollapseBtn.addEventListener("click", toggleSupervisorPanel);
1348
-
1349
- function toggleSupervisorPanel() {
1350
- supervisorCollapsed = !supervisorCollapsed;
1351
- $supervisorPanelBody.style.display = supervisorCollapsed ? "none" : "";
1352
- $supervisorCollapseBtn.textContent = supervisorCollapsed ? "▸" : "▾";
1353
- }
1354
-
1355
- /** Determine supervisor status from lock data. */
1356
- function supervisorStatusInfo(lock) {
1357
- if (!lock) return { status: "inactive", label: "Inactive", cls: "supervisor-inactive" };
1358
- if (lock.stale) return { status: "stale", label: "Stale", cls: "supervisor-stale" };
1359
- return { status: "active", label: "Active", cls: "supervisor-active" };
1360
- }
1361
-
1362
- /** Format a timestamp for the supervisor timeline. */
1363
- function formatSupervisorTime(ts) {
1364
- if (!ts) return "";
1365
- const d = new Date(ts);
1366
- return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
1367
- }
1368
-
1369
- /** Render the supervisor status indicator section. */
1370
- function renderSupervisorStatus(supervisor) {
1371
- const lock = supervisor.lock;
1372
- const info = supervisorStatusInfo(lock);
1373
-
1374
- // Update the header badge
1375
- $supervisorStatusBadge.textContent = info.label;
1376
- $supervisorStatusBadge.className = `supervisor-status-badge ${info.cls}`;
1377
-
1378
- let html = '<div class="supervisor-status-row">';
1379
- html += `<span class="supervisor-status-dot ${info.cls}"></span>`;
1380
- html += `<span class="supervisor-status-label">${info.label}</span>`;
1381
-
1382
- if (lock) {
1383
- if (lock.autonomy) {
1384
- html += `<span class="supervisor-autonomy-badge">${escapeHtml(lock.autonomy)}</span>`;
1385
- }
1386
- if (lock.heartbeat) {
1387
- html += `<span class="supervisor-heartbeat" title="Last heartbeat">♡ ${relativeTime(lock.heartbeat)}</span>`;
1388
- }
1389
- if (lock.sessionId) {
1390
- html += `<span class="supervisor-session-id" title="Session: ${escapeHtml(lock.sessionId)}">${escapeHtml(lock.sessionId)}</span>`;
1391
- }
1392
- }
1393
-
1394
- html += '</div>';
1395
- $supervisorStatusSection.innerHTML = html;
1396
- }
1397
-
1398
- /** Render the conversation history section. */
1399
- function renderSupervisorConversation(supervisor) {
1400
- const conversation = supervisor.conversation || [];
1401
-
1402
- if (conversation.length === 0) {
1403
- $supervisorConversationSection.innerHTML = '';
1404
- return;
1405
- }
1406
-
1407
- let html = '<div class="supervisor-subsection-title">Conversation</div>';
1408
- html += '<div class="supervisor-conversation-list">';
1409
-
1410
- for (const entry of conversation) {
1411
- const time = formatSupervisorTime(entry.ts || entry.timestamp);
1412
- const role = entry.role || "unknown";
1413
- const content = entry.content || entry.message || "";
1414
- const roleCls = role === "operator" ? "conv-role-operator" : "conv-role-supervisor";
1415
- const roleLabel = role === "operator" ? "Operator" : "Supervisor";
1416
-
1417
- html += `<div class="supervisor-conv-entry ${roleCls}">`;
1418
- html += ` <div class="supervisor-conv-header">`;
1419
- html += ` <span class="supervisor-conv-role">${roleLabel}</span>`;
1420
- if (time) html += `<span class="supervisor-conv-time">${time}</span>`;
1421
- html += ` </div>`;
1422
- html += ` <div class="supervisor-conv-content">${escapeHtml(content)}</div>`;
1423
- html += `</div>`;
1424
- }
1425
-
1426
- html += '</div>';
1427
- $supervisorConversationSection.innerHTML = html;
1428
- }
1429
-
1430
- /**
1431
- * Human-readable labels for supervisor recovery action identifiers.
1432
- * The supervisor LLM writes snake_case action names to actions.jsonl.
1433
- * This map translates them to operator-friendly labels for the dashboard.
1434
- */
1435
- const RECOVERY_ACTION_LABELS = {
1436
- // Conflict resolution
1437
- conflict_resolve_checkout_ours: "Auto-resolved merge conflict (kept task changes)",
1438
- conflict_resolve_checkout_theirs: "Auto-resolved merge conflict (kept base changes)",
1439
- conflict_resolve_manual: "Manual conflict resolution applied",
1440
-
1441
- // Merge agent
1442
- merge_retry: "Retried merge agent",
1443
- merge_session_kill: "Terminated stalled merge agent",
1444
- merge_force: "Forced merge with partial results",
1445
-
1446
- // Worker / task
1447
- worker_wrap_up: "Sent wrap-up signal to stalled worker",
1448
- task_retry: "Retried failed task",
1449
- task_skip: "Skipped task — unblocked dependents",
1450
- wave_force_merge: "Force-merged wave with mixed results",
1451
-
1452
- // Git / worktree
1453
- lock_clear: "Cleared stale git lock file",
1454
- worktree_remove: "Removed stale worktree",
1455
- worktree_prune: "Pruned stale worktrees",
1456
-
1457
- // Batch lifecycle
1458
- abort_hard: "Hard-aborted batch",
1459
- batch_resume: "Resumed batch after recovery",
1460
- supervisor_handoff: "Supervisor session handoff",
1461
-
1462
- // Diagnostics (usually not shown — filtered as non-recovery)
1463
- initial_status_check: "Checked initial batch status",
1464
- completion_status_check: "Verified batch completion status",
1465
- read_state: "Read batch state",
1466
- };
1467
-
1468
- /** Format a recovery action type string into a human-readable label. */
1469
- function formatRecoveryActionLabel(type) {
1470
- return RECOVERY_ACTION_LABELS[type] || type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1471
- }
1472
-
1473
- /** Merge supervisor actions and Tier 0 recovery events into a unified timeline.
1474
- * Actions from actions.jsonl and recovery events from events.jsonl are combined
1475
- * and sorted chronologically (per R002: show both Tier 0 and supervisor actions).
1476
- */
1477
- function buildRecoveryTimeline(supervisor) {
1478
- const actions = (supervisor.actions || []).map(a => ({
1479
- ts: a.ts || a.timestamp || 0,
1480
- tier: a.tier,
1481
- type: a.type || a.action || "unknown",
1482
- target: a.target || a.lane || a.taskId || "",
1483
- outcome: a.outcome || a.result || "",
1484
- reason: a.reason || "",
1485
- context: a.context || "",
1486
- detail: a.detail || "",
1487
- source: "action"
1488
- }));
1489
-
1490
- // Include Tier 0 recovery events from events.jsonl
1491
- const events = (supervisor.events || [])
1492
- .filter(e => e.tier === 0 || e.type === "recovery" || e.type === "tier0_recovery")
1493
- .map(e => ({
1494
- ts: e.ts || e.timestamp || 0,
1495
- tier: e.tier != null ? e.tier : 0,
1496
- type: e.type || "event",
1497
- target: e.target || e.lane || e.taskId || "",
1498
- outcome: e.outcome || e.result || "",
1499
- reason: e.reason || e.message || "",
1500
- context: e.context || "",
1501
- detail: e.detail || "",
1502
- source: "event"
1503
- }));
1504
-
1505
- const timeline = [...actions, ...events];
1506
- timeline.sort((a, b) => {
1507
- const tA = typeof a.ts === "string" ? new Date(a.ts).getTime() : a.ts;
1508
- const tB = typeof b.ts === "string" ? new Date(b.ts).getTime() : b.ts;
1509
- return tA - tB;
1510
- });
1511
-
1512
- return timeline;
1513
- }
1514
-
1515
- /** Render the recovery action timeline section. */
1516
- function renderSupervisorActions(supervisor) {
1517
- const timeline = buildRecoveryTimeline(supervisor);
1518
-
1519
- if (timeline.length === 0) {
1520
- $supervisorActionsSection.innerHTML = '';
1521
- return;
1522
- }
1523
-
1524
- let html = '<div class="supervisor-subsection-title">Recovery Actions</div>';
1525
- html += '<div class="supervisor-timeline">';
1526
-
1527
- for (const entry of timeline) {
1528
- const time = formatSupervisorTime(entry.ts);
1529
- const tier = entry.tier != null ? `T${entry.tier}` : "";
1530
- const type = entry.type;
1531
- const target = entry.target;
1532
- const outcome = entry.outcome;
1533
- const reason = entry.reason;
1534
- const description = entry.context || entry.detail || "";
1535
-
1536
- const outcomeCls = outcome === "success" || outcome === "recovered"
1537
- ? "action-success"
1538
- : outcome === "failed" || outcome === "error"
1539
- ? "action-failed"
1540
- : "action-pending";
1541
-
1542
- html += `<div class="supervisor-action-entry">`;
1543
- html += ` <div class="supervisor-action-left">`;
1544
- html += ` <span class="supervisor-action-time">${time}</span>`;
1545
- html += ` <span class="supervisor-action-dot ${outcomeCls}"></span>`;
1546
- html += ` </div>`;
1547
- html += ` <div class="supervisor-action-right">`;
1548
- html += ` <div class="supervisor-action-header">`;
1549
- if (tier) html += `<span class="supervisor-action-tier">${tier}</span>`;
1550
- html += ` <span class="supervisor-action-type" title="${escapeHtml(type)}">${escapeHtml(formatRecoveryActionLabel(type))}</span>`;
1551
- if (target) html += `<span class="supervisor-action-target">${escapeHtml(target)}</span>`;
1552
- if (outcome) html += `<span class="supervisor-action-outcome ${outcomeCls}">${escapeHtml(outcome)}</span>`;
1553
- html += ` </div>`;
1554
- if (description) {
1555
- const fullDesc = escapeHtml(description);
1556
- const truncated = description.length > 100 ? escapeHtml(description.slice(0, 100)) + "\u2026" : fullDesc;
1557
- html += `<div class="supervisor-action-description" title="${fullDesc}">${truncated}</div>`;
1558
- }
1559
- if (reason) {
1560
- html += `<div class="supervisor-action-reason">${escapeHtml(reason)}</div>`;
1561
- }
1562
- html += ` </div>`;
1563
- html += `</div>`;
1564
- }
1565
-
1566
- html += '</div>';
1567
- $supervisorActionsSection.innerHTML = html;
1568
- }
1569
-
1570
- /** Render the batch summary section (from summary.md). */
1571
- function renderSupervisorSummary(supervisor) {
1572
- const summary = supervisor.summary;
1573
-
1574
- if (!summary) {
1575
- $supervisorSummarySection.innerHTML = '';
1576
- return;
1577
- }
1578
-
1579
- let html = '<div class="supervisor-subsection-title">Batch Summary</div>';
1580
- html += '<div class="supervisor-summary-content">';
1581
- // Render the summary markdown using the STATUS.md renderer (reuse)
1582
- const { html: renderedMd } = renderStatusMd(summary);
1583
- html += renderedMd;
1584
- html += '</div>';
1585
- $supervisorSummarySection.innerHTML = html;
1586
- }
1587
-
1588
- /** Main supervisor panel render function. */
1589
- function renderSupervisor(data) {
1590
- const supervisor = data.supervisor;
1591
-
1592
- if (!supervisor) {
1593
- $supervisorPanel.style.display = "none";
1594
- return;
1595
- }
1596
-
1597
- $supervisorPanel.style.display = "";
1598
-
1599
- renderSupervisorStatus(supervisor);
1600
- renderSupervisorConversation(supervisor);
1601
- renderSupervisorActions(supervisor);
1602
- renderSupervisorSummary(supervisor);
1603
- }
1604
-
1605
- // ─── Full Render ────────────────────────────────────────────────────────────
1606
-
1607
- // ─── Current data (stored for conversation viewer) ──────────────────────────
1608
-
1609
- let currentData = null;
1610
-
1611
- function render(data) {
1612
- currentData = data;
1613
- const batch = data.batch;
1614
- const sessions = data.sessions ?? data.tmuxSessions ?? [];
1615
-
1616
- $lastUpdate.textContent = new Date().toLocaleTimeString();
1617
-
1618
- if (!batch) {
1619
- // TP-178: Clear viewer when batch disappears (#487)
1620
- if (lastBatchId && viewerMode) closeViewer();
1621
- lastBatchId = null;
1622
- renderHeader(null);
1623
- renderSummary(null);
1624
- renderSupervisor(data);
1625
- // Refresh history list (batch may have just finished)
1626
- if (!noBatchRendered) loadHistoryList();
1627
- renderNoBatch();
1628
- return;
1629
- }
1630
-
1631
- // TP-178: Detect batchId change — clear stale viewer state (#487)
1632
- if (batch.batchId && lastBatchId && batch.batchId !== lastBatchId && viewerMode) {
1633
- closeViewer();
1634
- }
1635
- lastBatchId = batch.batchId || null;
1636
-
1637
- // Live batch is running — hide history panel, reset viewing state
1638
- if (viewingHistoryId) {
1639
- viewingHistoryId = null;
1640
- $historyPanel.style.display = "none";
1641
- $historySelect.value = "";
1642
- }
1643
-
1644
- if (noBatchRendered) {
1645
- ensureContentPanels();
1646
- return;
1647
- }
1648
-
1649
- renderHeader(batch);
1650
- renderSummary(batch);
1651
-
1652
- // Update repo filter based on current batch data
1653
- const repos = buildRepoSet(batch);
1654
- updateRepoFilter(repos);
1655
-
1656
- renderSupervisor(data);
1657
- renderLanesTasks(batch, sessions);
1658
- renderMergeAgents(batch, sessions);
1659
- // TP-107: Runtime V2 panels
1660
- renderAgentsPanel(data.runtimeRegistry);
1661
- renderMessagesPanel(data.mailbox);
1662
- renderErrors(batch);
1663
-
1664
- const taskCount = (batch.tasks || []).length;
1665
- const laneCount = (batch.lanes || []).length;
1666
- const waveCount = (batch.wavePlan || []).length;
1667
- $footerInfo.textContent = `${taskCount} tasks · ${laneCount} lanes · ${waveCount} waves`;
1668
- }
1669
-
1670
- // ─── SSE Connection ─────────────────────────────────────────────────────────
1671
-
1672
- let eventSource = null;
1673
- let reconnectTimer = null;
1674
-
1675
- function connect() {
1676
- if (eventSource) eventSource.close();
1677
-
1678
- eventSource = new EventSource("/api/stream");
1679
-
1680
- eventSource.onopen = () => {
1681
- $connDot.className = "connection-dot connected";
1682
- $connDot.title = "Connected";
1683
- clearTimeout(reconnectTimer);
1684
- };
1685
-
1686
- eventSource.onmessage = (event) => {
1687
- try {
1688
- const data = JSON.parse(event.data);
1689
- render(data);
1690
- } catch (err) {
1691
- console.error("Failed to parse SSE data:", err);
1692
- }
1693
- };
1694
-
1695
- eventSource.onerror = () => {
1696
- $connDot.className = "connection-dot disconnected";
1697
- $connDot.title = "Disconnected — reconnecting…";
1698
- eventSource.close();
1699
- reconnectTimer = setTimeout(connect, 3000);
1700
- };
1701
- }
1702
-
1703
- // ─── Viewer Panel (Conversation + STATUS.md) ────────────────────────────────
1704
-
1705
- const $terminalPanel = document.getElementById("terminal-panel");
1706
- const $terminalTitle = document.getElementById("terminal-title");
1707
- const $terminalBody = document.getElementById("terminal-body");
1708
- const $terminalClose = document.getElementById("terminal-close");
1709
- const $autoScrollCheckbox = document.getElementById("auto-scroll-checkbox");
1710
- const $autoScrollText = document.getElementById("auto-scroll-text");
1711
-
1712
- // Viewer state
1713
- let viewerTimer = null;
1714
- let autoScrollOn = false;
1715
- let isProgrammaticScroll = false;
1716
-
1717
- // Conversation append-only state
1718
- let convRenderedLines = 0;
1719
-
1720
- // STATUS.md diff-and-skip state
1721
- let lastStatusMdText = "";
1722
-
1723
- // ── Open conversation viewer (TP-107: V2 events preferred, legacy fallback) ──
1724
-
1725
- /**
1726
- * Resolve a lane session ID to a Runtime V2 agent ID via the registry.
1727
- * Returns null if no V2 registry data is available.
1728
- */
1729
- function resolveV2AgentId(sessionName) {
1730
- if (!currentData || !currentData.runtimeRegistry || !currentData.runtimeRegistry.agents) return null;
1731
- const agents = currentData.runtimeRegistry.agents;
1732
- // Direct match on agentId
1733
- if (agents[sessionName]) return sessionName;
1734
- // Match by session ID prefix + "-worker" suffix (common V2 naming)
1735
- const workerKey = sessionName + '-worker';
1736
- if (agents[workerKey]) return workerKey;
1737
- // Search by laneNumber match from lane snapshots
1738
- for (const [id, agent] of Object.entries(agents)) {
1739
- if (agent.role === 'worker' && agent.laneNumber != null) {
1740
- const m = sessionName.match(/lane-(\d+)/);
1741
- if (m && parseInt(m[1]) === agent.laneNumber) return id;
1742
- }
1743
- }
1744
- return null;
1745
- }
1746
-
1747
- let viewerV2AgentId = null; // Runtime V2 agent ID for current conversation view
1748
-
1749
- function viewConversation(sessionName) {
1750
- // Toggle off if already viewing this session
1751
- if (viewerMode === 'conversation' && viewerTarget === sessionName && $terminalPanel.style.display !== 'none') {
1752
- closeViewer();
1753
- return;
1754
- }
1755
-
1756
- closeViewer();
1757
-
1758
- viewerMode = 'conversation';
1759
- viewerTarget = sessionName;
1760
- autoScrollOn = true;
1761
- convRenderedLines = 0;
1762
-
1763
- // TP-107: Resolve V2 agent ID for events endpoint
1764
- const v2AgentId = resolveV2AgentId(sessionName);
1765
- viewerV2AgentId = v2AgentId;
1766
-
1767
- const label = v2AgentId || sessionName;
1768
- $terminalTitle.textContent = `Worker Conversation — ${label}`;
1769
- $autoScrollText.textContent = 'Follow feed';
1770
- $autoScrollCheckbox.checked = true;
1771
- $terminalPanel.style.display = '';
1772
- $terminalBody.innerHTML = '<div class="conv-stream"></div>';
1773
-
1774
- pollConversation();
1775
- viewerTimer = setInterval(pollConversation, 2000);
1776
-
1777
- $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1778
- }
1779
-
1780
- function pollConversation() {
1781
- // TP-107: prefer V2 agent events when available, fallback to legacy conversation
1782
- const endpoint = viewerV2AgentId
1783
- ? `/api/agent-events/${encodeURIComponent(viewerV2AgentId)}`
1784
- : `/api/conversation/${encodeURIComponent(viewerTarget)}`;
1785
- const isV2 = !!viewerV2AgentId;
1786
-
1787
- fetch(endpoint)
1788
- .then(r => isV2 ? r.json() : r.text())
1789
- .then(data => {
1790
- if (isV2) {
1791
- renderV2AgentEvents(data);
1792
- return;
1793
- }
1794
- // Legacy: data is JSONL text
1795
- const text = data;
1796
- if (!text.trim()) {
1797
- if (convRenderedLines === 0) {
1798
- $terminalBody.innerHTML = '<div class="conv-empty">No conversation events yet…</div>';
1799
- }
1800
- return;
1801
- }
1802
-
1803
- const lines = text.trim().split('\n');
1804
-
1805
- // File was reset (new task on same lane) — full re-render
1806
- if (lines.length < convRenderedLines) {
1807
- convRenderedLines = 0;
1808
- const container = $terminalBody.querySelector('.conv-stream');
1809
- if (container) container.innerHTML = '';
1810
- }
1811
-
1812
- // Nothing new
1813
- if (lines.length === convRenderedLines) return;
1814
-
1815
- // Ensure container exists
1816
- let container = $terminalBody.querySelector('.conv-stream');
1817
- if (!container) {
1818
- $terminalBody.innerHTML = '';
1819
- container = document.createElement('div');
1820
- container.className = 'conv-stream';
1821
- $terminalBody.appendChild(container);
1822
- }
1823
-
1824
- // Append only new events
1825
- const newLines = lines.slice(convRenderedLines);
1826
- for (const line of newLines) {
1827
- try {
1828
- const event = JSON.parse(line);
1829
- const html = renderConvEvent(event);
1830
- if (html) container.insertAdjacentHTML('beforeend', html);
1831
- } catch { continue; }
1832
- }
1833
-
1834
- convRenderedLines = lines.length;
1835
-
1836
- // Auto-scroll to bottom
1837
- if (autoScrollOn) {
1838
- isProgrammaticScroll = true;
1839
- $terminalBody.scrollTop = $terminalBody.scrollHeight;
1840
- requestAnimationFrame(() => { isProgrammaticScroll = false; });
1841
- }
1842
- })
1843
- .catch(() => {});
1844
- }
1845
-
1846
- // ── Runtime V2 agent event renderer (TP-107) ──────────────────────────────
1847
-
1848
- // Stable cursor for V2 event rendering.
1849
- // Uses a signature string from the last rendered event so the sliding window
1850
- // (server caps at 300) doesn't stall when new tail events push older ones out.
1851
- let v2LastCursor = null; // signature of last rendered event
1852
- let v2FirstRender = true;
1853
-
1854
- function v2EventSignature(evt) {
1855
- return `${evt.ts || 0}:${evt.type || ''}:${JSON.stringify(evt.payload || {}).slice(0, 80)}`;
1856
- }
1857
-
1858
- function renderV2AgentEvents(events) {
1859
- if (!Array.isArray(events) || events.length === 0) {
1860
- if (v2FirstRender) {
1861
- $terminalBody.innerHTML = '<div class="conv-empty">No agent events yet…</div>';
1862
- }
1863
- return;
1864
- }
1865
-
1866
- let container = $terminalBody.querySelector('.conv-stream');
1867
-
1868
- if (v2FirstRender || !container) {
1869
- // First load or container missing: full render
1870
- $terminalBody.innerHTML = '';
1871
- container = document.createElement('div');
1872
- container.className = 'conv-stream';
1873
- $terminalBody.appendChild(container);
1874
- for (const evt of events) {
1875
- const html = renderV2Event(evt);
1876
- if (html) container.insertAdjacentHTML('beforeend', html);
1877
- }
1878
- v2LastCursor = v2EventSignature(events[events.length - 1]);
1879
- v2FirstRender = false;
1880
- } else {
1881
- // Incremental: find first unseen event after cursor
1882
- let cursorIdx = -1;
1883
- if (v2LastCursor) {
1884
- for (let i = events.length - 1; i >= 0; i--) {
1885
- if (v2EventSignature(events[i]) === v2LastCursor) {
1886
- cursorIdx = i;
1887
- break;
1888
- }
1889
- }
1890
- }
1891
-
1892
- if (cursorIdx === -1) {
1893
- // Cursor not found (rotation/restart): full re-render
1894
- container.innerHTML = '';
1895
- for (const evt of events) {
1896
- const html = renderV2Event(evt);
1897
- if (html) container.insertAdjacentHTML('beforeend', html);
1898
- }
1899
- } else if (cursorIdx < events.length - 1) {
1900
- // Append only new events after cursor
1901
- const newEvents = events.slice(cursorIdx + 1);
1902
- for (const evt of newEvents) {
1903
- const html = renderV2Event(evt);
1904
- if (html) container.insertAdjacentHTML('beforeend', html);
1905
- }
1906
- } else {
1907
- // No new events
1908
- return;
1909
- }
1910
-
1911
- v2LastCursor = v2EventSignature(events[events.length - 1]);
1912
- }
1913
-
1914
- if (autoScrollOn) {
1915
- isProgrammaticScroll = true;
1916
- $terminalBody.scrollTop = $terminalBody.scrollHeight;
1917
- requestAnimationFrame(() => { isProgrammaticScroll = false; });
1918
- }
1919
- }
1920
-
1921
- function renderV2Event(evt) {
1922
- const ts = evt.ts ? new Date(evt.ts).toLocaleTimeString() : '';
1923
- const type = evt.type || 'unknown';
1924
-
1925
- switch (type) {
1926
- case 'assistant_message':
1927
- return `<div class="conv-event conv-assistant"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">assistant</span><span class="conv-text">${escapeHtml((evt.payload?.text || '').slice(0, 2000))}</span></div>`;
1928
- case 'prompt_sent':
1929
- return `<div class="conv-event conv-user"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">user</span><span class="conv-text">${escapeHtml((evt.payload?.text || '').slice(0, 2000))}</span></div>`;
1930
- case 'tool_call':
1931
- return `<div class="conv-event conv-tool"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">tool</span><span class="conv-text">${escapeHtml(evt.payload?.tool || type)} ${escapeHtml((evt.payload?.path || '').slice(0, 200))}</span></div>`;
1932
- case 'tool_result':
1933
- return `<div class="conv-event conv-tool-result"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">result</span><span class="conv-text">${escapeHtml((evt.payload?.summary || '').slice(0, 500))}</span></div>`;
1934
- case 'agent_started':
1935
- return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">▶</span><span class="conv-text">Agent started (${escapeHtml(evt.role || '')} lane ${evt.laneNumber ?? '?'})</span></div>`;
1936
- case 'agent_exited':
1937
- return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">■</span><span class="conv-text">Agent exited (code ${evt.payload?.exitCode ?? '?'})</span></div>`;
1938
- case 'agent_crashed':
1939
- case 'agent_killed':
1940
- case 'agent_timeout':
1941
- return `<div class="conv-event conv-lifecycle conv-error"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">⚠</span><span class="conv-text">${escapeHtml(type)} ${escapeHtml(evt.payload?.reason || '')}</span></div>`;
1942
- case 'message_delivered':
1943
- return `<div class="conv-event conv-steer"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">✉</span><span class="conv-text">Steering: ${escapeHtml((evt.payload?.content || '').slice(0, 500))}</span></div>`;
1944
- case 'context_pressure':
1945
- return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">⚠</span><span class="conv-text">Context pressure: ${evt.payload?.pct ?? '?'}%</span></div>`;
1946
- default:
1947
- return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">•</span><span class="conv-text">${escapeHtml(type)}</span></div>`;
1948
- }
1949
- }
1950
-
1951
- // ── Segment-Scoped STATUS.md Helpers (TP-176) ──────────────────────────────
1952
-
1953
- /**
1954
- * Resolve the active segment repoId for a given task.
1955
- * Uses runtimeLaneSnapshots (active segment) and falls back to
1956
- * taskSegmentProgress (batch state).
1957
- * Returns { repoId, segmentInfo } or null if single-segment / unresolvable.
1958
- */
1959
- function resolveActiveSegmentForTask(taskId) {
1960
- if (!currentData) return null;
1961
- const batch = currentData.batch;
1962
- if (!batch) return null;
1963
- const task = (batch.tasks || []).find(t => t.taskId === taskId);
1964
- if (!task) return null;
1965
- const segmentIds = Array.isArray(task.segmentIds) ? task.segmentIds.filter(id => typeof id === "string") : [];
1966
- if (segmentIds.length <= 1) return null; // single-segment or no segments
1967
-
1968
- // Try to get active segment from runtime lane snapshots
1969
- const v2Snapshots = currentData.runtimeLaneSnapshots || {};
1970
- for (const snap of Object.values(v2Snapshots)) {
1971
- if (snap && snap.taskId === taskId && snap.segmentId) {
1972
- const parsed = parseSegmentId(snap.segmentId);
1973
- if (parsed) {
1974
- const idx = segmentIds.indexOf(snap.segmentId);
1975
- return {
1976
- repoId: parsed.repoId,
1977
- segmentInfo: {
1978
- index: idx >= 0 ? idx + 1 : null,
1979
- total: segmentIds.length,
1980
- repoId: parsed.repoId,
1981
- segmentId: snap.segmentId,
1982
- },
1983
- };
1984
- }
1985
- }
1986
- }
1987
-
1988
- // Fallback: use taskSegmentProgress (batch state)
1989
- const segmentStatusMap = buildSegmentStatusMap(batch);
1990
- const info = taskSegmentProgress(task, segmentStatusMap, null);
1991
- if (info && info.repoId) {
1992
- return { repoId: info.repoId, segmentInfo: info };
1993
- }
1994
- return null;
1995
- }
1996
-
1997
- /**
1998
- * Filter STATUS.md content to show only the active segment's blocks.
1999
- * Within each `### Step N:` section, removes `#### Segment: <otherRepo>` blocks
2000
- * and keeps only the block matching `activeRepoId`.
2001
- * Non-step content (metadata, notes, reviews, etc.) is preserved.
2002
- *
2003
- * Returns the filtered markdown string, or the original if no segment markers found.
2004
- */
2005
- function filterStatusMdForSegment(markdown, activeRepoId) {
2006
- if (!activeRepoId) return markdown;
2007
- const lines = markdown.split('\n');
2008
- const result = [];
2009
- let inStep = false; // inside a ### Step section
2010
- let inSegmentBlock = false; // inside a #### Segment: <repo> block
2011
- let segmentMatch = false; // current segment block matches active repo
2012
- let foundAnySegmentHeader = false;
2013
-
2014
- for (let i = 0; i < lines.length; i++) {
2015
- const line = lines[i];
2016
-
2017
- // Detect step headers: ### Step N: ...
2018
- if (/^###\s+Step\s+\d+/.test(line)) {
2019
- inStep = true;
2020
- inSegmentBlock = false;
2021
- segmentMatch = false;
2022
- result.push(line);
2023
- continue;
2024
- }
2025
-
2026
- // Detect non-step ### headers (e.g., ### Reviews, ### Notes)
2027
- if (/^###\s+/.test(line) && !/^###\s+Step\s+\d+/.test(line)) {
2028
- inStep = false;
2029
- inSegmentBlock = false;
2030
- segmentMatch = false;
2031
- result.push(line);
2032
- continue;
2033
- }
2034
-
2035
- // Inside a step section, detect #### Segment: <repoId> headers
2036
- if (inStep && /^####\s+Segment:\s*/.test(line)) {
2037
- foundAnySegmentHeader = true;
2038
- const segRepo = line.replace(/^####\s+Segment:\s*/, '').trim();
2039
- inSegmentBlock = true;
2040
- segmentMatch = (segRepo === activeRepoId);
2041
- if (segmentMatch) {
2042
- result.push(line);
2043
- }
2044
- continue;
2045
- }
2046
-
2047
- // Detect any other #### header (ends current segment block)
2048
- if (/^####\s+/.test(line)) {
2049
- inSegmentBlock = false;
2050
- segmentMatch = false;
2051
- result.push(line);
2052
- continue;
2053
- }
2054
-
2055
- // If we're in a segment block, only include matching lines
2056
- if (inSegmentBlock) {
2057
- if (segmentMatch) {
2058
- result.push(line);
2059
- }
2060
- continue;
2061
- }
2062
-
2063
- // Outside segment blocks: keep the line
2064
- result.push(line);
2065
- }
2066
-
2067
- // If no segment headers were found, return original (fallback for single-segment)
2068
- if (!foundAnySegmentHeader) return markdown;
2069
- return result.join('\n');
2070
- }
2071
-
2072
- // ── Open STATUS.md viewer ───────────────────────────────────────────────────
2073
-
2074
- function viewStatusMd(taskId) {
2075
- // Toggle off if already viewing this task
2076
- if (viewerMode === 'status-md' && viewerTarget === taskId && $terminalPanel.style.display !== 'none') {
2077
- closeViewer();
2078
- return;
2079
- }
2080
-
2081
- closeViewer();
2082
-
2083
- viewerMode = 'status-md';
2084
- viewerTarget = taskId;
2085
- autoScrollOn = false;
2086
- lastStatusMdText = '';
2087
-
2088
- // TP-176: Include segment context in title for multi-segment tasks
2089
- const segData = resolveActiveSegmentForTask(taskId);
2090
- if (segData && segData.segmentInfo) {
2091
- const label = segmentProgressText(segData.segmentInfo);
2092
- $terminalTitle.textContent = `STATUS.md — ${taskId} · ${label}`;
2093
- } else {
2094
- $terminalTitle.textContent = `STATUS.md — ${taskId}`;
2095
- }
2096
-
2097
- $autoScrollText.textContent = 'Track progress';
2098
- $autoScrollCheckbox.checked = false;
2099
- $terminalPanel.style.display = '';
2100
- $terminalBody.innerHTML = '<div class="conv-empty">Loading…</div>';
2101
-
2102
- pollStatusMd();
2103
- viewerTimer = setInterval(pollStatusMd, 2000);
2104
-
2105
- $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2106
- }
2107
-
2108
- function pollStatusMd() {
2109
- fetch(`/api/status-md/${encodeURIComponent(viewerTarget)}`)
2110
- .then(r => {
2111
- if (!r.ok) throw new Error('not found');
2112
- return r.text();
2113
- })
2114
- .then(text => {
2115
- // TP-176: Apply segment-scoped filtering for multi-segment tasks.
2116
- // Re-resolve on each poll since the active segment may change.
2117
- const segData = resolveActiveSegmentForTask(viewerTarget);
2118
- let displayText = text;
2119
- if (segData && segData.repoId) {
2120
- displayText = filterStatusMdForSegment(text, segData.repoId);
2121
- // Update title with current segment context (may change between polls)
2122
- const label = segmentProgressText(segData.segmentInfo);
2123
- $terminalTitle.textContent = `STATUS.md \u2014 ${viewerTarget} \u00b7 ${label}`;
2124
- }
2125
-
2126
- // Diff-and-skip: no change, no DOM update
2127
- if (displayText === lastStatusMdText) return;
2128
- lastStatusMdText = displayText;
2129
-
2130
- const { html, hasLastChecked } = renderStatusMd(displayText);
2131
- $terminalBody.innerHTML = html;
2132
-
2133
- // Update tracking highlight
2134
- updateTrackingHighlight();
2135
-
2136
- // Auto-scroll to last checked item
2137
- if (autoScrollOn && hasLastChecked) {
2138
- scrollToLastChecked();
2139
- }
2140
- })
2141
- .catch(() => {
2142
- if (!lastStatusMdText) {
2143
- $terminalBody.innerHTML = '<div class="conv-empty">STATUS.md not found</div>';
2144
- }
2145
- });
2146
- }
2147
-
2148
- // ── STATUS.md renderer ──────────────────────────────────────────────────────
2149
-
2150
- function renderStatusMd(markdown) {
2151
- const lines = markdown.split('\n');
2152
- let lastCheckedIdx = -1;
2153
-
2154
- // First pass: find last checked item
2155
- for (let i = 0; i < lines.length; i++) {
2156
- if (/^\s*-\s*\[x\]/i.test(lines[i])) lastCheckedIdx = i;
2157
- }
2158
-
2159
- let html = '<div class="status-md-content">';
2160
-
2161
- for (let i = 0; i < lines.length; i++) {
2162
- const line = lines[i];
2163
-
2164
- // Headings
2165
- const hMatch = line.match(/^(#{1,6})\s+(.+)/);
2166
- if (hMatch) {
2167
- const lvl = Math.min(hMatch[1].length, 4);
2168
- html += `<div class="status-md-h${lvl}">${renderInlineMd(hMatch[2])}</div>`;
2169
- continue;
2170
- }
2171
-
2172
- // Checked checkbox
2173
- if (/^\s*-\s*\[x\]/i.test(line)) {
2174
- const text = line.replace(/^\s*-\s*\[x\]\s*/i, '');
2175
- const isLast = i === lastCheckedIdx;
2176
- const cls = isLast ? 'status-md-check checked last-checked' : 'status-md-check checked';
2177
- const id = isLast ? ' id="last-checked"' : '';
2178
- html += `<div class="${cls}"${id}><span class="check-box">☑</span><span>${renderInlineMd(text)}</span></div>`;
2179
- continue;
2180
- }
2181
-
2182
- // Unchecked checkbox
2183
- if (/^\s*-\s*\[\s\]/.test(line)) {
2184
- const text = line.replace(/^\s*-\s*\[\s\]\s*/, '');
2185
- html += `<div class="status-md-check unchecked"><span class="check-box">☐</span><span>${renderInlineMd(text)}</span></div>`;
2186
- continue;
2187
- }
2188
-
2189
- // List item
2190
- const liMatch = line.match(/^\s*-\s+(.*)/);
2191
- if (liMatch) {
2192
- html += `<div class="status-md-li">• ${renderInlineMd(liMatch[1])}</div>`;
2193
- continue;
2194
- }
2195
-
2196
- // Empty line
2197
- if (!line.trim()) {
2198
- html += '<div class="status-md-spacer"></div>';
2199
- continue;
2200
- }
2201
-
2202
- // Plain text
2203
- html += `<div class="status-md-text">${renderInlineMd(line)}</div>`;
2204
- }
2205
-
2206
- html += '</div>';
2207
- return { html, hasLastChecked: lastCheckedIdx >= 0 };
2208
- }
2209
-
2210
- function renderInlineMd(text) {
2211
- let s = escapeHtml(text);
2212
- s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
2213
- s = s.replace(/`(.+?)`/g, '<code class="status-md-code">$1</code>');
2214
- return s;
2215
- }
2216
-
2217
- // ── Conversation event renderer ─────────────────────────────────────────────
2218
-
2219
- function renderConvEvent(event) {
2220
- switch (event.type) {
2221
- case "message_update": {
2222
- const delta = event.assistantMessageEvent;
2223
- if (delta?.type === "text_delta" && delta.delta) {
2224
- return `<span class="conv-text">${escapeHtml(delta.delta)}</span>`;
2225
- }
2226
- if (delta?.type === "thinking_delta" && delta.delta) {
2227
- return `<span class="conv-thinking">${escapeHtml(delta.delta)}</span>`;
2228
- }
2229
- return "";
2230
- }
2231
-
2232
- case "tool_call": {
2233
- const name = event.toolName || "unknown";
2234
- const argsStr = event.args?.path || event.args?.command || "";
2235
- return `<div class="conv-tool-call"><span class="conv-tool-name">🔧 ${escapeHtml(name)}</span> <span class="conv-tool-args">${escapeHtml(String(argsStr).substring(0, 200))}</span></div>`;
2236
- }
2237
-
2238
- case "tool_execution_start": {
2239
- const name = event.toolName || "unknown";
2240
- const argsStr = event.args?.path || event.args?.command || "";
2241
- return `<div class="conv-tool-call"><span class="conv-tool-name">🔧 ${escapeHtml(name)}</span> <span class="conv-tool-args">${escapeHtml(String(argsStr).substring(0, 200))}</span></div>`;
2242
- }
2243
-
2244
- case "tool_result": {
2245
- const output = event.output || event.result || "";
2246
- const truncated = String(output).length > 500 ? String(output).substring(0, 500) + "…" : String(output);
2247
- return `<div class="conv-tool-result"><pre>${escapeHtml(truncated)}</pre></div>`;
2248
- }
2249
-
2250
- case "message_end": {
2251
- const usage = event.message?.usage;
2252
- if (usage) {
2253
- const tokens = usage.totalTokens || (usage.input + usage.output) || 0;
2254
- return `<div class="conv-usage">Tokens: ${tokens.toLocaleString()}</div>`;
2255
- }
2256
- return "";
2257
- }
2258
-
2259
- default:
2260
- return "";
2261
- }
2262
- }
2263
-
2264
- // ── Auto-scroll logic ───────────────────────────────────────────────────────
2265
-
2266
- function scrollToLastChecked() {
2267
- const el = document.getElementById('last-checked');
2268
- if (!el) return;
2269
- isProgrammaticScroll = true;
2270
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2271
- setTimeout(() => { isProgrammaticScroll = false; }, 600);
2272
- }
2273
-
2274
- function updateTrackingHighlight() {
2275
- const container = $terminalBody.querySelector('.status-md-content');
2276
- if (container) {
2277
- container.classList.toggle('tracking', autoScrollOn && viewerMode === 'status-md');
2278
- }
2279
- }
2280
-
2281
- $autoScrollCheckbox.addEventListener('change', () => {
2282
- autoScrollOn = $autoScrollCheckbox.checked;
2283
- if (autoScrollOn) {
2284
- if (viewerMode === 'conversation') {
2285
- isProgrammaticScroll = true;
2286
- $terminalBody.scrollTop = $terminalBody.scrollHeight;
2287
- requestAnimationFrame(() => { isProgrammaticScroll = false; });
2288
- } else if (viewerMode === 'status-md') {
2289
- scrollToLastChecked();
2290
- updateTrackingHighlight();
2291
- }
2292
- } else {
2293
- updateTrackingHighlight();
2294
- }
2295
- });
2296
-
2297
- $terminalBody.addEventListener('scroll', () => {
2298
- if (isProgrammaticScroll) return;
2299
-
2300
- if (viewerMode === 'conversation') {
2301
- const isAtBottom = $terminalBody.scrollTop + $terminalBody.clientHeight >= $terminalBody.scrollHeight - 30;
2302
- if (isAtBottom && !autoScrollOn) {
2303
- autoScrollOn = true;
2304
- $autoScrollCheckbox.checked = true;
2305
- } else if (!isAtBottom && autoScrollOn) {
2306
- autoScrollOn = false;
2307
- $autoScrollCheckbox.checked = false;
2308
- }
2309
- } else if (viewerMode === 'status-md') {
2310
- if (autoScrollOn) {
2311
- autoScrollOn = false;
2312
- $autoScrollCheckbox.checked = false;
2313
- updateTrackingHighlight();
2314
- }
2315
- }
2316
- });
2317
-
2318
- // ── Close viewer ────────────────────────────────────────────────────────────
2319
-
2320
- function closeViewer() {
2321
- if (viewerTimer) {
2322
- clearInterval(viewerTimer);
2323
- viewerTimer = null;
2324
- }
2325
- viewerMode = null;
2326
- viewerTarget = null;
2327
- viewerV2AgentId = null;
2328
- autoScrollOn = false;
2329
- convRenderedLines = 0;
2330
- v2LastCursor = null;
2331
- v2FirstRender = true;
2332
- lastStatusMdText = '';
2333
- $terminalPanel.style.display = 'none';
2334
- $terminalBody.innerHTML = '';
2335
- }
2336
-
2337
- $terminalClose.addEventListener('click', closeViewer);
2338
-
2339
- // Make viewer functions available globally for onclick handlers
2340
- window.viewConversation = viewConversation;
2341
- window.viewStatusMd = viewStatusMd;
2342
-
2343
- // ─── History ────────────────────────────────────────────────────────────────
2344
-
2345
- /** Fetch the compact history list and populate the dropdown. */
2346
- function loadHistoryList() {
2347
- fetch("/api/history")
2348
- .then(r => r.json())
2349
- .then(list => {
2350
- historyList = list || [];
2351
- renderHistoryDropdown();
2352
- // Auto-select the latest history entry when no live batch is running.
2353
- // Always update the view here — renderNoBatch() shows a placeholder
2354
- // while this async fetch completes, so we need to replace it with
2355
- // the actual latest entry. This fixes #20 where the stale cached
2356
- // historyList caused the previous batch to be shown.
2357
- if (noBatchRendered && historyList.length > 0) {
2358
- viewHistoryEntry(historyList[0].batchId);
2359
- $historySelect.value = historyList[0].batchId;
2360
- } else if (noBatchRendered && historyList.length === 0) {
2361
- $historyBody.innerHTML = `
2362
- <div class="no-batch">
2363
- <div class="no-batch-icon">⏳</div>
2364
- <div class="no-batch-title">No batch running</div>
2365
- <div class="no-batch-hint">.pi/batch-state.json not found<br>Start an orchestrator batch to see the dashboard.</div>
2366
- </div>`;
2367
- $historyPanel.style.display = "";
2368
- }
2369
- })
2370
- .catch(() => {});
2371
- }
2372
-
2373
- function renderHistoryDropdown() {
2374
- $historySelect.innerHTML = '<option value="">History ▾</option>';
2375
- for (const h of historyList) {
2376
- const d = new Date(h.startedAt);
2377
- const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2378
- const timeStr = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
2379
- const statusIcon = h.status === "completed" ? "✓" : h.status === "partial" ? "⚠" : "✗";
2380
- const label = `${statusIcon} ${dateStr} ${timeStr} — ${h.totalTasks}tasks ${formatDuration(h.durationMs)}`;
2381
- const opt = document.createElement("option");
2382
- opt.value = h.batchId;
2383
- opt.textContent = label;
2384
- $historySelect.appendChild(opt);
2385
- }
2386
- }
2387
-
2388
- /** Load and display a specific historical batch. */
2389
- function viewHistoryEntry(batchId) {
2390
- if (!batchId) {
2391
- viewingHistoryId = null;
2392
- $historyPanel.style.display = "none";
2393
- return;
2394
- }
2395
- viewingHistoryId = batchId;
2396
- fetch(`/api/history/${encodeURIComponent(batchId)}`)
2397
- .then(r => r.json())
2398
- .then(entry => {
2399
- if (entry.error) {
2400
- $historyBody.innerHTML = `<div class="empty-state">${escapeHtml(entry.error)}</div>`;
2401
- } else {
2402
- renderHistorySummary(entry);
2403
- }
2404
- $historyPanel.style.display = "";
2405
- })
2406
- .catch(() => {
2407
- $historyBody.innerHTML = '<div class="empty-state">Failed to load batch details</div>';
2408
- $historyPanel.style.display = "";
2409
- });
2410
- }
2411
-
2412
- /** Render a full batch history summary. */
2413
- function renderHistorySummary(entry) {
2414
- const startDate = new Date(entry.startedAt).toLocaleString();
2415
- const endDate = entry.endedAt ? new Date(entry.endedAt).toLocaleString() : "—";
2416
- const tok = entry.tokens || {};
2417
- const totalIn = (tok.input || 0) + (tok.cacheRead || 0);
2418
- let tokenStr = `↑${formatTokens(totalIn)} ↓${formatTokens(tok.output || 0)}`;
2419
- const costStr = formatCost(tok.costUsd || 0);
2420
-
2421
- let html = `
2422
- <div class="history-header">
2423
- <span class="batch-id">${escapeHtml(entry.batchId)}</span>
2424
- <span class="batch-status ${entry.status}">${entry.status}</span>
2425
- <span class="batch-time">${startDate} → ${endDate}</span>
2426
- </div>
2427
-
2428
- <div class="history-stats">
2429
- <div class="stat-card">
2430
- <div class="stat-value">${entry.totalTasks}</div>
2431
- <div class="stat-label">Total Tasks</div>
2432
- </div>
2433
- <div class="stat-card">
2434
- <div class="stat-value" style="color:var(--green)">${entry.succeededTasks}</div>
2435
- <div class="stat-label">Succeeded</div>
2436
- </div>
2437
- <div class="stat-card">
2438
- <div class="stat-value" style="color:${entry.failedTasks > 0 ? 'var(--red)' : 'var(--text-muted)'}">${entry.failedTasks}</div>
2439
- <div class="stat-label">Failed</div>
2440
- </div>
2441
- <div class="stat-card">
2442
- <div class="stat-value">${entry.totalWaves}</div>
2443
- <div class="stat-label">Waves</div>
2444
- </div>
2445
- <div class="stat-card">
2446
- <div class="stat-value">${formatDuration(entry.durationMs)}</div>
2447
- <div class="stat-label">Duration</div>
2448
- </div>
2449
- <div class="stat-card stat-tokens">
2450
- <div class="stat-value">🪙 ${tokenStr}</div>
2451
- <div class="stat-label">Tokens</div>
2452
- </div>
2453
- ${costStr ? `<div class="stat-card">
2454
- <div class="stat-value" style="color:var(--yellow)">${costStr}</div>
2455
- <div class="stat-label">Cost</div>
2456
- </div>` : ""}
2457
- </div>`;
2458
-
2459
- // Wave table
2460
- if (entry.waves && entry.waves.length > 0) {
2461
- html += `<div class="history-section-title">Waves</div>`;
2462
- html += `<table class="history-waves-table"><thead><tr>
2463
- <th>Wave</th><th>Tasks</th><th>Merge</th><th>Duration</th><th>Tokens</th><th>Cost</th>
2464
- </tr></thead><tbody>`;
2465
- for (const w of entry.waves) {
2466
- const wTok = w.tokens || {};
2467
- const wTotalIn = (wTok.input || 0) + (wTok.cacheRead || 0);
2468
- let wTokenStr = `↑${formatTokens(wTotalIn)} ↓${formatTokens(wTok.output || 0)}`;
2469
- const mergeClass = w.mergeStatus === "succeeded" ? "status-succeeded" :
2470
- w.mergeStatus === "failed" ? "status-failed" : "status-stalled";
2471
- html += `<tr>
2472
- <td>Wave ${w.wave}</td>
2473
- <td>${w.tasks.join(", ")}</td>
2474
- <td><span class="status-badge ${mergeClass}">${w.mergeStatus}</span></td>
2475
- <td>${formatDuration(w.durationMs)}</td>
2476
- <td>${wTokenStr}</td>
2477
- <td style="color:var(--yellow)">${formatCost(wTok.costUsd || 0)}</td>
2478
- </tr>`;
2479
- }
2480
- html += `</tbody></table>`;
2481
- }
2482
-
2483
- // Task table
2484
- if (entry.tasks && entry.tasks.length > 0) {
2485
- html += `<div class="history-section-title">Tasks</div>`;
2486
- html += `<table class="history-tasks-table"><thead><tr>
2487
- <th>Task</th><th>Status</th><th>Wave</th><th>Lane</th><th>Duration</th><th>Tokens</th><th>Cost</th><th>Exit</th>
2488
- </tr></thead><tbody>`;
2489
- for (const t of entry.tasks) {
2490
- const tTok = t.tokens || {};
2491
- const tTotalIn = (tTok.input || 0) + (tTok.cacheRead || 0);
2492
- let tTokenStr = `↑${formatTokens(tTotalIn)} ↓${formatTokens(tTok.output || 0)}`;
2493
- const statusCls = `status-${t.status}`;
2494
- html += `<tr>
2495
- <td>${escapeHtml(t.taskId)}</td>
2496
- <td><span class="status-badge ${statusCls}">${t.status}</span></td>
2497
- <td>W${t.wave}</td>
2498
- <td>L${t.lane}</td>
2499
- <td>${formatDuration(t.durationMs)}</td>
2500
- <td>${tTokenStr}</td>
2501
- <td style="color:var(--yellow)">${formatCost(tTok.costUsd || 0)}</td>
2502
- <td style="font-size:0.8rem;color:var(--text-muted)">${t.exitReason ? escapeHtml(t.exitReason) : "—"}</td>
2503
- </tr>`;
2504
- }
2505
- html += `</tbody></table>`;
2506
- }
2507
-
2508
- $historyBody.innerHTML = html;
2509
- }
2510
-
2511
- /** Handle dropdown change. */
2512
- $historySelect.addEventListener("change", (e) => {
2513
- const batchId = e.target.value;
2514
- if (batchId) {
2515
- viewHistoryEntry(batchId);
2516
- } else {
2517
- // Switched to "History ▾" — go back to live view or latest
2518
- viewingHistoryId = null;
2519
- $historyPanel.style.display = "none";
2520
- }
2521
- });
2522
-
2523
- // ─── Theme Toggle ───────────────────────────────────────────────────────────
2524
-
2525
- const DARK_LOGO = "taskplane-word-white.svg";
2526
- const LIGHT_LOGO = "taskplane-word-color.svg";
2527
-
2528
- function applyTheme(theme) {
2529
- document.documentElement.setAttribute("data-theme", theme);
2530
- const logo = document.getElementById("header-logo");
2531
- const icon = document.getElementById("theme-toggle-icon");
2532
- if (logo) logo.src = theme === "light" ? LIGHT_LOGO : DARK_LOGO;
2533
- if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙";
2534
- }
2535
-
2536
- function loadThemePreference() {
2537
- fetch("/api/preferences")
2538
- .then(r => r.ok ? r.json() : { theme: "dark" })
2539
- .then(prefs => applyTheme(prefs.theme || "dark"))
2540
- .catch(() => applyTheme("dark"));
2541
- }
2542
-
2543
- function saveThemePreference(theme) {
2544
- fetch("/api/preferences", {
2545
- method: "POST",
2546
- headers: { "Content-Type": "application/json" },
2547
- body: JSON.stringify({ theme }),
2548
- }).catch(() => {}); // best-effort
2549
- }
2550
-
2551
- const $themeToggle = document.getElementById("theme-toggle");
2552
- if ($themeToggle) {
2553
- $themeToggle.addEventListener("click", () => {
2554
- const current = document.documentElement.getAttribute("data-theme") || "dark";
2555
- const next = current === "dark" ? "light" : "dark";
2556
- applyTheme(next);
2557
- saveThemePreference(next);
2558
- });
2559
- }
2560
-
2561
- // Load saved preference on startup
2562
- loadThemePreference();
2563
-
2564
- // ─── Boot ───────────────────────────────────────────────────────────────────
2565
-
2566
- connect();
2567
- loadHistoryList();
2568
-
2569
- // One-shot fetch on load (in case SSE is slow to connect)
2570
- fetch("/api/state")
2571
- .then(r => r.json())
2572
- .then(render)
2573
- .catch(() => {});
1
+ /**
2
+ * Orchestrator Web Dashboard — Frontend
3
+ *
4
+ * Connects to SSE endpoint for live state updates.
5
+ * Zero dependencies, vanilla JS.
6
+ */
7
+
8
+ // ─── Helpers ────────────────────────────────────────────────────────────────
9
+
10
+ function formatDuration(ms) {
11
+ if (!ms || ms <= 0) return "—";
12
+ const totalSec = Math.floor(ms / 1000);
13
+ const h = Math.floor(totalSec / 3600);
14
+ const m = Math.floor((totalSec % 3600) / 60);
15
+ const s = totalSec % 60;
16
+ if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
17
+ return `${m}m ${String(s).padStart(2, "0")}s`;
18
+ }
19
+
20
+ function relativeTime(epochOrIso) {
21
+ if (!epochOrIso) return "";
22
+ const ts = typeof epochOrIso === "string" ? new Date(epochOrIso).getTime() : epochOrIso;
23
+ if (isNaN(ts)) return "";
24
+ const diff = Date.now() - ts;
25
+ if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
26
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
27
+ return `${Math.floor(diff / 3600000)}h ago`;
28
+ }
29
+
30
+ function pctClass(pct) {
31
+ if (pct >= 100) return "pct-hi";
32
+ if (pct >= 50) return "pct-mid";
33
+ if (pct > 0) return "pct-low";
34
+ return "pct-0";
35
+ }
36
+
37
+ function escapeHtml(str) {
38
+ const div = document.createElement("div");
39
+ div.textContent = str;
40
+ return div.innerHTML;
41
+ }
42
+
43
+ /** Format token count as human-readable (e.g., 1.2k, 45k, 1.2M). */
44
+ function formatTokens(n) {
45
+ if (!n || n === 0) return "0";
46
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
47
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
48
+ return String(n);
49
+ }
50
+
51
+ function formatCost(usd) {
52
+ if (!usd || usd === 0) return "";
53
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
54
+ if (usd < 1) return `$${usd.toFixed(3)}`;
55
+ return `$${usd.toFixed(2)}`;
56
+ }
57
+
58
+ /**
59
+ * TP-107: Check if a lane has a live agent via the Runtime V2 registry.
60
+ * Returns true/false if registry data is available, null if no V2 data.
61
+ */
62
+ function isLaneAliveV2(laneNumber) {
63
+ if (!currentData || !currentData.runtimeRegistry || !currentData.runtimeRegistry.agents) return null;
64
+ const agents = Object.values(currentData.runtimeRegistry.agents);
65
+ const laneAgents = agents.filter(a => a.laneNumber === laneNumber);
66
+ if (laneAgents.length === 0) return null;
67
+ return laneAgents.some(a => a.status === 'running' || a.status === 'spawning');
68
+ }
69
+
70
+ /**
71
+ * TP-107: Merge Runtime V2 lane snapshot data onto legacy lane state.
72
+ * V2 fields take precedence when present; legacy fields are preserved as fallback.
73
+ */
74
+ function mergeV2LaneSnapshot(legacyLs, v2snap) {
75
+ const base = legacyLs ? { ...legacyLs } : {};
76
+ // Overlay V2 fields from nested worker snapshot onto flat legacy shape.
77
+ // RuntimeLaneSnapshot has worker: { status, elapsedMs, toolCalls, contextPct, ... }
78
+ const w = v2snap.worker;
79
+ if (w) {
80
+ // Map V2 agent status to legacy dashboard status strings
81
+ if (w.status) {
82
+ const statusMap = { running: 'running', spawning: 'running', exited: 'done', crashed: 'error', killed: 'done', timed_out: 'error', wrapping_up: 'running' };
83
+ base.workerStatus = statusMap[w.status] || w.status;
84
+ }
85
+ if (w.elapsedMs != null) base.workerElapsed = w.elapsedMs;
86
+ if (w.contextPct != null) base.workerContextPct = w.contextPct;
87
+ if (w.toolCalls != null) base.workerToolCount = w.toolCalls;
88
+ if (w.lastTool) base.workerLastTool = w.lastTool;
89
+ if (w.costUsd != null) base.workerCostUsd = w.costUsd;
90
+ if (w.inputTokens != null) base.workerInputTokens = w.inputTokens;
91
+ if (w.outputTokens != null) base.workerOutputTokens = w.outputTokens;
92
+ if (w.cacheReadTokens != null) base.workerCacheReadTokens = w.cacheReadTokens;
93
+ if (w.cacheWriteTokens != null) base.workerCacheWriteTokens = w.cacheWriteTokens;
94
+ }
95
+ if (v2snap.taskId) base.taskId = v2snap.taskId;
96
+ if (v2snap.batchId) base.batchId = v2snap.batchId;
97
+ // Enrich progress display from V2 snapshot
98
+ if (v2snap.progress) {
99
+ base._v2Progress = v2snap.progress;
100
+ }
101
+ return base;
102
+ }
103
+
104
+ function isReviewerActiveForTask(ls, task) {
105
+ if (!ls || !task) return false;
106
+ return !!(ls.reviewerStatus === "running" && task.status === "running" && (!ls.taskId || ls.taskId === task.taskId));
107
+ }
108
+
109
+ /** Build a compact token summary string from lane state sidecar data.
110
+ * Display: ↑total_input ↓output (cost)
111
+ * Anthropic splits input into: uncached `input` + `cacheRead`.
112
+ * Both represent tokens the model processed as input.
113
+ * We show the combined figure as ↑ for clarity.
114
+ */
115
+ function tokenSummaryFromLaneState(ls) {
116
+ if (!ls) return "";
117
+ const inp = ls.workerInputTokens || 0;
118
+ const out = ls.workerOutputTokens || 0;
119
+ const cr = ls.workerCacheReadTokens || 0;
120
+ const cw = ls.workerCacheWriteTokens || 0;
121
+ const cost = ls.workerCostUsd || 0;
122
+ const totalIn = inp + cr; // uncached + cached = total input processed
123
+ if (totalIn === 0 && out === 0) return "";
124
+ let s = `↑${formatTokens(totalIn)} ↓${formatTokens(out)}`;
125
+ if (cost > 0) s += ` ${formatCost(cost)}`;
126
+ return s;
127
+ }
128
+
129
+ function tokenSummaryFromReviewerLaneState(ls) {
130
+ if (!ls) return "";
131
+ const inp = ls.reviewerInputTokens || 0;
132
+ const out = ls.reviewerOutputTokens || 0;
133
+ const cr = ls.reviewerCacheReadTokens || 0;
134
+ const cw = ls.reviewerCacheWriteTokens || 0;
135
+ const cost = ls.reviewerCostUsd || 0;
136
+ const totalIn = inp + cr; // uncached + cached = total input processed
137
+ if (totalIn === 0 && out === 0) return "";
138
+ let s = `↑${formatTokens(totalIn)} ↓${formatTokens(out)}`;
139
+ if (cost > 0) s += ` ${formatCost(cost)}`;
140
+ return s;
141
+ }
142
+
143
+ /** Build compact telemetry badge HTML for retry/compaction indicators.
144
+ * Only shows badges when telemetry data has meaningful values.
145
+ * @param {object|null} tel - Telemetry data for a lane (from currentData.telemetry[prefix])
146
+ * @param {boolean} [suppressRetry=false] - When true, hide the retrying badge
147
+ * (used when reviewer is active — long tool calls trigger false retry signals)
148
+ * @returns {string} HTML string with badges, or "" if nothing to show
149
+ */
150
+ function telemetryBadgesHtml(tel, suppressRetry) {
151
+ if (!tel) return "";
152
+ let badges = "";
153
+ if (tel.retryActive && !suppressRetry) {
154
+ const err = tel.lastRetryError ? ` — ${tel.lastRetryError}` : "";
155
+ badges += `<span class="telem-badge telem-retry-active" title="Retry in progress${escapeHtml(err)}">🔄 retrying</span>`;
156
+ } else if (tel.retries > 0 && !suppressRetry) {
157
+ badges += `<span class="telem-badge telem-retry" title="${tel.retries} auto-retry event(s)">🔄 ${tel.retries}</span>`;
158
+ }
159
+ if (tel.compactions > 0) {
160
+ badges += `<span class="telem-badge telem-compaction" title="${tel.compactions} context compaction(s)">🗜 ${tel.compactions}</span>`;
161
+ }
162
+ return badges;
163
+ }
164
+
165
+ // ─── Copy to Clipboard ──────────────────────────────────────────────────────
166
+
167
+ let toastEl = null;
168
+ let toastTimer = null;
169
+
170
+ function showCopyToast(text) {
171
+ if (!toastEl) {
172
+ toastEl = document.createElement("div");
173
+ toastEl.className = "copy-toast";
174
+ document.body.appendChild(toastEl);
175
+ }
176
+ toastEl.textContent = `Copied: ${text}`;
177
+ toastEl.classList.add("visible");
178
+ clearTimeout(toastTimer);
179
+ toastTimer = setTimeout(() => toastEl.classList.remove("visible"), 2000);
180
+ }
181
+
182
+ function copySessionId(sessionName) {
183
+ // Retained for potential future use but no longer rendered in the UI.
184
+ navigator.clipboard.writeText(sessionName).then(() => {
185
+ showCopyToast(`session ${sessionName}`);
186
+ const btn = document.querySelector(`[data-session="${sessionName}"]`);
187
+ if (btn) {
188
+ btn.classList.add("copied");
189
+ setTimeout(() => btn.classList.remove("copied"), 1500);
190
+ }
191
+ }).catch(() => {
192
+ // Fallback: select the text
193
+ const btn = document.querySelector(`[data-session="${sessionName}"]`);
194
+ if (btn) {
195
+ const range = document.createRange();
196
+ range.selectNodeContents(btn);
197
+ window.getSelection().removeAllRanges();
198
+ window.getSelection().addRange(range);
199
+ }
200
+ });
201
+ }
202
+
203
+
204
+
205
+ // ─── DOM References ─────────────────────────────────────────────────────────
206
+
207
+ const $ = (id) => document.getElementById(id);
208
+
209
+ const $batchId = $("batch-id");
210
+ const $batchPhase = $("batch-phase");
211
+ const $connDot = $("conn-dot");
212
+ const $lastUpdate = $("last-update");
213
+ const $progressBarBg = $("progress-bar-bg");
214
+ const $overallPct = $("overall-pct");
215
+ const $summaryCounts = $("summary-counts");
216
+ const $summaryElapsed = $("summary-elapsed");
217
+ const $summaryWaves = $("summary-waves");
218
+ const $lanesTasksBody = $("lanes-tasks-body");
219
+ const $mergeBody = $("merge-body");
220
+ const $errorsPanel = $("errors-panel");
221
+ const $errorsBody = $("errors-body");
222
+ const $footerInfo = $("footer-info");
223
+ const $content = $("content");
224
+ const $historySelect = $("history-select");
225
+ const $historyPanel = $("history-panel");
226
+ const $historyBody = $("history-body");
227
+
228
+ // ─── Repo Filter State ──────────────────────────────────────────────────────
229
+
230
+ const $repoFilter = $("repo-filter");
231
+ let selectedRepo = ""; // "" means "All repos"
232
+ let knownRepos = []; // sorted list of known repo IDs
233
+ let repoFilterVisible = false;
234
+
235
+ // ─── History State ──────────────────────────────────────────────────────────
236
+
237
+ let historyList = []; // compact batch summaries
238
+ let viewingHistoryId = null; // batchId if viewing history, null if live
239
+
240
+ // ─── Viewer State ───────────────────────────────────────────────────────────
241
+
242
+ let viewerMode = null; // "conversation" | "status-md" | null
243
+ let viewerTarget = null; // session name (conversation) or taskId (status-md)
244
+ let lastBatchId = null; // TP-178: track batchId for stale viewer detection (#487)
245
+
246
+ // ─── Repo Helpers ───────────────────────────────────────────────────────────
247
+
248
+ /**
249
+ * Build a sorted, deduplicated list of repo IDs from the batch payload.
250
+ * Returns empty array when mode !== "workspace" or when fewer than 2 repos.
251
+ */
252
+ function buildRepoSet(batch) {
253
+ if (!batch || batch.mode !== "workspace") return [];
254
+
255
+ const repos = new Set();
256
+ for (const lane of (batch.lanes || [])) {
257
+ if (lane.repoId) repos.add(lane.repoId);
258
+ }
259
+ for (const task of (batch.tasks || [])) {
260
+ const rid = task.resolvedRepoId || task.repoId;
261
+ if (rid) repos.add(rid);
262
+ }
263
+ for (const mr of (batch.mergeResults || [])) {
264
+ for (const rr of (mr.repoResults || [])) {
265
+ if (rr.repoId) repos.add(rr.repoId);
266
+ }
267
+ }
268
+ const sorted = Array.from(repos).sort();
269
+ return sorted.length >= 2 ? sorted : [];
270
+ }
271
+
272
+ /**
273
+ * Update the repo filter dropdown options and visibility.
274
+ * Resets selection to "All repos" if the previously selected repo disappeared.
275
+ */
276
+ function updateRepoFilter(repos) {
277
+ knownRepos = repos;
278
+ const shouldShow = repos.length >= 2;
279
+
280
+ if (shouldShow !== repoFilterVisible) {
281
+ $repoFilter.style.display = shouldShow ? "" : "none";
282
+ repoFilterVisible = shouldShow;
283
+ }
284
+
285
+ if (!shouldShow) {
286
+ selectedRepo = "";
287
+ return;
288
+ }
289
+
290
+ // If selected repo disappeared, reset to "All"
291
+ if (selectedRepo && !repos.includes(selectedRepo)) {
292
+ selectedRepo = "";
293
+ }
294
+
295
+ // Rebuild options only if repo set changed
296
+ const currentOpts = Array.from($repoFilter.options).slice(1).map(o => o.value);
297
+ const changed = currentOpts.length !== repos.length || currentOpts.some((v, i) => v !== repos[i]);
298
+ if (changed) {
299
+ // Preserve selection
300
+ const prev = selectedRepo;
301
+ $repoFilter.innerHTML = '<option value="">All repos</option>';
302
+ for (const r of repos) {
303
+ const opt = document.createElement("option");
304
+ opt.value = r;
305
+ opt.textContent = r;
306
+ $repoFilter.appendChild(opt);
307
+ }
308
+ $repoFilter.value = prev;
309
+ }
310
+ }
311
+
312
+ /** Get the effective repo ID for a task (prefer resolvedRepoId, fallback repoId). */
313
+ function taskRepoId(task) {
314
+ return task.resolvedRepoId || task.repoId || undefined;
315
+ }
316
+
317
+ /** Render a repo badge span. Returns "" if repoId is falsy or repos not active. */
318
+ function repoBadgeHtml(repoId, extraClass) {
319
+ if (!repoId || knownRepos.length < 2) return "";
320
+ return `<span class="repo-badge ${extraClass || ""}" title="Repo: ${escapeHtml(repoId)}">${escapeHtml(repoId)}</span>`;
321
+ }
322
+
323
+ function parseSegmentId(segmentId) {
324
+ if (!segmentId || typeof segmentId !== "string") return null;
325
+ const sep = segmentId.indexOf("::");
326
+ if (sep <= 0 || sep >= segmentId.length - 2) return null;
327
+ return {
328
+ taskId: segmentId.slice(0, sep),
329
+ repoId: segmentId.slice(sep + 2),
330
+ };
331
+ }
332
+
333
+ function segmentProgressText(segmentInfo) {
334
+ if (!segmentInfo) return "";
335
+ const repo = segmentInfo.repoId || "unknown";
336
+ if (segmentInfo.index && segmentInfo.total) {
337
+ return `Segment ${segmentInfo.index}/${segmentInfo.total}: ${repo}`;
338
+ }
339
+ return `Segment: ${repo}`;
340
+ }
341
+
342
+ function buildSegmentStatusMap(batch) {
343
+ const map = new Map();
344
+ for (const seg of (batch?.segments || [])) {
345
+ if (seg && typeof seg.segmentId === "string") {
346
+ map.set(seg.segmentId, seg.status || "pending");
347
+ }
348
+ }
349
+ return map;
350
+ }
351
+
352
+ function taskSegmentProgress(task, segmentStatusMap, forcedActiveSegmentId) {
353
+ const segmentIds = Array.isArray(task?.segmentIds)
354
+ ? task.segmentIds.filter(id => typeof id === "string")
355
+ : [];
356
+ // Repo-singleton (or repo-mode) tasks should stay visually clean.
357
+ if (segmentIds.length <= 1) return null;
358
+
359
+ const activeSegmentId = forcedActiveSegmentId || task.activeSegmentId;
360
+ let currentSegmentId = activeSegmentId && segmentIds.includes(activeSegmentId)
361
+ ? activeSegmentId
362
+ : null;
363
+
364
+ if (!currentSegmentId) {
365
+ if (task.status === "pending" || task.status === "running") {
366
+ currentSegmentId = segmentIds.find((id) => {
367
+ const status = segmentStatusMap.get(id);
368
+ return !["succeeded", "failed", "stalled", "skipped"].includes(status);
369
+ }) || segmentIds[segmentIds.length - 1];
370
+ } else {
371
+ currentSegmentId = segmentIds[segmentIds.length - 1];
372
+ }
373
+ }
374
+
375
+ const idx = Math.max(0, segmentIds.indexOf(currentSegmentId));
376
+ const parsed = parseSegmentId(currentSegmentId);
377
+ return {
378
+ index: idx + 1,
379
+ total: segmentIds.length,
380
+ repoId: parsed?.repoId || taskRepoId(task) || undefined,
381
+ segmentId: currentSegmentId,
382
+ };
383
+ }
384
+
385
+ function laneActiveSegmentInfo(v2snap, laneTasks, segmentStatusMap) {
386
+ if (!v2snap || !v2snap.segmentId) return null;
387
+ const parsed = parseSegmentId(v2snap.segmentId);
388
+ if (!parsed) return null;
389
+
390
+ const ownerTaskId = v2snap.taskId || parsed.taskId;
391
+ const ownerTask = (laneTasks || []).find(t => t.taskId === ownerTaskId) || null;
392
+ if (ownerTask) {
393
+ const byTask = taskSegmentProgress(ownerTask, segmentStatusMap, v2snap.segmentId);
394
+ if (byTask) return byTask;
395
+ return null;
396
+ }
397
+
398
+ return {
399
+ index: null,
400
+ total: null,
401
+ repoId: parsed.repoId,
402
+ segmentId: v2snap.segmentId,
403
+ };
404
+ }
405
+
406
+ // Repo filter change handler
407
+ $repoFilter.addEventListener("change", (e) => {
408
+ selectedRepo = e.target.value;
409
+ // Re-render with current data
410
+ if (currentData) {
411
+ const batch = currentData.batch;
412
+ const sessions = currentData.sessions ?? currentData.tmuxSessions ?? [];
413
+ if (batch) {
414
+ renderLanesTasks(batch, sessions);
415
+ renderMergeAgents(batch, sessions);
416
+ }
417
+ }
418
+ });
419
+
420
+ // ─── Render: Header ─────────────────────────────────────────────────────────
421
+
422
+ function renderHeader(batch) {
423
+ if (!batch) {
424
+ $batchId.textContent = "—";
425
+ $batchPhase.textContent = "No batch";
426
+ $batchPhase.className = "header-badge badge-phase";
427
+ return;
428
+ }
429
+ $batchId.textContent = batch.batchId;
430
+ $batchPhase.textContent = batch.phase;
431
+ $batchPhase.className = `header-badge badge-phase phase-${batch.phase}`;
432
+ }
433
+
434
+ // ─── Render: Summary ────────────────────────────────────────────────────────
435
+
436
+ function renderSummary(batch) {
437
+ if (!batch) {
438
+ $progressBarBg.innerHTML = "";
439
+ $overallPct.textContent = "0%";
440
+ $summaryCounts.innerHTML = "";
441
+ $summaryElapsed.textContent = "—";
442
+ $summaryWaves.innerHTML = "";
443
+ return;
444
+ }
445
+
446
+ const tasks = batch.tasks || [];
447
+ const total = tasks.length;
448
+ const succeeded = tasks.filter(t => t.status === "succeeded").length;
449
+ const running = tasks.filter(t => t.status === "running").length;
450
+ const failed = tasks.filter(t => t.status === "failed").length;
451
+ const stalled = tasks.filter(t => t.status === "stalled").length;
452
+ const pending = tasks.filter(t => t.status === "pending").length;
453
+
454
+ // ── Checkbox-based progress by wave ──────────────────────────
455
+ const taskMap = new Map(tasks.map(t => [t.taskId, t]));
456
+ const wavePlan = batch.wavePlan || [tasks.map(t => t.taskId)]; // fallback: single wave
457
+ const currentWaveIdx = batch.currentWaveIndex || 0;
458
+
459
+ // TP-148: Build wave segment context — for each task appearing in multiple waves,
460
+ // determine which segment corresponds to each wave appearance.
461
+ const taskWaveAppearance = new Map(); // taskId → count of appearances so far
462
+ const waveSegmentLabels = wavePlan.map((taskIds) => {
463
+ const labels = new Map(); // taskId → label string
464
+ for (const tid of taskIds) {
465
+ const task = taskMap.get(tid);
466
+ const segmentIds = task?.segmentIds;
467
+ if (!segmentIds || segmentIds.length <= 1) continue;
468
+ const count = (taskWaveAppearance.get(tid) || 0);
469
+ taskWaveAppearance.set(tid, count + 1);
470
+ const segId = segmentIds[count];
471
+ if (segId) {
472
+ const parsed = parseSegmentId(segId);
473
+ const repo = parsed ? parsed.repoId : "";
474
+ labels.set(tid, `${tid} (segment ${count + 1}/${segmentIds.length}: ${repo})`);
475
+ }
476
+ }
477
+ return labels;
478
+ });
479
+
480
+ // Compute per-wave and overall checkbox totals
481
+ let batchChecked = 0, batchTotal = 0;
482
+ const waveStats = wavePlan.map((taskIds, waveIdx) => {
483
+ let wChecked = 0, wTotal = 0;
484
+ let allSucceeded = taskIds.length > 0;
485
+ for (const tid of taskIds) {
486
+ const t = taskMap.get(tid);
487
+ if (!t || t.status !== "succeeded") allSucceeded = false;
488
+ if (t && t.status === "succeeded" && t.statusData) {
489
+ // Succeeded task with statusData: count as fully done even if
490
+ // STATUS.md checkboxes weren't all ticked before .DONE was created
491
+ const total = t.statusData.total || 1;
492
+ wChecked += total;
493
+ wTotal += total;
494
+ } else if (t && t.statusData) {
495
+ wChecked += t.statusData.checked || 0;
496
+ wTotal += t.statusData.total || 0;
497
+ } else if (t && t.status === "succeeded") {
498
+ // Succeeded tasks may not have statusData if STATUS.md was cleaned up
499
+ // Count as fully done — use a small placeholder if no data
500
+ wChecked += 1;
501
+ wTotal += 1;
502
+ }
503
+ }
504
+ batchChecked += wChecked;
505
+ batchTotal += wTotal;
506
+ return { waveIdx, taskIds, checked: wChecked, total: wTotal, allSucceeded };
507
+ });
508
+
509
+ const overallPct = batchTotal > 0 ? Math.round((batchChecked / batchTotal) * 100) : 0;
510
+ $overallPct.textContent = `${overallPct}%`;
511
+
512
+ // Build segmented progress bar — each wave gets a proportional segment
513
+ let barHtml = "";
514
+ for (const ws of waveStats) {
515
+ const segWidthPct = batchTotal > 0 ? (ws.total / batchTotal) * 100 : (100 / waveStats.length);
516
+ const fillPct = ws.total > 0 ? (ws.checked / ws.total) * 100 : 0;
517
+ const checkboxDone = ws.checked === ws.total && ws.total > 0;
518
+ const pastWave = ws.waveIdx < currentWaveIdx;
519
+ const batchDone = batch.phase === "completed";
520
+ // TP-178: During merging, only past waves are truly done. The current wave's
521
+ // checkboxDone/allSucceeded can be true (tasks finished) but the wave itself
522
+ // isn't done until the merge completes. (#493)
523
+ const isMerging = batch.phase === "merging";
524
+ const isDone = batchDone || pastWave || (!isMerging && (checkboxDone || ws.allSucceeded));
525
+ const isMergingWave = isMerging && ws.waveIdx === currentWaveIdx;
526
+ const isCurrent = ws.waveIdx === currentWaveIdx && (batch.phase === "executing" || isMerging);
527
+ const isFuture = ws.waveIdx > currentWaveIdx && (batch.phase === "executing" || isMerging);
528
+
529
+ const fillClass = isDone ? "pct-hi" : fillPct > 50 ? "pct-mid" : fillPct > 0 ? "pct-low" : "pct-0";
530
+ const fillWidth = isDone ? 100 : fillPct;
531
+ // TP-178: Add merging visual state for the wave currently being merged (#493)
532
+ const segClass = isMergingWave ? "wave-seg-current wave-seg-merging" : isCurrent ? "wave-seg-current" : isFuture ? "wave-seg-future" : "";
533
+
534
+ // TP-148: Use segment-aware labels in tooltip when available
535
+ const segLabels = waveSegmentLabels[ws.waveIdx] || new Map();
536
+ const tooltipTasks = ws.taskIds.map(tid => segLabels.get(tid) || tid).join(', ');
537
+ barHtml += `<div class="wave-seg ${segClass}" style="width:${segWidthPct.toFixed(1)}%" title="W${ws.waveIdx + 1}: ${ws.checked}/${ws.total} checkboxes (${tooltipTasks})">`;
538
+ barHtml += ` <div class="wave-seg-fill ${fillClass}" style="width:${fillWidth.toFixed(1)}%"></div>`;
539
+ barHtml += ` <span class="wave-seg-label">W${ws.waveIdx + 1}</span>`;
540
+ barHtml += `</div>`;
541
+ }
542
+ $progressBarBg.innerHTML = barHtml;
543
+
544
+ let countsHtml = "";
545
+ if (succeeded > 0) countsHtml += `<span class="count-chip count-succeeded"><span class="count-num">${succeeded}</span><span class="count-icon">✓</span></span>`;
546
+ if (running > 0) countsHtml += `<span class="count-chip count-running"><span class="count-num">${running}</span><span class="count-icon">▶</span></span>`;
547
+ if (failed > 0) countsHtml += `<span class="count-chip count-failed"><span class="count-num">${failed}</span><span class="count-icon">✗</span></span>`;
548
+ if (stalled > 0) countsHtml += `<span class="count-chip count-stalled"><span class="count-num">${stalled}</span><span class="count-icon">⏸</span></span>`;
549
+ if (pending > 0) countsHtml += `<span class="count-chip count-pending"><span class="count-num">${pending}</span><span class="count-icon">◌</span></span>`;
550
+ countsHtml += `<span class="count-total">/ ${total}</span>`;
551
+ $summaryCounts.innerHTML = countsHtml;
552
+
553
+ const elapsed = batch.startedAt ? Date.now() - batch.startedAt : 0;
554
+ let elapsedStr = `elapsed: ${formatDuration(elapsed)}`;
555
+ if (batch.updatedAt) elapsedStr += ` · updated: ${relativeTime(batch.updatedAt)}`;
556
+
557
+ // Aggregate tokens/cost for summary.
558
+ // Runtime V2 snapshots are authoritative when present; legacy lane-state sidecars are fallback.
559
+ const laneStates = currentData?.laneStates || {};
560
+ const runtimeLaneSnapshots = currentData?.runtimeLaneSnapshots || {};
561
+ const v2Snaps = Object.values(runtimeLaneSnapshots);
562
+
563
+ let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCostFromSnapshots = 0;
564
+
565
+ if (v2Snaps.length > 0) {
566
+ for (const snap of v2Snaps) {
567
+ const w = snap?.worker || {};
568
+ batchInput += w.inputTokens || 0;
569
+ batchOutput += w.outputTokens || 0;
570
+ batchCacheRead += w.cacheReadTokens || 0;
571
+ batchCacheWrite += w.cacheWriteTokens || 0;
572
+ batchCostFromSnapshots += w.costUsd || 0;
573
+
574
+ const r = snap?.reviewer || null;
575
+ if (r) {
576
+ batchInput += r.inputTokens || 0;
577
+ batchOutput += r.outputTokens || 0;
578
+ batchCacheRead += r.cacheReadTokens || 0;
579
+ batchCacheWrite += r.cacheWriteTokens || 0;
580
+ batchCostFromSnapshots += r.costUsd || 0;
581
+ }
582
+ }
583
+ } else {
584
+ // Legacy fallback
585
+ for (const ls of Object.values(laneStates)) {
586
+ batchInput += ls.workerInputTokens || 0;
587
+ batchOutput += ls.workerOutputTokens || 0;
588
+ batchCacheRead += ls.workerCacheReadTokens || 0;
589
+ batchCacheWrite += ls.workerCacheWriteTokens || 0;
590
+ batchCostFromSnapshots += ls.workerCostUsd || 0;
591
+ }
592
+ }
593
+
594
+ // Keep server-computed cost as fallback for uncovered early-start lanes.
595
+ const batchCost = batchCostFromSnapshots > 0
596
+ ? batchCostFromSnapshots
597
+ : ((currentData?.batchTotalCost != null && currentData.batchTotalCost > 0)
598
+ ? currentData.batchTotalCost
599
+ : 0);
600
+ const batchTotalIn = batchInput + batchCacheRead;
601
+ if (batchTotalIn > 0 || batchOutput > 0) {
602
+ let tokenStr = ` · tokens: ↑${formatTokens(batchTotalIn)} ↓${formatTokens(batchOutput)}`;
603
+ if (batchCost > 0) tokenStr += ` · cost: ${formatCost(batchCost)}`;
604
+ elapsedStr += tokenStr;
605
+ }
606
+
607
+ $summaryElapsed.textContent = elapsedStr;
608
+
609
+ // Waves
610
+ if (batch.wavePlan && batch.wavePlan.length > 0) {
611
+ const waveIdx = batch.currentWaveIndex || 0;
612
+ let wavesHtml = '<span style="color:var(--text-muted); font-weight:600; margin-right:4px;">Waves</span>';
613
+ batch.wavePlan.forEach((taskIds, i) => {
614
+ // TP-178: During merging, only past waves are done; current wave shows merging state (#493)
615
+ const isDone = i < waveIdx || batch.phase === "completed";
616
+ const isCurrent = i === waveIdx && (batch.phase === "executing" || batch.phase === "merging");
617
+ const isMergingChip = i === waveIdx && batch.phase === "merging";
618
+ const cls = isDone ? "done" : isMergingChip ? "current merging" : isCurrent ? "current" : "";
619
+ wavesHtml += `<span class="wave-chip ${cls}">W${i + 1} [${taskIds.join(", ")}]</span>`;
620
+ });
621
+ $summaryWaves.innerHTML = wavesHtml;
622
+ } else {
623
+ $summaryWaves.innerHTML = "";
624
+ }
625
+ }
626
+
627
+ // ─── Render: Lanes + Tasks (integrated) ─────────────────────────────────────
628
+
629
+ function renderLanesTasks(batch, sessions) {
630
+ if (!batch || !batch.lanes || batch.lanes.length === 0) {
631
+ $lanesTasksBody.innerHTML = '<div class="empty-state">No lanes</div>';
632
+ return;
633
+ }
634
+
635
+ const tasks = batch.tasks || [];
636
+ const sessionSet = new Set(sessions || []);
637
+ const laneStates = currentData?.laneStates || {};
638
+ const telemetry = currentData?.telemetry || {};
639
+ // TP-107: V2 lane snapshots take precedence over legacy lane states when present
640
+ const v2Snapshots = currentData?.runtimeLaneSnapshots || {};
641
+ const showRepos = knownRepos.length >= 2;
642
+ const segmentStatusMap = buildSegmentStatusMap(batch);
643
+ let html = "";
644
+
645
+ for (const lane of batch.lanes) {
646
+ const laneTasks = (lane.taskIds || []).map(tid => tasks.find(t => t.taskId === tid)).filter(Boolean);
647
+ const v2snap = v2Snapshots[lane.laneNumber] || null;
648
+ const laneActiveSegment = laneActiveSegmentInfo(v2snap, laneTasks, segmentStatusMap);
649
+
650
+ // Repo filtering: if a repo is selected, skip lanes that don't match
651
+ if (selectedRepo && showRepos) {
652
+ const laneMatchesRepo = (lane.repoId === selectedRepo) ||
653
+ laneTasks.some(t => (taskRepoId(t) || lane.repoId) === selectedRepo);
654
+ if (!laneMatchesRepo) continue;
655
+ }
656
+
657
+ // TP-107: check Runtime V2 registry for liveness first, fall back to session list
658
+ const laneSessionId = lane.laneSessionId;
659
+ const v2Alive = isLaneAliveV2(lane.laneNumber);
660
+ const alive = v2Alive !== null ? v2Alive : sessionSet.has(laneSessionId);
661
+
662
+
663
+ // Lane header
664
+ html += `<div class="lane-group">`;
665
+ html += `<div class="lane-header">`;
666
+ html += ` <span class="lane-num">${lane.laneNumber}</span>`;
667
+ html += ` <div class="lane-meta">`;
668
+ html += ` <span class="lane-session">${escapeHtml(laneSessionId || "—")}</span>`;
669
+ html += ` <span class="lane-branch">${escapeHtml(lane.branch || "—")}</span>`;
670
+ if (showRepos && lane.repoId) {
671
+ html += ` ${repoBadgeHtml(lane.repoId, "repo-badge-lane")}`;
672
+ }
673
+ if (laneActiveSegment) {
674
+ html += ` <span class="lane-segment" title="${escapeHtml(laneActiveSegment.segmentId || segmentProgressText(laneActiveSegment))}">${escapeHtml(segmentProgressText(laneActiveSegment))}</span>`;
675
+ }
676
+ html += ` </div>`;
677
+ html += ` <div class="lane-right">`;
678
+ html += ` <span class="session-dot ${alive ? "alive" : "dead"}" title="${alive ? "session alive" : "session not active"}"></span>`;
679
+ // View button: shows conversation stream when available
680
+ const isViewingConv = viewerMode === 'conversation' && viewerTarget === laneSessionId;
681
+ html += ` <button class="session-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(laneSessionId)}')" title="View worker conversation">👁 View</button>`;
682
+
683
+ html += ` </div>`;
684
+ html += `</div>`;
685
+
686
+ // Task rows for this lane
687
+ if (laneTasks.length === 0) {
688
+ html += `<div class="task-row"><span class="task-icon"></span><span style="color:var(--text-faint);grid-column:2/-1;">No tasks assigned</span></div>`;
689
+ }
690
+
691
+ // Get lane state and telemetry for worker stats
692
+ // TP-107: V2 lane snapshots take precedence when present
693
+ const legacyLs = laneStates[laneSessionId] || null;
694
+ const ls = v2snap ? mergeV2LaneSnapshot(legacyLs, v2snap) : legacyLs;
695
+ const tel = telemetry[laneSessionId] || null;
696
+
697
+ for (const task of laneTasks) {
698
+ // Repo filtering at task level
699
+ const tRepo = taskRepoId(task) || lane.repoId;
700
+ if (selectedRepo && showRepos && tRepo !== selectedRepo) continue;
701
+
702
+ const sd = task.statusData;
703
+ const dur = task.startedAt
704
+ ? formatDuration((task.endedAt || Date.now()) - task.startedAt)
705
+ : "—";
706
+ const segmentInfo = taskSegmentProgress(task, segmentStatusMap, null);
707
+ const packetHomeRepo = typeof task.packetRepoId === "string" ? task.packetRepoId : "";
708
+ const showPacketHome = !!packetHomeRepo && packetHomeRepo !== (tRepo || lane.repoId || "");
709
+
710
+ // Progress cell
711
+ // TP-174: Prefer V2 snapshot progress (segment-scoped when available)
712
+ // over full STATUS.md counts when the task is actively running on this lane.
713
+ // TP-176: Succeeded tasks always show 100% regardless of sidecar/statusData (#491).
714
+ let progressHtml = "";
715
+ const v2p = ls && ls._v2Progress;
716
+ const taskMatch = v2p && ls.taskId === task.taskId;
717
+ // Split V2 usage: progress needs totals > 0, but step/iter can be used whenever present
718
+ const useV2Progress = taskMatch && v2p.total > 0;
719
+ const useV2Step = taskMatch && !!v2p.currentStep;
720
+ if (task.status === "succeeded") {
721
+ // #491 fix: succeeded tasks always show 100%
722
+ progressHtml = `
723
+ <div class="task-progress">
724
+ <div class="task-progress-bar"><div class="task-progress-fill pct-hi" style="width:100%"></div></div>
725
+ <span class="task-progress-text">100%</span>
726
+ </div>`;
727
+ } else if (useV2Progress || (sd && sd.total > 0)) {
728
+ const displayChecked = useV2Progress ? v2p.checked : sd.checked;
729
+ const displayTotal = useV2Progress ? v2p.total : sd.total;
730
+ const displayProgress = displayTotal > 0 ? Math.round((displayChecked / displayTotal) * 100) : 0;
731
+ const fillClass = pctClass(displayProgress);
732
+ progressHtml = `
733
+ <div class="task-progress">
734
+ <div class="task-progress-bar">
735
+ <div class="task-progress-fill ${fillClass}" style="width:${displayProgress}%"></div>
736
+ </div>
737
+ <span class="task-progress-text">${displayProgress}% ${displayChecked}/${displayTotal}</span>
738
+ </div>`;
739
+ } else if (task.status === "running") {
740
+ // #494 fix: running tasks without meaningful totals show executing indicator
741
+ // This covers non-final segments, early execution before sidecar captures, and stale 0/0 data
742
+ progressHtml = `
743
+ <div class="task-progress">
744
+ <div class="task-progress-bar"><div class="task-progress-fill pct-low task-progress-executing" style="width:100%"></div></div>
745
+ <span class="task-progress-text">executing…</span>
746
+ </div>`;
747
+ } else if (task.status === "pending") {
748
+ progressHtml = `
749
+ <div class="task-progress">
750
+ <div class="task-progress-bar"><div class="task-progress-fill pct-0" style="width:0%"></div></div>
751
+ <span class="task-progress-text">0%</span>
752
+ </div>`;
753
+ } else {
754
+ progressHtml = '<span style="color:var(--text-faint)">—</span>';
755
+ }
756
+
757
+ // Step cell
758
+ // TP-178: Prefer V2 snapshot currentStep (refreshed every sidecar poll) over
759
+ // server-parsed statusData which can lag behind (#488).
760
+ let stepHtml = "";
761
+ if (task.status === "succeeded") {
762
+ // TP-178: Succeeded tasks always show "Complete" regardless of sidecar data (#491)
763
+ stepHtml = '<span style="color:var(--green)">Complete</span>';
764
+ } else if (sd || useV2Step) {
765
+ // #488 fix: prefer V2 step name whenever present (even if totals are 0)
766
+ const stepName = useV2Step ? v2p.currentStep : (sd ? sd.currentStep : "Unknown");
767
+ const iter = (useV2Step && v2p.iteration != null) ? v2p.iteration : (sd ? sd.iteration : 0);
768
+ const revs = (useV2Step && v2p.reviews != null) ? v2p.reviews : (sd ? sd.reviews : 0);
769
+ stepHtml = escapeHtml(stepName);
770
+ if (iter > 0) stepHtml += `<span class="task-iter">i${iter}</span>`;
771
+ if (revs > 0) stepHtml += `<span class="task-iter">r${revs}</span>`;
772
+ } else if (task.status === "pending") {
773
+ stepHtml = '<span style="color:var(--text-faint)">Waiting</span>';
774
+ } else {
775
+ stepHtml = `<span style="color:var(--text-faint)">${escapeHtml(task.exitReason || "—")}</span>`;
776
+ }
777
+
778
+ const detailBits = [];
779
+ if (segmentInfo) {
780
+ detailBits.push(`<span class="task-segment-progress" title="${escapeHtml(segmentInfo.segmentId || segmentProgressText(segmentInfo))}">${escapeHtml(segmentProgressText(segmentInfo))}</span>`);
781
+ }
782
+ if (showPacketHome) {
783
+ detailBits.push(`<span class="task-packet-home" title="Task packet home repo">packet: ${escapeHtml(packetHomeRepo)}</span>`);
784
+ }
785
+ if (detailBits.length > 0) {
786
+ stepHtml = `${detailBits.join('<span class="task-detail-sep"> · </span>')}<span class="task-detail-sep"> · </span><span class="task-step-main">${stepHtml}</span>`;
787
+ }
788
+
789
+ // Worker stats from lane state sidecar + telemetry badges
790
+ let workerHtml = "";
791
+ // Reviewer sub-row should only appear under the active running task in this lane.
792
+ // Runtime V2 snapshots provide taskId; during early startup it can be briefly unset,
793
+ // so allow a task-status fallback while still avoiding duplicate rows.
794
+ const reviewerActive = isReviewerActiveForTask(ls, task);
795
+ const telemBadges = task.status !== "pending" ? telemetryBadgesHtml(tel, reviewerActive) : "";
796
+ if (ls && ls.workerStatus === "running" && task.status === "running") {
797
+ const elapsed = ls.workerElapsed ? `${Math.round(ls.workerElapsed / 1000)}s` : "";
798
+ const tools = ls.workerToolCount || 0;
799
+ const ctx = ls.workerContextPct ? `${Math.round(ls.workerContextPct)}%` : "";
800
+ const lastTool = reviewerActive ? "[awaiting review]" : (ls.workerLastTool || "");
801
+ const tokenStr = tokenSummaryFromLaneState(ls);
802
+ workerHtml = `<div class="worker-stats">`;
803
+ workerHtml += `<span class="worker-stat" title="Worker elapsed">⏱ ${elapsed}</span>`;
804
+ workerHtml += `<span class="worker-stat" title="Tool calls">🔧 ${tools}</span>`;
805
+ if (ctx) workerHtml += `<span class="worker-stat" title="Context window used">📊 ${ctx}</span>`;
806
+ if (tokenStr) workerHtml += `<span class="worker-stat" title="Tokens: input↑ output↓ cacheRead(R) cacheWrite(W)">🪙 ${tokenStr}</span>`;
807
+ if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="${reviewerActive ? 'Waiting for reviewer' : 'Last tool call'}">${reviewerActive ? '<span style="color:var(--yellow)">' + escapeHtml(lastTool) + '</span>' : escapeHtml(lastTool)}</span>`;
808
+ workerHtml += telemBadges;
809
+ workerHtml += `</div>`;
810
+ } else if (!ls && tel && task.status === "running") {
811
+ // Running task with telemetry but no lane-state yet (early startup)
812
+ const lastTool = tel.lastTool || "";
813
+ workerHtml = `<div class="worker-stats">`;
814
+ if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="Last tool call">${escapeHtml(lastTool)}</span>`;
815
+ workerHtml += telemBadges;
816
+ workerHtml += `</div>`;
817
+ } else if (ls && ls.workerStatus === "done" && task.status !== "pending") {
818
+ workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--green)">✓ Worker done</span>${telemBadges}</div>`;
819
+ } else if (ls && ls.workerStatus === "error" && task.status !== "pending") {
820
+ workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--red)">✗ Worker error</span>${telemBadges}</div>`;
821
+ } else if (telemBadges && task.status !== "pending") {
822
+ // No lane-state but telemetry exists (done/error lane without sidecar)
823
+ workerHtml = `<div class="worker-stats">${telemBadges}</div>`;
824
+ }
825
+
826
+ // Reviewer sub-row: shown when reviewer is actively running
827
+ let reviewerRowHtml = "";
828
+ if (reviewerActive) {
829
+ const rElapsed = ls.reviewerElapsed ? `${Math.round(ls.reviewerElapsed / 1000)}s` : "";
830
+ const rTools = ls.reviewerToolCount || 0;
831
+ const rCtx = ls.reviewerContextPct ? `${Math.round(ls.reviewerContextPct)}%` : "";
832
+ const rLastTool = ls.reviewerLastTool || "";
833
+ const rTokenStr = tokenSummaryFromReviewerLaneState(ls);
834
+ const rType = ls.reviewerType || "review";
835
+ const rStep = ls.reviewerStep || "?";
836
+ reviewerRowHtml = `
837
+ <div class="task-row reviewer-sub-row">
838
+ <span class="task-icon"></span>
839
+ <span class="task-actions"></span>
840
+ <span class="reviewer-label">📋 Reviewer</span>
841
+ <span class="reviewer-type">${escapeHtml(rType)} · Step ${rStep}</span>
842
+ <span class="task-duration"></span>
843
+ <span></span>
844
+ <span class="task-step">
845
+ <div class="worker-stats reviewer-stats">
846
+ <span class="worker-stat" title="Reviewer elapsed">⏱ ${rElapsed}</span>
847
+ <span class="worker-stat" title="Reviewer tool calls">🔧 ${rTools}</span>
848
+ ${rCtx ? `<span class="worker-stat" title="Reviewer context used">📊 ${rCtx}</span>` : ""}
849
+ ${rTokenStr ? `<span class="worker-stat" title="Reviewer tokens: input↑ output↓ cacheRead(R) cacheWrite(W)">🪙 ${rTokenStr}</span>` : ""}
850
+ ${rLastTool ? `<span class="worker-stat worker-last-tool" title="Reviewer last tool">${escapeHtml(rLastTool)}</span>` : ""}
851
+ </div>
852
+ </span>
853
+ </div>`;
854
+ }
855
+
856
+ const isViewingStatus = viewerMode === 'status-md' && viewerTarget === task.taskId;
857
+ const eyeHtml = task.status !== 'pending'
858
+ ? `<button class="viewer-eye-btn${isViewingStatus ? ' active' : ''}" onclick="viewStatusMd('${escapeHtml(task.taskId)}')" title="View STATUS.md">👁</button>`
859
+ : '';
860
+
861
+ html += `
862
+ <div class="task-row">
863
+ <span class="task-icon"><span class="status-dot ${task.status}"></span></span>
864
+ <span class="task-actions">${eyeHtml}</span>
865
+ <span class="task-id status-${task.status}">${escapeHtml(task.taskId)}${showRepos ? repoBadgeHtml(tRepo, "repo-badge-task") : ""}</span>
866
+ <span><span class="status-badge status-${task.status}"><span class="status-dot ${task.status}"></span> ${task.status}</span></span>
867
+ <span class="task-duration">${dur}</span>
868
+ <span>${progressHtml}</span>
869
+ <span class="task-step">${stepHtml}${workerHtml}</span>
870
+ </div>`;
871
+ html += reviewerRowHtml;
872
+ }
873
+
874
+ html += `</div>`; // close lane-group
875
+ }
876
+
877
+ $lanesTasksBody.innerHTML = html;
878
+ }
879
+
880
+ // ─── Render: Merge Agents ───────────────────────────────────────────────────
881
+
882
+ /** Build full telemetry HTML for a merge agent (parity with worker stats).
883
+ * Shows: elapsed, tool count, context %, cost, current tool, retry/compaction badges.
884
+ * Returns empty string if no meaningful telemetry exists.
885
+ */
886
+ function mergeTelemetryHtml(tel, alive) {
887
+ if (!tel) return '<span class="merge-no-data">—</span>';
888
+ const hasData = (tel.inputTokens || 0) > 0 || (tel.outputTokens || 0) > 0 ||
889
+ (tel.toolCalls || 0) > 0 || (tel.cost || 0) > 0;
890
+ if (!hasData) return '<span class="merge-no-data">—</span>';
891
+
892
+ let html = '<div class="merge-stats">';
893
+
894
+ // Elapsed time
895
+ if (tel.startedAt) {
896
+ const elapsed = Date.now() - tel.startedAt;
897
+ html += `<span class="worker-stat" title="Merge elapsed">⏱ ${formatDuration(elapsed)}</span>`;
898
+ }
899
+
900
+ // Tool calls
901
+ if (tel.toolCalls > 0) {
902
+ html += `<span class="worker-stat" title="Tool calls">🔧 ${tel.toolCalls}</span>`;
903
+ }
904
+
905
+ // Context %
906
+ if (tel.contextPct > 0) {
907
+ html += `<span class="worker-stat" title="Context window used">📊 ${Math.round(tel.contextPct)}%</span>`;
908
+ }
909
+
910
+ // Tokens + cost
911
+ const inp = (tel.inputTokens || 0) + (tel.cacheReadTokens || 0);
912
+ const out = tel.outputTokens || 0;
913
+ const cost = tel.cost || 0;
914
+ if (inp > 0 || out > 0) {
915
+ let tokenStr = `↑${formatTokens(inp)} ↓${formatTokens(out)}`;
916
+ if (cost > 0) tokenStr += ` ${formatCost(cost)}`;
917
+ html += `<span class="worker-stat" title="Tokens">🪙 ${tokenStr}</span>`;
918
+ }
919
+
920
+ // Current tool (if alive/active) or last tool (completed merges)
921
+ if (alive && tel.currentTool) {
922
+ html += `<span class="worker-stat worker-last-tool" title="Current tool">${escapeHtml(tel.currentTool)}</span>`;
923
+ } else if (!alive && tel.lastTool) {
924
+ html += `<span class="worker-stat worker-last-tool" title="Last tool">${escapeHtml(tel.lastTool)}</span>`;
925
+ }
926
+
927
+ // Retry/compaction badges (reuse shared helper)
928
+ html += telemetryBadgesHtml(tel);
929
+
930
+ html += '</div>';
931
+ return html;
932
+ }
933
+
934
+ function renderMergeAgents(batch, sessions) {
935
+ const mergeResults = batch?.mergeResults || [];
936
+ const sessionSet = new Set(sessions || []);
937
+ const showRepos = knownRepos.length >= 2;
938
+ const telemetry = currentData?.telemetry || {};
939
+
940
+ // Check for active merge sessions (convention: {prefix}-{opId}-merge-{N})
941
+ const mergeSessions = (sessions || []).filter(s => s.includes("-merge-"));
942
+
943
+ // Derive merge session name from lane session naming pattern.
944
+ // Lane sessions: "{prefix}-{opId}-lane-{N}", merge sessions: "{prefix}-{opId}-merge-{N}".
945
+ // Extract the prefix-opId part from the first lane and use it to construct merge names.
946
+ const lanes = batch?.lanes || [];
947
+ let mergePrefix = "orch-merge"; // fallback for legacy/unknown patterns
948
+ if (lanes.length > 0 && lanes[0].laneSessionId) {
949
+ const laneName = lanes[0].laneSessionId;
950
+ const laneMatch = laneName.match(/^(.+)-lane-\d+$/);
951
+ if (laneMatch) {
952
+ mergePrefix = laneMatch[1] + "-merge";
953
+ }
954
+ }
955
+ // Helper: get merge session name for a merge number
956
+ const getMergeSessionName = (mergeNum) => `${mergePrefix}-${mergeNum}`;
957
+
958
+ if (mergeResults.length === 0 && mergeSessions.length === 0) {
959
+ $mergeBody.innerHTML = '<div class="empty-state">No merge agents active</div>';
960
+ return;
961
+ }
962
+
963
+ let html = '<table class="merge-table"><thead><tr>';
964
+ html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th><th>Session ID</th><th>Details</th>';
965
+ html += '</tr></thead><tbody>';
966
+
967
+ // Track sessions shown in wave result rows so we don't duplicate them below
968
+ const shownSessions = new Set();
969
+
970
+ // Show merge results
971
+ for (const mr of mergeResults) {
972
+ // Repo filtering: if a repo is selected and this merge has repoResults,
973
+ // check if the selected repo is among them
974
+ const repoResults = mr.repoResults || [];
975
+ if (selectedRepo && showRepos && repoResults.length >= 1) {
976
+ const hasSelectedRepo = repoResults.some(rr => rr.repoId === selectedRepo);
977
+ if (!hasSelectedRepo) continue;
978
+ }
979
+
980
+ const statusCls = mr.status === "succeeded" ? "status-succeeded"
981
+ : mr.status === "partial" ? "status-stalled"
982
+ : "status-failed";
983
+
984
+ // Merge session mapping: derive from lane numbers involved in this wave.
985
+ // Merge sessions are named by lane number (e.g., ...-merge-1), not wave index.
986
+ // Extract lane numbers from repoResults or from batch tasks for this wave.
987
+ const waveLaneNums = new Set();
988
+ const repoResults2 = mr.repoResults || [];
989
+ for (const rr of repoResults2) {
990
+ for (const ln of (rr.laneNumbers || [])) waveLaneNums.add(ln);
991
+ }
992
+ // Fallback: find lane numbers from tasks assigned to this wave
993
+ if (waveLaneNums.size === 0 && batch.wavePlan && batch.wavePlan[mr.waveIndex]) {
994
+ const waveTaskIds = new Set(batch.wavePlan[mr.waveIndex]);
995
+ for (const t of (batch.tasks || [])) {
996
+ if (waveTaskIds.has(t.taskId) && t.laneNumber != null) {
997
+ waveLaneNums.add(t.laneNumber);
998
+ }
999
+ }
1000
+ }
1001
+ // Find alive merge sessions matching the wave's lane numbers
1002
+ let effectiveSession = null;
1003
+ for (const ln of waveLaneNums) {
1004
+ const candidate = getMergeSessionName(ln);
1005
+ if (sessionSet.has(candidate) && !shownSessions.has(candidate)) {
1006
+ effectiveSession = candidate;
1007
+ break;
1008
+ }
1009
+ }
1010
+ // Fallback: any unshown alive merge session
1011
+ if (!effectiveSession) {
1012
+ effectiveSession = mergeSessions.find(s => sessionSet.has(s) && !shownSessions.has(s)) || null;
1013
+ }
1014
+ const effectiveAlive = !!effectiveSession;
1015
+ if (effectiveSession) shownSessions.add(effectiveSession);
1016
+
1017
+ // TP-178: Find merge telemetry precisely using waveIndex (#498).
1018
+ // First try matching by waveIndex from the telemetry entries (injected from merge snapshots).
1019
+ // Then fall back to session-based matching (lane numbers), but never use a
1020
+ // catch-all fallback that grabs any merge session's telemetry.
1021
+ let mergeTel = null;
1022
+ // Priority 1: Match by waveIndex in telemetry entries
1023
+ for (const [telKey, tel] of Object.entries(telemetry)) {
1024
+ if (tel._source === "merge-snapshot" && tel.waveIndex === mr.waveIndex) {
1025
+ mergeTel = tel;
1026
+ break;
1027
+ }
1028
+ }
1029
+ // Priority 2: Match by lane number session
1030
+ if (!mergeTel) {
1031
+ for (const ln of waveLaneNums) {
1032
+ const candidate = getMergeSessionName(ln);
1033
+ if (telemetry[candidate]) { mergeTel = telemetry[candidate]; break; }
1034
+ }
1035
+ }
1036
+ // Priority 3: Effective session telemetry only (no catch-all fallback)
1037
+ if (!mergeTel && effectiveSession) mergeTel = telemetry[effectiveSession] || null;
1038
+
1039
+ html += `<tr>`;
1040
+ html += `<td class="merge-wave-cell">Wave ${mr.waveIndex + 1}</td>`;
1041
+ html += `<td><span class="status-badge ${statusCls}">${mr.status}</span></td>`;
1042
+ html += `<td class="merge-session-cell">${effectiveAlive ? escapeHtml(effectiveSession) : "—"}</td>`;
1043
+ // Full telemetry cell
1044
+ html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(mergeTel, effectiveAlive)}</td>`;
1045
+ html += `<td>`;
1046
+ html += '<span class="merge-no-data">—</span>';
1047
+ html += `</td>`;
1048
+ html += `<td class="merge-detail-cell">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
1049
+ html += `</tr>`;
1050
+
1051
+ // Per-repo sub-rows: show when workspace mode has repo results
1052
+ if (showRepos && repoResults.length >= 1) {
1053
+ const displayRepos = selectedRepo
1054
+ ? repoResults.filter(rr => rr.repoId === selectedRepo)
1055
+ : repoResults;
1056
+
1057
+ for (const rr of displayRepos) {
1058
+ const rrStatusCls = rr.status === "succeeded" ? "status-succeeded"
1059
+ : rr.status === "partial" ? "status-stalled"
1060
+ : "status-failed";
1061
+ const rrLanes = (rr.laneNumbers || []).map(n => `L${n}`).join(", ") || "—";
1062
+ const rrDetail = rr.failureReason ? escapeHtml(rr.failureReason) : "—";
1063
+
1064
+ html += `<tr class="merge-repo-row">`;
1065
+ html += `<td>${repoBadgeHtml(rr.repoId)}</td>`;
1066
+ html += `<td><span class="status-badge ${rrStatusCls}">${rr.status}</span></td>`;
1067
+ html += `<td class="merge-session-cell">${rrLanes}</td>`;
1068
+ html += `<td></td>`; /* telemetry placeholder */
1069
+ html += `<td></td>`; /* attach placeholder */
1070
+ html += `<td class="merge-detail-cell">${rrDetail}</td>`;
1071
+ html += `</tr>`;
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ // Show active merge sessions not yet in results
1077
+ for (const sess of mergeSessions) {
1078
+ if (shownSessions.has(sess)) continue;
1079
+
1080
+ const sessTel = telemetry[sess] || null;
1081
+ html += `<tr>`;
1082
+ html += `<td class="merge-wave-cell">—</td>`;
1083
+ html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
1084
+ html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
1085
+ // Full telemetry cell for active merge session
1086
+ html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
1087
+ html += `<td>—</td>`;
1088
+ html += `<td>—</td>`;
1089
+ html += `</tr>`;
1090
+ }
1091
+
1092
+ html += '</tbody></table>';
1093
+ $mergeBody.innerHTML = html;
1094
+ }
1095
+
1096
+ // ─── Render: Runtime V2 Agents (TP-107) ─────────────────────────────────────
1097
+
1098
+ function renderAgentsPanel(registry) {
1099
+ const $panel = document.getElementById('agents-panel');
1100
+ const $body = document.getElementById('agents-body');
1101
+ if (!$panel || !$body) return;
1102
+
1103
+ if (!registry || !registry.agents || Object.keys(registry.agents).length === 0) {
1104
+ $panel.style.display = 'none';
1105
+ return;
1106
+ }
1107
+
1108
+ $panel.style.display = '';
1109
+ const agents = Object.values(registry.agents);
1110
+ let html = '<div class="agents-grid">';
1111
+
1112
+ for (const agent of agents) {
1113
+ const isCrash = ['crashed', 'timed_out'].includes(agent.status);
1114
+ const isTerminal = ['exited', 'crashed', 'timed_out', 'killed'].includes(agent.status);
1115
+ const statusClass = isTerminal ? (isCrash ? 'agent-terminal agent-crashed' : 'agent-terminal') : 'agent-live';
1116
+ const icon = isCrash ? '\u{1F534}' : (isTerminal ? '\u26AA' : '\u{1F7E2}');
1117
+ // Display label: exited and killed both show as 'shutdown' — the mechanism is an
1118
+ // implementation detail. Only crashed/timed_out warrant a different label.
1119
+ const displayStatus = (agent.status === 'exited' || agent.status === 'killed') ? 'shutdown'
1120
+ : agent.status === 'timed_out' ? 'timed out'
1121
+ : agent.status;
1122
+ const elapsed = agent.startedAt ? Math.round((Date.now() - agent.startedAt) / 1000) : 0;
1123
+ const elapsedStr = elapsed > 0 ? formatDuration(elapsed * 1000) : '';
1124
+
1125
+ html += `<div class="agent-card ${statusClass}">`;
1126
+ html += `<div class="agent-header">${icon} <strong>${escapeHtml(agent.agentId)}</strong></div>`;
1127
+ html += `<div class="agent-meta">`;
1128
+ html += `<span class="agent-badge">${escapeHtml(agent.role)}</span>`;
1129
+ if (agent.laneNumber != null) html += `<span class="agent-badge">lane ${agent.laneNumber}</span>`;
1130
+ if (agent.taskId) html += `<span class="agent-badge">${escapeHtml(agent.taskId)}</span>`;
1131
+ html += `<span class="agent-badge agent-status-${agent.status}">${escapeHtml(displayStatus)}</span>`;
1132
+ if (elapsedStr && !isTerminal) html += `<span class="agent-badge">${elapsedStr}</span>`;
1133
+ html += `</div>`;
1134
+ html += `</div>`;
1135
+ }
1136
+
1137
+ html += '</div>';
1138
+ $body.innerHTML = html;
1139
+ }
1140
+
1141
+ // ─── Render: Mailbox Messages (TP-107) ──────────────────────────────────────
1142
+
1143
+ function renderMessagesPanel(mailbox) {
1144
+ const $panel = document.getElementById('messages-panel');
1145
+ const $body = document.getElementById('messages-body');
1146
+ if (!$panel || !$body) return;
1147
+
1148
+ // TP-093: event-authoritative model — prefer audit events, fallback to directory scan
1149
+ const auditEvents = mailbox?.auditEvents || [];
1150
+ const dirMessages = mailbox?.messages || [];
1151
+ const hasData = auditEvents.length > 0 || dirMessages.length > 0;
1152
+
1153
+ if (!mailbox || !hasData) {
1154
+ $panel.style.display = 'none';
1155
+ return;
1156
+ }
1157
+
1158
+ $panel.style.display = '';
1159
+ let html = '<div class="messages-list">';
1160
+
1161
+ if (auditEvents.length > 0) {
1162
+ // Primary: render from audit event stream (authoritative, durable)
1163
+ for (const evt of auditEvents) {
1164
+ html += renderMailboxAuditEvent(evt);
1165
+ }
1166
+ } else {
1167
+ // Fallback: render from directory scan (legacy compatibility)
1168
+ for (const msg of dirMessages) {
1169
+ html += renderMailboxDirMessage(msg);
1170
+ }
1171
+ }
1172
+
1173
+ html += '</div>';
1174
+ $body.innerHTML = html;
1175
+ }
1176
+
1177
+ /** Render a single mailbox audit event (events.jsonl row). */
1178
+ function renderMailboxAuditEvent(evt) {
1179
+ const ts = evt.ts ? new Date(evt.ts).toLocaleTimeString() : '';
1180
+ const type = evt.type || '';
1181
+
1182
+ let direction = '';
1183
+ let statusBadge = '';
1184
+ let typeBadge = '';
1185
+ let preview = '';
1186
+
1187
+ if (type === 'message_sent') {
1188
+ const isBroadcast = evt.broadcast;
1189
+ direction = isBroadcast ? '\u2192 all (broadcast)' : `\u2192 ${escapeHtml(evt.to || '')}`;
1190
+ statusBadge = '<span class="msg-badge msg-delivered">sent</span>';
1191
+ typeBadge = `<span class="msg-badge msg-type">${escapeHtml(evt.messageType || '')}</span>`;
1192
+ preview = evt.contentPreview || '';
1193
+ } else if (type === 'message_delivered') {
1194
+ direction = `\u2192 ${escapeHtml(evt.to || '')}`;
1195
+ statusBadge = evt.broadcast
1196
+ ? '<span class="msg-badge msg-delivered">broadcast delivered</span>'
1197
+ : '<span class="msg-badge msg-delivered">delivered</span>';
1198
+ typeBadge = evt.messageType ? `<span class="msg-badge msg-type">${escapeHtml(evt.messageType)}</span>` : '';
1199
+ preview = evt.contentPreview || '';
1200
+ } else if (type === 'message_replied' || type === 'message_escalated') {
1201
+ direction = `\u2190 ${escapeHtml(evt.from || '')}`;
1202
+ statusBadge = type === 'message_escalated'
1203
+ ? '<span class="msg-badge msg-reply">escalation</span>'
1204
+ : '<span class="msg-badge msg-reply">reply</span>';
1205
+ typeBadge = evt.messageType ? `<span class="msg-badge msg-type">${escapeHtml(evt.messageType)}</span>` : '';
1206
+ preview = evt.contentPreview || '';
1207
+ } else if (type === 'message_rate_limited') {
1208
+ direction = `\u2192 ${escapeHtml(evt.to || '')}`;
1209
+ statusBadge = '<span class="msg-badge msg-rate-limited">rate limited</span>';
1210
+ const waitSec = evt.retryAfterMs ? Math.ceil(evt.retryAfterMs / 1000) : '?';
1211
+ preview = `${evt.reason || 'Rate limited'} (retry in ${waitSec}s)`;
1212
+ } else {
1213
+ // Unknown event type — render generically
1214
+ direction = evt.from ? `${escapeHtml(evt.from)}` : '';
1215
+ preview = JSON.stringify(evt);
1216
+ }
1217
+
1218
+ return `<div class="message-row">`
1219
+ + `<span class="msg-time">${escapeHtml(ts)}</span>`
1220
+ + `<span class="msg-direction">${direction}</span>`
1221
+ + typeBadge
1222
+ + statusBadge
1223
+ + `<span class="msg-preview">${escapeHtml(preview)}</span>`
1224
+ + `</div>`;
1225
+ }
1226
+
1227
+ /** Render a single directory-scanned message (legacy fallback). */
1228
+ function renderMailboxDirMessage(msg) {
1229
+ const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
1230
+ // TP-093: for broadcast per-agent ack markers, show recipient identity instead of "_broadcast"
1231
+ let direction;
1232
+ if (msg.to === 'supervisor') {
1233
+ direction = '\u2190 supervisor';
1234
+ } else if (msg._isBroadcast && msg._agentDir && msg._agentDir !== '_broadcast') {
1235
+ direction = `\u2192 ${escapeHtml(msg._agentDir)} (broadcast)`;
1236
+ } else {
1237
+ direction = `\u2192 ${escapeHtml(msg.to || msg._agentDir || '')}`;
1238
+ }
1239
+ let statusBadge;
1240
+ if (msg._status === 'pending') statusBadge = '<span class="msg-badge msg-pending">pending</span>';
1241
+ else if (msg._status === 'delivered') statusBadge = '<span class="msg-badge msg-delivered">delivered</span>';
1242
+ else if (msg._status === 'reply') statusBadge = '<span class="msg-badge msg-reply">reply</span>';
1243
+ else if (msg._status === 'reply-acked') statusBadge = '<span class="msg-badge msg-delivered">reply (acked)</span>';
1244
+ else statusBadge = '';
1245
+ const typeBadge = `<span class="msg-badge msg-type">${escapeHtml(msg.type || '')}</span>`;
1246
+ const preview = msg.content || '';
1247
+ const broadcastTag = msg._isBroadcast ? ' <span class="msg-badge msg-type">broadcast</span>' : '';
1248
+
1249
+ return `<div class="message-row">`
1250
+ + `<span class="msg-time">${escapeHtml(ts)}</span>`
1251
+ + `<span class="msg-direction">${direction}</span>`
1252
+ + typeBadge
1253
+ + statusBadge
1254
+ + broadcastTag
1255
+ + `<span class="msg-preview">${escapeHtml(preview)}</span>`
1256
+ + `</div>`;
1257
+ }
1258
+
1259
+
1260
+ // ─── Render: Errors ─────────────────────────────────────────────────────────
1261
+
1262
+ function renderErrors(batch) {
1263
+ const errors = batch?.errors || [];
1264
+ if (errors.length === 0) {
1265
+ $errorsPanel.style.display = "none";
1266
+ return;
1267
+ }
1268
+ $errorsPanel.style.display = "";
1269
+ let html = "";
1270
+ for (const err of errors.slice(-10)) {
1271
+ const msg = typeof err === "string" ? err : err.message || JSON.stringify(err);
1272
+ html += `<div class="error-item"><span class="error-bullet">●</span><span class="error-text">${escapeHtml(msg)}</span></div>`;
1273
+ }
1274
+ $errorsBody.innerHTML = html;
1275
+ }
1276
+
1277
+ // ─── Render: No Batch ───────────────────────────────────────────────────────
1278
+
1279
+ let noBatchRendered = false;
1280
+
1281
+ function renderNoBatch() {
1282
+ if (noBatchRendered) return;
1283
+ noBatchRendered = true;
1284
+
1285
+ // Hide repo filter when no batch
1286
+ updateRepoFilter([]);
1287
+
1288
+ // Hide live panels, show history panel
1289
+ const $lanesPanel = document.getElementById("lanes-tasks-panel");
1290
+ const $mergePanel = document.getElementById("merge-panel");
1291
+ if ($lanesPanel) $lanesPanel.style.display = "none";
1292
+ if ($mergePanel) $mergePanel.style.display = "none";
1293
+ if ($errorsPanel) $errorsPanel.style.display = "none";
1294
+
1295
+ // Show a placeholder while history loads. loadHistoryList() (called in
1296
+ // render() just before this) is async — the fresh list may not be
1297
+ // available yet. The loadHistoryList callback will replace this with
1298
+ // the actual latest entry once it resolves.
1299
+ if (!viewingHistoryId) {
1300
+ $historyBody.innerHTML = `
1301
+ <div class="no-batch">
1302
+ <div class="no-batch-icon">⏳</div>
1303
+ <div class="no-batch-title">Batch complete</div>
1304
+ <div class="no-batch-hint">Loading history…</div>
1305
+ </div>`;
1306
+ $historyPanel.style.display = "";
1307
+ }
1308
+ }
1309
+
1310
+ function ensureContentPanels() {
1311
+ if (noBatchRendered) {
1312
+ // A live batch started — restore panels without full page reload.
1313
+ // Reset the no-batch state and re-show content panels.
1314
+ noBatchRendered = false;
1315
+ const $lanesPanel = document.getElementById("lanes-tasks-panel");
1316
+ const $mergePanel = document.getElementById("merge-panel");
1317
+ if ($lanesPanel) $lanesPanel.style.display = "";
1318
+ if ($mergePanel) $mergePanel.style.display = "";
1319
+ if ($errorsPanel) $errorsPanel.style.display = "";
1320
+ $historyPanel.style.display = "none";
1321
+ viewingHistoryId = null;
1322
+ // Re-render with current data
1323
+ if (currentData) render(currentData);
1324
+ }
1325
+ }
1326
+
1327
+ // ─── Supervisor Panel ───────────────────────────────────────────────────────
1328
+
1329
+ const $supervisorPanel = $("supervisor-panel");
1330
+ const $supervisorStatusBadge = $("supervisor-status-badge");
1331
+ const $supervisorCollapseBtn = $("supervisor-collapse-btn");
1332
+ const $supervisorPanelBody = $("supervisor-panel-body");
1333
+ const $supervisorStatusSection = $("supervisor-status-section");
1334
+ const $supervisorConversationSection = $("supervisor-conversation-section");
1335
+ const $supervisorActionsSection = $("supervisor-actions-section");
1336
+ const $supervisorSummarySection = $("supervisor-summary-section");
1337
+
1338
+ let supervisorCollapsed = false;
1339
+
1340
+ // Toggle collapse on header click
1341
+ $("supervisor-panel-toggle").addEventListener("click", (e) => {
1342
+ // Don't toggle when clicking the collapse button itself (it has its own handler)
1343
+ if (e.target.id === "supervisor-collapse-btn") return;
1344
+ toggleSupervisorPanel();
1345
+ });
1346
+
1347
+ $supervisorCollapseBtn.addEventListener("click", toggleSupervisorPanel);
1348
+
1349
+ function toggleSupervisorPanel() {
1350
+ supervisorCollapsed = !supervisorCollapsed;
1351
+ $supervisorPanelBody.style.display = supervisorCollapsed ? "none" : "";
1352
+ $supervisorCollapseBtn.textContent = supervisorCollapsed ? "▸" : "▾";
1353
+ }
1354
+
1355
+ /** Determine supervisor status from lock data. */
1356
+ function supervisorStatusInfo(lock) {
1357
+ if (!lock) return { status: "inactive", label: "Inactive", cls: "supervisor-inactive" };
1358
+ if (lock.stale) return { status: "stale", label: "Stale", cls: "supervisor-stale" };
1359
+ return { status: "active", label: "Active", cls: "supervisor-active" };
1360
+ }
1361
+
1362
+ /** Format a timestamp for the supervisor timeline. */
1363
+ function formatSupervisorTime(ts) {
1364
+ if (!ts) return "";
1365
+ const d = new Date(ts);
1366
+ return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
1367
+ }
1368
+
1369
+ /** Render the supervisor status indicator section. */
1370
+ function renderSupervisorStatus(supervisor) {
1371
+ const lock = supervisor.lock;
1372
+ const info = supervisorStatusInfo(lock);
1373
+
1374
+ // Update the header badge
1375
+ $supervisorStatusBadge.textContent = info.label;
1376
+ $supervisorStatusBadge.className = `supervisor-status-badge ${info.cls}`;
1377
+
1378
+ let html = '<div class="supervisor-status-row">';
1379
+ html += `<span class="supervisor-status-dot ${info.cls}"></span>`;
1380
+ html += `<span class="supervisor-status-label">${info.label}</span>`;
1381
+
1382
+ if (lock) {
1383
+ if (lock.autonomy) {
1384
+ html += `<span class="supervisor-autonomy-badge">${escapeHtml(lock.autonomy)}</span>`;
1385
+ }
1386
+ if (lock.heartbeat) {
1387
+ html += `<span class="supervisor-heartbeat" title="Last heartbeat">♡ ${relativeTime(lock.heartbeat)}</span>`;
1388
+ }
1389
+ if (lock.sessionId) {
1390
+ html += `<span class="supervisor-session-id" title="Session: ${escapeHtml(lock.sessionId)}">${escapeHtml(lock.sessionId)}</span>`;
1391
+ }
1392
+ }
1393
+
1394
+ html += '</div>';
1395
+ $supervisorStatusSection.innerHTML = html;
1396
+ }
1397
+
1398
+ /** Render the conversation history section. */
1399
+ function renderSupervisorConversation(supervisor) {
1400
+ const conversation = supervisor.conversation || [];
1401
+
1402
+ if (conversation.length === 0) {
1403
+ $supervisorConversationSection.innerHTML = '';
1404
+ return;
1405
+ }
1406
+
1407
+ let html = '<div class="supervisor-subsection-title">Conversation</div>';
1408
+ html += '<div class="supervisor-conversation-list">';
1409
+
1410
+ for (const entry of conversation) {
1411
+ const time = formatSupervisorTime(entry.ts || entry.timestamp);
1412
+ const role = entry.role || "unknown";
1413
+ const content = entry.content || entry.message || "";
1414
+ const roleCls = role === "operator" ? "conv-role-operator" : "conv-role-supervisor";
1415
+ const roleLabel = role === "operator" ? "Operator" : "Supervisor";
1416
+
1417
+ html += `<div class="supervisor-conv-entry ${roleCls}">`;
1418
+ html += ` <div class="supervisor-conv-header">`;
1419
+ html += ` <span class="supervisor-conv-role">${roleLabel}</span>`;
1420
+ if (time) html += `<span class="supervisor-conv-time">${time}</span>`;
1421
+ html += ` </div>`;
1422
+ html += ` <div class="supervisor-conv-content">${escapeHtml(content)}</div>`;
1423
+ html += `</div>`;
1424
+ }
1425
+
1426
+ html += '</div>';
1427
+ $supervisorConversationSection.innerHTML = html;
1428
+ }
1429
+
1430
+ /**
1431
+ * Human-readable labels for supervisor recovery action identifiers.
1432
+ * The supervisor LLM writes snake_case action names to actions.jsonl.
1433
+ * This map translates them to operator-friendly labels for the dashboard.
1434
+ */
1435
+ const RECOVERY_ACTION_LABELS = {
1436
+ // Conflict resolution
1437
+ conflict_resolve_checkout_ours: "Auto-resolved merge conflict (kept task changes)",
1438
+ conflict_resolve_checkout_theirs: "Auto-resolved merge conflict (kept base changes)",
1439
+ conflict_resolve_manual: "Manual conflict resolution applied",
1440
+
1441
+ // Merge agent
1442
+ merge_retry: "Retried merge agent",
1443
+ merge_session_kill: "Terminated stalled merge agent",
1444
+ merge_force: "Forced merge with partial results",
1445
+
1446
+ // Worker / task
1447
+ worker_wrap_up: "Sent wrap-up signal to stalled worker",
1448
+ task_retry: "Retried failed task",
1449
+ task_skip: "Skipped task — unblocked dependents",
1450
+ wave_force_merge: "Force-merged wave with mixed results",
1451
+
1452
+ // Git / worktree
1453
+ lock_clear: "Cleared stale git lock file",
1454
+ worktree_remove: "Removed stale worktree",
1455
+ worktree_prune: "Pruned stale worktrees",
1456
+
1457
+ // Batch lifecycle
1458
+ abort_hard: "Hard-aborted batch",
1459
+ batch_resume: "Resumed batch after recovery",
1460
+ supervisor_handoff: "Supervisor session handoff",
1461
+
1462
+ // Diagnostics (usually not shown — filtered as non-recovery)
1463
+ initial_status_check: "Checked initial batch status",
1464
+ completion_status_check: "Verified batch completion status",
1465
+ read_state: "Read batch state",
1466
+ };
1467
+
1468
+ /** Format a recovery action type string into a human-readable label. */
1469
+ function formatRecoveryActionLabel(type) {
1470
+ return RECOVERY_ACTION_LABELS[type] || type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1471
+ }
1472
+
1473
+ /** Merge supervisor actions and Tier 0 recovery events into a unified timeline.
1474
+ * Actions from actions.jsonl and recovery events from events.jsonl are combined
1475
+ * and sorted chronologically (per R002: show both Tier 0 and supervisor actions).
1476
+ */
1477
+ function buildRecoveryTimeline(supervisor) {
1478
+ const actions = (supervisor.actions || []).map(a => ({
1479
+ ts: a.ts || a.timestamp || 0,
1480
+ tier: a.tier,
1481
+ type: a.type || a.action || "unknown",
1482
+ target: a.target || a.lane || a.taskId || "",
1483
+ outcome: a.outcome || a.result || "",
1484
+ reason: a.reason || "",
1485
+ context: a.context || "",
1486
+ detail: a.detail || "",
1487
+ source: "action"
1488
+ }));
1489
+
1490
+ // Include Tier 0 recovery events from events.jsonl
1491
+ const events = (supervisor.events || [])
1492
+ .filter(e => e.tier === 0 || e.type === "recovery" || e.type === "tier0_recovery")
1493
+ .map(e => ({
1494
+ ts: e.ts || e.timestamp || 0,
1495
+ tier: e.tier != null ? e.tier : 0,
1496
+ type: e.type || "event",
1497
+ target: e.target || e.lane || e.taskId || "",
1498
+ outcome: e.outcome || e.result || "",
1499
+ reason: e.reason || e.message || "",
1500
+ context: e.context || "",
1501
+ detail: e.detail || "",
1502
+ source: "event"
1503
+ }));
1504
+
1505
+ const timeline = [...actions, ...events];
1506
+ timeline.sort((a, b) => {
1507
+ const tA = typeof a.ts === "string" ? new Date(a.ts).getTime() : a.ts;
1508
+ const tB = typeof b.ts === "string" ? new Date(b.ts).getTime() : b.ts;
1509
+ return tA - tB;
1510
+ });
1511
+
1512
+ return timeline;
1513
+ }
1514
+
1515
+ /** Render the recovery action timeline section. */
1516
+ function renderSupervisorActions(supervisor) {
1517
+ const timeline = buildRecoveryTimeline(supervisor);
1518
+
1519
+ if (timeline.length === 0) {
1520
+ $supervisorActionsSection.innerHTML = '';
1521
+ return;
1522
+ }
1523
+
1524
+ let html = '<div class="supervisor-subsection-title">Recovery Actions</div>';
1525
+ html += '<div class="supervisor-timeline">';
1526
+
1527
+ for (const entry of timeline) {
1528
+ const time = formatSupervisorTime(entry.ts);
1529
+ const tier = entry.tier != null ? `T${entry.tier}` : "";
1530
+ const type = entry.type;
1531
+ const target = entry.target;
1532
+ const outcome = entry.outcome;
1533
+ const reason = entry.reason;
1534
+ const description = entry.context || entry.detail || "";
1535
+
1536
+ const outcomeCls = outcome === "success" || outcome === "recovered"
1537
+ ? "action-success"
1538
+ : outcome === "failed" || outcome === "error"
1539
+ ? "action-failed"
1540
+ : "action-pending";
1541
+
1542
+ html += `<div class="supervisor-action-entry">`;
1543
+ html += ` <div class="supervisor-action-left">`;
1544
+ html += ` <span class="supervisor-action-time">${time}</span>`;
1545
+ html += ` <span class="supervisor-action-dot ${outcomeCls}"></span>`;
1546
+ html += ` </div>`;
1547
+ html += ` <div class="supervisor-action-right">`;
1548
+ html += ` <div class="supervisor-action-header">`;
1549
+ if (tier) html += `<span class="supervisor-action-tier">${tier}</span>`;
1550
+ html += ` <span class="supervisor-action-type" title="${escapeHtml(type)}">${escapeHtml(formatRecoveryActionLabel(type))}</span>`;
1551
+ if (target) html += `<span class="supervisor-action-target">${escapeHtml(target)}</span>`;
1552
+ if (outcome) html += `<span class="supervisor-action-outcome ${outcomeCls}">${escapeHtml(outcome)}</span>`;
1553
+ html += ` </div>`;
1554
+ if (description) {
1555
+ const fullDesc = escapeHtml(description);
1556
+ const truncated = description.length > 100 ? escapeHtml(description.slice(0, 100)) + "\u2026" : fullDesc;
1557
+ html += `<div class="supervisor-action-description" title="${fullDesc}">${truncated}</div>`;
1558
+ }
1559
+ if (reason) {
1560
+ html += `<div class="supervisor-action-reason">${escapeHtml(reason)}</div>`;
1561
+ }
1562
+ html += ` </div>`;
1563
+ html += `</div>`;
1564
+ }
1565
+
1566
+ html += '</div>';
1567
+ $supervisorActionsSection.innerHTML = html;
1568
+ }
1569
+
1570
+ /** Render the batch summary section (from summary.md). */
1571
+ function renderSupervisorSummary(supervisor) {
1572
+ const summary = supervisor.summary;
1573
+
1574
+ if (!summary) {
1575
+ $supervisorSummarySection.innerHTML = '';
1576
+ return;
1577
+ }
1578
+
1579
+ let html = '<div class="supervisor-subsection-title">Batch Summary</div>';
1580
+ html += '<div class="supervisor-summary-content">';
1581
+ // Render the summary markdown using the STATUS.md renderer (reuse)
1582
+ const { html: renderedMd } = renderStatusMd(summary);
1583
+ html += renderedMd;
1584
+ html += '</div>';
1585
+ $supervisorSummarySection.innerHTML = html;
1586
+ }
1587
+
1588
+ /** Main supervisor panel render function. */
1589
+ function renderSupervisor(data) {
1590
+ const supervisor = data.supervisor;
1591
+
1592
+ if (!supervisor) {
1593
+ $supervisorPanel.style.display = "none";
1594
+ return;
1595
+ }
1596
+
1597
+ $supervisorPanel.style.display = "";
1598
+
1599
+ renderSupervisorStatus(supervisor);
1600
+ renderSupervisorConversation(supervisor);
1601
+ renderSupervisorActions(supervisor);
1602
+ renderSupervisorSummary(supervisor);
1603
+ }
1604
+
1605
+ // ─── Full Render ────────────────────────────────────────────────────────────
1606
+
1607
+ // ─── Current data (stored for conversation viewer) ──────────────────────────
1608
+
1609
+ let currentData = null;
1610
+
1611
+ function render(data) {
1612
+ currentData = data;
1613
+ const batch = data.batch;
1614
+ const sessions = data.sessions ?? data.tmuxSessions ?? [];
1615
+
1616
+ $lastUpdate.textContent = new Date().toLocaleTimeString();
1617
+
1618
+ if (!batch) {
1619
+ // TP-178: Clear viewer when batch disappears (#487)
1620
+ if (lastBatchId && viewerMode) closeViewer();
1621
+ lastBatchId = null;
1622
+ renderHeader(null);
1623
+ renderSummary(null);
1624
+ renderSupervisor(data);
1625
+ // Refresh history list (batch may have just finished)
1626
+ if (!noBatchRendered) loadHistoryList();
1627
+ renderNoBatch();
1628
+ return;
1629
+ }
1630
+
1631
+ // TP-178: Detect batchId change — clear stale viewer state (#487)
1632
+ if (batch.batchId && lastBatchId && batch.batchId !== lastBatchId && viewerMode) {
1633
+ closeViewer();
1634
+ }
1635
+ lastBatchId = batch.batchId || null;
1636
+
1637
+ // Live batch is running — hide history panel, reset viewing state
1638
+ if (viewingHistoryId) {
1639
+ viewingHistoryId = null;
1640
+ $historyPanel.style.display = "none";
1641
+ $historySelect.value = "";
1642
+ }
1643
+
1644
+ if (noBatchRendered) {
1645
+ ensureContentPanels();
1646
+ return;
1647
+ }
1648
+
1649
+ renderHeader(batch);
1650
+ renderSummary(batch);
1651
+
1652
+ // Update repo filter based on current batch data
1653
+ const repos = buildRepoSet(batch);
1654
+ updateRepoFilter(repos);
1655
+
1656
+ renderSupervisor(data);
1657
+ renderLanesTasks(batch, sessions);
1658
+ renderMergeAgents(batch, sessions);
1659
+ // TP-107: Runtime V2 panels
1660
+ renderAgentsPanel(data.runtimeRegistry);
1661
+ renderMessagesPanel(data.mailbox);
1662
+ renderErrors(batch);
1663
+
1664
+ const taskCount = (batch.tasks || []).length;
1665
+ const laneCount = (batch.lanes || []).length;
1666
+ const waveCount = (batch.wavePlan || []).length;
1667
+ $footerInfo.textContent = `${taskCount} tasks · ${laneCount} lanes · ${waveCount} waves`;
1668
+ }
1669
+
1670
+ // ─── SSE Connection ─────────────────────────────────────────────────────────
1671
+
1672
+ let eventSource = null;
1673
+ let reconnectTimer = null;
1674
+
1675
+ function connect() {
1676
+ if (eventSource) eventSource.close();
1677
+
1678
+ eventSource = new EventSource("/api/stream");
1679
+
1680
+ eventSource.onopen = () => {
1681
+ $connDot.className = "connection-dot connected";
1682
+ $connDot.title = "Connected";
1683
+ clearTimeout(reconnectTimer);
1684
+ };
1685
+
1686
+ eventSource.onmessage = (event) => {
1687
+ try {
1688
+ const data = JSON.parse(event.data);
1689
+ render(data);
1690
+ } catch (err) {
1691
+ console.error("Failed to parse SSE data:", err);
1692
+ }
1693
+ };
1694
+
1695
+ eventSource.onerror = () => {
1696
+ $connDot.className = "connection-dot disconnected";
1697
+ $connDot.title = "Disconnected — reconnecting…";
1698
+ eventSource.close();
1699
+ reconnectTimer = setTimeout(connect, 3000);
1700
+ };
1701
+ }
1702
+
1703
+ // ─── Viewer Panel (Conversation + STATUS.md) ────────────────────────────────
1704
+
1705
+ const $terminalPanel = document.getElementById("terminal-panel");
1706
+ const $terminalTitle = document.getElementById("terminal-title");
1707
+ const $terminalBody = document.getElementById("terminal-body");
1708
+ const $terminalClose = document.getElementById("terminal-close");
1709
+ const $autoScrollCheckbox = document.getElementById("auto-scroll-checkbox");
1710
+ const $autoScrollText = document.getElementById("auto-scroll-text");
1711
+
1712
+ // Viewer state
1713
+ let viewerTimer = null;
1714
+ let autoScrollOn = false;
1715
+ let isProgrammaticScroll = false;
1716
+
1717
+ // Conversation append-only state
1718
+ let convRenderedLines = 0;
1719
+
1720
+ // STATUS.md diff-and-skip state
1721
+ let lastStatusMdText = "";
1722
+
1723
+ // ── Open conversation viewer (TP-107: V2 events preferred, legacy fallback) ──
1724
+
1725
+ /**
1726
+ * Resolve a lane session ID to a Runtime V2 agent ID via the registry.
1727
+ * Returns null if no V2 registry data is available.
1728
+ */
1729
+ function resolveV2AgentId(sessionName) {
1730
+ if (!currentData || !currentData.runtimeRegistry || !currentData.runtimeRegistry.agents) return null;
1731
+ const agents = currentData.runtimeRegistry.agents;
1732
+ // Direct match on agentId
1733
+ if (agents[sessionName]) return sessionName;
1734
+ // Match by session ID prefix + "-worker" suffix (common V2 naming)
1735
+ const workerKey = sessionName + '-worker';
1736
+ if (agents[workerKey]) return workerKey;
1737
+ // Search by laneNumber match from lane snapshots
1738
+ for (const [id, agent] of Object.entries(agents)) {
1739
+ if (agent.role === 'worker' && agent.laneNumber != null) {
1740
+ const m = sessionName.match(/lane-(\d+)/);
1741
+ if (m && parseInt(m[1]) === agent.laneNumber) return id;
1742
+ }
1743
+ }
1744
+ return null;
1745
+ }
1746
+
1747
+ let viewerV2AgentId = null; // Runtime V2 agent ID for current conversation view
1748
+
1749
+ function viewConversation(sessionName) {
1750
+ // Toggle off if already viewing this session
1751
+ if (viewerMode === 'conversation' && viewerTarget === sessionName && $terminalPanel.style.display !== 'none') {
1752
+ closeViewer();
1753
+ return;
1754
+ }
1755
+
1756
+ closeViewer();
1757
+
1758
+ viewerMode = 'conversation';
1759
+ viewerTarget = sessionName;
1760
+ autoScrollOn = true;
1761
+ convRenderedLines = 0;
1762
+
1763
+ // TP-107: Resolve V2 agent ID for events endpoint
1764
+ const v2AgentId = resolveV2AgentId(sessionName);
1765
+ viewerV2AgentId = v2AgentId;
1766
+
1767
+ const label = v2AgentId || sessionName;
1768
+ $terminalTitle.textContent = `Worker Conversation — ${label}`;
1769
+ $autoScrollText.textContent = 'Follow feed';
1770
+ $autoScrollCheckbox.checked = true;
1771
+ $terminalPanel.style.display = '';
1772
+ $terminalBody.innerHTML = '<div class="conv-stream"></div>';
1773
+
1774
+ pollConversation();
1775
+ viewerTimer = setInterval(pollConversation, 2000);
1776
+
1777
+ $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1778
+ }
1779
+
1780
+ function pollConversation() {
1781
+ // TP-107: prefer V2 agent events when available, fallback to legacy conversation
1782
+ const endpoint = viewerV2AgentId
1783
+ ? `/api/agent-events/${encodeURIComponent(viewerV2AgentId)}`
1784
+ : `/api/conversation/${encodeURIComponent(viewerTarget)}`;
1785
+ const isV2 = !!viewerV2AgentId;
1786
+
1787
+ fetch(endpoint)
1788
+ .then(r => isV2 ? r.json() : r.text())
1789
+ .then(data => {
1790
+ if (isV2) {
1791
+ renderV2AgentEvents(data);
1792
+ return;
1793
+ }
1794
+ // Legacy: data is JSONL text
1795
+ const text = data;
1796
+ if (!text.trim()) {
1797
+ if (convRenderedLines === 0) {
1798
+ $terminalBody.innerHTML = '<div class="conv-empty">No conversation events yet…</div>';
1799
+ }
1800
+ return;
1801
+ }
1802
+
1803
+ const lines = text.trim().split('\n');
1804
+
1805
+ // File was reset (new task on same lane) — full re-render
1806
+ if (lines.length < convRenderedLines) {
1807
+ convRenderedLines = 0;
1808
+ const container = $terminalBody.querySelector('.conv-stream');
1809
+ if (container) container.innerHTML = '';
1810
+ }
1811
+
1812
+ // Nothing new
1813
+ if (lines.length === convRenderedLines) return;
1814
+
1815
+ // Ensure container exists
1816
+ let container = $terminalBody.querySelector('.conv-stream');
1817
+ if (!container) {
1818
+ $terminalBody.innerHTML = '';
1819
+ container = document.createElement('div');
1820
+ container.className = 'conv-stream';
1821
+ $terminalBody.appendChild(container);
1822
+ }
1823
+
1824
+ // Append only new events
1825
+ const newLines = lines.slice(convRenderedLines);
1826
+ for (const line of newLines) {
1827
+ try {
1828
+ const event = JSON.parse(line);
1829
+ const html = renderConvEvent(event);
1830
+ if (html) container.insertAdjacentHTML('beforeend', html);
1831
+ } catch { continue; }
1832
+ }
1833
+
1834
+ convRenderedLines = lines.length;
1835
+
1836
+ // Auto-scroll to bottom
1837
+ if (autoScrollOn) {
1838
+ isProgrammaticScroll = true;
1839
+ $terminalBody.scrollTop = $terminalBody.scrollHeight;
1840
+ requestAnimationFrame(() => { isProgrammaticScroll = false; });
1841
+ }
1842
+ })
1843
+ .catch(() => {});
1844
+ }
1845
+
1846
+ // ── Runtime V2 agent event renderer (TP-107) ──────────────────────────────
1847
+
1848
+ // Stable cursor for V2 event rendering.
1849
+ // Uses a signature string from the last rendered event so the sliding window
1850
+ // (server caps at 300) doesn't stall when new tail events push older ones out.
1851
+ let v2LastCursor = null; // signature of last rendered event
1852
+ let v2FirstRender = true;
1853
+
1854
+ function v2EventSignature(evt) {
1855
+ return `${evt.ts || 0}:${evt.type || ''}:${JSON.stringify(evt.payload || {}).slice(0, 80)}`;
1856
+ }
1857
+
1858
+ function renderV2AgentEvents(events) {
1859
+ if (!Array.isArray(events) || events.length === 0) {
1860
+ if (v2FirstRender) {
1861
+ $terminalBody.innerHTML = '<div class="conv-empty">No agent events yet…</div>';
1862
+ }
1863
+ return;
1864
+ }
1865
+
1866
+ let container = $terminalBody.querySelector('.conv-stream');
1867
+
1868
+ if (v2FirstRender || !container) {
1869
+ // First load or container missing: full render
1870
+ $terminalBody.innerHTML = '';
1871
+ container = document.createElement('div');
1872
+ container.className = 'conv-stream';
1873
+ $terminalBody.appendChild(container);
1874
+ for (const evt of events) {
1875
+ const html = renderV2Event(evt);
1876
+ if (html) container.insertAdjacentHTML('beforeend', html);
1877
+ }
1878
+ v2LastCursor = v2EventSignature(events[events.length - 1]);
1879
+ v2FirstRender = false;
1880
+ } else {
1881
+ // Incremental: find first unseen event after cursor
1882
+ let cursorIdx = -1;
1883
+ if (v2LastCursor) {
1884
+ for (let i = events.length - 1; i >= 0; i--) {
1885
+ if (v2EventSignature(events[i]) === v2LastCursor) {
1886
+ cursorIdx = i;
1887
+ break;
1888
+ }
1889
+ }
1890
+ }
1891
+
1892
+ if (cursorIdx === -1) {
1893
+ // Cursor not found (rotation/restart): full re-render
1894
+ container.innerHTML = '';
1895
+ for (const evt of events) {
1896
+ const html = renderV2Event(evt);
1897
+ if (html) container.insertAdjacentHTML('beforeend', html);
1898
+ }
1899
+ } else if (cursorIdx < events.length - 1) {
1900
+ // Append only new events after cursor
1901
+ const newEvents = events.slice(cursorIdx + 1);
1902
+ for (const evt of newEvents) {
1903
+ const html = renderV2Event(evt);
1904
+ if (html) container.insertAdjacentHTML('beforeend', html);
1905
+ }
1906
+ } else {
1907
+ // No new events
1908
+ return;
1909
+ }
1910
+
1911
+ v2LastCursor = v2EventSignature(events[events.length - 1]);
1912
+ }
1913
+
1914
+ if (autoScrollOn) {
1915
+ isProgrammaticScroll = true;
1916
+ $terminalBody.scrollTop = $terminalBody.scrollHeight;
1917
+ requestAnimationFrame(() => { isProgrammaticScroll = false; });
1918
+ }
1919
+ }
1920
+
1921
+ function renderV2Event(evt) {
1922
+ const ts = evt.ts ? new Date(evt.ts).toLocaleTimeString() : '';
1923
+ const type = evt.type || 'unknown';
1924
+
1925
+ switch (type) {
1926
+ case 'assistant_message':
1927
+ return `<div class="conv-event conv-assistant"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">assistant</span><span class="conv-text">${escapeHtml((evt.payload?.text || '').slice(0, 2000))}</span></div>`;
1928
+ case 'prompt_sent':
1929
+ return `<div class="conv-event conv-user"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">user</span><span class="conv-text">${escapeHtml((evt.payload?.text || '').slice(0, 2000))}</span></div>`;
1930
+ case 'tool_call':
1931
+ return `<div class="conv-event conv-tool"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">tool</span><span class="conv-text">${escapeHtml(evt.payload?.tool || type)} ${escapeHtml((evt.payload?.path || '').slice(0, 200))}</span></div>`;
1932
+ case 'tool_result':
1933
+ return `<div class="conv-event conv-tool-result"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">result</span><span class="conv-text">${escapeHtml((evt.payload?.summary || '').slice(0, 500))}</span></div>`;
1934
+ case 'agent_started':
1935
+ return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">▶</span><span class="conv-text">Agent started (${escapeHtml(evt.role || '')} lane ${evt.laneNumber ?? '?'})</span></div>`;
1936
+ case 'agent_exited':
1937
+ return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">■</span><span class="conv-text">Agent exited (code ${evt.payload?.exitCode ?? '?'})</span></div>`;
1938
+ case 'agent_crashed':
1939
+ case 'agent_killed':
1940
+ case 'agent_timeout':
1941
+ return `<div class="conv-event conv-lifecycle conv-error"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">⚠</span><span class="conv-text">${escapeHtml(type)} ${escapeHtml(evt.payload?.reason || '')}</span></div>`;
1942
+ case 'message_delivered':
1943
+ return `<div class="conv-event conv-steer"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">✉</span><span class="conv-text">Steering: ${escapeHtml((evt.payload?.content || '').slice(0, 500))}</span></div>`;
1944
+ case 'context_pressure':
1945
+ return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">⚠</span><span class="conv-text">Context pressure: ${evt.payload?.pct ?? '?'}%</span></div>`;
1946
+ default:
1947
+ return `<div class="conv-event conv-lifecycle"><span class="conv-ts">${escapeHtml(ts)}</span><span class="conv-role">•</span><span class="conv-text">${escapeHtml(type)}</span></div>`;
1948
+ }
1949
+ }
1950
+
1951
+ // ── Segment-Scoped STATUS.md Helpers (TP-176) ──────────────────────────────
1952
+
1953
+ /**
1954
+ * Resolve the active segment repoId for a given task.
1955
+ * Uses runtimeLaneSnapshots (active segment) and falls back to
1956
+ * taskSegmentProgress (batch state).
1957
+ * Returns { repoId, segmentInfo } or null if single-segment / unresolvable.
1958
+ */
1959
+ function resolveActiveSegmentForTask(taskId) {
1960
+ if (!currentData) return null;
1961
+ const batch = currentData.batch;
1962
+ if (!batch) return null;
1963
+ const task = (batch.tasks || []).find(t => t.taskId === taskId);
1964
+ if (!task) return null;
1965
+ const segmentIds = Array.isArray(task.segmentIds) ? task.segmentIds.filter(id => typeof id === "string") : [];
1966
+ if (segmentIds.length <= 1) return null; // single-segment or no segments
1967
+
1968
+ // Try to get active segment from runtime lane snapshots
1969
+ const v2Snapshots = currentData.runtimeLaneSnapshots || {};
1970
+ for (const snap of Object.values(v2Snapshots)) {
1971
+ if (snap && snap.taskId === taskId && snap.segmentId) {
1972
+ const parsed = parseSegmentId(snap.segmentId);
1973
+ if (parsed) {
1974
+ const idx = segmentIds.indexOf(snap.segmentId);
1975
+ return {
1976
+ repoId: parsed.repoId,
1977
+ segmentInfo: {
1978
+ index: idx >= 0 ? idx + 1 : null,
1979
+ total: segmentIds.length,
1980
+ repoId: parsed.repoId,
1981
+ segmentId: snap.segmentId,
1982
+ },
1983
+ };
1984
+ }
1985
+ }
1986
+ }
1987
+
1988
+ // Fallback: use taskSegmentProgress (batch state)
1989
+ const segmentStatusMap = buildSegmentStatusMap(batch);
1990
+ const info = taskSegmentProgress(task, segmentStatusMap, null);
1991
+ if (info && info.repoId) {
1992
+ return { repoId: info.repoId, segmentInfo: info };
1993
+ }
1994
+ return null;
1995
+ }
1996
+
1997
+ /**
1998
+ * Filter STATUS.md content to show only the active segment's blocks.
1999
+ * Within each `### Step N:` section, removes `#### Segment: <otherRepo>` blocks
2000
+ * and keeps only the block matching `activeRepoId`.
2001
+ * Non-step content (metadata, notes, reviews, etc.) is preserved.
2002
+ *
2003
+ * Returns the filtered markdown string, or the original if no segment markers found.
2004
+ */
2005
+ function filterStatusMdForSegment(markdown, activeRepoId) {
2006
+ if (!activeRepoId) return markdown;
2007
+ const lines = markdown.split('\n');
2008
+ const result = [];
2009
+ let inStep = false; // inside a ### Step section
2010
+ let inSegmentBlock = false; // inside a #### Segment: <repo> block
2011
+ let segmentMatch = false; // current segment block matches active repo
2012
+ let foundAnySegmentHeader = false;
2013
+
2014
+ for (let i = 0; i < lines.length; i++) {
2015
+ const line = lines[i];
2016
+
2017
+ // Detect step headers: ### Step N: ...
2018
+ if (/^###\s+Step\s+\d+/.test(line)) {
2019
+ inStep = true;
2020
+ inSegmentBlock = false;
2021
+ segmentMatch = false;
2022
+ result.push(line);
2023
+ continue;
2024
+ }
2025
+
2026
+ // Detect non-step ### headers (e.g., ### Reviews, ### Notes)
2027
+ if (/^###\s+/.test(line) && !/^###\s+Step\s+\d+/.test(line)) {
2028
+ inStep = false;
2029
+ inSegmentBlock = false;
2030
+ segmentMatch = false;
2031
+ result.push(line);
2032
+ continue;
2033
+ }
2034
+
2035
+ // Inside a step section, detect #### Segment: <repoId> headers
2036
+ if (inStep && /^####\s+Segment:\s*/.test(line)) {
2037
+ foundAnySegmentHeader = true;
2038
+ const segRepo = line.replace(/^####\s+Segment:\s*/, '').trim();
2039
+ inSegmentBlock = true;
2040
+ segmentMatch = (segRepo === activeRepoId);
2041
+ if (segmentMatch) {
2042
+ result.push(line);
2043
+ }
2044
+ continue;
2045
+ }
2046
+
2047
+ // Detect any other #### header (ends current segment block)
2048
+ if (/^####\s+/.test(line)) {
2049
+ inSegmentBlock = false;
2050
+ segmentMatch = false;
2051
+ result.push(line);
2052
+ continue;
2053
+ }
2054
+
2055
+ // If we're in a segment block, only include matching lines
2056
+ if (inSegmentBlock) {
2057
+ if (segmentMatch) {
2058
+ result.push(line);
2059
+ }
2060
+ continue;
2061
+ }
2062
+
2063
+ // Outside segment blocks: keep the line
2064
+ result.push(line);
2065
+ }
2066
+
2067
+ // If no segment headers were found, return original (fallback for single-segment)
2068
+ if (!foundAnySegmentHeader) return markdown;
2069
+ return result.join('\n');
2070
+ }
2071
+
2072
+ // ── Open STATUS.md viewer ───────────────────────────────────────────────────
2073
+
2074
+ function viewStatusMd(taskId) {
2075
+ // Toggle off if already viewing this task
2076
+ if (viewerMode === 'status-md' && viewerTarget === taskId && $terminalPanel.style.display !== 'none') {
2077
+ closeViewer();
2078
+ return;
2079
+ }
2080
+
2081
+ closeViewer();
2082
+
2083
+ viewerMode = 'status-md';
2084
+ viewerTarget = taskId;
2085
+ autoScrollOn = false;
2086
+ lastStatusMdText = '';
2087
+
2088
+ // TP-176: Include segment context in title for multi-segment tasks
2089
+ const segData = resolveActiveSegmentForTask(taskId);
2090
+ if (segData && segData.segmentInfo) {
2091
+ const label = segmentProgressText(segData.segmentInfo);
2092
+ $terminalTitle.textContent = `STATUS.md — ${taskId} · ${label}`;
2093
+ } else {
2094
+ $terminalTitle.textContent = `STATUS.md — ${taskId}`;
2095
+ }
2096
+
2097
+ $autoScrollText.textContent = 'Track progress';
2098
+ $autoScrollCheckbox.checked = false;
2099
+ $terminalPanel.style.display = '';
2100
+ $terminalBody.innerHTML = '<div class="conv-empty">Loading…</div>';
2101
+
2102
+ pollStatusMd();
2103
+ viewerTimer = setInterval(pollStatusMd, 2000);
2104
+
2105
+ $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2106
+ }
2107
+
2108
+ function pollStatusMd() {
2109
+ fetch(`/api/status-md/${encodeURIComponent(viewerTarget)}`)
2110
+ .then(r => {
2111
+ if (!r.ok) throw new Error('not found');
2112
+ return r.text();
2113
+ })
2114
+ .then(text => {
2115
+ // TP-176: Apply segment-scoped filtering for multi-segment tasks.
2116
+ // Re-resolve on each poll since the active segment may change.
2117
+ const segData = resolveActiveSegmentForTask(viewerTarget);
2118
+ let displayText = text;
2119
+ if (segData && segData.repoId) {
2120
+ displayText = filterStatusMdForSegment(text, segData.repoId);
2121
+ // Update title with current segment context (may change between polls)
2122
+ const label = segmentProgressText(segData.segmentInfo);
2123
+ $terminalTitle.textContent = `STATUS.md \u2014 ${viewerTarget} \u00b7 ${label}`;
2124
+ }
2125
+
2126
+ // Diff-and-skip: no change, no DOM update
2127
+ if (displayText === lastStatusMdText) return;
2128
+ lastStatusMdText = displayText;
2129
+
2130
+ const { html, hasLastChecked } = renderStatusMd(displayText);
2131
+ $terminalBody.innerHTML = html;
2132
+
2133
+ // Update tracking highlight
2134
+ updateTrackingHighlight();
2135
+
2136
+ // Auto-scroll to last checked item
2137
+ if (autoScrollOn && hasLastChecked) {
2138
+ scrollToLastChecked();
2139
+ }
2140
+ })
2141
+ .catch(() => {
2142
+ if (!lastStatusMdText) {
2143
+ $terminalBody.innerHTML = '<div class="conv-empty">STATUS.md not found</div>';
2144
+ }
2145
+ });
2146
+ }
2147
+
2148
+ // ── STATUS.md renderer ──────────────────────────────────────────────────────
2149
+
2150
+ function renderStatusMd(markdown) {
2151
+ const lines = markdown.split('\n');
2152
+ let lastCheckedIdx = -1;
2153
+
2154
+ // First pass: find last checked item
2155
+ for (let i = 0; i < lines.length; i++) {
2156
+ if (/^\s*-\s*\[x\]/i.test(lines[i])) lastCheckedIdx = i;
2157
+ }
2158
+
2159
+ let html = '<div class="status-md-content">';
2160
+
2161
+ for (let i = 0; i < lines.length; i++) {
2162
+ const line = lines[i];
2163
+
2164
+ // Headings
2165
+ const hMatch = line.match(/^(#{1,6})\s+(.+)/);
2166
+ if (hMatch) {
2167
+ const lvl = Math.min(hMatch[1].length, 4);
2168
+ html += `<div class="status-md-h${lvl}">${renderInlineMd(hMatch[2])}</div>`;
2169
+ continue;
2170
+ }
2171
+
2172
+ // Checked checkbox
2173
+ if (/^\s*-\s*\[x\]/i.test(line)) {
2174
+ const text = line.replace(/^\s*-\s*\[x\]\s*/i, '');
2175
+ const isLast = i === lastCheckedIdx;
2176
+ const cls = isLast ? 'status-md-check checked last-checked' : 'status-md-check checked';
2177
+ const id = isLast ? ' id="last-checked"' : '';
2178
+ html += `<div class="${cls}"${id}><span class="check-box">☑</span><span>${renderInlineMd(text)}</span></div>`;
2179
+ continue;
2180
+ }
2181
+
2182
+ // Unchecked checkbox
2183
+ if (/^\s*-\s*\[\s\]/.test(line)) {
2184
+ const text = line.replace(/^\s*-\s*\[\s\]\s*/, '');
2185
+ html += `<div class="status-md-check unchecked"><span class="check-box">☐</span><span>${renderInlineMd(text)}</span></div>`;
2186
+ continue;
2187
+ }
2188
+
2189
+ // List item
2190
+ const liMatch = line.match(/^\s*-\s+(.*)/);
2191
+ if (liMatch) {
2192
+ html += `<div class="status-md-li">• ${renderInlineMd(liMatch[1])}</div>`;
2193
+ continue;
2194
+ }
2195
+
2196
+ // Empty line
2197
+ if (!line.trim()) {
2198
+ html += '<div class="status-md-spacer"></div>';
2199
+ continue;
2200
+ }
2201
+
2202
+ // Plain text
2203
+ html += `<div class="status-md-text">${renderInlineMd(line)}</div>`;
2204
+ }
2205
+
2206
+ html += '</div>';
2207
+ return { html, hasLastChecked: lastCheckedIdx >= 0 };
2208
+ }
2209
+
2210
+ function renderInlineMd(text) {
2211
+ let s = escapeHtml(text);
2212
+ s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
2213
+ s = s.replace(/`(.+?)`/g, '<code class="status-md-code">$1</code>');
2214
+ return s;
2215
+ }
2216
+
2217
+ // ── Conversation event renderer ─────────────────────────────────────────────
2218
+
2219
+ function renderConvEvent(event) {
2220
+ switch (event.type) {
2221
+ case "message_update": {
2222
+ const delta = event.assistantMessageEvent;
2223
+ if (delta?.type === "text_delta" && delta.delta) {
2224
+ return `<span class="conv-text">${escapeHtml(delta.delta)}</span>`;
2225
+ }
2226
+ if (delta?.type === "thinking_delta" && delta.delta) {
2227
+ return `<span class="conv-thinking">${escapeHtml(delta.delta)}</span>`;
2228
+ }
2229
+ return "";
2230
+ }
2231
+
2232
+ case "tool_call": {
2233
+ const name = event.toolName || "unknown";
2234
+ const argsStr = event.args?.path || event.args?.command || "";
2235
+ return `<div class="conv-tool-call"><span class="conv-tool-name">🔧 ${escapeHtml(name)}</span> <span class="conv-tool-args">${escapeHtml(String(argsStr).substring(0, 200))}</span></div>`;
2236
+ }
2237
+
2238
+ case "tool_execution_start": {
2239
+ const name = event.toolName || "unknown";
2240
+ const argsStr = event.args?.path || event.args?.command || "";
2241
+ return `<div class="conv-tool-call"><span class="conv-tool-name">🔧 ${escapeHtml(name)}</span> <span class="conv-tool-args">${escapeHtml(String(argsStr).substring(0, 200))}</span></div>`;
2242
+ }
2243
+
2244
+ case "tool_result": {
2245
+ const output = event.output || event.result || "";
2246
+ const truncated = String(output).length > 500 ? String(output).substring(0, 500) + "…" : String(output);
2247
+ return `<div class="conv-tool-result"><pre>${escapeHtml(truncated)}</pre></div>`;
2248
+ }
2249
+
2250
+ case "message_end": {
2251
+ const usage = event.message?.usage;
2252
+ if (usage) {
2253
+ const tokens = usage.totalTokens || (usage.input + usage.output) || 0;
2254
+ return `<div class="conv-usage">Tokens: ${tokens.toLocaleString()}</div>`;
2255
+ }
2256
+ return "";
2257
+ }
2258
+
2259
+ default:
2260
+ return "";
2261
+ }
2262
+ }
2263
+
2264
+ // ── Auto-scroll logic ───────────────────────────────────────────────────────
2265
+
2266
+ function scrollToLastChecked() {
2267
+ const el = document.getElementById('last-checked');
2268
+ if (!el) return;
2269
+ isProgrammaticScroll = true;
2270
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2271
+ setTimeout(() => { isProgrammaticScroll = false; }, 600);
2272
+ }
2273
+
2274
+ function updateTrackingHighlight() {
2275
+ const container = $terminalBody.querySelector('.status-md-content');
2276
+ if (container) {
2277
+ container.classList.toggle('tracking', autoScrollOn && viewerMode === 'status-md');
2278
+ }
2279
+ }
2280
+
2281
+ $autoScrollCheckbox.addEventListener('change', () => {
2282
+ autoScrollOn = $autoScrollCheckbox.checked;
2283
+ if (autoScrollOn) {
2284
+ if (viewerMode === 'conversation') {
2285
+ isProgrammaticScroll = true;
2286
+ $terminalBody.scrollTop = $terminalBody.scrollHeight;
2287
+ requestAnimationFrame(() => { isProgrammaticScroll = false; });
2288
+ } else if (viewerMode === 'status-md') {
2289
+ scrollToLastChecked();
2290
+ updateTrackingHighlight();
2291
+ }
2292
+ } else {
2293
+ updateTrackingHighlight();
2294
+ }
2295
+ });
2296
+
2297
+ $terminalBody.addEventListener('scroll', () => {
2298
+ if (isProgrammaticScroll) return;
2299
+
2300
+ if (viewerMode === 'conversation') {
2301
+ const isAtBottom = $terminalBody.scrollTop + $terminalBody.clientHeight >= $terminalBody.scrollHeight - 30;
2302
+ if (isAtBottom && !autoScrollOn) {
2303
+ autoScrollOn = true;
2304
+ $autoScrollCheckbox.checked = true;
2305
+ } else if (!isAtBottom && autoScrollOn) {
2306
+ autoScrollOn = false;
2307
+ $autoScrollCheckbox.checked = false;
2308
+ }
2309
+ } else if (viewerMode === 'status-md') {
2310
+ if (autoScrollOn) {
2311
+ autoScrollOn = false;
2312
+ $autoScrollCheckbox.checked = false;
2313
+ updateTrackingHighlight();
2314
+ }
2315
+ }
2316
+ });
2317
+
2318
+ // ── Close viewer ────────────────────────────────────────────────────────────
2319
+
2320
+ function closeViewer() {
2321
+ if (viewerTimer) {
2322
+ clearInterval(viewerTimer);
2323
+ viewerTimer = null;
2324
+ }
2325
+ viewerMode = null;
2326
+ viewerTarget = null;
2327
+ viewerV2AgentId = null;
2328
+ autoScrollOn = false;
2329
+ convRenderedLines = 0;
2330
+ v2LastCursor = null;
2331
+ v2FirstRender = true;
2332
+ lastStatusMdText = '';
2333
+ $terminalPanel.style.display = 'none';
2334
+ $terminalBody.innerHTML = '';
2335
+ }
2336
+
2337
+ $terminalClose.addEventListener('click', closeViewer);
2338
+
2339
+ // Make viewer functions available globally for onclick handlers
2340
+ window.viewConversation = viewConversation;
2341
+ window.viewStatusMd = viewStatusMd;
2342
+
2343
+ // ─── History ────────────────────────────────────────────────────────────────
2344
+
2345
+ /** Fetch the compact history list and populate the dropdown. */
2346
+ function loadHistoryList() {
2347
+ fetch("/api/history")
2348
+ .then(r => r.json())
2349
+ .then(list => {
2350
+ historyList = list || [];
2351
+ renderHistoryDropdown();
2352
+ // Auto-select the latest history entry when no live batch is running.
2353
+ // Always update the view here — renderNoBatch() shows a placeholder
2354
+ // while this async fetch completes, so we need to replace it with
2355
+ // the actual latest entry. This fixes #20 where the stale cached
2356
+ // historyList caused the previous batch to be shown.
2357
+ if (noBatchRendered && historyList.length > 0) {
2358
+ viewHistoryEntry(historyList[0].batchId);
2359
+ $historySelect.value = historyList[0].batchId;
2360
+ } else if (noBatchRendered && historyList.length === 0) {
2361
+ $historyBody.innerHTML = `
2362
+ <div class="no-batch">
2363
+ <div class="no-batch-icon">⏳</div>
2364
+ <div class="no-batch-title">No batch running</div>
2365
+ <div class="no-batch-hint">.pi/batch-state.json not found<br>Start an orchestrator batch to see the dashboard.</div>
2366
+ </div>`;
2367
+ $historyPanel.style.display = "";
2368
+ }
2369
+ })
2370
+ .catch(() => {});
2371
+ }
2372
+
2373
+ function renderHistoryDropdown() {
2374
+ $historySelect.innerHTML = '<option value="">History ▾</option>';
2375
+ for (const h of historyList) {
2376
+ const d = new Date(h.startedAt);
2377
+ const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2378
+ const timeStr = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
2379
+ const statusIcon = h.status === "completed" ? "✓" : h.status === "partial" ? "⚠" : "✗";
2380
+ const label = `${statusIcon} ${dateStr} ${timeStr} — ${h.totalTasks}tasks ${formatDuration(h.durationMs)}`;
2381
+ const opt = document.createElement("option");
2382
+ opt.value = h.batchId;
2383
+ opt.textContent = label;
2384
+ $historySelect.appendChild(opt);
2385
+ }
2386
+ }
2387
+
2388
+ /** Load and display a specific historical batch. */
2389
+ function viewHistoryEntry(batchId) {
2390
+ if (!batchId) {
2391
+ viewingHistoryId = null;
2392
+ $historyPanel.style.display = "none";
2393
+ return;
2394
+ }
2395
+ viewingHistoryId = batchId;
2396
+ fetch(`/api/history/${encodeURIComponent(batchId)}`)
2397
+ .then(r => r.json())
2398
+ .then(entry => {
2399
+ if (entry.error) {
2400
+ $historyBody.innerHTML = `<div class="empty-state">${escapeHtml(entry.error)}</div>`;
2401
+ } else {
2402
+ renderHistorySummary(entry);
2403
+ }
2404
+ $historyPanel.style.display = "";
2405
+ })
2406
+ .catch(() => {
2407
+ $historyBody.innerHTML = '<div class="empty-state">Failed to load batch details</div>';
2408
+ $historyPanel.style.display = "";
2409
+ });
2410
+ }
2411
+
2412
+ /** Render a full batch history summary. */
2413
+ function renderHistorySummary(entry) {
2414
+ const startDate = new Date(entry.startedAt).toLocaleString();
2415
+ const endDate = entry.endedAt ? new Date(entry.endedAt).toLocaleString() : "—";
2416
+ const tok = entry.tokens || {};
2417
+ const totalIn = (tok.input || 0) + (tok.cacheRead || 0);
2418
+ let tokenStr = `↑${formatTokens(totalIn)} ↓${formatTokens(tok.output || 0)}`;
2419
+ const costStr = formatCost(tok.costUsd || 0);
2420
+
2421
+ let html = `
2422
+ <div class="history-header">
2423
+ <span class="batch-id">${escapeHtml(entry.batchId)}</span>
2424
+ <span class="batch-status ${entry.status}">${entry.status}</span>
2425
+ <span class="batch-time">${startDate} → ${endDate}</span>
2426
+ </div>
2427
+
2428
+ <div class="history-stats">
2429
+ <div class="stat-card">
2430
+ <div class="stat-value">${entry.totalTasks}</div>
2431
+ <div class="stat-label">Total Tasks</div>
2432
+ </div>
2433
+ <div class="stat-card">
2434
+ <div class="stat-value" style="color:var(--green)">${entry.succeededTasks}</div>
2435
+ <div class="stat-label">Succeeded</div>
2436
+ </div>
2437
+ <div class="stat-card">
2438
+ <div class="stat-value" style="color:${entry.failedTasks > 0 ? 'var(--red)' : 'var(--text-muted)'}">${entry.failedTasks}</div>
2439
+ <div class="stat-label">Failed</div>
2440
+ </div>
2441
+ <div class="stat-card">
2442
+ <div class="stat-value">${entry.totalWaves}</div>
2443
+ <div class="stat-label">Waves</div>
2444
+ </div>
2445
+ <div class="stat-card">
2446
+ <div class="stat-value">${formatDuration(entry.durationMs)}</div>
2447
+ <div class="stat-label">Duration</div>
2448
+ </div>
2449
+ <div class="stat-card stat-tokens">
2450
+ <div class="stat-value">🪙 ${tokenStr}</div>
2451
+ <div class="stat-label">Tokens</div>
2452
+ </div>
2453
+ ${costStr ? `<div class="stat-card">
2454
+ <div class="stat-value" style="color:var(--yellow)">${costStr}</div>
2455
+ <div class="stat-label">Cost</div>
2456
+ </div>` : ""}
2457
+ </div>`;
2458
+
2459
+ // Wave table
2460
+ if (entry.waves && entry.waves.length > 0) {
2461
+ html += `<div class="history-section-title">Waves</div>`;
2462
+ html += `<table class="history-waves-table"><thead><tr>
2463
+ <th>Wave</th><th>Tasks</th><th>Merge</th><th>Duration</th><th>Tokens</th><th>Cost</th>
2464
+ </tr></thead><tbody>`;
2465
+ for (const w of entry.waves) {
2466
+ const wTok = w.tokens || {};
2467
+ const wTotalIn = (wTok.input || 0) + (wTok.cacheRead || 0);
2468
+ let wTokenStr = `↑${formatTokens(wTotalIn)} ↓${formatTokens(wTok.output || 0)}`;
2469
+ const mergeClass = w.mergeStatus === "succeeded" ? "status-succeeded" :
2470
+ w.mergeStatus === "failed" ? "status-failed" : "status-stalled";
2471
+ html += `<tr>
2472
+ <td>Wave ${w.wave}</td>
2473
+ <td>${w.tasks.join(", ")}</td>
2474
+ <td><span class="status-badge ${mergeClass}">${w.mergeStatus}</span></td>
2475
+ <td>${formatDuration(w.durationMs)}</td>
2476
+ <td>${wTokenStr}</td>
2477
+ <td style="color:var(--yellow)">${formatCost(wTok.costUsd || 0)}</td>
2478
+ </tr>`;
2479
+ }
2480
+ html += `</tbody></table>`;
2481
+ }
2482
+
2483
+ // Task table
2484
+ if (entry.tasks && entry.tasks.length > 0) {
2485
+ html += `<div class="history-section-title">Tasks</div>`;
2486
+ html += `<table class="history-tasks-table"><thead><tr>
2487
+ <th>Task</th><th>Status</th><th>Wave</th><th>Lane</th><th>Duration</th><th>Tokens</th><th>Cost</th><th>Exit</th>
2488
+ </tr></thead><tbody>`;
2489
+ for (const t of entry.tasks) {
2490
+ const tTok = t.tokens || {};
2491
+ const tTotalIn = (tTok.input || 0) + (tTok.cacheRead || 0);
2492
+ let tTokenStr = `↑${formatTokens(tTotalIn)} ↓${formatTokens(tTok.output || 0)}`;
2493
+ const statusCls = `status-${t.status}`;
2494
+ html += `<tr>
2495
+ <td>${escapeHtml(t.taskId)}</td>
2496
+ <td><span class="status-badge ${statusCls}">${t.status}</span></td>
2497
+ <td>W${t.wave}</td>
2498
+ <td>L${t.lane}</td>
2499
+ <td>${formatDuration(t.durationMs)}</td>
2500
+ <td>${tTokenStr}</td>
2501
+ <td style="color:var(--yellow)">${formatCost(tTok.costUsd || 0)}</td>
2502
+ <td style="font-size:0.8rem;color:var(--text-muted)">${t.exitReason ? escapeHtml(t.exitReason) : "—"}</td>
2503
+ </tr>`;
2504
+ }
2505
+ html += `</tbody></table>`;
2506
+ }
2507
+
2508
+ $historyBody.innerHTML = html;
2509
+ }
2510
+
2511
+ /** Handle dropdown change. */
2512
+ $historySelect.addEventListener("change", (e) => {
2513
+ const batchId = e.target.value;
2514
+ if (batchId) {
2515
+ viewHistoryEntry(batchId);
2516
+ } else {
2517
+ // Switched to "History ▾" — go back to live view or latest
2518
+ viewingHistoryId = null;
2519
+ $historyPanel.style.display = "none";
2520
+ }
2521
+ });
2522
+
2523
+ // ─── Theme Toggle ───────────────────────────────────────────────────────────
2524
+
2525
+ const DARK_LOGO = "taskplane-word-white.svg";
2526
+ const LIGHT_LOGO = "taskplane-word-color.svg";
2527
+
2528
+ function applyTheme(theme) {
2529
+ document.documentElement.setAttribute("data-theme", theme);
2530
+ const logo = document.getElementById("header-logo");
2531
+ const icon = document.getElementById("theme-toggle-icon");
2532
+ if (logo) logo.src = theme === "light" ? LIGHT_LOGO : DARK_LOGO;
2533
+ if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙";
2534
+ }
2535
+
2536
+ function loadThemePreference() {
2537
+ fetch("/api/preferences")
2538
+ .then(r => r.ok ? r.json() : { theme: "dark" })
2539
+ .then(prefs => applyTheme(prefs.theme || "dark"))
2540
+ .catch(() => applyTheme("dark"));
2541
+ }
2542
+
2543
+ function saveThemePreference(theme) {
2544
+ fetch("/api/preferences", {
2545
+ method: "POST",
2546
+ headers: { "Content-Type": "application/json" },
2547
+ body: JSON.stringify({ theme }),
2548
+ }).catch(() => {}); // best-effort
2549
+ }
2550
+
2551
+ const $themeToggle = document.getElementById("theme-toggle");
2552
+ if ($themeToggle) {
2553
+ $themeToggle.addEventListener("click", () => {
2554
+ const current = document.documentElement.getAttribute("data-theme") || "dark";
2555
+ const next = current === "dark" ? "light" : "dark";
2556
+ applyTheme(next);
2557
+ saveThemePreference(next);
2558
+ });
2559
+ }
2560
+
2561
+ // Load saved preference on startup
2562
+ loadThemePreference();
2563
+
2564
+ // ─── Boot ───────────────────────────────────────────────────────────────────
2565
+
2566
+ connect();
2567
+ loadHistoryList();
2568
+
2569
+ // One-shot fetch on load (in case SSE is slow to connect)
2570
+ fetch("/api/state")
2571
+ .then(r => r.json())
2572
+ .then(render)
2573
+ .catch(() => {});