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