taskplane 0.1.6 → 0.1.8
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.
- package/dashboard/public/app.js +1144 -1144
- package/dashboard/public/index.html +98 -98
- package/dashboard/public/style.css +1108 -1108
- package/dashboard/server.cjs +638 -638
- package/extensions/taskplane/execution.ts +160 -17
- package/extensions/taskplane/extension.ts +122 -90
- package/extensions/taskplane/types.ts +4 -0
- package/package.json +57 -57
package/dashboard/public/app.js
CHANGED
|
@@ -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(() => {});
|