taskplane 0.1.6 → 0.1.7

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.
@@ -1,1144 +1,1144 @@
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(epochMs) {
21
- if (!epochMs) return "";
22
- const diff = Date.now() - epochMs;
23
- if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
24
- if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
25
- return `${Math.floor(diff / 3600000)}h ago`;
26
- }
27
-
28
- function pctClass(pct) {
29
- if (pct >= 100) return "pct-hi";
30
- if (pct >= 50) return "pct-mid";
31
- if (pct > 0) return "pct-low";
32
- return "pct-0";
33
- }
34
-
35
- function escapeHtml(str) {
36
- const div = document.createElement("div");
37
- div.textContent = str;
38
- return div.innerHTML;
39
- }
40
-
41
- /** Format token count as human-readable (e.g., 1.2k, 45k, 1.2M). */
42
- function formatTokens(n) {
43
- if (!n || n === 0) return "0";
44
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
45
- if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
46
- return String(n);
47
- }
48
-
49
- function formatCost(usd) {
50
- if (!usd || usd === 0) return "";
51
- if (usd < 0.01) return `$${usd.toFixed(4)}`;
52
- if (usd < 1) return `$${usd.toFixed(3)}`;
53
- return `$${usd.toFixed(2)}`;
54
- }
55
-
56
- /** Build a compact token summary string from lane state sidecar data.
57
- * Display: ↑total_input ↓output (cost)
58
- * Anthropic splits input into: uncached `input` + `cacheRead`.
59
- * Both represent tokens the model processed as input.
60
- * We show the combined figure as ↑ for clarity.
61
- */
62
- function tokenSummaryFromLaneState(ls) {
63
- if (!ls) return "";
64
- const inp = ls.workerInputTokens || 0;
65
- const out = ls.workerOutputTokens || 0;
66
- const cr = ls.workerCacheReadTokens || 0;
67
- const cw = ls.workerCacheWriteTokens || 0;
68
- const cost = ls.workerCostUsd || 0;
69
- const totalIn = inp + cr; // uncached + cached = total input processed
70
- if (totalIn === 0 && out === 0) return "";
71
- let s = `↑${formatTokens(totalIn)} ↓${formatTokens(out)}`;
72
- if (cost > 0) s += ` ${formatCost(cost)}`;
73
- return s;
74
- }
75
-
76
- // ─── Copy to Clipboard ──────────────────────────────────────────────────────
77
-
78
- let toastEl = null;
79
- let toastTimer = null;
80
-
81
- function showCopyToast(text) {
82
- if (!toastEl) {
83
- toastEl = document.createElement("div");
84
- toastEl.className = "copy-toast";
85
- document.body.appendChild(toastEl);
86
- }
87
- toastEl.textContent = `Copied: ${text}`;
88
- toastEl.classList.add("visible");
89
- clearTimeout(toastTimer);
90
- toastTimer = setTimeout(() => toastEl.classList.remove("visible"), 2000);
91
- }
92
-
93
- function copyTmuxCmd(sessionName) {
94
- const cmd = `tmux attach -t ${sessionName}`;
95
- navigator.clipboard.writeText(cmd).then(() => {
96
- showCopyToast(cmd);
97
- // Flash the button
98
- const btn = document.querySelector(`[data-tmux="${sessionName}"]`);
99
- if (btn) {
100
- btn.classList.add("copied");
101
- setTimeout(() => btn.classList.remove("copied"), 1500);
102
- }
103
- }).catch(() => {
104
- // Fallback: select the text
105
- const btn = document.querySelector(`[data-tmux="${sessionName}"]`);
106
- if (btn) {
107
- const range = document.createRange();
108
- range.selectNodeContents(btn);
109
- window.getSelection().removeAllRanges();
110
- window.getSelection().addRange(range);
111
- }
112
- });
113
- }
114
-
115
-
116
-
117
- // ─── DOM References ─────────────────────────────────────────────────────────
118
-
119
- const $ = (id) => document.getElementById(id);
120
-
121
- const $batchId = $("batch-id");
122
- const $batchPhase = $("batch-phase");
123
- const $connDot = $("conn-dot");
124
- const $lastUpdate = $("last-update");
125
- const $progressBarBg = $("progress-bar-bg");
126
- const $overallPct = $("overall-pct");
127
- const $summaryCounts = $("summary-counts");
128
- const $summaryElapsed = $("summary-elapsed");
129
- const $summaryWaves = $("summary-waves");
130
- const $lanesTasksBody = $("lanes-tasks-body");
131
- const $mergeBody = $("merge-body");
132
- const $errorsPanel = $("errors-panel");
133
- const $errorsBody = $("errors-body");
134
- const $footerInfo = $("footer-info");
135
- const $content = $("content");
136
- const $historySelect = $("history-select");
137
- const $historyPanel = $("history-panel");
138
- const $historyBody = $("history-body");
139
-
140
- // ─── History State ──────────────────────────────────────────────────────────
141
-
142
- let historyList = []; // compact batch summaries
143
- let viewingHistoryId = null; // batchId if viewing history, null if live
144
-
145
- // ─── Viewer State ───────────────────────────────────────────────────────────
146
-
147
- let viewerMode = null; // "conversation" | "status-md" | null
148
- let viewerTarget = null; // session name (conversation) or taskId (status-md)
149
-
150
- // ─── Render: Header ─────────────────────────────────────────────────────────
151
-
152
- function renderHeader(batch) {
153
- if (!batch) {
154
- $batchId.textContent = "—";
155
- $batchPhase.textContent = "No batch";
156
- $batchPhase.className = "header-badge badge-phase";
157
- return;
158
- }
159
- $batchId.textContent = batch.batchId;
160
- $batchPhase.textContent = batch.phase;
161
- $batchPhase.className = `header-badge badge-phase phase-${batch.phase}`;
162
- }
163
-
164
- // ─── Render: Summary ────────────────────────────────────────────────────────
165
-
166
- function renderSummary(batch) {
167
- if (!batch) {
168
- $progressBarBg.innerHTML = "";
169
- $overallPct.textContent = "0%";
170
- $summaryCounts.innerHTML = "";
171
- $summaryElapsed.textContent = "—";
172
- $summaryWaves.innerHTML = "";
173
- return;
174
- }
175
-
176
- const tasks = batch.tasks || [];
177
- const total = tasks.length;
178
- const succeeded = tasks.filter(t => t.status === "succeeded").length;
179
- const running = tasks.filter(t => t.status === "running").length;
180
- const failed = tasks.filter(t => t.status === "failed").length;
181
- const stalled = tasks.filter(t => t.status === "stalled").length;
182
- const pending = tasks.filter(t => t.status === "pending").length;
183
-
184
- // ── Checkbox-based progress by wave ──────────────────────────
185
- const taskMap = new Map(tasks.map(t => [t.taskId, t]));
186
- const wavePlan = batch.wavePlan || [tasks.map(t => t.taskId)]; // fallback: single wave
187
- const currentWaveIdx = batch.currentWaveIndex || 0;
188
-
189
- // Compute per-wave and overall checkbox totals
190
- let batchChecked = 0, batchTotal = 0;
191
- const waveStats = wavePlan.map((taskIds, waveIdx) => {
192
- let wChecked = 0, wTotal = 0;
193
- for (const tid of taskIds) {
194
- const t = taskMap.get(tid);
195
- if (t && t.statusData) {
196
- wChecked += t.statusData.checked || 0;
197
- wTotal += t.statusData.total || 0;
198
- } else if (t && t.status === "succeeded") {
199
- // Succeeded tasks may not have statusData if STATUS.md was cleaned up
200
- // Count as fully done — use a small placeholder if no data
201
- wChecked += 1;
202
- wTotal += 1;
203
- }
204
- }
205
- batchChecked += wChecked;
206
- batchTotal += wTotal;
207
- return { waveIdx, taskIds, checked: wChecked, total: wTotal };
208
- });
209
-
210
- const overallPct = batchTotal > 0 ? Math.round((batchChecked / batchTotal) * 100) : 0;
211
- $overallPct.textContent = `${overallPct}%`;
212
-
213
- // Build segmented progress bar — each wave gets a proportional segment
214
- let barHtml = "";
215
- for (const ws of waveStats) {
216
- const segWidthPct = batchTotal > 0 ? (ws.total / batchTotal) * 100 : (100 / waveStats.length);
217
- const fillPct = ws.total > 0 ? (ws.checked / ws.total) * 100 : 0;
218
- const isDone = ws.checked === ws.total && ws.total > 0;
219
- const isCurrent = ws.waveIdx === currentWaveIdx && batch.phase === "executing";
220
- const isFuture = ws.waveIdx > currentWaveIdx && batch.phase === "executing";
221
-
222
- const fillClass = isDone ? "pct-hi" : fillPct > 50 ? "pct-mid" : fillPct > 0 ? "pct-low" : "pct-0";
223
- const segClass = isCurrent ? "wave-seg-current" : isFuture ? "wave-seg-future" : "";
224
-
225
- barHtml += `<div class="wave-seg ${segClass}" style="width:${segWidthPct.toFixed(1)}%" title="W${ws.waveIdx + 1}: ${ws.checked}/${ws.total} checkboxes (${ws.taskIds.join(', ')})">`;
226
- barHtml += ` <div class="wave-seg-fill ${fillClass}" style="width:${fillPct.toFixed(1)}%"></div>`;
227
- barHtml += ` <span class="wave-seg-label">W${ws.waveIdx + 1}</span>`;
228
- barHtml += `</div>`;
229
- }
230
- $progressBarBg.innerHTML = barHtml;
231
-
232
- let countsHtml = "";
233
- if (succeeded > 0) countsHtml += `<span class="count-chip count-succeeded"><span class="count-num">${succeeded}</span><span class="count-icon">✓</span></span>`;
234
- if (running > 0) countsHtml += `<span class="count-chip count-running"><span class="count-num">${running}</span><span class="count-icon">▶</span></span>`;
235
- if (failed > 0) countsHtml += `<span class="count-chip count-failed"><span class="count-num">${failed}</span><span class="count-icon">✗</span></span>`;
236
- if (stalled > 0) countsHtml += `<span class="count-chip count-stalled"><span class="count-num">${stalled}</span><span class="count-icon">⏸</span></span>`;
237
- if (pending > 0) countsHtml += `<span class="count-chip count-pending"><span class="count-num">${pending}</span><span class="count-icon">◌</span></span>`;
238
- countsHtml += `<span class="count-total">/ ${total}</span>`;
239
- $summaryCounts.innerHTML = countsHtml;
240
-
241
- const elapsed = batch.startedAt ? Date.now() - batch.startedAt : 0;
242
- let elapsedStr = `elapsed: ${formatDuration(elapsed)}`;
243
- if (batch.updatedAt) elapsedStr += ` · updated: ${relativeTime(batch.updatedAt)}`;
244
-
245
- // Aggregate tokens across all active lane states
246
- const laneStates = currentData?.laneStates || {};
247
- let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCost = 0;
248
- for (const ls of Object.values(laneStates)) {
249
- batchInput += ls.workerInputTokens || 0;
250
- batchOutput += ls.workerOutputTokens || 0;
251
- batchCacheRead += ls.workerCacheReadTokens || 0;
252
- batchCacheWrite += ls.workerCacheWriteTokens || 0;
253
- batchCost += ls.workerCostUsd || 0;
254
- }
255
- const batchTotalIn = batchInput + batchCacheRead;
256
- if (batchTotalIn > 0 || batchOutput > 0) {
257
- let tokenStr = ` · tokens: ↑${formatTokens(batchTotalIn)} ↓${formatTokens(batchOutput)}`;
258
- if (batchCost > 0) tokenStr += ` · cost: ${formatCost(batchCost)}`;
259
- elapsedStr += tokenStr;
260
- }
261
-
262
- $summaryElapsed.textContent = elapsedStr;
263
-
264
- // Waves
265
- if (batch.wavePlan && batch.wavePlan.length > 0) {
266
- const waveIdx = batch.currentWaveIndex || 0;
267
- let wavesHtml = '<span style="color:var(--text-muted); font-weight:600; margin-right:4px;">Waves</span>';
268
- batch.wavePlan.forEach((taskIds, i) => {
269
- const isDone = i < waveIdx || batch.phase === "completed" || batch.phase === "merging";
270
- const isCurrent = i === waveIdx && batch.phase === "executing";
271
- const cls = isDone ? "done" : isCurrent ? "current" : "";
272
- wavesHtml += `<span class="wave-chip ${cls}">W${i + 1} [${taskIds.join(", ")}]</span>`;
273
- });
274
- $summaryWaves.innerHTML = wavesHtml;
275
- } else {
276
- $summaryWaves.innerHTML = "";
277
- }
278
- }
279
-
280
- // ─── Render: Lanes + Tasks (integrated) ─────────────────────────────────────
281
-
282
- function renderLanesTasks(batch, tmuxSessions) {
283
- if (!batch || !batch.lanes || batch.lanes.length === 0) {
284
- $lanesTasksBody.innerHTML = '<div class="empty-state">No lanes</div>';
285
- return;
286
- }
287
-
288
- const tasks = batch.tasks || [];
289
- const tmuxSet = new Set(tmuxSessions || []);
290
- const laneStates = currentData?.laneStates || {};
291
- let html = "";
292
-
293
- for (const lane of batch.lanes) {
294
- const alive = tmuxSet.has(lane.tmuxSessionName);
295
- const tmuxCmd = `tmux attach -t ${lane.tmuxSessionName}`;
296
-
297
- // Lane header
298
- html += `<div class="lane-group">`;
299
- html += `<div class="lane-header">`;
300
- html += ` <span class="lane-num">${lane.laneNumber}</span>`;
301
- html += ` <div class="lane-meta">`;
302
- html += ` <span class="lane-session">${escapeHtml(lane.tmuxSessionName || "—")}</span>`;
303
- html += ` <span class="lane-branch">${escapeHtml(lane.branch || "—")}</span>`;
304
- html += ` </div>`;
305
- html += ` <div class="lane-right">`;
306
- html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "tmux alive" : "tmux dead"}"></span>`;
307
- // View button: shows conversation stream if available, else tmux pane
308
- const isViewingConv = viewerMode === 'conversation' && viewerTarget === lane.tmuxSessionName;
309
- html += ` <button class="tmux-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(lane.tmuxSessionName)}')" title="View worker conversation">👁 View</button>`;
310
- if (alive) {
311
- html += ` <span class="tmux-cmd" data-tmux="${escapeHtml(lane.tmuxSessionName)}" onclick="copyTmuxCmd('${escapeHtml(lane.tmuxSessionName)}')" title="Click to copy">${escapeHtml(tmuxCmd)}</span>`;
312
- } else {
313
- html += ` <span class="tmux-cmd dead-session">${escapeHtml(tmuxCmd)}</span>`;
314
- }
315
- html += ` </div>`;
316
- html += `</div>`;
317
-
318
- // Task rows for this lane
319
- const laneTasks = (lane.taskIds || []).map(tid => tasks.find(t => t.taskId === tid)).filter(Boolean);
320
-
321
- if (laneTasks.length === 0) {
322
- 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>`;
323
- }
324
-
325
- // Get lane state for worker stats
326
- const ls = laneStates[lane.tmuxSessionName] || null;
327
-
328
- for (const task of laneTasks) {
329
- const sd = task.statusData;
330
- const dur = task.startedAt
331
- ? formatDuration((task.endedAt || Date.now()) - task.startedAt)
332
- : "—";
333
-
334
- // Progress cell
335
- let progressHtml = "";
336
- if (sd) {
337
- const fillClass = pctClass(sd.progress);
338
- progressHtml = `
339
- <div class="task-progress">
340
- <div class="task-progress-bar">
341
- <div class="task-progress-fill ${fillClass}" style="width:${sd.progress}%"></div>
342
- </div>
343
- <span class="task-progress-text">${sd.progress}% ${sd.checked}/${sd.total}</span>
344
- </div>`;
345
- } else if (task.status === "succeeded") {
346
- progressHtml = `
347
- <div class="task-progress">
348
- <div class="task-progress-bar"><div class="task-progress-fill pct-hi" style="width:100%"></div></div>
349
- <span class="task-progress-text">100%</span>
350
- </div>`;
351
- } else if (task.status === "pending") {
352
- progressHtml = `
353
- <div class="task-progress">
354
- <div class="task-progress-bar"><div class="task-progress-fill pct-0" style="width:0%"></div></div>
355
- <span class="task-progress-text">0%</span>
356
- </div>`;
357
- } else {
358
- progressHtml = '<span style="color:var(--text-faint)">—</span>';
359
- }
360
-
361
- // Step cell
362
- let stepHtml = "";
363
- if (sd) {
364
- stepHtml = escapeHtml(sd.currentStep);
365
- if (sd.iteration > 0) stepHtml += `<span class="task-iter">i${sd.iteration}</span>`;
366
- if (sd.reviews > 0) stepHtml += `<span class="task-iter">r${sd.reviews}</span>`;
367
- } else if (task.status === "succeeded") {
368
- stepHtml = '<span style="color:var(--green)">Complete</span>';
369
- } else if (task.status === "pending") {
370
- stepHtml = '<span style="color:var(--text-faint)">Waiting</span>';
371
- } else {
372
- stepHtml = `<span style="color:var(--text-faint)">${escapeHtml(task.exitReason || "—")}</span>`;
373
- }
374
-
375
- // Worker stats from lane state sidecar — only show for the active (running) task
376
- let workerHtml = "";
377
- if (ls && ls.workerStatus === "running" && task.status === "running") {
378
- const elapsed = ls.workerElapsed ? `${Math.round(ls.workerElapsed / 1000)}s` : "";
379
- const tools = ls.workerToolCount || 0;
380
- const ctx = ls.workerContextPct ? `${Math.round(ls.workerContextPct)}%` : "";
381
- const lastTool = ls.workerLastTool || "";
382
- const tokenStr = tokenSummaryFromLaneState(ls);
383
- workerHtml = `<div class="worker-stats">`;
384
- workerHtml += `<span class="worker-stat" title="Worker elapsed">⏱ ${elapsed}</span>`;
385
- workerHtml += `<span class="worker-stat" title="Tool calls">🔧 ${tools}</span>`;
386
- if (ctx) workerHtml += `<span class="worker-stat" title="Context window used">📊 ${ctx}</span>`;
387
- if (tokenStr) workerHtml += `<span class="worker-stat" title="Tokens: input↑ output↓ cacheRead(R) cacheWrite(W)">🪙 ${tokenStr}</span>`;
388
- if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="Last tool call">${escapeHtml(lastTool)}</span>`;
389
- workerHtml += `</div>`;
390
- } else if (ls && ls.workerStatus === "done" && task.status !== "pending") {
391
- workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--green)">✓ Worker done</span></div>`;
392
- } else if (ls && ls.workerStatus === "error" && task.status !== "pending") {
393
- workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--red)">✗ Worker error</span></div>`;
394
- }
395
-
396
- const isViewingStatus = viewerMode === 'status-md' && viewerTarget === task.taskId;
397
- const eyeHtml = task.status !== 'pending'
398
- ? `<button class="viewer-eye-btn${isViewingStatus ? ' active' : ''}" onclick="viewStatusMd('${escapeHtml(task.taskId)}')" title="View STATUS.md">👁</button>`
399
- : '';
400
-
401
- html += `
402
- <div class="task-row">
403
- <span class="task-icon"><span class="status-dot ${task.status}"></span></span>
404
- <span class="task-actions">${eyeHtml}</span>
405
- <span class="task-id status-${task.status}">${escapeHtml(task.taskId)}</span>
406
- <span><span class="status-badge status-${task.status}"><span class="status-dot ${task.status}"></span> ${task.status}</span></span>
407
- <span class="task-duration">${dur}</span>
408
- <span>${progressHtml}</span>
409
- <span class="task-step">${stepHtml}${workerHtml}</span>
410
- </div>`;
411
- }
412
-
413
- html += `</div>`; // close lane-group
414
- }
415
-
416
- $lanesTasksBody.innerHTML = html;
417
- }
418
-
419
- // ─── Render: Merge Agents ───────────────────────────────────────────────────
420
-
421
- function renderMergeAgents(batch, tmuxSessions) {
422
- const mergeResults = batch?.mergeResults || [];
423
- const tmuxSet = new Set(tmuxSessions || []);
424
-
425
- // Check for active merge sessions (convention: orch-merge-*)
426
- const mergeSessions = (tmuxSessions || []).filter(s => s.startsWith("orch-merge"));
427
-
428
- if (mergeResults.length === 0 && mergeSessions.length === 0) {
429
- $mergeBody.innerHTML = '<div class="empty-state">No merge agents active</div>';
430
- return;
431
- }
432
-
433
- let html = '<table class="merge-table"><thead><tr>';
434
- html += '<th>Wave</th><th>Status</th><th>Session</th><th>Attach</th><th>Details</th>';
435
- html += '</tr></thead><tbody>';
436
-
437
- // Show merge results
438
- for (const mr of mergeResults) {
439
- const statusCls = mr.status === "succeeded" ? "status-succeeded"
440
- : mr.status === "partial" ? "status-stalled"
441
- : "status-failed";
442
-
443
- // Look for matching tmux session
444
- const sessionName = `orch-merge-w${mr.waveIndex + 1}`;
445
- const alive = tmuxSet.has(sessionName);
446
-
447
- html += `<tr>`;
448
- html += `<td style="font-family:var(--font-mono);">Wave ${mr.waveIndex + 1}</td>`;
449
- html += `<td><span class="status-badge ${statusCls}">${mr.status}</span></td>`;
450
- html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${alive ? escapeHtml(sessionName) : "—"}</td>`;
451
- html += `<td>`;
452
- if (alive) {
453
- const cmd = `tmux attach -t ${sessionName}`;
454
- html += `<span class="tmux-cmd" data-tmux="${escapeHtml(sessionName)}" onclick="copyTmuxCmd('${escapeHtml(sessionName)}')" title="Click to copy">${escapeHtml(cmd)}</span>`;
455
- } else {
456
- html += '<span style="color:var(--text-faint);">—</span>';
457
- }
458
- html += `</td>`;
459
- html += `<td style="font-size:0.8rem;color:var(--text-muted);">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
460
- html += `</tr>`;
461
- }
462
-
463
- // Show active merge sessions not yet in results
464
- for (const sess of mergeSessions) {
465
- const alreadyShown = mergeResults.some((mr) => `orch-merge-w${mr.waveIndex + 1}` === sess);
466
- if (alreadyShown) continue;
467
-
468
- const cmd = `tmux attach -t ${sess}`;
469
- html += `<tr>`;
470
- html += `<td style="font-family:var(--font-mono);">—</td>`;
471
- html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
472
- html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${escapeHtml(sess)}</td>`;
473
- html += `<td><span class="tmux-cmd" data-tmux="${escapeHtml(sess)}" onclick="copyTmuxCmd('${escapeHtml(sess)}')" title="Click to copy">${escapeHtml(cmd)}</span></td>`;
474
- html += `<td>—</td>`;
475
- html += `</tr>`;
476
- }
477
-
478
- html += '</tbody></table>';
479
- $mergeBody.innerHTML = html;
480
- }
481
-
482
- // ─── Render: Errors ─────────────────────────────────────────────────────────
483
-
484
- function renderErrors(batch) {
485
- const errors = batch?.errors || [];
486
- if (errors.length === 0) {
487
- $errorsPanel.style.display = "none";
488
- return;
489
- }
490
- $errorsPanel.style.display = "";
491
- let html = "";
492
- for (const err of errors.slice(-10)) {
493
- const msg = typeof err === "string" ? err : err.message || JSON.stringify(err);
494
- html += `<div class="error-item"><span class="error-bullet">●</span><span class="error-text">${escapeHtml(msg)}</span></div>`;
495
- }
496
- $errorsBody.innerHTML = html;
497
- }
498
-
499
- // ─── Render: No Batch ───────────────────────────────────────────────────────
500
-
501
- let noBatchRendered = false;
502
-
503
- function renderNoBatch() {
504
- if (noBatchRendered) return;
505
- noBatchRendered = true;
506
-
507
- // Hide live panels, show history panel
508
- const $lanesPanel = document.getElementById("lanes-tasks-panel");
509
- const $mergePanel = document.getElementById("merge-panel");
510
- if ($lanesPanel) $lanesPanel.style.display = "none";
511
- if ($mergePanel) $mergePanel.style.display = "none";
512
- if ($errorsPanel) $errorsPanel.style.display = "none";
513
-
514
- // Try to show the latest history entry
515
- if (historyList.length > 0 && !viewingHistoryId) {
516
- viewHistoryEntry(historyList[0].batchId);
517
- $historySelect.value = historyList[0].batchId;
518
- } else if (historyList.length === 0) {
519
- // No history yet — show placeholder in the history panel
520
- $historyBody.innerHTML = `
521
- <div class="no-batch">
522
- <div class="no-batch-icon">⏳</div>
523
- <div class="no-batch-title">No batch running</div>
524
- <div class="no-batch-hint">.pi/batch-state.json not found<br>Start an orchestrator batch to see the dashboard.</div>
525
- </div>`;
526
- $historyPanel.style.display = "";
527
- }
528
- }
529
-
530
- function ensureContentPanels() {
531
- if (noBatchRendered) {
532
- // A live batch started — restore panels and reload
533
- location.reload();
534
- }
535
- }
536
-
537
- // ─── Full Render ────────────────────────────────────────────────────────────
538
-
539
- // ─── Current data (stored for conversation viewer) ──────────────────────────
540
-
541
- let currentData = null;
542
-
543
- function render(data) {
544
- currentData = data;
545
- const batch = data.batch;
546
- const tmux = data.tmuxSessions || [];
547
-
548
- $lastUpdate.textContent = new Date().toLocaleTimeString();
549
-
550
- if (!batch) {
551
- renderHeader(null);
552
- renderSummary(null);
553
- // Refresh history list (batch may have just finished)
554
- if (!noBatchRendered) loadHistoryList();
555
- renderNoBatch();
556
- return;
557
- }
558
-
559
- // Live batch is running — hide history panel, reset viewing state
560
- if (viewingHistoryId) {
561
- viewingHistoryId = null;
562
- $historyPanel.style.display = "none";
563
- $historySelect.value = "";
564
- }
565
-
566
- if (noBatchRendered) {
567
- ensureContentPanels();
568
- return;
569
- }
570
-
571
- renderHeader(batch);
572
- renderSummary(batch);
573
- renderLanesTasks(batch, tmux);
574
- renderMergeAgents(batch, tmux);
575
- renderErrors(batch);
576
-
577
- const taskCount = (batch.tasks || []).length;
578
- const laneCount = (batch.lanes || []).length;
579
- const waveCount = (batch.wavePlan || []).length;
580
- $footerInfo.textContent = `${taskCount} tasks · ${laneCount} lanes · ${waveCount} waves`;
581
- }
582
-
583
- // ─── SSE Connection ─────────────────────────────────────────────────────────
584
-
585
- let eventSource = null;
586
- let reconnectTimer = null;
587
-
588
- function connect() {
589
- if (eventSource) eventSource.close();
590
-
591
- eventSource = new EventSource("/api/stream");
592
-
593
- eventSource.onopen = () => {
594
- $connDot.className = "connection-dot connected";
595
- $connDot.title = "Connected";
596
- clearTimeout(reconnectTimer);
597
- };
598
-
599
- eventSource.onmessage = (event) => {
600
- try {
601
- const data = JSON.parse(event.data);
602
- render(data);
603
- } catch (err) {
604
- console.error("Failed to parse SSE data:", err);
605
- }
606
- };
607
-
608
- eventSource.onerror = () => {
609
- $connDot.className = "connection-dot disconnected";
610
- $connDot.title = "Disconnected — reconnecting…";
611
- eventSource.close();
612
- reconnectTimer = setTimeout(connect, 3000);
613
- };
614
- }
615
-
616
- // ─── Viewer Panel (Conversation + STATUS.md) ────────────────────────────────
617
-
618
- const $terminalPanel = document.getElementById("terminal-panel");
619
- const $terminalTitle = document.getElementById("terminal-title");
620
- const $terminalBody = document.getElementById("terminal-body");
621
- const $terminalClose = document.getElementById("terminal-close");
622
- const $autoScrollCheckbox = document.getElementById("auto-scroll-checkbox");
623
- const $autoScrollText = document.getElementById("auto-scroll-text");
624
-
625
- // Viewer state
626
- let viewerTimer = null;
627
- let autoScrollOn = false;
628
- let isProgrammaticScroll = false;
629
-
630
- // Conversation append-only state
631
- let convRenderedLines = 0;
632
-
633
- // STATUS.md diff-and-skip state
634
- let lastStatusMdText = "";
635
-
636
- // ── Open conversation viewer ────────────────────────────────────────────────
637
-
638
- function viewConversation(sessionName) {
639
- // Toggle off if already viewing this session
640
- if (viewerMode === 'conversation' && viewerTarget === sessionName && $terminalPanel.style.display !== 'none') {
641
- closeViewer();
642
- return;
643
- }
644
-
645
- closeViewer();
646
-
647
- viewerMode = 'conversation';
648
- viewerTarget = sessionName;
649
- autoScrollOn = true;
650
- convRenderedLines = 0;
651
-
652
- $terminalTitle.textContent = `Worker Conversation — ${sessionName}`;
653
- $autoScrollText.textContent = 'Follow feed';
654
- $autoScrollCheckbox.checked = true;
655
- $terminalPanel.style.display = '';
656
- $terminalBody.innerHTML = '<div class="conv-stream"></div>';
657
-
658
- pollConversation();
659
- viewerTimer = setInterval(pollConversation, 2000);
660
-
661
- $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
662
- }
663
-
664
- function pollConversation() {
665
- fetch(`/api/conversation/${encodeURIComponent(viewerTarget)}`)
666
- .then(r => r.text())
667
- .then(text => {
668
- if (!text.trim()) {
669
- if (convRenderedLines === 0) {
670
- $terminalBody.innerHTML = '<div class="conv-empty">No conversation events yet…</div>';
671
- }
672
- return;
673
- }
674
-
675
- const lines = text.trim().split('\n');
676
-
677
- // File was reset (new task on same lane) — full re-render
678
- if (lines.length < convRenderedLines) {
679
- convRenderedLines = 0;
680
- const container = $terminalBody.querySelector('.conv-stream');
681
- if (container) container.innerHTML = '';
682
- }
683
-
684
- // Nothing new
685
- if (lines.length === convRenderedLines) return;
686
-
687
- // Ensure container exists
688
- let container = $terminalBody.querySelector('.conv-stream');
689
- if (!container) {
690
- $terminalBody.innerHTML = '';
691
- container = document.createElement('div');
692
- container.className = 'conv-stream';
693
- $terminalBody.appendChild(container);
694
- }
695
-
696
- // Append only new events
697
- const newLines = lines.slice(convRenderedLines);
698
- for (const line of newLines) {
699
- try {
700
- const event = JSON.parse(line);
701
- const html = renderConvEvent(event);
702
- if (html) container.insertAdjacentHTML('beforeend', html);
703
- } catch { continue; }
704
- }
705
-
706
- convRenderedLines = lines.length;
707
-
708
- // Auto-scroll to bottom
709
- if (autoScrollOn) {
710
- isProgrammaticScroll = true;
711
- $terminalBody.scrollTop = $terminalBody.scrollHeight;
712
- requestAnimationFrame(() => { isProgrammaticScroll = false; });
713
- }
714
- })
715
- .catch(() => {});
716
- }
717
-
718
- // ── Open STATUS.md viewer ───────────────────────────────────────────────────
719
-
720
- function viewStatusMd(taskId) {
721
- // Toggle off if already viewing this task
722
- if (viewerMode === 'status-md' && viewerTarget === taskId && $terminalPanel.style.display !== 'none') {
723
- closeViewer();
724
- return;
725
- }
726
-
727
- closeViewer();
728
-
729
- viewerMode = 'status-md';
730
- viewerTarget = taskId;
731
- autoScrollOn = false;
732
- lastStatusMdText = '';
733
-
734
- $terminalTitle.textContent = `STATUS.md — ${taskId}`;
735
- $autoScrollText.textContent = 'Track progress';
736
- $autoScrollCheckbox.checked = false;
737
- $terminalPanel.style.display = '';
738
- $terminalBody.innerHTML = '<div class="conv-empty">Loading…</div>';
739
-
740
- pollStatusMd();
741
- viewerTimer = setInterval(pollStatusMd, 2000);
742
-
743
- $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
744
- }
745
-
746
- function pollStatusMd() {
747
- fetch(`/api/status-md/${encodeURIComponent(viewerTarget)}`)
748
- .then(r => {
749
- if (!r.ok) throw new Error('not found');
750
- return r.text();
751
- })
752
- .then(text => {
753
- // Diff-and-skip: no change, no DOM update
754
- if (text === lastStatusMdText) return;
755
- lastStatusMdText = text;
756
-
757
- const { html, hasLastChecked } = renderStatusMd(text);
758
- $terminalBody.innerHTML = html;
759
-
760
- // Update tracking highlight
761
- updateTrackingHighlight();
762
-
763
- // Auto-scroll to last checked item
764
- if (autoScrollOn && hasLastChecked) {
765
- scrollToLastChecked();
766
- }
767
- })
768
- .catch(() => {
769
- if (!lastStatusMdText) {
770
- $terminalBody.innerHTML = '<div class="conv-empty">STATUS.md not found</div>';
771
- }
772
- });
773
- }
774
-
775
- // ── STATUS.md renderer ──────────────────────────────────────────────────────
776
-
777
- function renderStatusMd(markdown) {
778
- const lines = markdown.split('\n');
779
- let lastCheckedIdx = -1;
780
-
781
- // First pass: find last checked item
782
- for (let i = 0; i < lines.length; i++) {
783
- if (/^\s*-\s*\[x\]/i.test(lines[i])) lastCheckedIdx = i;
784
- }
785
-
786
- let html = '<div class="status-md-content">';
787
-
788
- for (let i = 0; i < lines.length; i++) {
789
- const line = lines[i];
790
-
791
- // Headings
792
- const hMatch = line.match(/^(#{1,6})\s+(.+)/);
793
- if (hMatch) {
794
- const lvl = Math.min(hMatch[1].length, 4);
795
- html += `<div class="status-md-h${lvl}">${renderInlineMd(hMatch[2])}</div>`;
796
- continue;
797
- }
798
-
799
- // Checked checkbox
800
- if (/^\s*-\s*\[x\]/i.test(line)) {
801
- const text = line.replace(/^\s*-\s*\[x\]\s*/i, '');
802
- const isLast = i === lastCheckedIdx;
803
- const cls = isLast ? 'status-md-check checked last-checked' : 'status-md-check checked';
804
- const id = isLast ? ' id="last-checked"' : '';
805
- html += `<div class="${cls}"${id}><span class="check-box">☑</span><span>${renderInlineMd(text)}</span></div>`;
806
- continue;
807
- }
808
-
809
- // Unchecked checkbox
810
- if (/^\s*-\s*\[\s\]/.test(line)) {
811
- const text = line.replace(/^\s*-\s*\[\s\]\s*/, '');
812
- html += `<div class="status-md-check unchecked"><span class="check-box">☐</span><span>${renderInlineMd(text)}</span></div>`;
813
- continue;
814
- }
815
-
816
- // List item
817
- const liMatch = line.match(/^\s*-\s+(.*)/);
818
- if (liMatch) {
819
- html += `<div class="status-md-li">• ${renderInlineMd(liMatch[1])}</div>`;
820
- continue;
821
- }
822
-
823
- // Empty line
824
- if (!line.trim()) {
825
- html += '<div class="status-md-spacer"></div>';
826
- continue;
827
- }
828
-
829
- // Plain text
830
- html += `<div class="status-md-text">${renderInlineMd(line)}</div>`;
831
- }
832
-
833
- html += '</div>';
834
- return { html, hasLastChecked: lastCheckedIdx >= 0 };
835
- }
836
-
837
- function renderInlineMd(text) {
838
- let s = escapeHtml(text);
839
- s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
840
- s = s.replace(/`(.+?)`/g, '<code class="status-md-code">$1</code>');
841
- return s;
842
- }
843
-
844
- // ── Conversation event renderer ─────────────────────────────────────────────
845
-
846
- function renderConvEvent(event) {
847
- switch (event.type) {
848
- case "message_update": {
849
- const delta = event.assistantMessageEvent;
850
- if (delta?.type === "text_delta" && delta.delta) {
851
- return `<span class="conv-text">${escapeHtml(delta.delta)}</span>`;
852
- }
853
- if (delta?.type === "thinking_delta" && delta.delta) {
854
- return `<span class="conv-thinking">${escapeHtml(delta.delta)}</span>`;
855
- }
856
- return "";
857
- }
858
-
859
- case "tool_call": {
860
- const name = event.toolName || "unknown";
861
- const argsStr = event.args?.path || event.args?.command || "";
862
- 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>`;
863
- }
864
-
865
- case "tool_execution_start": {
866
- const name = event.toolName || "unknown";
867
- const argsStr = event.args?.path || event.args?.command || "";
868
- 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>`;
869
- }
870
-
871
- case "tool_result": {
872
- const output = event.output || event.result || "";
873
- const truncated = String(output).length > 500 ? String(output).substring(0, 500) + "…" : String(output);
874
- return `<div class="conv-tool-result"><pre>${escapeHtml(truncated)}</pre></div>`;
875
- }
876
-
877
- case "message_end": {
878
- const usage = event.message?.usage;
879
- if (usage) {
880
- const tokens = usage.totalTokens || (usage.input + usage.output) || 0;
881
- return `<div class="conv-usage">Tokens: ${tokens.toLocaleString()}</div>`;
882
- }
883
- return "";
884
- }
885
-
886
- default:
887
- return "";
888
- }
889
- }
890
-
891
- // ── Auto-scroll logic ───────────────────────────────────────────────────────
892
-
893
- function scrollToLastChecked() {
894
- const el = document.getElementById('last-checked');
895
- if (!el) return;
896
- isProgrammaticScroll = true;
897
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
898
- setTimeout(() => { isProgrammaticScroll = false; }, 600);
899
- }
900
-
901
- function updateTrackingHighlight() {
902
- const container = $terminalBody.querySelector('.status-md-content');
903
- if (container) {
904
- container.classList.toggle('tracking', autoScrollOn && viewerMode === 'status-md');
905
- }
906
- }
907
-
908
- $autoScrollCheckbox.addEventListener('change', () => {
909
- autoScrollOn = $autoScrollCheckbox.checked;
910
- if (autoScrollOn) {
911
- if (viewerMode === 'conversation') {
912
- isProgrammaticScroll = true;
913
- $terminalBody.scrollTop = $terminalBody.scrollHeight;
914
- requestAnimationFrame(() => { isProgrammaticScroll = false; });
915
- } else if (viewerMode === 'status-md') {
916
- scrollToLastChecked();
917
- updateTrackingHighlight();
918
- }
919
- } else {
920
- updateTrackingHighlight();
921
- }
922
- });
923
-
924
- $terminalBody.addEventListener('scroll', () => {
925
- if (isProgrammaticScroll) return;
926
-
927
- if (viewerMode === 'conversation') {
928
- const isAtBottom = $terminalBody.scrollTop + $terminalBody.clientHeight >= $terminalBody.scrollHeight - 30;
929
- if (isAtBottom && !autoScrollOn) {
930
- autoScrollOn = true;
931
- $autoScrollCheckbox.checked = true;
932
- } else if (!isAtBottom && autoScrollOn) {
933
- autoScrollOn = false;
934
- $autoScrollCheckbox.checked = false;
935
- }
936
- } else if (viewerMode === 'status-md') {
937
- if (autoScrollOn) {
938
- autoScrollOn = false;
939
- $autoScrollCheckbox.checked = false;
940
- updateTrackingHighlight();
941
- }
942
- }
943
- });
944
-
945
- // ── Close viewer ────────────────────────────────────────────────────────────
946
-
947
- function closeViewer() {
948
- if (viewerTimer) {
949
- clearInterval(viewerTimer);
950
- viewerTimer = null;
951
- }
952
- viewerMode = null;
953
- viewerTarget = null;
954
- autoScrollOn = false;
955
- convRenderedLines = 0;
956
- lastStatusMdText = '';
957
- $terminalPanel.style.display = 'none';
958
- $terminalBody.innerHTML = '';
959
- }
960
-
961
- $terminalClose.addEventListener('click', closeViewer);
962
-
963
- // Make viewer functions available globally for onclick handlers
964
- window.viewConversation = viewConversation;
965
- window.viewStatusMd = viewStatusMd;
966
-
967
- // ─── History ────────────────────────────────────────────────────────────────
968
-
969
- /** Fetch the compact history list and populate the dropdown. */
970
- function loadHistoryList() {
971
- fetch("/api/history")
972
- .then(r => r.json())
973
- .then(list => {
974
- historyList = list || [];
975
- renderHistoryDropdown();
976
- // If no live batch and no history shown yet, auto-select latest
977
- if (noBatchRendered && !viewingHistoryId && historyList.length > 0) {
978
- viewHistoryEntry(historyList[0].batchId);
979
- $historySelect.value = historyList[0].batchId;
980
- }
981
- })
982
- .catch(() => {});
983
- }
984
-
985
- function renderHistoryDropdown() {
986
- $historySelect.innerHTML = '<option value="">History ▾</option>';
987
- for (const h of historyList) {
988
- const d = new Date(h.startedAt);
989
- const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
990
- const timeStr = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
991
- const statusIcon = h.status === "completed" ? "✓" : h.status === "partial" ? "⚠" : "✗";
992
- const label = `${statusIcon} ${dateStr} ${timeStr} — ${h.totalTasks}tasks ${formatDuration(h.durationMs)}`;
993
- const opt = document.createElement("option");
994
- opt.value = h.batchId;
995
- opt.textContent = label;
996
- $historySelect.appendChild(opt);
997
- }
998
- }
999
-
1000
- /** Load and display a specific historical batch. */
1001
- function viewHistoryEntry(batchId) {
1002
- if (!batchId) {
1003
- viewingHistoryId = null;
1004
- $historyPanel.style.display = "none";
1005
- return;
1006
- }
1007
- viewingHistoryId = batchId;
1008
- fetch(`/api/history/${encodeURIComponent(batchId)}`)
1009
- .then(r => r.json())
1010
- .then(entry => {
1011
- if (entry.error) {
1012
- $historyBody.innerHTML = `<div class="empty-state">${escapeHtml(entry.error)}</div>`;
1013
- } else {
1014
- renderHistorySummary(entry);
1015
- }
1016
- $historyPanel.style.display = "";
1017
- })
1018
- .catch(() => {
1019
- $historyBody.innerHTML = '<div class="empty-state">Failed to load batch details</div>';
1020
- $historyPanel.style.display = "";
1021
- });
1022
- }
1023
-
1024
- /** Render a full batch history summary. */
1025
- function renderHistorySummary(entry) {
1026
- const startDate = new Date(entry.startedAt).toLocaleString();
1027
- const endDate = entry.endedAt ? new Date(entry.endedAt).toLocaleString() : "—";
1028
- const tok = entry.tokens || {};
1029
- const totalIn = (tok.input || 0) + (tok.cacheRead || 0);
1030
- let tokenStr = `↑${formatTokens(totalIn)} ↓${formatTokens(tok.output || 0)}`;
1031
- const costStr = formatCost(tok.costUsd || 0);
1032
-
1033
- let html = `
1034
- <div class="history-header">
1035
- <span class="batch-id">${escapeHtml(entry.batchId)}</span>
1036
- <span class="batch-status ${entry.status}">${entry.status}</span>
1037
- <span class="batch-time">${startDate} → ${endDate}</span>
1038
- </div>
1039
-
1040
- <div class="history-stats">
1041
- <div class="stat-card">
1042
- <div class="stat-value">${entry.totalTasks}</div>
1043
- <div class="stat-label">Total Tasks</div>
1044
- </div>
1045
- <div class="stat-card">
1046
- <div class="stat-value" style="color:var(--green)">${entry.succeededTasks}</div>
1047
- <div class="stat-label">Succeeded</div>
1048
- </div>
1049
- <div class="stat-card">
1050
- <div class="stat-value" style="color:${entry.failedTasks > 0 ? 'var(--red)' : 'var(--text-muted)'}">${entry.failedTasks}</div>
1051
- <div class="stat-label">Failed</div>
1052
- </div>
1053
- <div class="stat-card">
1054
- <div class="stat-value">${entry.totalWaves}</div>
1055
- <div class="stat-label">Waves</div>
1056
- </div>
1057
- <div class="stat-card">
1058
- <div class="stat-value">${formatDuration(entry.durationMs)}</div>
1059
- <div class="stat-label">Duration</div>
1060
- </div>
1061
- <div class="stat-card stat-tokens">
1062
- <div class="stat-value">🪙 ${tokenStr}</div>
1063
- <div class="stat-label">Tokens</div>
1064
- </div>
1065
- ${costStr ? `<div class="stat-card">
1066
- <div class="stat-value" style="color:var(--yellow)">${costStr}</div>
1067
- <div class="stat-label">Cost</div>
1068
- </div>` : ""}
1069
- </div>`;
1070
-
1071
- // Wave table
1072
- if (entry.waves && entry.waves.length > 0) {
1073
- html += `<div class="history-section-title">Waves</div>`;
1074
- html += `<table class="history-waves-table"><thead><tr>
1075
- <th>Wave</th><th>Tasks</th><th>Merge</th><th>Duration</th><th>Tokens</th><th>Cost</th>
1076
- </tr></thead><tbody>`;
1077
- for (const w of entry.waves) {
1078
- const wTok = w.tokens || {};
1079
- const wTotalIn = (wTok.input || 0) + (wTok.cacheRead || 0);
1080
- let wTokenStr = `↑${formatTokens(wTotalIn)} ↓${formatTokens(wTok.output || 0)}`;
1081
- const mergeClass = w.mergeStatus === "succeeded" ? "status-succeeded" :
1082
- w.mergeStatus === "failed" ? "status-failed" : "status-stalled";
1083
- html += `<tr>
1084
- <td>Wave ${w.wave}</td>
1085
- <td>${w.tasks.join(", ")}</td>
1086
- <td><span class="status-badge ${mergeClass}">${w.mergeStatus}</span></td>
1087
- <td>${formatDuration(w.durationMs)}</td>
1088
- <td>${wTokenStr}</td>
1089
- <td style="color:var(--yellow)">${formatCost(wTok.costUsd || 0)}</td>
1090
- </tr>`;
1091
- }
1092
- html += `</tbody></table>`;
1093
- }
1094
-
1095
- // Task table
1096
- if (entry.tasks && entry.tasks.length > 0) {
1097
- html += `<div class="history-section-title">Tasks</div>`;
1098
- html += `<table class="history-tasks-table"><thead><tr>
1099
- <th>Task</th><th>Status</th><th>Wave</th><th>Lane</th><th>Duration</th><th>Tokens</th><th>Cost</th><th>Exit</th>
1100
- </tr></thead><tbody>`;
1101
- for (const t of entry.tasks) {
1102
- const tTok = t.tokens || {};
1103
- const tTotalIn = (tTok.input || 0) + (tTok.cacheRead || 0);
1104
- let tTokenStr = `↑${formatTokens(tTotalIn)} ↓${formatTokens(tTok.output || 0)}`;
1105
- const statusCls = `status-${t.status}`;
1106
- html += `<tr>
1107
- <td>${escapeHtml(t.taskId)}</td>
1108
- <td><span class="status-badge ${statusCls}">${t.status}</span></td>
1109
- <td>W${t.wave}</td>
1110
- <td>L${t.lane}</td>
1111
- <td>${formatDuration(t.durationMs)}</td>
1112
- <td>${tTokenStr}</td>
1113
- <td style="color:var(--yellow)">${formatCost(tTok.costUsd || 0)}</td>
1114
- <td style="font-size:0.8rem;color:var(--text-muted)">${t.exitReason ? escapeHtml(t.exitReason) : "—"}</td>
1115
- </tr>`;
1116
- }
1117
- html += `</tbody></table>`;
1118
- }
1119
-
1120
- $historyBody.innerHTML = html;
1121
- }
1122
-
1123
- /** Handle dropdown change. */
1124
- $historySelect.addEventListener("change", (e) => {
1125
- const batchId = e.target.value;
1126
- if (batchId) {
1127
- viewHistoryEntry(batchId);
1128
- } else {
1129
- // Switched to "History ▾" — go back to live view or latest
1130
- viewingHistoryId = null;
1131
- $historyPanel.style.display = "none";
1132
- }
1133
- });
1134
-
1135
- // ─── Boot ───────────────────────────────────────────────────────────────────
1136
-
1137
- connect();
1138
- loadHistoryList();
1139
-
1140
- // One-shot fetch on load (in case SSE is slow to connect)
1141
- fetch("/api/state")
1142
- .then(r => r.json())
1143
- .then(render)
1144
- .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(epochMs) {
21
+ if (!epochMs) return "";
22
+ const diff = Date.now() - epochMs;
23
+ if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
24
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
25
+ return `${Math.floor(diff / 3600000)}h ago`;
26
+ }
27
+
28
+ function pctClass(pct) {
29
+ if (pct >= 100) return "pct-hi";
30
+ if (pct >= 50) return "pct-mid";
31
+ if (pct > 0) return "pct-low";
32
+ return "pct-0";
33
+ }
34
+
35
+ function escapeHtml(str) {
36
+ const div = document.createElement("div");
37
+ div.textContent = str;
38
+ return div.innerHTML;
39
+ }
40
+
41
+ /** Format token count as human-readable (e.g., 1.2k, 45k, 1.2M). */
42
+ function formatTokens(n) {
43
+ if (!n || n === 0) return "0";
44
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
45
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
46
+ return String(n);
47
+ }
48
+
49
+ function formatCost(usd) {
50
+ if (!usd || usd === 0) return "";
51
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
52
+ if (usd < 1) return `$${usd.toFixed(3)}`;
53
+ return `$${usd.toFixed(2)}`;
54
+ }
55
+
56
+ /** Build a compact token summary string from lane state sidecar data.
57
+ * Display: ↑total_input ↓output (cost)
58
+ * Anthropic splits input into: uncached `input` + `cacheRead`.
59
+ * Both represent tokens the model processed as input.
60
+ * We show the combined figure as ↑ for clarity.
61
+ */
62
+ function tokenSummaryFromLaneState(ls) {
63
+ if (!ls) return "";
64
+ const inp = ls.workerInputTokens || 0;
65
+ const out = ls.workerOutputTokens || 0;
66
+ const cr = ls.workerCacheReadTokens || 0;
67
+ const cw = ls.workerCacheWriteTokens || 0;
68
+ const cost = ls.workerCostUsd || 0;
69
+ const totalIn = inp + cr; // uncached + cached = total input processed
70
+ if (totalIn === 0 && out === 0) return "";
71
+ let s = `↑${formatTokens(totalIn)} ↓${formatTokens(out)}`;
72
+ if (cost > 0) s += ` ${formatCost(cost)}`;
73
+ return s;
74
+ }
75
+
76
+ // ─── Copy to Clipboard ──────────────────────────────────────────────────────
77
+
78
+ let toastEl = null;
79
+ let toastTimer = null;
80
+
81
+ function showCopyToast(text) {
82
+ if (!toastEl) {
83
+ toastEl = document.createElement("div");
84
+ toastEl.className = "copy-toast";
85
+ document.body.appendChild(toastEl);
86
+ }
87
+ toastEl.textContent = `Copied: ${text}`;
88
+ toastEl.classList.add("visible");
89
+ clearTimeout(toastTimer);
90
+ toastTimer = setTimeout(() => toastEl.classList.remove("visible"), 2000);
91
+ }
92
+
93
+ function copyTmuxCmd(sessionName) {
94
+ const cmd = `tmux attach -t ${sessionName}`;
95
+ navigator.clipboard.writeText(cmd).then(() => {
96
+ showCopyToast(cmd);
97
+ // Flash the button
98
+ const btn = document.querySelector(`[data-tmux="${sessionName}"]`);
99
+ if (btn) {
100
+ btn.classList.add("copied");
101
+ setTimeout(() => btn.classList.remove("copied"), 1500);
102
+ }
103
+ }).catch(() => {
104
+ // Fallback: select the text
105
+ const btn = document.querySelector(`[data-tmux="${sessionName}"]`);
106
+ if (btn) {
107
+ const range = document.createRange();
108
+ range.selectNodeContents(btn);
109
+ window.getSelection().removeAllRanges();
110
+ window.getSelection().addRange(range);
111
+ }
112
+ });
113
+ }
114
+
115
+
116
+
117
+ // ─── DOM References ─────────────────────────────────────────────────────────
118
+
119
+ const $ = (id) => document.getElementById(id);
120
+
121
+ const $batchId = $("batch-id");
122
+ const $batchPhase = $("batch-phase");
123
+ const $connDot = $("conn-dot");
124
+ const $lastUpdate = $("last-update");
125
+ const $progressBarBg = $("progress-bar-bg");
126
+ const $overallPct = $("overall-pct");
127
+ const $summaryCounts = $("summary-counts");
128
+ const $summaryElapsed = $("summary-elapsed");
129
+ const $summaryWaves = $("summary-waves");
130
+ const $lanesTasksBody = $("lanes-tasks-body");
131
+ const $mergeBody = $("merge-body");
132
+ const $errorsPanel = $("errors-panel");
133
+ const $errorsBody = $("errors-body");
134
+ const $footerInfo = $("footer-info");
135
+ const $content = $("content");
136
+ const $historySelect = $("history-select");
137
+ const $historyPanel = $("history-panel");
138
+ const $historyBody = $("history-body");
139
+
140
+ // ─── History State ──────────────────────────────────────────────────────────
141
+
142
+ let historyList = []; // compact batch summaries
143
+ let viewingHistoryId = null; // batchId if viewing history, null if live
144
+
145
+ // ─── Viewer State ───────────────────────────────────────────────────────────
146
+
147
+ let viewerMode = null; // "conversation" | "status-md" | null
148
+ let viewerTarget = null; // session name (conversation) or taskId (status-md)
149
+
150
+ // ─── Render: Header ─────────────────────────────────────────────────────────
151
+
152
+ function renderHeader(batch) {
153
+ if (!batch) {
154
+ $batchId.textContent = "—";
155
+ $batchPhase.textContent = "No batch";
156
+ $batchPhase.className = "header-badge badge-phase";
157
+ return;
158
+ }
159
+ $batchId.textContent = batch.batchId;
160
+ $batchPhase.textContent = batch.phase;
161
+ $batchPhase.className = `header-badge badge-phase phase-${batch.phase}`;
162
+ }
163
+
164
+ // ─── Render: Summary ────────────────────────────────────────────────────────
165
+
166
+ function renderSummary(batch) {
167
+ if (!batch) {
168
+ $progressBarBg.innerHTML = "";
169
+ $overallPct.textContent = "0%";
170
+ $summaryCounts.innerHTML = "";
171
+ $summaryElapsed.textContent = "—";
172
+ $summaryWaves.innerHTML = "";
173
+ return;
174
+ }
175
+
176
+ const tasks = batch.tasks || [];
177
+ const total = tasks.length;
178
+ const succeeded = tasks.filter(t => t.status === "succeeded").length;
179
+ const running = tasks.filter(t => t.status === "running").length;
180
+ const failed = tasks.filter(t => t.status === "failed").length;
181
+ const stalled = tasks.filter(t => t.status === "stalled").length;
182
+ const pending = tasks.filter(t => t.status === "pending").length;
183
+
184
+ // ── Checkbox-based progress by wave ──────────────────────────
185
+ const taskMap = new Map(tasks.map(t => [t.taskId, t]));
186
+ const wavePlan = batch.wavePlan || [tasks.map(t => t.taskId)]; // fallback: single wave
187
+ const currentWaveIdx = batch.currentWaveIndex || 0;
188
+
189
+ // Compute per-wave and overall checkbox totals
190
+ let batchChecked = 0, batchTotal = 0;
191
+ const waveStats = wavePlan.map((taskIds, waveIdx) => {
192
+ let wChecked = 0, wTotal = 0;
193
+ for (const tid of taskIds) {
194
+ const t = taskMap.get(tid);
195
+ if (t && t.statusData) {
196
+ wChecked += t.statusData.checked || 0;
197
+ wTotal += t.statusData.total || 0;
198
+ } else if (t && t.status === "succeeded") {
199
+ // Succeeded tasks may not have statusData if STATUS.md was cleaned up
200
+ // Count as fully done — use a small placeholder if no data
201
+ wChecked += 1;
202
+ wTotal += 1;
203
+ }
204
+ }
205
+ batchChecked += wChecked;
206
+ batchTotal += wTotal;
207
+ return { waveIdx, taskIds, checked: wChecked, total: wTotal };
208
+ });
209
+
210
+ const overallPct = batchTotal > 0 ? Math.round((batchChecked / batchTotal) * 100) : 0;
211
+ $overallPct.textContent = `${overallPct}%`;
212
+
213
+ // Build segmented progress bar — each wave gets a proportional segment
214
+ let barHtml = "";
215
+ for (const ws of waveStats) {
216
+ const segWidthPct = batchTotal > 0 ? (ws.total / batchTotal) * 100 : (100 / waveStats.length);
217
+ const fillPct = ws.total > 0 ? (ws.checked / ws.total) * 100 : 0;
218
+ const isDone = ws.checked === ws.total && ws.total > 0;
219
+ const isCurrent = ws.waveIdx === currentWaveIdx && batch.phase === "executing";
220
+ const isFuture = ws.waveIdx > currentWaveIdx && batch.phase === "executing";
221
+
222
+ const fillClass = isDone ? "pct-hi" : fillPct > 50 ? "pct-mid" : fillPct > 0 ? "pct-low" : "pct-0";
223
+ const segClass = isCurrent ? "wave-seg-current" : isFuture ? "wave-seg-future" : "";
224
+
225
+ barHtml += `<div class="wave-seg ${segClass}" style="width:${segWidthPct.toFixed(1)}%" title="W${ws.waveIdx + 1}: ${ws.checked}/${ws.total} checkboxes (${ws.taskIds.join(', ')})">`;
226
+ barHtml += ` <div class="wave-seg-fill ${fillClass}" style="width:${fillPct.toFixed(1)}%"></div>`;
227
+ barHtml += ` <span class="wave-seg-label">W${ws.waveIdx + 1}</span>`;
228
+ barHtml += `</div>`;
229
+ }
230
+ $progressBarBg.innerHTML = barHtml;
231
+
232
+ let countsHtml = "";
233
+ if (succeeded > 0) countsHtml += `<span class="count-chip count-succeeded"><span class="count-num">${succeeded}</span><span class="count-icon">✓</span></span>`;
234
+ if (running > 0) countsHtml += `<span class="count-chip count-running"><span class="count-num">${running}</span><span class="count-icon">▶</span></span>`;
235
+ if (failed > 0) countsHtml += `<span class="count-chip count-failed"><span class="count-num">${failed}</span><span class="count-icon">✗</span></span>`;
236
+ if (stalled > 0) countsHtml += `<span class="count-chip count-stalled"><span class="count-num">${stalled}</span><span class="count-icon">⏸</span></span>`;
237
+ if (pending > 0) countsHtml += `<span class="count-chip count-pending"><span class="count-num">${pending}</span><span class="count-icon">◌</span></span>`;
238
+ countsHtml += `<span class="count-total">/ ${total}</span>`;
239
+ $summaryCounts.innerHTML = countsHtml;
240
+
241
+ const elapsed = batch.startedAt ? Date.now() - batch.startedAt : 0;
242
+ let elapsedStr = `elapsed: ${formatDuration(elapsed)}`;
243
+ if (batch.updatedAt) elapsedStr += ` · updated: ${relativeTime(batch.updatedAt)}`;
244
+
245
+ // Aggregate tokens across all active lane states
246
+ const laneStates = currentData?.laneStates || {};
247
+ let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCost = 0;
248
+ for (const ls of Object.values(laneStates)) {
249
+ batchInput += ls.workerInputTokens || 0;
250
+ batchOutput += ls.workerOutputTokens || 0;
251
+ batchCacheRead += ls.workerCacheReadTokens || 0;
252
+ batchCacheWrite += ls.workerCacheWriteTokens || 0;
253
+ batchCost += ls.workerCostUsd || 0;
254
+ }
255
+ const batchTotalIn = batchInput + batchCacheRead;
256
+ if (batchTotalIn > 0 || batchOutput > 0) {
257
+ let tokenStr = ` · tokens: ↑${formatTokens(batchTotalIn)} ↓${formatTokens(batchOutput)}`;
258
+ if (batchCost > 0) tokenStr += ` · cost: ${formatCost(batchCost)}`;
259
+ elapsedStr += tokenStr;
260
+ }
261
+
262
+ $summaryElapsed.textContent = elapsedStr;
263
+
264
+ // Waves
265
+ if (batch.wavePlan && batch.wavePlan.length > 0) {
266
+ const waveIdx = batch.currentWaveIndex || 0;
267
+ let wavesHtml = '<span style="color:var(--text-muted); font-weight:600; margin-right:4px;">Waves</span>';
268
+ batch.wavePlan.forEach((taskIds, i) => {
269
+ const isDone = i < waveIdx || batch.phase === "completed" || batch.phase === "merging";
270
+ const isCurrent = i === waveIdx && batch.phase === "executing";
271
+ const cls = isDone ? "done" : isCurrent ? "current" : "";
272
+ wavesHtml += `<span class="wave-chip ${cls}">W${i + 1} [${taskIds.join(", ")}]</span>`;
273
+ });
274
+ $summaryWaves.innerHTML = wavesHtml;
275
+ } else {
276
+ $summaryWaves.innerHTML = "";
277
+ }
278
+ }
279
+
280
+ // ─── Render: Lanes + Tasks (integrated) ─────────────────────────────────────
281
+
282
+ function renderLanesTasks(batch, tmuxSessions) {
283
+ if (!batch || !batch.lanes || batch.lanes.length === 0) {
284
+ $lanesTasksBody.innerHTML = '<div class="empty-state">No lanes</div>';
285
+ return;
286
+ }
287
+
288
+ const tasks = batch.tasks || [];
289
+ const tmuxSet = new Set(tmuxSessions || []);
290
+ const laneStates = currentData?.laneStates || {};
291
+ let html = "";
292
+
293
+ for (const lane of batch.lanes) {
294
+ const alive = tmuxSet.has(lane.tmuxSessionName);
295
+ const tmuxCmd = `tmux attach -t ${lane.tmuxSessionName}`;
296
+
297
+ // Lane header
298
+ html += `<div class="lane-group">`;
299
+ html += `<div class="lane-header">`;
300
+ html += ` <span class="lane-num">${lane.laneNumber}</span>`;
301
+ html += ` <div class="lane-meta">`;
302
+ html += ` <span class="lane-session">${escapeHtml(lane.tmuxSessionName || "—")}</span>`;
303
+ html += ` <span class="lane-branch">${escapeHtml(lane.branch || "—")}</span>`;
304
+ html += ` </div>`;
305
+ html += ` <div class="lane-right">`;
306
+ html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "tmux alive" : "tmux dead"}"></span>`;
307
+ // View button: shows conversation stream if available, else tmux pane
308
+ const isViewingConv = viewerMode === 'conversation' && viewerTarget === lane.tmuxSessionName;
309
+ html += ` <button class="tmux-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(lane.tmuxSessionName)}')" title="View worker conversation">👁 View</button>`;
310
+ if (alive) {
311
+ html += ` <span class="tmux-cmd" data-tmux="${escapeHtml(lane.tmuxSessionName)}" onclick="copyTmuxCmd('${escapeHtml(lane.tmuxSessionName)}')" title="Click to copy">${escapeHtml(tmuxCmd)}</span>`;
312
+ } else {
313
+ html += ` <span class="tmux-cmd dead-session">${escapeHtml(tmuxCmd)}</span>`;
314
+ }
315
+ html += ` </div>`;
316
+ html += `</div>`;
317
+
318
+ // Task rows for this lane
319
+ const laneTasks = (lane.taskIds || []).map(tid => tasks.find(t => t.taskId === tid)).filter(Boolean);
320
+
321
+ if (laneTasks.length === 0) {
322
+ 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>`;
323
+ }
324
+
325
+ // Get lane state for worker stats
326
+ const ls = laneStates[lane.tmuxSessionName] || null;
327
+
328
+ for (const task of laneTasks) {
329
+ const sd = task.statusData;
330
+ const dur = task.startedAt
331
+ ? formatDuration((task.endedAt || Date.now()) - task.startedAt)
332
+ : "—";
333
+
334
+ // Progress cell
335
+ let progressHtml = "";
336
+ if (sd) {
337
+ const fillClass = pctClass(sd.progress);
338
+ progressHtml = `
339
+ <div class="task-progress">
340
+ <div class="task-progress-bar">
341
+ <div class="task-progress-fill ${fillClass}" style="width:${sd.progress}%"></div>
342
+ </div>
343
+ <span class="task-progress-text">${sd.progress}% ${sd.checked}/${sd.total}</span>
344
+ </div>`;
345
+ } else if (task.status === "succeeded") {
346
+ progressHtml = `
347
+ <div class="task-progress">
348
+ <div class="task-progress-bar"><div class="task-progress-fill pct-hi" style="width:100%"></div></div>
349
+ <span class="task-progress-text">100%</span>
350
+ </div>`;
351
+ } else if (task.status === "pending") {
352
+ progressHtml = `
353
+ <div class="task-progress">
354
+ <div class="task-progress-bar"><div class="task-progress-fill pct-0" style="width:0%"></div></div>
355
+ <span class="task-progress-text">0%</span>
356
+ </div>`;
357
+ } else {
358
+ progressHtml = '<span style="color:var(--text-faint)">—</span>';
359
+ }
360
+
361
+ // Step cell
362
+ let stepHtml = "";
363
+ if (sd) {
364
+ stepHtml = escapeHtml(sd.currentStep);
365
+ if (sd.iteration > 0) stepHtml += `<span class="task-iter">i${sd.iteration}</span>`;
366
+ if (sd.reviews > 0) stepHtml += `<span class="task-iter">r${sd.reviews}</span>`;
367
+ } else if (task.status === "succeeded") {
368
+ stepHtml = '<span style="color:var(--green)">Complete</span>';
369
+ } else if (task.status === "pending") {
370
+ stepHtml = '<span style="color:var(--text-faint)">Waiting</span>';
371
+ } else {
372
+ stepHtml = `<span style="color:var(--text-faint)">${escapeHtml(task.exitReason || "—")}</span>`;
373
+ }
374
+
375
+ // Worker stats from lane state sidecar — only show for the active (running) task
376
+ let workerHtml = "";
377
+ if (ls && ls.workerStatus === "running" && task.status === "running") {
378
+ const elapsed = ls.workerElapsed ? `${Math.round(ls.workerElapsed / 1000)}s` : "";
379
+ const tools = ls.workerToolCount || 0;
380
+ const ctx = ls.workerContextPct ? `${Math.round(ls.workerContextPct)}%` : "";
381
+ const lastTool = ls.workerLastTool || "";
382
+ const tokenStr = tokenSummaryFromLaneState(ls);
383
+ workerHtml = `<div class="worker-stats">`;
384
+ workerHtml += `<span class="worker-stat" title="Worker elapsed">⏱ ${elapsed}</span>`;
385
+ workerHtml += `<span class="worker-stat" title="Tool calls">🔧 ${tools}</span>`;
386
+ if (ctx) workerHtml += `<span class="worker-stat" title="Context window used">📊 ${ctx}</span>`;
387
+ if (tokenStr) workerHtml += `<span class="worker-stat" title="Tokens: input↑ output↓ cacheRead(R) cacheWrite(W)">🪙 ${tokenStr}</span>`;
388
+ if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="Last tool call">${escapeHtml(lastTool)}</span>`;
389
+ workerHtml += `</div>`;
390
+ } else if (ls && ls.workerStatus === "done" && task.status !== "pending") {
391
+ workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--green)">✓ Worker done</span></div>`;
392
+ } else if (ls && ls.workerStatus === "error" && task.status !== "pending") {
393
+ workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--red)">✗ Worker error</span></div>`;
394
+ }
395
+
396
+ const isViewingStatus = viewerMode === 'status-md' && viewerTarget === task.taskId;
397
+ const eyeHtml = task.status !== 'pending'
398
+ ? `<button class="viewer-eye-btn${isViewingStatus ? ' active' : ''}" onclick="viewStatusMd('${escapeHtml(task.taskId)}')" title="View STATUS.md">👁</button>`
399
+ : '';
400
+
401
+ html += `
402
+ <div class="task-row">
403
+ <span class="task-icon"><span class="status-dot ${task.status}"></span></span>
404
+ <span class="task-actions">${eyeHtml}</span>
405
+ <span class="task-id status-${task.status}">${escapeHtml(task.taskId)}</span>
406
+ <span><span class="status-badge status-${task.status}"><span class="status-dot ${task.status}"></span> ${task.status}</span></span>
407
+ <span class="task-duration">${dur}</span>
408
+ <span>${progressHtml}</span>
409
+ <span class="task-step">${stepHtml}${workerHtml}</span>
410
+ </div>`;
411
+ }
412
+
413
+ html += `</div>`; // close lane-group
414
+ }
415
+
416
+ $lanesTasksBody.innerHTML = html;
417
+ }
418
+
419
+ // ─── Render: Merge Agents ───────────────────────────────────────────────────
420
+
421
+ function renderMergeAgents(batch, tmuxSessions) {
422
+ const mergeResults = batch?.mergeResults || [];
423
+ const tmuxSet = new Set(tmuxSessions || []);
424
+
425
+ // Check for active merge sessions (convention: orch-merge-*)
426
+ const mergeSessions = (tmuxSessions || []).filter(s => s.startsWith("orch-merge"));
427
+
428
+ if (mergeResults.length === 0 && mergeSessions.length === 0) {
429
+ $mergeBody.innerHTML = '<div class="empty-state">No merge agents active</div>';
430
+ return;
431
+ }
432
+
433
+ let html = '<table class="merge-table"><thead><tr>';
434
+ html += '<th>Wave</th><th>Status</th><th>Session</th><th>Attach</th><th>Details</th>';
435
+ html += '</tr></thead><tbody>';
436
+
437
+ // Show merge results
438
+ for (const mr of mergeResults) {
439
+ const statusCls = mr.status === "succeeded" ? "status-succeeded"
440
+ : mr.status === "partial" ? "status-stalled"
441
+ : "status-failed";
442
+
443
+ // Look for matching tmux session
444
+ const sessionName = `orch-merge-w${mr.waveIndex + 1}`;
445
+ const alive = tmuxSet.has(sessionName);
446
+
447
+ html += `<tr>`;
448
+ html += `<td style="font-family:var(--font-mono);">Wave ${mr.waveIndex + 1}</td>`;
449
+ html += `<td><span class="status-badge ${statusCls}">${mr.status}</span></td>`;
450
+ html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${alive ? escapeHtml(sessionName) : "—"}</td>`;
451
+ html += `<td>`;
452
+ if (alive) {
453
+ const cmd = `tmux attach -t ${sessionName}`;
454
+ html += `<span class="tmux-cmd" data-tmux="${escapeHtml(sessionName)}" onclick="copyTmuxCmd('${escapeHtml(sessionName)}')" title="Click to copy">${escapeHtml(cmd)}</span>`;
455
+ } else {
456
+ html += '<span style="color:var(--text-faint);">—</span>';
457
+ }
458
+ html += `</td>`;
459
+ html += `<td style="font-size:0.8rem;color:var(--text-muted);">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
460
+ html += `</tr>`;
461
+ }
462
+
463
+ // Show active merge sessions not yet in results
464
+ for (const sess of mergeSessions) {
465
+ const alreadyShown = mergeResults.some((mr) => `orch-merge-w${mr.waveIndex + 1}` === sess);
466
+ if (alreadyShown) continue;
467
+
468
+ const cmd = `tmux attach -t ${sess}`;
469
+ html += `<tr>`;
470
+ html += `<td style="font-family:var(--font-mono);">—</td>`;
471
+ html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
472
+ html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${escapeHtml(sess)}</td>`;
473
+ html += `<td><span class="tmux-cmd" data-tmux="${escapeHtml(sess)}" onclick="copyTmuxCmd('${escapeHtml(sess)}')" title="Click to copy">${escapeHtml(cmd)}</span></td>`;
474
+ html += `<td>—</td>`;
475
+ html += `</tr>`;
476
+ }
477
+
478
+ html += '</tbody></table>';
479
+ $mergeBody.innerHTML = html;
480
+ }
481
+
482
+ // ─── Render: Errors ─────────────────────────────────────────────────────────
483
+
484
+ function renderErrors(batch) {
485
+ const errors = batch?.errors || [];
486
+ if (errors.length === 0) {
487
+ $errorsPanel.style.display = "none";
488
+ return;
489
+ }
490
+ $errorsPanel.style.display = "";
491
+ let html = "";
492
+ for (const err of errors.slice(-10)) {
493
+ const msg = typeof err === "string" ? err : err.message || JSON.stringify(err);
494
+ html += `<div class="error-item"><span class="error-bullet">●</span><span class="error-text">${escapeHtml(msg)}</span></div>`;
495
+ }
496
+ $errorsBody.innerHTML = html;
497
+ }
498
+
499
+ // ─── Render: No Batch ───────────────────────────────────────────────────────
500
+
501
+ let noBatchRendered = false;
502
+
503
+ function renderNoBatch() {
504
+ if (noBatchRendered) return;
505
+ noBatchRendered = true;
506
+
507
+ // Hide live panels, show history panel
508
+ const $lanesPanel = document.getElementById("lanes-tasks-panel");
509
+ const $mergePanel = document.getElementById("merge-panel");
510
+ if ($lanesPanel) $lanesPanel.style.display = "none";
511
+ if ($mergePanel) $mergePanel.style.display = "none";
512
+ if ($errorsPanel) $errorsPanel.style.display = "none";
513
+
514
+ // Try to show the latest history entry
515
+ if (historyList.length > 0 && !viewingHistoryId) {
516
+ viewHistoryEntry(historyList[0].batchId);
517
+ $historySelect.value = historyList[0].batchId;
518
+ } else if (historyList.length === 0) {
519
+ // No history yet — show placeholder in the history panel
520
+ $historyBody.innerHTML = `
521
+ <div class="no-batch">
522
+ <div class="no-batch-icon">⏳</div>
523
+ <div class="no-batch-title">No batch running</div>
524
+ <div class="no-batch-hint">.pi/batch-state.json not found<br>Start an orchestrator batch to see the dashboard.</div>
525
+ </div>`;
526
+ $historyPanel.style.display = "";
527
+ }
528
+ }
529
+
530
+ function ensureContentPanels() {
531
+ if (noBatchRendered) {
532
+ // A live batch started — restore panels and reload
533
+ location.reload();
534
+ }
535
+ }
536
+
537
+ // ─── Full Render ────────────────────────────────────────────────────────────
538
+
539
+ // ─── Current data (stored for conversation viewer) ──────────────────────────
540
+
541
+ let currentData = null;
542
+
543
+ function render(data) {
544
+ currentData = data;
545
+ const batch = data.batch;
546
+ const tmux = data.tmuxSessions || [];
547
+
548
+ $lastUpdate.textContent = new Date().toLocaleTimeString();
549
+
550
+ if (!batch) {
551
+ renderHeader(null);
552
+ renderSummary(null);
553
+ // Refresh history list (batch may have just finished)
554
+ if (!noBatchRendered) loadHistoryList();
555
+ renderNoBatch();
556
+ return;
557
+ }
558
+
559
+ // Live batch is running — hide history panel, reset viewing state
560
+ if (viewingHistoryId) {
561
+ viewingHistoryId = null;
562
+ $historyPanel.style.display = "none";
563
+ $historySelect.value = "";
564
+ }
565
+
566
+ if (noBatchRendered) {
567
+ ensureContentPanels();
568
+ return;
569
+ }
570
+
571
+ renderHeader(batch);
572
+ renderSummary(batch);
573
+ renderLanesTasks(batch, tmux);
574
+ renderMergeAgents(batch, tmux);
575
+ renderErrors(batch);
576
+
577
+ const taskCount = (batch.tasks || []).length;
578
+ const laneCount = (batch.lanes || []).length;
579
+ const waveCount = (batch.wavePlan || []).length;
580
+ $footerInfo.textContent = `${taskCount} tasks · ${laneCount} lanes · ${waveCount} waves`;
581
+ }
582
+
583
+ // ─── SSE Connection ─────────────────────────────────────────────────────────
584
+
585
+ let eventSource = null;
586
+ let reconnectTimer = null;
587
+
588
+ function connect() {
589
+ if (eventSource) eventSource.close();
590
+
591
+ eventSource = new EventSource("/api/stream");
592
+
593
+ eventSource.onopen = () => {
594
+ $connDot.className = "connection-dot connected";
595
+ $connDot.title = "Connected";
596
+ clearTimeout(reconnectTimer);
597
+ };
598
+
599
+ eventSource.onmessage = (event) => {
600
+ try {
601
+ const data = JSON.parse(event.data);
602
+ render(data);
603
+ } catch (err) {
604
+ console.error("Failed to parse SSE data:", err);
605
+ }
606
+ };
607
+
608
+ eventSource.onerror = () => {
609
+ $connDot.className = "connection-dot disconnected";
610
+ $connDot.title = "Disconnected — reconnecting…";
611
+ eventSource.close();
612
+ reconnectTimer = setTimeout(connect, 3000);
613
+ };
614
+ }
615
+
616
+ // ─── Viewer Panel (Conversation + STATUS.md) ────────────────────────────────
617
+
618
+ const $terminalPanel = document.getElementById("terminal-panel");
619
+ const $terminalTitle = document.getElementById("terminal-title");
620
+ const $terminalBody = document.getElementById("terminal-body");
621
+ const $terminalClose = document.getElementById("terminal-close");
622
+ const $autoScrollCheckbox = document.getElementById("auto-scroll-checkbox");
623
+ const $autoScrollText = document.getElementById("auto-scroll-text");
624
+
625
+ // Viewer state
626
+ let viewerTimer = null;
627
+ let autoScrollOn = false;
628
+ let isProgrammaticScroll = false;
629
+
630
+ // Conversation append-only state
631
+ let convRenderedLines = 0;
632
+
633
+ // STATUS.md diff-and-skip state
634
+ let lastStatusMdText = "";
635
+
636
+ // ── Open conversation viewer ────────────────────────────────────────────────
637
+
638
+ function viewConversation(sessionName) {
639
+ // Toggle off if already viewing this session
640
+ if (viewerMode === 'conversation' && viewerTarget === sessionName && $terminalPanel.style.display !== 'none') {
641
+ closeViewer();
642
+ return;
643
+ }
644
+
645
+ closeViewer();
646
+
647
+ viewerMode = 'conversation';
648
+ viewerTarget = sessionName;
649
+ autoScrollOn = true;
650
+ convRenderedLines = 0;
651
+
652
+ $terminalTitle.textContent = `Worker Conversation — ${sessionName}`;
653
+ $autoScrollText.textContent = 'Follow feed';
654
+ $autoScrollCheckbox.checked = true;
655
+ $terminalPanel.style.display = '';
656
+ $terminalBody.innerHTML = '<div class="conv-stream"></div>';
657
+
658
+ pollConversation();
659
+ viewerTimer = setInterval(pollConversation, 2000);
660
+
661
+ $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
662
+ }
663
+
664
+ function pollConversation() {
665
+ fetch(`/api/conversation/${encodeURIComponent(viewerTarget)}`)
666
+ .then(r => r.text())
667
+ .then(text => {
668
+ if (!text.trim()) {
669
+ if (convRenderedLines === 0) {
670
+ $terminalBody.innerHTML = '<div class="conv-empty">No conversation events yet…</div>';
671
+ }
672
+ return;
673
+ }
674
+
675
+ const lines = text.trim().split('\n');
676
+
677
+ // File was reset (new task on same lane) — full re-render
678
+ if (lines.length < convRenderedLines) {
679
+ convRenderedLines = 0;
680
+ const container = $terminalBody.querySelector('.conv-stream');
681
+ if (container) container.innerHTML = '';
682
+ }
683
+
684
+ // Nothing new
685
+ if (lines.length === convRenderedLines) return;
686
+
687
+ // Ensure container exists
688
+ let container = $terminalBody.querySelector('.conv-stream');
689
+ if (!container) {
690
+ $terminalBody.innerHTML = '';
691
+ container = document.createElement('div');
692
+ container.className = 'conv-stream';
693
+ $terminalBody.appendChild(container);
694
+ }
695
+
696
+ // Append only new events
697
+ const newLines = lines.slice(convRenderedLines);
698
+ for (const line of newLines) {
699
+ try {
700
+ const event = JSON.parse(line);
701
+ const html = renderConvEvent(event);
702
+ if (html) container.insertAdjacentHTML('beforeend', html);
703
+ } catch { continue; }
704
+ }
705
+
706
+ convRenderedLines = lines.length;
707
+
708
+ // Auto-scroll to bottom
709
+ if (autoScrollOn) {
710
+ isProgrammaticScroll = true;
711
+ $terminalBody.scrollTop = $terminalBody.scrollHeight;
712
+ requestAnimationFrame(() => { isProgrammaticScroll = false; });
713
+ }
714
+ })
715
+ .catch(() => {});
716
+ }
717
+
718
+ // ── Open STATUS.md viewer ───────────────────────────────────────────────────
719
+
720
+ function viewStatusMd(taskId) {
721
+ // Toggle off if already viewing this task
722
+ if (viewerMode === 'status-md' && viewerTarget === taskId && $terminalPanel.style.display !== 'none') {
723
+ closeViewer();
724
+ return;
725
+ }
726
+
727
+ closeViewer();
728
+
729
+ viewerMode = 'status-md';
730
+ viewerTarget = taskId;
731
+ autoScrollOn = false;
732
+ lastStatusMdText = '';
733
+
734
+ $terminalTitle.textContent = `STATUS.md — ${taskId}`;
735
+ $autoScrollText.textContent = 'Track progress';
736
+ $autoScrollCheckbox.checked = false;
737
+ $terminalPanel.style.display = '';
738
+ $terminalBody.innerHTML = '<div class="conv-empty">Loading…</div>';
739
+
740
+ pollStatusMd();
741
+ viewerTimer = setInterval(pollStatusMd, 2000);
742
+
743
+ $terminalPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
744
+ }
745
+
746
+ function pollStatusMd() {
747
+ fetch(`/api/status-md/${encodeURIComponent(viewerTarget)}`)
748
+ .then(r => {
749
+ if (!r.ok) throw new Error('not found');
750
+ return r.text();
751
+ })
752
+ .then(text => {
753
+ // Diff-and-skip: no change, no DOM update
754
+ if (text === lastStatusMdText) return;
755
+ lastStatusMdText = text;
756
+
757
+ const { html, hasLastChecked } = renderStatusMd(text);
758
+ $terminalBody.innerHTML = html;
759
+
760
+ // Update tracking highlight
761
+ updateTrackingHighlight();
762
+
763
+ // Auto-scroll to last checked item
764
+ if (autoScrollOn && hasLastChecked) {
765
+ scrollToLastChecked();
766
+ }
767
+ })
768
+ .catch(() => {
769
+ if (!lastStatusMdText) {
770
+ $terminalBody.innerHTML = '<div class="conv-empty">STATUS.md not found</div>';
771
+ }
772
+ });
773
+ }
774
+
775
+ // ── STATUS.md renderer ──────────────────────────────────────────────────────
776
+
777
+ function renderStatusMd(markdown) {
778
+ const lines = markdown.split('\n');
779
+ let lastCheckedIdx = -1;
780
+
781
+ // First pass: find last checked item
782
+ for (let i = 0; i < lines.length; i++) {
783
+ if (/^\s*-\s*\[x\]/i.test(lines[i])) lastCheckedIdx = i;
784
+ }
785
+
786
+ let html = '<div class="status-md-content">';
787
+
788
+ for (let i = 0; i < lines.length; i++) {
789
+ const line = lines[i];
790
+
791
+ // Headings
792
+ const hMatch = line.match(/^(#{1,6})\s+(.+)/);
793
+ if (hMatch) {
794
+ const lvl = Math.min(hMatch[1].length, 4);
795
+ html += `<div class="status-md-h${lvl}">${renderInlineMd(hMatch[2])}</div>`;
796
+ continue;
797
+ }
798
+
799
+ // Checked checkbox
800
+ if (/^\s*-\s*\[x\]/i.test(line)) {
801
+ const text = line.replace(/^\s*-\s*\[x\]\s*/i, '');
802
+ const isLast = i === lastCheckedIdx;
803
+ const cls = isLast ? 'status-md-check checked last-checked' : 'status-md-check checked';
804
+ const id = isLast ? ' id="last-checked"' : '';
805
+ html += `<div class="${cls}"${id}><span class="check-box">☑</span><span>${renderInlineMd(text)}</span></div>`;
806
+ continue;
807
+ }
808
+
809
+ // Unchecked checkbox
810
+ if (/^\s*-\s*\[\s\]/.test(line)) {
811
+ const text = line.replace(/^\s*-\s*\[\s\]\s*/, '');
812
+ html += `<div class="status-md-check unchecked"><span class="check-box">☐</span><span>${renderInlineMd(text)}</span></div>`;
813
+ continue;
814
+ }
815
+
816
+ // List item
817
+ const liMatch = line.match(/^\s*-\s+(.*)/);
818
+ if (liMatch) {
819
+ html += `<div class="status-md-li">• ${renderInlineMd(liMatch[1])}</div>`;
820
+ continue;
821
+ }
822
+
823
+ // Empty line
824
+ if (!line.trim()) {
825
+ html += '<div class="status-md-spacer"></div>';
826
+ continue;
827
+ }
828
+
829
+ // Plain text
830
+ html += `<div class="status-md-text">${renderInlineMd(line)}</div>`;
831
+ }
832
+
833
+ html += '</div>';
834
+ return { html, hasLastChecked: lastCheckedIdx >= 0 };
835
+ }
836
+
837
+ function renderInlineMd(text) {
838
+ let s = escapeHtml(text);
839
+ s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
840
+ s = s.replace(/`(.+?)`/g, '<code class="status-md-code">$1</code>');
841
+ return s;
842
+ }
843
+
844
+ // ── Conversation event renderer ─────────────────────────────────────────────
845
+
846
+ function renderConvEvent(event) {
847
+ switch (event.type) {
848
+ case "message_update": {
849
+ const delta = event.assistantMessageEvent;
850
+ if (delta?.type === "text_delta" && delta.delta) {
851
+ return `<span class="conv-text">${escapeHtml(delta.delta)}</span>`;
852
+ }
853
+ if (delta?.type === "thinking_delta" && delta.delta) {
854
+ return `<span class="conv-thinking">${escapeHtml(delta.delta)}</span>`;
855
+ }
856
+ return "";
857
+ }
858
+
859
+ case "tool_call": {
860
+ const name = event.toolName || "unknown";
861
+ const argsStr = event.args?.path || event.args?.command || "";
862
+ 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>`;
863
+ }
864
+
865
+ case "tool_execution_start": {
866
+ const name = event.toolName || "unknown";
867
+ const argsStr = event.args?.path || event.args?.command || "";
868
+ 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>`;
869
+ }
870
+
871
+ case "tool_result": {
872
+ const output = event.output || event.result || "";
873
+ const truncated = String(output).length > 500 ? String(output).substring(0, 500) + "…" : String(output);
874
+ return `<div class="conv-tool-result"><pre>${escapeHtml(truncated)}</pre></div>`;
875
+ }
876
+
877
+ case "message_end": {
878
+ const usage = event.message?.usage;
879
+ if (usage) {
880
+ const tokens = usage.totalTokens || (usage.input + usage.output) || 0;
881
+ return `<div class="conv-usage">Tokens: ${tokens.toLocaleString()}</div>`;
882
+ }
883
+ return "";
884
+ }
885
+
886
+ default:
887
+ return "";
888
+ }
889
+ }
890
+
891
+ // ── Auto-scroll logic ───────────────────────────────────────────────────────
892
+
893
+ function scrollToLastChecked() {
894
+ const el = document.getElementById('last-checked');
895
+ if (!el) return;
896
+ isProgrammaticScroll = true;
897
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
898
+ setTimeout(() => { isProgrammaticScroll = false; }, 600);
899
+ }
900
+
901
+ function updateTrackingHighlight() {
902
+ const container = $terminalBody.querySelector('.status-md-content');
903
+ if (container) {
904
+ container.classList.toggle('tracking', autoScrollOn && viewerMode === 'status-md');
905
+ }
906
+ }
907
+
908
+ $autoScrollCheckbox.addEventListener('change', () => {
909
+ autoScrollOn = $autoScrollCheckbox.checked;
910
+ if (autoScrollOn) {
911
+ if (viewerMode === 'conversation') {
912
+ isProgrammaticScroll = true;
913
+ $terminalBody.scrollTop = $terminalBody.scrollHeight;
914
+ requestAnimationFrame(() => { isProgrammaticScroll = false; });
915
+ } else if (viewerMode === 'status-md') {
916
+ scrollToLastChecked();
917
+ updateTrackingHighlight();
918
+ }
919
+ } else {
920
+ updateTrackingHighlight();
921
+ }
922
+ });
923
+
924
+ $terminalBody.addEventListener('scroll', () => {
925
+ if (isProgrammaticScroll) return;
926
+
927
+ if (viewerMode === 'conversation') {
928
+ const isAtBottom = $terminalBody.scrollTop + $terminalBody.clientHeight >= $terminalBody.scrollHeight - 30;
929
+ if (isAtBottom && !autoScrollOn) {
930
+ autoScrollOn = true;
931
+ $autoScrollCheckbox.checked = true;
932
+ } else if (!isAtBottom && autoScrollOn) {
933
+ autoScrollOn = false;
934
+ $autoScrollCheckbox.checked = false;
935
+ }
936
+ } else if (viewerMode === 'status-md') {
937
+ if (autoScrollOn) {
938
+ autoScrollOn = false;
939
+ $autoScrollCheckbox.checked = false;
940
+ updateTrackingHighlight();
941
+ }
942
+ }
943
+ });
944
+
945
+ // ── Close viewer ────────────────────────────────────────────────────────────
946
+
947
+ function closeViewer() {
948
+ if (viewerTimer) {
949
+ clearInterval(viewerTimer);
950
+ viewerTimer = null;
951
+ }
952
+ viewerMode = null;
953
+ viewerTarget = null;
954
+ autoScrollOn = false;
955
+ convRenderedLines = 0;
956
+ lastStatusMdText = '';
957
+ $terminalPanel.style.display = 'none';
958
+ $terminalBody.innerHTML = '';
959
+ }
960
+
961
+ $terminalClose.addEventListener('click', closeViewer);
962
+
963
+ // Make viewer functions available globally for onclick handlers
964
+ window.viewConversation = viewConversation;
965
+ window.viewStatusMd = viewStatusMd;
966
+
967
+ // ─── History ────────────────────────────────────────────────────────────────
968
+
969
+ /** Fetch the compact history list and populate the dropdown. */
970
+ function loadHistoryList() {
971
+ fetch("/api/history")
972
+ .then(r => r.json())
973
+ .then(list => {
974
+ historyList = list || [];
975
+ renderHistoryDropdown();
976
+ // If no live batch and no history shown yet, auto-select latest
977
+ if (noBatchRendered && !viewingHistoryId && historyList.length > 0) {
978
+ viewHistoryEntry(historyList[0].batchId);
979
+ $historySelect.value = historyList[0].batchId;
980
+ }
981
+ })
982
+ .catch(() => {});
983
+ }
984
+
985
+ function renderHistoryDropdown() {
986
+ $historySelect.innerHTML = '<option value="">History ▾</option>';
987
+ for (const h of historyList) {
988
+ const d = new Date(h.startedAt);
989
+ const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
990
+ const timeStr = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
991
+ const statusIcon = h.status === "completed" ? "✓" : h.status === "partial" ? "⚠" : "✗";
992
+ const label = `${statusIcon} ${dateStr} ${timeStr} — ${h.totalTasks}tasks ${formatDuration(h.durationMs)}`;
993
+ const opt = document.createElement("option");
994
+ opt.value = h.batchId;
995
+ opt.textContent = label;
996
+ $historySelect.appendChild(opt);
997
+ }
998
+ }
999
+
1000
+ /** Load and display a specific historical batch. */
1001
+ function viewHistoryEntry(batchId) {
1002
+ if (!batchId) {
1003
+ viewingHistoryId = null;
1004
+ $historyPanel.style.display = "none";
1005
+ return;
1006
+ }
1007
+ viewingHistoryId = batchId;
1008
+ fetch(`/api/history/${encodeURIComponent(batchId)}`)
1009
+ .then(r => r.json())
1010
+ .then(entry => {
1011
+ if (entry.error) {
1012
+ $historyBody.innerHTML = `<div class="empty-state">${escapeHtml(entry.error)}</div>`;
1013
+ } else {
1014
+ renderHistorySummary(entry);
1015
+ }
1016
+ $historyPanel.style.display = "";
1017
+ })
1018
+ .catch(() => {
1019
+ $historyBody.innerHTML = '<div class="empty-state">Failed to load batch details</div>';
1020
+ $historyPanel.style.display = "";
1021
+ });
1022
+ }
1023
+
1024
+ /** Render a full batch history summary. */
1025
+ function renderHistorySummary(entry) {
1026
+ const startDate = new Date(entry.startedAt).toLocaleString();
1027
+ const endDate = entry.endedAt ? new Date(entry.endedAt).toLocaleString() : "—";
1028
+ const tok = entry.tokens || {};
1029
+ const totalIn = (tok.input || 0) + (tok.cacheRead || 0);
1030
+ let tokenStr = `↑${formatTokens(totalIn)} ↓${formatTokens(tok.output || 0)}`;
1031
+ const costStr = formatCost(tok.costUsd || 0);
1032
+
1033
+ let html = `
1034
+ <div class="history-header">
1035
+ <span class="batch-id">${escapeHtml(entry.batchId)}</span>
1036
+ <span class="batch-status ${entry.status}">${entry.status}</span>
1037
+ <span class="batch-time">${startDate} → ${endDate}</span>
1038
+ </div>
1039
+
1040
+ <div class="history-stats">
1041
+ <div class="stat-card">
1042
+ <div class="stat-value">${entry.totalTasks}</div>
1043
+ <div class="stat-label">Total Tasks</div>
1044
+ </div>
1045
+ <div class="stat-card">
1046
+ <div class="stat-value" style="color:var(--green)">${entry.succeededTasks}</div>
1047
+ <div class="stat-label">Succeeded</div>
1048
+ </div>
1049
+ <div class="stat-card">
1050
+ <div class="stat-value" style="color:${entry.failedTasks > 0 ? 'var(--red)' : 'var(--text-muted)'}">${entry.failedTasks}</div>
1051
+ <div class="stat-label">Failed</div>
1052
+ </div>
1053
+ <div class="stat-card">
1054
+ <div class="stat-value">${entry.totalWaves}</div>
1055
+ <div class="stat-label">Waves</div>
1056
+ </div>
1057
+ <div class="stat-card">
1058
+ <div class="stat-value">${formatDuration(entry.durationMs)}</div>
1059
+ <div class="stat-label">Duration</div>
1060
+ </div>
1061
+ <div class="stat-card stat-tokens">
1062
+ <div class="stat-value">🪙 ${tokenStr}</div>
1063
+ <div class="stat-label">Tokens</div>
1064
+ </div>
1065
+ ${costStr ? `<div class="stat-card">
1066
+ <div class="stat-value" style="color:var(--yellow)">${costStr}</div>
1067
+ <div class="stat-label">Cost</div>
1068
+ </div>` : ""}
1069
+ </div>`;
1070
+
1071
+ // Wave table
1072
+ if (entry.waves && entry.waves.length > 0) {
1073
+ html += `<div class="history-section-title">Waves</div>`;
1074
+ html += `<table class="history-waves-table"><thead><tr>
1075
+ <th>Wave</th><th>Tasks</th><th>Merge</th><th>Duration</th><th>Tokens</th><th>Cost</th>
1076
+ </tr></thead><tbody>`;
1077
+ for (const w of entry.waves) {
1078
+ const wTok = w.tokens || {};
1079
+ const wTotalIn = (wTok.input || 0) + (wTok.cacheRead || 0);
1080
+ let wTokenStr = `↑${formatTokens(wTotalIn)} ↓${formatTokens(wTok.output || 0)}`;
1081
+ const mergeClass = w.mergeStatus === "succeeded" ? "status-succeeded" :
1082
+ w.mergeStatus === "failed" ? "status-failed" : "status-stalled";
1083
+ html += `<tr>
1084
+ <td>Wave ${w.wave}</td>
1085
+ <td>${w.tasks.join(", ")}</td>
1086
+ <td><span class="status-badge ${mergeClass}">${w.mergeStatus}</span></td>
1087
+ <td>${formatDuration(w.durationMs)}</td>
1088
+ <td>${wTokenStr}</td>
1089
+ <td style="color:var(--yellow)">${formatCost(wTok.costUsd || 0)}</td>
1090
+ </tr>`;
1091
+ }
1092
+ html += `</tbody></table>`;
1093
+ }
1094
+
1095
+ // Task table
1096
+ if (entry.tasks && entry.tasks.length > 0) {
1097
+ html += `<div class="history-section-title">Tasks</div>`;
1098
+ html += `<table class="history-tasks-table"><thead><tr>
1099
+ <th>Task</th><th>Status</th><th>Wave</th><th>Lane</th><th>Duration</th><th>Tokens</th><th>Cost</th><th>Exit</th>
1100
+ </tr></thead><tbody>`;
1101
+ for (const t of entry.tasks) {
1102
+ const tTok = t.tokens || {};
1103
+ const tTotalIn = (tTok.input || 0) + (tTok.cacheRead || 0);
1104
+ let tTokenStr = `↑${formatTokens(tTotalIn)} ↓${formatTokens(tTok.output || 0)}`;
1105
+ const statusCls = `status-${t.status}`;
1106
+ html += `<tr>
1107
+ <td>${escapeHtml(t.taskId)}</td>
1108
+ <td><span class="status-badge ${statusCls}">${t.status}</span></td>
1109
+ <td>W${t.wave}</td>
1110
+ <td>L${t.lane}</td>
1111
+ <td>${formatDuration(t.durationMs)}</td>
1112
+ <td>${tTokenStr}</td>
1113
+ <td style="color:var(--yellow)">${formatCost(tTok.costUsd || 0)}</td>
1114
+ <td style="font-size:0.8rem;color:var(--text-muted)">${t.exitReason ? escapeHtml(t.exitReason) : "—"}</td>
1115
+ </tr>`;
1116
+ }
1117
+ html += `</tbody></table>`;
1118
+ }
1119
+
1120
+ $historyBody.innerHTML = html;
1121
+ }
1122
+
1123
+ /** Handle dropdown change. */
1124
+ $historySelect.addEventListener("change", (e) => {
1125
+ const batchId = e.target.value;
1126
+ if (batchId) {
1127
+ viewHistoryEntry(batchId);
1128
+ } else {
1129
+ // Switched to "History ▾" — go back to live view or latest
1130
+ viewingHistoryId = null;
1131
+ $historyPanel.style.display = "none";
1132
+ }
1133
+ });
1134
+
1135
+ // ─── Boot ───────────────────────────────────────────────────────────────────
1136
+
1137
+ connect();
1138
+ loadHistoryList();
1139
+
1140
+ // One-shot fetch on load (in case SSE is slow to connect)
1141
+ fetch("/api/state")
1142
+ .then(r => r.json())
1143
+ .then(render)
1144
+ .catch(() => {});