@ra3orblade/swarm 0.9.0 → 0.11.0
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/README.md +39 -17
- package/dist/swarm-hook.js +41 -0
- package/dist/swarm.js +210 -3
- package/dist/swarmd.js +2816 -144
- package/package.json +1 -1
- package/web/app.js +847 -66
- package/web/icons.js +2 -2
- package/web/index.html +110 -30
- package/web/menus.js +7 -7
- package/web/release-notes.js +1 -1
- package/web/table.js +1 -1
- package/web/viz.js +122 -2
package/web/app.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
const $ = (s) => document.querySelector(s);
|
|
2
2
|
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
|
3
|
+
// Open a URL in the user's browser. The desktop app's webview has no new-window handler, so
|
|
4
|
+
// `window.open` and target=_blank silently do nothing there — route through Tauri's shell opener
|
|
5
|
+
// when it is present (capability `shell:allow-open`), and fall back to window.open in a browser.
|
|
6
|
+
const openExternal = (url) => {
|
|
7
|
+
const shell = window.__TAURI__?.shell;
|
|
8
|
+
if (shell?.open) shell.open(url).catch(() => window.open(url, "_blank"));
|
|
9
|
+
else window.open(url, "_blank");
|
|
10
|
+
};
|
|
11
|
+
// Every absolute link (PR titles, docs, search hits, dev-server ports) takes the same path.
|
|
12
|
+
document.addEventListener("click", (e) => {
|
|
13
|
+
const a = e.target.closest?.('a[href^="http"]');
|
|
14
|
+
if (!a) return;
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
openExternal(a.href);
|
|
17
|
+
});
|
|
3
18
|
// M8.2b daemon token: `swarm ui` (and the desktop app) open the dashboard with ?token=…; it is kept
|
|
4
19
|
// in sessionStorage, stripped from the URL, and sent on every /v1 request. Loopback without a token
|
|
5
20
|
// still works while `[daemon] auth = "loopback-optional"`.
|
|
@@ -65,7 +80,7 @@ document.addEventListener("keydown", (ev) => {
|
|
|
65
80
|
window.swarmZoom(dir);
|
|
66
81
|
});
|
|
67
82
|
// `dirty`: a UI-side change (selection, view, filter) needs a render even when the daemon snapshot is unchanged.
|
|
68
|
-
const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, dispatch: null, questions: [], budget: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, dirty: true };
|
|
83
|
+
const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, dispatch: null, questions: [], budget: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, collisions: null, outcomes: null, dirty: true };
|
|
69
84
|
|
|
70
85
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
71
86
|
const ago = (iso) => { const d = (Date.now() - new Date(iso)) / 1000; return d < 60 ? `${d | 0}s` : d < 3600 ? `${(d / 60) | 0}m` : d < 86400 ? `${(d / 3600) | 0}h` : `${(d / 86400) | 0}d`; };
|
|
@@ -79,7 +94,12 @@ const projGlyph = (p, size = 14) => p?.icon
|
|
|
79
94
|
const projCell = (id) => { const p = state.projects.find((x) => x.id === id); return p ? `${projGlyph(p, 12)} ${esc(p.name)}` : esc(projName(id)); };
|
|
80
95
|
const projName = (id) => state.projects.find((p) => p.id === id)?.name ?? (id === "p_unknown" ? "?" : "(removed)");
|
|
81
96
|
const short = (p) => String(p ?? "").replace(/^\/Users\/[^/]+/, "~");
|
|
82
|
-
|
|
97
|
+
// Never wider than 5 characters, so a numeric column never has to ellipsize a number: without a
|
|
98
|
+
// billions step a 2.8B context read "2820.0M", and the tenth is noise once the mantissa is 3 digits.
|
|
99
|
+
// At most 3 significant digits, so a numeric column never has to ellipsize a number: without a
|
|
100
|
+
// billions step a 2.8B context read "2820.0M", and the tenth is noise once the mantissa is 3 digits.
|
|
101
|
+
const unit = (n, div, suffix) => `${(n / div).toFixed((n /= div) >= 100 ? 0 : n >= 10 ? 1 : 2)}${suffix}`;
|
|
102
|
+
const tok = (n) => (n >= 1e9 ? unit(n, 1e9, "B") : n >= 1e6 ? unit(n, 1e6, "M") : n >= 1e3 ? `${(n / 1e3).toFixed(0)}k` : String(n | 0));
|
|
83
103
|
const usd = (n) => (n == null ? '<span class="dim">—</span>' : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`);
|
|
84
104
|
const model = (m) => (m ? m.replace(/^claude-/, "").replace(/-\d{8}$/, "") : "");
|
|
85
105
|
const sumBy = (arr, f) => arr.reduce((a, x) => a + (f(x) ?? 0), 0);
|
|
@@ -88,7 +108,14 @@ const ic = (name, size = 14, cls = "") => (window.icon ? window.icon(name, size,
|
|
|
88
108
|
const kindIcon = (s) => ic(s.kind === "subagent" ? "tree-structure" : s.kind === "spawned" ? "play" : "keyboard", 13, "kind");
|
|
89
109
|
// pixel-art illustrations for empty states (crispEdges, theme-green; won't clash with icon packs)
|
|
90
110
|
function pixmap(rows, cell = 6) {
|
|
91
|
-
|
|
111
|
+
// All three tones are derived from the accent, so they are guaranteed to separate in either
|
|
112
|
+
// theme. The old palette used --c4 for the shade, whose luminance in light mode (0.158) is
|
|
113
|
+
// indistinguishable from --acc's (0.160) — the outline simply vanished into the face.
|
|
114
|
+
const C = {
|
|
115
|
+
X: "var(--acc)",
|
|
116
|
+
g: "color-mix(in srgb, var(--acc) 45%, white)",
|
|
117
|
+
d: "color-mix(in srgb, var(--acc) 58%, black)",
|
|
118
|
+
};
|
|
92
119
|
const w = Math.max(...rows.map((r) => r.length)) * cell;
|
|
93
120
|
const h = rows.length * cell;
|
|
94
121
|
let r = "";
|
|
@@ -101,17 +128,43 @@ function pixmap(rows, cell = 6) {
|
|
|
101
128
|
return `<svg class="px" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg">${r}</svg>`;
|
|
102
129
|
}
|
|
103
130
|
const PX = {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
// The palette carries three tones (accent / light / dark) and this used one, which is why it
|
|
132
|
+
// read as a flat blob. Outline, ear modules and features in `d`; face and body in `X`; antenna
|
|
133
|
+
// tips and the chest light in `g`. The body tapers rather than sitting under the head as a slab.
|
|
134
|
+
idle: () =>
|
|
135
|
+
pixmap(
|
|
136
|
+
[
|
|
137
|
+
" g g ",
|
|
138
|
+
" X X ",
|
|
139
|
+
" X X ",
|
|
140
|
+
" ddddddddddddd ",
|
|
141
|
+
" dXXXXXXXXXXXd ",
|
|
142
|
+
" dggXXXXXXXXXd ",
|
|
143
|
+
" dddXXddXXXddXXddd ",
|
|
144
|
+
" dddXXddXXXddXXddd ",
|
|
145
|
+
" dddXXXXXXXXXXXddd ",
|
|
146
|
+
" dddXXdddddddXXddd ",
|
|
147
|
+
" dXXXXXXXXXXXd ",
|
|
148
|
+
" dXXXXXXXXXXXd ",
|
|
149
|
+
" ddddddddddddd ",
|
|
150
|
+
" XXX ",
|
|
151
|
+
" ddddd ",
|
|
152
|
+
" ddddddddddd ",
|
|
153
|
+
" dXXdggXXXXXXXdXXd ",
|
|
154
|
+
" dXXdXggggXddddXXd ",
|
|
155
|
+
" dXXdXggggXXXXdXXd ",
|
|
156
|
+
" dXXdXXXXXXddddXXd ",
|
|
157
|
+
" dXXdXXXXXXXXXdXXd ",
|
|
158
|
+
" dXXdXdddXXXXXdXXd ",
|
|
159
|
+
" ddddXXXXXXXXXdddd ",
|
|
160
|
+
" ddddddddddd ",
|
|
161
|
+
" XX XX ",
|
|
162
|
+
" XX XX ",
|
|
163
|
+
" XXXX XXXX ",
|
|
164
|
+
" dddd dddd ",
|
|
165
|
+
],
|
|
166
|
+
4,
|
|
167
|
+
),
|
|
115
168
|
folder: () => pixmap([
|
|
116
169
|
" XXXX ",
|
|
117
170
|
"XXXXXXXXXX",
|
|
@@ -148,6 +201,19 @@ const agentBadge = (a) => (a ? `<span class="badge agent" style="color:${viz.age
|
|
|
148
201
|
let raf = 0;
|
|
149
202
|
const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; render(); }); };
|
|
150
203
|
const touch = () => { state.dirty = true; schedule(); };
|
|
204
|
+
// `render()` refuses to paint while a menu is open (it would detach the anchor the menu is
|
|
205
|
+
// positioned against) and defers the frame instead. fancy-menus exposes no close callback, so the
|
|
206
|
+
// deferred paint has to wait for the close — armed from that bail, where the menu is known to be
|
|
207
|
+
// open. Without it a menu action (switch view, ack, release…) only lands on the next 5s poll,
|
|
208
|
+
// which reads as a dead click. One boolean check per frame, only while a menu is open.
|
|
209
|
+
// The menus island re-broadcasts the package's `useIsAnyMenuOpen` as `menus:openchange`.
|
|
210
|
+
window.addEventListener("menus:openchange", (e) => {
|
|
211
|
+
if (e.detail?.open) return;
|
|
212
|
+
// Menu closed: drop the trigger's open state and paint whatever render() deferred.
|
|
213
|
+
for (const b of $$("#viewnav .navgrp.open")) b.classList.remove("open");
|
|
214
|
+
for (const el of $$(".menu-open")) el.classList.remove("menu-open");
|
|
215
|
+
if (state.dirty) schedule();
|
|
216
|
+
});
|
|
151
217
|
// Last snapshot body + last render time: an unchanged snapshot (same seq, same data) skips the render
|
|
152
218
|
// unless the UI changed, or `ago`-style cells are older than 30s.
|
|
153
219
|
let lastSnap = "", lastRenderAt = 0;
|
|
@@ -203,13 +269,109 @@ async function refresh() {
|
|
|
203
269
|
incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
|
|
204
270
|
state.allIncidents = inc;
|
|
205
271
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
272
|
+
let linChanged = false;
|
|
273
|
+
if (state.view === "graphs" && (state.graphTab ?? "collisions") === "lineage" && !state.session) {
|
|
274
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
275
|
+
const open = (state.lineageOpen ?? []).map((g) => `&expand=${encodeURIComponent(g)}`).join("");
|
|
276
|
+
const lin = await fetch(`/v1/graphs/lineage${q || "?"}${open}`).then((r) => r.json()).catch(() => state.lineage);
|
|
277
|
+
linChanged = JSON.stringify(lin) !== JSON.stringify(state.lineage);
|
|
278
|
+
state.lineage = lin;
|
|
279
|
+
}
|
|
280
|
+
let colChanged = false;
|
|
281
|
+
if (state.view === "graphs" && (state.graphTab ?? "collisions") !== "lineage" && !state.session) {
|
|
282
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
283
|
+
const col = await fetch(`/v1/graphs/collisions${q}`).then((r) => r.json()).catch(() => state.collisions);
|
|
284
|
+
colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
|
|
285
|
+
state.collisions = col;
|
|
286
|
+
}
|
|
287
|
+
let waitChanged = false;
|
|
288
|
+
if ((state.view === "fleet" || state.view === "stats") && !state.session) {
|
|
289
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
290
|
+
const w = await fetch(`/v1/waiting${q}`).then((r) => r.json()).catch(() => state.waiting);
|
|
291
|
+
waitChanged = JSON.stringify(w) !== JSON.stringify(state.waiting);
|
|
292
|
+
state.waiting = w;
|
|
293
|
+
}
|
|
294
|
+
let hygChanged = false;
|
|
295
|
+
if (state.view === "hygiene" && !state.session) {
|
|
296
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
297
|
+
const hy = await fetch(`/v1/hygiene${q}`).then((r) => r.json()).catch(() => state.hygiene);
|
|
298
|
+
hygChanged = JSON.stringify(hy) !== JSON.stringify(state.hygiene);
|
|
299
|
+
state.hygiene = hy;
|
|
300
|
+
}
|
|
301
|
+
let ctxChanged = false;
|
|
302
|
+
if (state.view === "context" && !state.session) {
|
|
303
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
304
|
+
const cx = await fetch(`/v1/context${q}`).then((r) => r.json()).catch(() => state.context);
|
|
305
|
+
ctxChanged = JSON.stringify(cx) !== JSON.stringify(state.context);
|
|
306
|
+
state.context = cx;
|
|
307
|
+
}
|
|
308
|
+
let trialsChanged = false;
|
|
309
|
+
if (state.view === "trials" && !state.session) {
|
|
310
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
311
|
+
const tr = await fetch(`/v1/ab${q}`).then((r) => r.json()).then((r) => r.trials ?? []).catch(() => state.trials);
|
|
312
|
+
trialsChanged = JSON.stringify(tr) !== JSON.stringify(state.trials);
|
|
313
|
+
state.trials = tr;
|
|
314
|
+
}
|
|
315
|
+
let provChanged = false;
|
|
316
|
+
if (state.view === "provenance" && !state.session) {
|
|
317
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
318
|
+
const pv = await fetch(`/v1/provenance${q}`).then((r) => r.json()).catch(() => state.provenance);
|
|
319
|
+
provChanged = JSON.stringify(pv) !== JSON.stringify(state.provenance);
|
|
320
|
+
state.provenance = pv;
|
|
321
|
+
}
|
|
322
|
+
let mcpChanged = false;
|
|
323
|
+
if (state.view === "mcp" && !state.session) {
|
|
324
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
325
|
+
const m = await fetch(`/v1/mcp/health${q}`).then((r) => r.json()).catch(() => state.mcpHealth);
|
|
326
|
+
mcpChanged = JSON.stringify(m) !== JSON.stringify(state.mcpHealth);
|
|
327
|
+
state.mcpHealth = m;
|
|
328
|
+
}
|
|
329
|
+
let ghChanged = false;
|
|
330
|
+
if (state.view === "gates" && !state.session) {
|
|
331
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
332
|
+
const gh = await fetch(`/v1/gates/health${q}`).then((r) => r.json()).catch(() => state.gateHealth);
|
|
333
|
+
ghChanged = JSON.stringify(gh) !== JSON.stringify(state.gateHealth);
|
|
334
|
+
state.gateHealth = gh;
|
|
335
|
+
}
|
|
336
|
+
let outChanged = false;
|
|
337
|
+
if (state.view === "outcomes" && !state.session) {
|
|
338
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
339
|
+
const o = await fetch(`/v1/outcomes${q}`).then((r) => r.json()).catch(() => state.outcomes);
|
|
340
|
+
outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
|
|
341
|
+
state.outcomes = o;
|
|
342
|
+
}
|
|
343
|
+
if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || linChanged || outChanged || waitChanged || ghChanged || mcpChanged || ctxChanged || provChanged || trialsChanged || hygChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
|
|
344
|
+
}
|
|
345
|
+
// M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
|
|
346
|
+
// deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
|
|
347
|
+
const VIEW_DEFS = [
|
|
348
|
+
{ id: "fleet", label: "Fleet", icon: "squares-four", group: "Observe", render: () => renderFleet() },
|
|
349
|
+
{ id: "timeline", label: "Timeline", icon: "clock-counter-clockwise", group: "Observe", render: () => renderTimeline() },
|
|
350
|
+
{ id: "graphs", label: "Graphs", icon: "tree-structure", group: "Observe", render: () => renderGraphs(), badge: () => state.collisions?.contested ?? 0 },
|
|
351
|
+
{ id: "board", label: "Board", icon: "stack", group: "Work", render: () => renderBoard() },
|
|
352
|
+
{ id: "prs", label: "PRs", icon: "git-pull-request", group: "Work", render: () => renderPRs() },
|
|
353
|
+
{ id: "trials", label: "Trials", icon: "robot", group: "Work", render: () => renderTrials(), badge: () => (state.trials ?? []).filter((t) => t.verdict === "undecided").length },
|
|
354
|
+
{ id: "hygiene", label: "Hygiene", icon: "trash", group: "Work", render: () => renderHygiene(), badge: () => state.hygiene?.totals?.issues ?? 0 },
|
|
355
|
+
// not "check": inside a menu a tick reads as "this item is selected" rather than as an icon
|
|
356
|
+
{ id: "outcomes", label: "Outcomes", icon: "git-branch", group: "Insight", render: () => renderOutcomes() },
|
|
357
|
+
{ id: "gates", label: "Gates", icon: "shield", group: "Insight", render: () => renderGateHealth(), badge: () => state.gateHealth?.totals?.flakyGates ?? 0 },
|
|
358
|
+
{ id: "mcp", label: "MCP", icon: "plugs-connected", group: "Insight", render: () => renderMcpHealth() },
|
|
359
|
+
{ id: "context", label: "Context", icon: "brain", group: "Insight", render: () => renderContext() },
|
|
360
|
+
{ id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
|
|
361
|
+
{ id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
|
|
362
|
+
{ id: "search", label: "Search", icon: "magnifying-glass", group: "Insight", render: () => renderSearch() },
|
|
363
|
+
{ id: "provenance", label: "Provenance", icon: "git-commit", group: "Guard", render: () => renderProvenance(), badge: () => state.provenance?.totals?.untracked ?? 0 },
|
|
364
|
+
{ id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
|
|
365
|
+
];
|
|
366
|
+
const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
|
|
367
|
+
const VIEWS = VIEW_DEFS.map((v) => v.id);
|
|
368
|
+
let navHtml = ""; // last-rendered nav html; declared before the restore block below calls renderNav()
|
|
209
369
|
// restore last view + project selection (persisted UI state)
|
|
210
370
|
{
|
|
211
371
|
const v = localStorage.getItem("swarm.view");
|
|
212
372
|
if (VIEWS.includes(v)) state.view = v;
|
|
373
|
+
const gt = localStorage.getItem("swarm.graphTab");
|
|
374
|
+
if (gt === "lineage" || gt === "collisions") state.graphTab = gt;
|
|
213
375
|
const sel = localStorage.getItem("swarm.sel");
|
|
214
376
|
if (sel) state.sel = sel;
|
|
215
377
|
// Deep links win over persisted state: ?view=board&project=<id>&session=<id>
|
|
@@ -217,12 +379,12 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
|
|
|
217
379
|
if (VIEWS.includes(q.get("view"))) state.view = q.get("view");
|
|
218
380
|
if (q.has("project")) state.sel = q.get("project") || null;
|
|
219
381
|
// Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
|
|
220
|
-
|
|
382
|
+
renderNav();
|
|
221
383
|
}
|
|
222
384
|
function render() {
|
|
223
385
|
// A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
|
|
224
386
|
// hold the frame while one is open; the next poll or interaction paints it.
|
|
225
|
-
if (window.menus?.isOpen()) { state.dirty = true; return; }
|
|
387
|
+
if (window.menus?.isOpen()) { state.dirty = true; return; } // the menus:openchange listener paints on close
|
|
226
388
|
// Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
|
|
227
389
|
const af = document.activeElement;
|
|
228
390
|
const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
|
|
@@ -231,14 +393,7 @@ function render() {
|
|
|
231
393
|
if (!dragPid) renderProjects(); // a re-render mid-drag would yank the row out from under the cursor
|
|
232
394
|
renderHeader();
|
|
233
395
|
if (state.session) renderSession();
|
|
234
|
-
else
|
|
235
|
-
else if (state.view === "stats") { loadStats(); renderStats(); } // loadStats is a no-op while the cache is fresh
|
|
236
|
-
else if (state.view === "search") renderSearch();
|
|
237
|
-
else if (state.view === "timeline") renderTimeline();
|
|
238
|
-
else if (state.view === "board") renderBoard();
|
|
239
|
-
else if (state.view === "incidents") renderIncidentsView();
|
|
240
|
-
else if (state.view === "prs") renderPRs();
|
|
241
|
-
else renderFleet();
|
|
396
|
+
else (viewDef(state.view)?.render ?? viewDef("fleet").render)();
|
|
242
397
|
if (keep) {
|
|
243
398
|
const el = document.querySelector(`input[data-filter="${keep.key}"][data-tid="${keep.tid}"]`);
|
|
244
399
|
if (el) { el.focus(); el.setSelectionRange(keep.pos, keep.pos); }
|
|
@@ -249,10 +404,59 @@ function renderHeader() {
|
|
|
249
404
|
const today = state.spend ? sumBy(state.spend.byProjectToday, (x) => x.cost) : 0;
|
|
250
405
|
const html = `Today <b>${usd(today)}</b>`;
|
|
251
406
|
if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
407
|
+
renderNav();
|
|
408
|
+
}
|
|
409
|
+
// View nav in the header: one button per group (Observe / Work / Insight / Guard); clicking one
|
|
410
|
+
// opens a fancy-menus dropdown of that group's views. Rebuilt only when the html changes (active
|
|
411
|
+
// view, badges) so the 5s poll doesn't churn the DOM — and never while its menu is open.
|
|
412
|
+
function showView(id) {
|
|
413
|
+
state.view = id;
|
|
414
|
+
localStorage.setItem("swarm.view", id);
|
|
415
|
+
state.session = null;
|
|
416
|
+
state.dirty = true;
|
|
417
|
+
refresh();
|
|
255
418
|
}
|
|
419
|
+
function viewGroups() {
|
|
420
|
+
const groups = [];
|
|
421
|
+
for (const v of VIEW_DEFS) {
|
|
422
|
+
const g = groups.find((x) => x.name === v.group) ?? groups[groups.push({ name: v.group, views: [] }) - 1];
|
|
423
|
+
g.views.push(v);
|
|
424
|
+
}
|
|
425
|
+
return groups;
|
|
426
|
+
}
|
|
427
|
+
function renderNav() {
|
|
428
|
+
const html = viewGroups()
|
|
429
|
+
.map((g) => {
|
|
430
|
+
const n = g.views.reduce((a, v) => a + (v.badge?.() ?? 0), 0);
|
|
431
|
+
const on = !state.session && g.views.some((v) => v.id === state.view);
|
|
432
|
+
// The group name alone never says which of its views you are on, so ten destinations hid
|
|
433
|
+
// behind four words. The active group carries the view's own label.
|
|
434
|
+
const cur = on ? g.views.find((v) => v.id === state.view) : null;
|
|
435
|
+
return `<button class="navgrp ${on ? "on" : ""}" data-grp="${g.name}"${on ? ' aria-current="page"' : ""} aria-haspopup="menu">${g.name}${cur ? `<span class="navview">${esc(cur.label)}</span>` : ""}${n ? `<b class="navcount">${n > 99 ? "99+" : n}</b>` : ""}${ic("chevron-down", 12, "chev")}</button>`;
|
|
436
|
+
})
|
|
437
|
+
.join("");
|
|
438
|
+
if (html !== navHtml) { navHtml = html; $("#viewnav").innerHTML = html; }
|
|
439
|
+
}
|
|
440
|
+
// Delegated: the group buttons are re-rendered, so the listener lives on the container.
|
|
441
|
+
$("#viewnav").addEventListener("click", (ev) => {
|
|
442
|
+
const btn = ev.target.closest("[data-grp]");
|
|
443
|
+
if (!btn) return;
|
|
444
|
+
const g = viewGroups().find((x) => x.name === btn.dataset.grp);
|
|
445
|
+
if (!g || !window.menus) return;
|
|
446
|
+
btn.classList.add("open"); // cleared by menus:openchange when the menu closes
|
|
447
|
+
window.menus.open(btn, {
|
|
448
|
+
items: g.views.map((v) => {
|
|
449
|
+
const n = v.badge?.() ?? 0;
|
|
450
|
+
return {
|
|
451
|
+
label: v.label,
|
|
452
|
+
icon: v.icon,
|
|
453
|
+
caption: n ? String(n) : undefined,
|
|
454
|
+
pressed: !state.session && state.view === v.id,
|
|
455
|
+
run: () => showView(v.id),
|
|
456
|
+
};
|
|
457
|
+
}),
|
|
458
|
+
});
|
|
459
|
+
});
|
|
256
460
|
|
|
257
461
|
const isLive = (s) => s.state === "active" || s.state === "waiting";
|
|
258
462
|
// One pass over sessions → live count per project (+ "" for all), instead of a filter per sidebar row.
|
|
@@ -345,10 +549,19 @@ function onboarding() {
|
|
|
345
549
|
|
|
346
550
|
// ---------- fleet
|
|
347
551
|
// Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
|
|
552
|
+
// M9.4: this session is blocked on a person right now — the badge says for how long, and on what.
|
|
553
|
+
const waitFor = (sid) => (state.waiting?.sessions ?? []).find((w) => w.sessionId === sid);
|
|
554
|
+
const WAIT_WHAT = { permission: "a permission prompt", question: "a question it asked", notification: "a notification" };
|
|
555
|
+
function waitBadge(sid) {
|
|
556
|
+
const w = waitFor(sid);
|
|
557
|
+
if (!w?.openSince) return "";
|
|
558
|
+
const what = WAIT_WHAT[w.openKind] ?? "you";
|
|
559
|
+
return ` <span class="badge warn" title="Blocked on ${esc(what)} since ${esc(w.openSince)}${w.openLabel ? ` — ${esc(w.openLabel)}` : ""}">Waiting ${ago(w.openSince)}</span>`;
|
|
560
|
+
}
|
|
348
561
|
const FLEET_COLS = [
|
|
349
562
|
{ key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
|
|
350
563
|
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
|
|
351
|
-
{ key: "session", label: "session", width: 210, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}${(state.questions ?? []).some((q) => q.sessionId === s.id) ? ' <span class="badge warn" title="This agent asked a question only a human can answer — open the session">Asking</span>' : ""}` },
|
|
564
|
+
{ key: "session", label: "session", width: 210, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}${(state.questions ?? []).some((q) => q.sessionId === s.id) ? ' <span class="badge warn" title="This agent asked a question only a human can answer — open the session">Asking</span>' : ""}${s.stuck ? ` <span class="badge bad" title="${esc(s.stuck)} — heuristic, nothing was interrupted; open the session to judge">Stuck</span>` : ""}${waitBadge(s.id)}` },
|
|
352
565
|
{ key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
|
|
353
566
|
{ key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
|
|
354
567
|
const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
|
|
@@ -441,7 +654,11 @@ function renderBoardKpis() {
|
|
|
441
654
|
const orphaned = claims.filter((c) => c.state === "orphaned").length;
|
|
442
655
|
const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
|
|
443
656
|
const dirty = wts.filter((w) => w.dirty > 0).length, merged = wts.filter((w) => !w.main && w.merged).length;
|
|
444
|
-
|
|
657
|
+
// The snapshot carries only the 20 most recent open incidents, so counting that window caps the
|
|
658
|
+
// KPI at 20 while the Guard badge shows the real number. Both now read the same true count.
|
|
659
|
+
const inc = state.sel
|
|
660
|
+
? (state.openIncidentsByProject?.[state.sel] ?? (state.incidents ?? []).filter((i) => inSel(i.projectId) && !i.acked).length)
|
|
661
|
+
: (state.openIncidents ?? (state.incidents ?? []).filter((i) => !i.acked).length);
|
|
445
662
|
const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
|
|
446
663
|
const ready = tasks ? tasks.filter((t) => t.ready).length : null;
|
|
447
664
|
const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
|
|
@@ -1031,6 +1248,34 @@ document.addEventListener("change", async (ev) => {
|
|
|
1031
1248
|
});
|
|
1032
1249
|
document.addEventListener("input", (ev) => { if (ev.target.id === "psIcon") { $("#psImage").value = ""; setIconPreview(ev.target.value.trim()); for (const e of $$(".emoji")) e.classList.toggle("on", e.dataset.emoji === ev.target.value.trim()); } });
|
|
1033
1250
|
document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
|
|
1251
|
+
// M9.4: how much of the fleet's time is spent waiting on a person, and on what. Blocked time is
|
|
1252
|
+
// not idle time — it is the agent standing still with the work half-done, which is why it gets a
|
|
1253
|
+
// number rather than a footnote.
|
|
1254
|
+
function waitingSection() {
|
|
1255
|
+
const w = state.waiting;
|
|
1256
|
+
if (!w?.totals?.episodes) return "";
|
|
1257
|
+
const t = w.totals;
|
|
1258
|
+
const kindRow = (k, label) => {
|
|
1259
|
+
const x = t.byKind[k];
|
|
1260
|
+
return x?.episodes ? `<tr><td>${label}</td><td class="num">${x.episodes}</td><td class="num">${dur(x.blockedMs)}</td></tr>` : "";
|
|
1261
|
+
};
|
|
1262
|
+
const top = w.sessions.slice(0, 8).map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
|
|
1263
|
+
<td>${esc(s.title ?? s.sessionId.slice(0, 8))}${s.openSince ? ` <span class="badge warn">waiting ${ago(s.openSince)}</span>` : ""}</td>
|
|
1264
|
+
<td class="num">${s.episodes}</td><td class="num">${dur(s.blockedMs)}</td><td class="num">${dur(s.longestMs)}</td></tr>`).join("");
|
|
1265
|
+
return `<h2 class="mt-sec">Waiting on you <span>last 7 days · time agents spent blocked on a person${t.waitingNow ? ` · <b class="navcount">${t.waitingNow} waiting now</b>` : ""}</span></h2>
|
|
1266
|
+
<div class="cols">
|
|
1267
|
+
<div class="chart-card" style="margin:0"><h3>By what blocked them</h3>
|
|
1268
|
+
<table class="mini"><thead><tr><th>kind</th><th class="num">times</th><th class="num">blocked</th></tr></thead>
|
|
1269
|
+
<tbody>${kindRow("permission", "Permission prompt")}${kindRow("question", "Question it asked")}${kindRow("notification", "Notification")}
|
|
1270
|
+
<tr><td><b>Total</b></td><td class="num"><b>${t.episodes}</b></td><td class="num"><b>${dur(t.blockedMs)}</b></td></tr>
|
|
1271
|
+
<tr><td class="dim">median wait</td><td class="num"></td><td class="num dim">${dur(t.medianMs)}</td></tr>
|
|
1272
|
+
<tr><td class="dim">longest wait</td><td class="num"></td><td class="num dim">${dur(t.longestMs)}</td></tr>
|
|
1273
|
+
</tbody></table></div>
|
|
1274
|
+
<div class="chart-card" style="margin:0"><h3>Sessions that waited most</h3>
|
|
1275
|
+
<table class="mini"><thead><tr><th>session</th><th class="num">waits</th><th class="num">blocked</th><th class="num">longest</th></tr></thead>
|
|
1276
|
+
<tbody>${top}</tbody></table></div>
|
|
1277
|
+
</div>`;
|
|
1278
|
+
}
|
|
1034
1279
|
function renderStats() {
|
|
1035
1280
|
const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
|
|
1036
1281
|
const scope = state.sel ? esc(projName(state.sel)) : "all projects";
|
|
@@ -1118,6 +1363,7 @@ function renderStats() {
|
|
|
1118
1363
|
<div class="chart-card" style="margin:0"><h3>Tool leaderboard <span>calls · all time</span></h3>${st.tools.length ? viz.hbars(st.tools.map(([k, v]) => [toolName(k), v])) : '<div class="dim">no tool calls yet</div>'}</div>
|
|
1119
1364
|
<div><h2 style="margin-top:0">Records</h2><div class="records">${records}</div></div>
|
|
1120
1365
|
</div>
|
|
1366
|
+
${waitingSection()}
|
|
1121
1367
|
<p class="dim" style="margin-top:var(--gap-sec)">Word counts assume ~0.75 words per token; a novel is 90k words. Costs use list prices, as on Spend. ${pct(T.sidechainTurns, T.turns)} of turns came from subagents.</p>`;
|
|
1122
1368
|
}
|
|
1123
1369
|
|
|
@@ -1136,6 +1382,365 @@ async function loadTimelineDetail() {
|
|
|
1136
1382
|
if (state.view === "timeline" && !state.session) touch();
|
|
1137
1383
|
} finally { tlDetail.busy = false; }
|
|
1138
1384
|
}
|
|
1385
|
+
// M9.2: Outcomes — did the agent's work survive? Branch → PR → merged / reverted, with
|
|
1386
|
+
// scorecards per model and per agent. Data from /v1/outcomes (fetched by the poll while open).
|
|
1387
|
+
const outBadge = (o) => ({ merged: '<span class="badge ok">merged</span>', reverted: '<span class="badge bad">reverted</span>', open: '<span class="badge acc">open</span>', "no-pr": '<span class="badge">no PR</span>' })[o] ?? esc(o);
|
|
1388
|
+
const ratePct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
|
|
1389
|
+
const hrs = (x) => (x == null || x < 0 ? "—" : x < 1 ? `${Math.round(x * 60)}m` : x < 48 ? `${x.toFixed(1)}h` : `${(x / 24).toFixed(1)}d`);
|
|
1390
|
+
const scoreCols = (label) => [
|
|
1391
|
+
{ key: "key", label, width: 150, get: (r) => r.key, cell: (r) => `<b>${esc(label === "model" ? model(r.key) : viz.agentName(r.key))}</b>` },
|
|
1392
|
+
{ key: "branches", label: "branches", width: 80, num: true, get: (r) => r.branches, cell: (r) => String(r.branches) },
|
|
1393
|
+
{ key: "merged", label: "merged", width: 72, num: true, get: (r) => r.merged, cell: (r) => String(r.merged) },
|
|
1394
|
+
{ key: "reverted", label: "reverted", width: 78, num: true, get: (r) => r.reverted, cell: (r) => (r.reverted ? `<b style="color:var(--bad)">${r.reverted}</b>` : "0") },
|
|
1395
|
+
{ key: "open", label: "open", width: 60, num: true, get: (r) => r.open, cell: (r) => String(r.open) },
|
|
1396
|
+
{ key: "nopr", label: "no PR", width: 64, num: true, get: (r) => r.noPr, cell: (r) => String(r.noPr) },
|
|
1397
|
+
{ key: "rate", label: "merge rate", width: 92, num: true, get: (r) => r.mergeRate ?? -1, cell: (r) => ratePct(r.mergeRate) },
|
|
1398
|
+
{ key: "lead", label: "median lead", width: 98, num: true, get: (r) => r.medianLeadHours ?? -1, cell: (r) => hrs(r.medianLeadHours) },
|
|
1399
|
+
{ key: "cpm", label: "$ / merge", width: 84, num: true, get: (r) => r.costPerMerge ?? -1, cell: (r) => (r.costPerMerge == null ? "—" : usd(r.costPerMerge)) },
|
|
1400
|
+
];
|
|
1401
|
+
const BRANCH_COLS = [
|
|
1402
|
+
{ key: "branch", label: "branch", width: 190, get: (r) => r.branch, cell: (r) => `<span class="br">${esc(r.branch)}</span>` },
|
|
1403
|
+
{ key: "outcome", label: "outcome", width: 92, cls: "td-badge", get: (r) => r.outcome, cell: (r) => outBadge(r.outcome) },
|
|
1404
|
+
{ key: "pr", label: "PR", flex: true, get: (r) => r.title ?? "", cell: (r) => (r.prNumber ? `<a href="${esc(r.url ?? "#")}" target="_blank" rel="noreferrer">#${r.prNumber}</a> <span class="dim">${esc(r.title ?? "")}</span>` : '<span class="faint">—</span>') },
|
|
1405
|
+
{ key: "model", label: "model", width: 92, get: (r) => model(r.model), cell: (r) => `<span class="br">${esc(model(r.model))}</span>` },
|
|
1406
|
+
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (r) => agentLabel(r.agent), cell: (r) => agentBadge(r.agent) },
|
|
1407
|
+
{ key: "sessions", label: "sessions", width: 76, num: true, get: (r) => r.sessions.length, cell: (r) => String(r.sessions.length) },
|
|
1408
|
+
{ key: "cost", label: "cost", width: 64, num: true, get: (r) => r.costUsd, cell: (r) => usd(r.costUsd) },
|
|
1409
|
+
{ key: "lead", label: "lead", width: 64, num: true, get: (r) => r.leadHours ?? -1, cell: (r) => hrs(r.leadHours) },
|
|
1410
|
+
];
|
|
1411
|
+
function renderOutcomes() {
|
|
1412
|
+
const o = state.outcomes;
|
|
1413
|
+
const head = (sub) => `<h2>Outcomes <span>${sub}</span></h2>`;
|
|
1414
|
+
if (!o) {
|
|
1415
|
+
$("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}Loading…</div>`;
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
if (!o.branches?.length) {
|
|
1419
|
+
$("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}No agent branches yet${state.sel ? " in this project" : ""}.<br>Outcomes fill in as sessions work on branches and their PRs merge — or get reverted.</div>`;
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const n = (k) => o.branches.filter((b) => b.outcome === k).length;
|
|
1423
|
+
const rev = n("reverted");
|
|
1424
|
+
$("#main").innerHTML =
|
|
1425
|
+
head(`${o.branches.length} branch${o.branches.length === 1 ? "" : "es"} · ${n("merged")} merged · ${rev ? `<b style="color:var(--bad)">${rev} reverted</b>` : "0 reverted"} · ${n("open")} open`) +
|
|
1426
|
+
`<h2 class="mt-sec">By model <span>who ships work that survives</span></h2>` +
|
|
1427
|
+
dataTable({ id: "outcomes-model", columns: scoreCols("model"), rows: o.byModel, rerender: touch }) +
|
|
1428
|
+
(o.byAgent.length > 1 ? `<h2 class="mt-sec">By agent</h2>${dataTable({ id: "outcomes-agent", columns: scoreCols("agent"), rows: o.byAgent, rerender: touch })}` : "") +
|
|
1429
|
+
`<h2 class="mt-sec">Branches <span>latest first</span></h2>` +
|
|
1430
|
+
dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100), rerender: touch });
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// M9.5: where the context window goes. Character counts are exact (every tool response is stored);
|
|
1434
|
+
// the token figures are a flat 4:1 estimate and say so. Re-reading a file is the waste metric —
|
|
1435
|
+
// the first read is work, every copy after it is the price of having forgotten.
|
|
1436
|
+
// `toolName` puts the server first, so four MCP tools all truncated to "claude-in-c…" and the
|
|
1437
|
+
// half that tells them apart was the half cut off. Lead with the tool, keep a short server hint.
|
|
1438
|
+
function ctxToolLabel(tool) {
|
|
1439
|
+
const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(tool);
|
|
1440
|
+
if (!m) return tool;
|
|
1441
|
+
const srv = m[1].replace(/[-_]/g, " ").split(" ").map((w) => w[0]).join("").toLowerCase();
|
|
1442
|
+
return `${m[2]} · ${srv}`;
|
|
1443
|
+
}
|
|
1444
|
+
function renderContext() {
|
|
1445
|
+
const c = state.context;
|
|
1446
|
+
const head = (sub) => `<h2>Context <span>${sub}</span></h2>`;
|
|
1447
|
+
if (!c) { $("#main").innerHTML = head("where the window goes") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1448
|
+
if (!c.totals.sessions) {
|
|
1449
|
+
$("#main").innerHTML = head("where the window goes") + `<div class="empty">${PX.idle()}No tool results in the last 7 days${state.sel ? " in this project" : ""}.</div>`;
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const t = c.totals;
|
|
1453
|
+
const chars = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n));
|
|
1454
|
+
const kpi = (l, v, d, cls = "") => `<div class="kpi ${cls}"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
|
|
1455
|
+
const kpis = `<div class="kpis">${
|
|
1456
|
+
kpi("Returned by tools", `${chars(t.toolChars)}`, `characters · ≈${chars(t.toolTokens)} tokens`)
|
|
1457
|
+
}${kpi("Spent re-reading", chars(t.wastedChars), t.wasteShare ? `${Math.round(t.wasteShare * 100)}% of it · ${t.rereadFiles} file${t.rereadFiles === 1 ? "" : "s"}` : "nothing re-read", t.wasteShare > 0.1 ? "hot" : t.wasteShare > 0.03 ? "warm" : "")
|
|
1458
|
+
}${kpi("Cache hit", `${Math.round(t.cacheHit * 100)}%`, "of the window came back free")
|
|
1459
|
+
}${kpi("Sessions", t.sessions, "with tool activity")}</div>`;
|
|
1460
|
+
|
|
1461
|
+
const worst = c.sessions.filter((s) => s.wastedChars > 0).slice(0, 10);
|
|
1462
|
+
const rows = worst.map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
|
|
1463
|
+
<td>${esc(s.title ?? s.sessionId.slice(0, 8))}</td>
|
|
1464
|
+
<td class="num">${chars(s.toolChars)}</td>
|
|
1465
|
+
<td class="num"><b>${chars(s.wastedChars)}</b></td>
|
|
1466
|
+
<td class="num">${Math.round(s.wasteShare * 100)}%</td>
|
|
1467
|
+
<td>${s.worst.map((w) => `<span class="br" title="${esc(w.path)} — read ${w.reads}× · ${chars(w.wastedChars)} chars re-read">${esc(w.path.split("/").slice(-1)[0])} <b>${w.reads}×</b></span>`).join(" ")}</td>
|
|
1468
|
+
</tr>`).join("");
|
|
1469
|
+
|
|
1470
|
+
$("#main").innerHTML = head(`last 7 days · ${chars(t.toolChars)} characters returned by tools`) + kpis +
|
|
1471
|
+
`<div class="cols">
|
|
1472
|
+
<div class="chart-card" style="margin:0"><h3>What fills the window <span>by tool · characters returned</span></h3>
|
|
1473
|
+
${viz.hbars(c.byTool.map((x) => [ctxToolLabel(x.tool), x.chars, `${chars(x.chars)} · ${x.calls}`]))}</div>
|
|
1474
|
+
<div class="chart-card" style="margin:0"><h3>Re-read waste <span>the same file, read again</span></h3>
|
|
1475
|
+
${worst.length ? `<table class="mini"><thead><tr><th>session</th><th class="num">returned</th><th class="num">wasted</th><th class="num">share</th><th>worst files</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="dim">Nothing was read twice — no waste to report.</div>'}</div>
|
|
1476
|
+
</div>
|
|
1477
|
+
<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Character counts are exact — every tool response is stored. Token figures are a flat 4:1 estimate. <b>MCP tool schemas and the system prompt are not included</b>: Swarm sees tool calls, never the schemas or the prompt preamble, so they are left out rather than guessed at.</p>`;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// M9.18: the same task run by N models side by side. An arm is its own task id, so each has its
|
|
1481
|
+
// own claim and worktree and the ledger's one-holder rule is untouched — see core/abtrial.ts.
|
|
1482
|
+
const VERDICT = { winner: ["ok", "Decided"], undecided: ["acc", "Running"], "all-failed": ["bad", "No winner"] };
|
|
1483
|
+
function renderTrials() {
|
|
1484
|
+
const trials = state.trials;
|
|
1485
|
+
const head = (sub) => `<h2>Trials <span>${sub}</span>${state.sel ? `<span class="grow"></span><span class="chip" id="abNew">${ic("plus", 12)} New trial</span>` : ""}</h2>`;
|
|
1486
|
+
if (!trials) { $("#main").innerHTML = head("same task, different models") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1487
|
+
if (!trials.length) {
|
|
1488
|
+
$("#main").innerHTML = head("same task, different models") + `<div class="empty">${PX.idle()}No trials yet${state.sel ? "" : " — pick a project to start one"}.<br>A trial runs one task on several models at once and compares what each produced: cost, wall time, gates, diff size.</div>`;
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
const secs = (v) => (v === null ? '<span class="dim">—</span>' : dur(v));
|
|
1492
|
+
const cols = [
|
|
1493
|
+
{ key: "arm", label: "arm", width: 130, get: (a) => a.label, cell: (a) => `<b>${esc(a.label)}</b>${a.winner ? ' <span class="badge ok">Winner</span>' : ""}` },
|
|
1494
|
+
{ key: "state", label: "state", width: 116, get: (a) => a.ineligibleFor ?? "", cell: (a) => (a.eligible ? '<span class="badge ok">Passed</span>' : `<span class="badge ${a.state === "running" ? "acc" : "warn"}" title="This arm cannot win: ${esc(a.ineligibleFor ?? "")}">${esc(a.ineligibleFor ?? "—")}</span>`) },
|
|
1495
|
+
{ key: "cost", label: "cost", width: 74, num: true, get: (a) => a.costUsd, cell: (a) => usd(a.costUsd) },
|
|
1496
|
+
{ key: "wall", label: "wall", width: 74, num: true, get: (a) => a.wallMs ?? -1, cell: (a) => secs(a.wallMs) },
|
|
1497
|
+
{ key: "turns", label: "turns", width: 64, num: true, get: (a) => a.turns, cell: (a) => a.turns },
|
|
1498
|
+
{ key: "gates", label: "gates", width: 84, num: true, get: (a) => a.gatesFailed * -1 + a.gatesPassed, cell: (a) => `${a.gatesPassed ? `<span class="badge ok">${a.gatesPassed}</span>` : ""}${a.gatesFailed ? ` <span class="badge bad">${a.gatesFailed}</span>` : ""}${!a.gatesPassed && !a.gatesFailed ? '<span class="dim">none</span>' : ""}` },
|
|
1499
|
+
{ key: "diff", label: "diff", width: 108, num: true, get: (a) => a.churn ?? -1, cell: (a) => (a.churn === null ? '<span class="dim">measuring…</span>' : `<span title="${a.filesChanged} file${a.filesChanged === 1 ? "" : "s"} · +${a.insertions} −${a.deletions}">${a.churn} lines</span>`) },
|
|
1500
|
+
{ key: "sess", label: "session", flex: true, get: (a) => a.sessionId ?? "", cell: (a) => (a.sessionId ? `<a href="#" data-s="${esc(a.sessionId)}">${esc(a.model ?? a.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
|
|
1501
|
+
];
|
|
1502
|
+
const block = (t) => {
|
|
1503
|
+
const v = VERDICT[t.verdict] ?? VERDICT.undecided;
|
|
1504
|
+
const sub = `${t.totals.arms} arm${t.totals.arms === 1 ? "" : "s"} · ${t.totals.finished} finished · ${usd(t.totals.costUsd)} spent${t.winner ? ` · <b>${esc(t.winner)}</b> won${t.totals.savedUsd > 0.005 ? `, ${usd(t.totals.savedUsd)} cheaper than the dearest` : ""}` : ""}`;
|
|
1505
|
+
return `<h2 class="mt-sec">${esc(t.task)} <span class="badge ${v[0]}">${v[1]}</span> <span>${sub}</span></h2>` +
|
|
1506
|
+
dataTable({ id: `ab-${t.task}`, columns: cols, rows: t.arms, rerender: touch });
|
|
1507
|
+
};
|
|
1508
|
+
const running = trials.filter((t) => t.verdict === "undecided").length;
|
|
1509
|
+
$("#main").innerHTML = head(`${trials.length} trial${trials.length === 1 ? "" : "s"}${running ? ` · ${running} still running` : ""}`) +
|
|
1510
|
+
trials.map(block).join("") +
|
|
1511
|
+
`<p class="dim" style="margin-top:12px;font-size:var(--fs-sm)">An arm wins only if it finished and passed every gate it ran; among those, the cheapest wins and wall time breaks ties. A cheap arm that failed a gate never wins — the cheap wrong answer is not the answer. Each arm claims <code>task#arm</code>, so it gets its own worktree and the one-holder claim is never bent.</p>`;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// M9.14: issue → task → claim → session → branch → PR → merged, as one row per piece of work.
|
|
1515
|
+
// The six link dots are the graph: a filled run that stops is exactly where the trail goes cold.
|
|
1516
|
+
const LINK_ORDER = ["task", "claim", "session", "branch", "pr", "merged"];
|
|
1517
|
+
const BREAK_LABEL = {
|
|
1518
|
+
"no-task": ["bad", "No task", "landed with no task behind it"],
|
|
1519
|
+
unclaimed: ["warn", "Unclaimed", "no claim was ever taken for this task"],
|
|
1520
|
+
"no-session": ["warn", "No session", "claimed, but no session did the work"],
|
|
1521
|
+
"no-branch": ["warn", "No branch", "worked on, but never reached a branch"],
|
|
1522
|
+
"no-pr": ["warn", "No PR", "a branch exists but no pull request"],
|
|
1523
|
+
"open-pr": ["acc", "Open PR", "the pull request has not merged yet"],
|
|
1524
|
+
};
|
|
1525
|
+
// Lead time spans minutes to months, and "889.4h" both overflows a numeric column and means
|
|
1526
|
+
// nothing to a reader. Never wider than 5 characters.
|
|
1527
|
+
function leadTime(h) {
|
|
1528
|
+
if (h < 1) return `${Math.round(h * 60)}m`;
|
|
1529
|
+
if (h < 48) return `${h.toFixed(h < 10 ? 1 : 0)}h`;
|
|
1530
|
+
const d = h / 24;
|
|
1531
|
+
return d < 100 ? `${d.toFixed(d < 10 ? 1 : 0)}d` : `${Math.round(d / 7)}w`;
|
|
1532
|
+
}
|
|
1533
|
+
function renderProvenance() {
|
|
1534
|
+
const p = state.provenance;
|
|
1535
|
+
const head = (sub) => `<h2>Provenance <span>${sub}</span></h2>`;
|
|
1536
|
+
if (!p) { $("#main").innerHTML = head("follow the work back") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1537
|
+
if (!p.chains.length) {
|
|
1538
|
+
$("#main").innerHTML = head("follow the work back") + `<div class="empty">${PX.idle()}Nothing to trace${state.sel ? " in this project" : ""}.<br>Chains appear once a task source is configured or a branch reaches a pull request.</div>`;
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
const t = p.totals;
|
|
1542
|
+
const kpi = (l, v, d, cls = "") => `<div class="kpi ${cls}"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
|
|
1543
|
+
const kpis = `<div class="kpis">${
|
|
1544
|
+
kpi("Traced", `${t.complete}/${t.tasks}`, "reach a merged PR", t.complete ? "" : "warm")
|
|
1545
|
+
}${kpi("Untracked", t.untracked, t.untracked ? "landed with no task" : "all work has a task", t.untracked ? "hot" : "")
|
|
1546
|
+
}${kpi("Unclaimed", t.unclaimed, "tasks nobody claimed", t.unclaimed ? "warm" : "")
|
|
1547
|
+
}${kpi("Traced spend", usd(t.costUsd), "across every chain")}</div>`;
|
|
1548
|
+
|
|
1549
|
+
const track = (c) => `<span class="track" title="${LINK_ORDER.map((k) => `${k}: ${c.links[k] ? "yes" : "no"}`).join(" · ")}">${
|
|
1550
|
+
LINK_ORDER.map((k) => `<i class="${c.links[k] ? "on" : ""}"></i>`).join("")}</span>`;
|
|
1551
|
+
const cols = [
|
|
1552
|
+
{ key: "what", label: "task / branch", width: 190, get: (c) => c.task, cell: (c) => `<b title="${esc(c.task)}${c.fromTask ? "" : " — a branch with no task behind it"}">${esc(c.task)}</b>${c.fromTask ? "" : ' <span class="badge">branch</span>'}` },
|
|
1553
|
+
{ key: "track", label: "chain", width: 92, sortable: false, filterable: false, get: (c) => c.depth, cell: track },
|
|
1554
|
+
{ key: "gap", label: "trail ends at", width: 118, get: (c) => c.brokenAt ?? "", cell: (c) => { const b = BREAK_LABEL[c.brokenAt]; return b ? `<span class="badge ${b[0]}" title="${esc(b[2])}">${b[1]}</span>` : '<span class="badge ok">Merged</span>'; } },
|
|
1555
|
+
{ key: "title", label: "what it was", flex: true, get: (c) => c.title, cell: (c) => `<span class="now" title="${esc(c.title)}">${esc(c.title)}</span>` },
|
|
1556
|
+
{ key: "who", label: "held by", width: 120, get: (c) => c.holders.join(","), cell: (c) => (c.holders.length ? esc(c.holders.join(", ")) : '<span class="dim">—</span>') },
|
|
1557
|
+
{ key: "sess", label: "sessions", width: 78, num: true, get: (c) => c.sessions.length, cell: (c) => (c.sessions.length ? `<a href="#" data-s="${esc(c.sessions[0].id)}" title="${esc(c.sessions.map((s) => s.title ?? s.id).join(" · "))}">${c.sessions.length}</a>` : '<span class="dim">0</span>') },
|
|
1558
|
+
{ key: "pr", label: "PR", width: 74, num: true, get: (c) => c.prNumber ?? 0, cell: (c) => (c.prNumber ? `<a href="${esc(c.prUrl ?? "#")}" target="_blank" rel="noopener">#${c.prNumber}</a>` : '<span class="dim">—</span>') },
|
|
1559
|
+
{ key: "cost", label: "cost", width: 74, num: true, get: (c) => c.costUsd, cell: (c) => usd(c.costUsd) },
|
|
1560
|
+
{ key: "lead", label: "lead", width: 68, num: true, get: (c) => c.leadHours ?? -1, cell: (c) => (c.leadHours === null ? '<span class="dim">—</span>' : leadTime(c.leadHours)) },
|
|
1561
|
+
];
|
|
1562
|
+
$("#main").innerHTML = head(`${t.tasks} chain${t.tasks === 1 ? "" : "s"} · ${t.untracked ? `<b class="navcount">${t.untracked} untracked</b>` : "every branch has a task"}`) + kpis +
|
|
1563
|
+
dataTable({ id: "provenance", columns: cols, rows: p.chains, rerender: touch }) +
|
|
1564
|
+
`<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">The six dots are task · claim · session · branch · PR · merged — a filled run that stops is where the trail goes cold. Chains are walked from both ends: from tasks forward, and from branches back, so <b>work that landed with no task behind it</b> shows up too. Task rows carry no issue link because the task source records ids and titles, not URLs.</p>`;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// M9.6: which MCP servers the fleet waits on. Latency is hook-to-hook — the wall-clock between
|
|
1568
|
+
// PreToolUse and PostToolUse — so it is what the agent actually waited for, including any time a
|
|
1569
|
+
// call spent behind a permission prompt. That is why the view leads with p50/p95, not max.
|
|
1570
|
+
function renderMcpHealth() {
|
|
1571
|
+
const h = state.mcpHealth;
|
|
1572
|
+
const head = (sub) => `<h2>MCP <span>${sub}</span></h2>`;
|
|
1573
|
+
if (!h) { $("#main").innerHTML = head("server health") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1574
|
+
if (!h.servers.length) {
|
|
1575
|
+
$("#main").innerHTML = head("server health") + `<div class="empty">${PX.idle()}No tool calls in the last 7 days${state.sel ? " in this project" : ""}.</div>`;
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const t = h.totals;
|
|
1579
|
+
const ms = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : v < 60_000 ? `${(v / 1000).toFixed(1)}s` : dur(v));
|
|
1580
|
+
const cols = [
|
|
1581
|
+
{ key: "server", label: "server", width: 170, get: (s) => s.server, cell: (s) => `<b>${esc(s.server)}</b>${s.mcp ? "" : ' <span class="badge">built-in</span>'}` },
|
|
1582
|
+
{ key: "calls", label: "calls", width: 74, num: true, get: (s) => s.calls, cell: (s) => s.calls.toLocaleString() },
|
|
1583
|
+
{ key: "sessions", label: "sessions", width: 78, num: true, get: (s) => s.sessions, cell: (s) => s.sessions },
|
|
1584
|
+
{ key: "p50", label: "p50", width: 68, num: true, get: (s) => s.p50Ms ?? -1, cell: (s) => ms(s.p50Ms) },
|
|
1585
|
+
{ key: "p95", label: "p95", width: 68, num: true, get: (s) => s.p95Ms ?? -1, cell: (s) => ms(s.p95Ms) },
|
|
1586
|
+
{ key: "max", label: "slowest", width: 78, num: true, get: (s) => s.maxMs ?? -1, cell: (s) => `<span class="dim" title="Includes any time the call spent waiting on a person">${ms(s.maxMs)}</span>` },
|
|
1587
|
+
{ key: "wait", label: "waited", width: 82, num: true, get: (s) => s.totalMs, cell: (s) => dur(s.totalMs) },
|
|
1588
|
+
{ key: "unans", label: "no reply", width: 78, num: true, get: (s) => s.unanswered, cell: (s) => (s.unanswered ? `<b class="bad">${s.unanswered}</b>` : '<span class="dim">0</span>') },
|
|
1589
|
+
{ key: "err", label: "errors", width: 74, num: true, get: (s) => s.errorRate, cell: (s) => (s.errors ? `<b class="bad">${Math.round(s.errorRate * 100)}%</b>` : '<span class="dim">0</span>') },
|
|
1590
|
+
{ key: "tools", label: "busiest tools", flex: true, sortable: false, get: () => null, cell: (s) => s.tools.map((x) => `<span class="br" title="${esc(x.tool)} · ${x.calls} calls${x.p50Ms === null ? "" : ` · p50 ${x.p50Ms}ms`}">${esc(x.tool)} <b>${x.calls}</b></span>`).join(" ") },
|
|
1591
|
+
];
|
|
1592
|
+
const share = t.totalMs ? Math.round((t.mcpMs / t.totalMs) * 100) : 0;
|
|
1593
|
+
const sub = `${t.servers} MCP server${t.servers === 1 ? "" : "s"} · ${t.calls.toLocaleString()} call${t.calls === 1 ? "" : "s"} · last 7 days · ${dur(t.mcpMs)} waiting on MCP (${share}% of all tool time)`;
|
|
1594
|
+
$("#main").innerHTML = head(sub) +
|
|
1595
|
+
dataTable({ id: "mcp-health", columns: cols, rows: h.servers, rerender: touch }) +
|
|
1596
|
+
`<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Latency is measured hook to hook, so it is the wall-clock an agent actually waited — a call held behind a permission prompt carries that wait too, which is why <b>slowest</b> can be hours and p50/p95 are the numbers to read. <b>errors</b> counts only unambiguous failures: a command that merely prints the word "error" is not one.</p>`;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// M9.7: gate flakiness and cost. A gate that flips on the *same task* told you two different
|
|
1600
|
+
// things about identical work — that is the number worth ranking on, not a raw fail count.
|
|
1601
|
+
function renderGateHealth() {
|
|
1602
|
+
const h = state.gateHealth;
|
|
1603
|
+
const head = (sub) => `<h2>Gates <span>${sub}</span></h2>`;
|
|
1604
|
+
if (!h) { $("#main").innerHTML = head("flakiness and wall-clock") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1605
|
+
if (!h.gates.length) {
|
|
1606
|
+
$("#main").innerHTML = head("flakiness and wall-clock") + `<div class="empty">${PX.idle()}No gate runs in the last 30 days${state.sel ? " in this project" : ""}.<br>Gates appear here once <code>swarm_gate_run</code> or a workflow's gate step records one.</div>`;
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
const t = h.totals;
|
|
1610
|
+
const secs = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : `${(v / 1000).toFixed(1)}s`);
|
|
1611
|
+
// Oldest-first strip, matching Recent gates on the Board.
|
|
1612
|
+
const strip = (g) => {
|
|
1613
|
+
const rs = [...g.history].reverse();
|
|
1614
|
+
return `<span class="gh" title="last ${rs.length} run${rs.length === 1 ? "" : "s"}, oldest first">${rs.map((r) => `<i class="${r.verdict === "pass" ? "ok" : "bad"}" title="${esc(r.task)} · ${esc(r.at)}${r.durationMs === null ? "" : ` · ${(r.durationMs / 1000).toFixed(1)}s`}"></i>`).join("")}</span>`;
|
|
1615
|
+
};
|
|
1616
|
+
const cols = [
|
|
1617
|
+
{ key: "gate", label: "gate", width: 150, get: (g) => g.gate, cell: (g) => `<b>${esc(g.gate)}</b>${g.flaky ? ' <span class="badge bad" title="This gate returned both a pass and a fail on the same task">Flaky</span>' : ""}` },
|
|
1618
|
+
{ key: "history", label: "history", width: 150, sortable: false, filterable: false, get: () => null, cell: strip },
|
|
1619
|
+
{ key: "runs", label: "runs", width: 60, num: true, get: (g) => g.runs, cell: (g) => g.runs },
|
|
1620
|
+
{ key: "pass", label: "pass rate", width: 84, num: true, get: (g) => g.passRate, cell: (g) => `${Math.round(g.passRate * 100)}%` },
|
|
1621
|
+
{ key: "flips", label: "flips", width: 64, num: true, get: (g) => g.flips, cell: (g) => (g.flips ? `<b class="bad">${g.flips}</b>` : '<span class="dim">0</span>') },
|
|
1622
|
+
{ key: "p50", label: "p50", width: 66, num: true, get: (g) => g.p50Ms ?? -1, cell: (g) => secs(g.p50Ms) },
|
|
1623
|
+
{ key: "p95", label: "p95", width: 66, num: true, get: (g) => g.p95Ms ?? -1, cell: (g) => secs(g.p95Ms) },
|
|
1624
|
+
{ key: "max", label: "slowest", width: 74, num: true, get: (g) => g.maxMs ?? -1, cell: (g) => secs(g.maxMs) },
|
|
1625
|
+
{ key: "total", label: "total", width: 74, num: true, get: (g) => g.totalMs, cell: (g) => (g.timedRuns ? dur(g.totalMs) : '<span class="dim">—</span>') },
|
|
1626
|
+
{ key: "last", label: "last", flex: true, get: (g) => g.lastAt ?? "", cell: (g) => (g.lastAt ? `${g.lastVerdict === "pass" ? '<span class="badge ok">Pass</span>' : '<span class="badge warn">Fail</span>'} <span class="dim">${ago(g.lastAt)}</span>` : '<span class="dim">—</span>') },
|
|
1627
|
+
];
|
|
1628
|
+
const sub = `${t.gates} gate${t.gates === 1 ? "" : "s"} · ${t.runs} run${t.runs === 1 ? "" : "s"} · last 30 days${t.flakyGates ? ` · <b class="navcount">${t.flakyGates} flaky</b>` : " · none flaky"}${t.totalMs ? ` · ${dur(t.totalMs)} of wall-clock` : ""}`;
|
|
1629
|
+
$("#main").innerHTML = head(sub) +
|
|
1630
|
+
dataTable({ id: "gate-health", columns: cols, rows: h.gates, rerender: touch }) +
|
|
1631
|
+
`<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Flaky = the same gate returned both a pass and a fail on one task. A gate that fails on one task and passes on another is doing its job, and is not counted. Durations cover executed gates only — a gate an agent simply recorded has no wall-clock.</p>`;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// M9.8: machine hygiene — what the fleet left behind. Observation plus the two actions that
|
|
1635
|
+
// already exist (stop a process, remove a worktree); nothing here reclaims anything on its own,
|
|
1636
|
+
// and a worktree with uncommitted or unpushed work is never offered as safe.
|
|
1637
|
+
const ISSUE_BADGE = {
|
|
1638
|
+
dead: ["bad", "Dead"], orphaned: ["bad", "Orphaned"], hungry: ["warn", "Hungry"],
|
|
1639
|
+
stale: ["warn", "Stale"], abandoned: ["warn", "Abandoned"], heavy: ["", "Heavy"],
|
|
1640
|
+
};
|
|
1641
|
+
const mb = (kb) => (kb === null || kb === undefined ? '<span class="dim">—</span>' : kb >= 1024 * 1024 ? `${(kb / 1024 / 1024).toFixed(1)} GB` : `${Math.round(kb / 1024)} MB`);
|
|
1642
|
+
const issueBadge = (i) => { const b = ISSUE_BADGE[i]; return b ? `<span class="badge ${b[0]}">${b[1]}</span>` : '<span class="dim">ok</span>'; };
|
|
1643
|
+
function renderHygiene() {
|
|
1644
|
+
const h = state.hygiene;
|
|
1645
|
+
const head = (sub) => `<h2>Hygiene <span>${sub}</span></h2>`;
|
|
1646
|
+
if (!h) { $("#main").innerHTML = head("what the fleet left behind") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1647
|
+
const t = h.totals;
|
|
1648
|
+
if (!h.processes.length && !h.worktrees.length) {
|
|
1649
|
+
$("#main").innerHTML = head("what the fleet left behind") + `<div class="empty">${PX.idle()}Nothing tracked${state.sel ? " in this project" : ""}.<br>Processes started through <code>swarm serve</code> / <code>proc</code> and this machine's worktrees appear here.</div>`;
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const kpi = (l, v, d, cls = "") => `<div class="kpi ${cls}"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
|
|
1653
|
+
const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
|
|
1654
|
+
// Disk is sampled in the background, so "0 MB" before the first sweep would be a lie — say so.
|
|
1655
|
+
const sampled = h.worktrees.filter((w) => w.diskKb !== null).length;
|
|
1656
|
+
const diskPending = h.worktrees.length > 0 && sampled === 0;
|
|
1657
|
+
const totalDisk = diskPending ? "measuring…" : mb(t.diskKb);
|
|
1658
|
+
const kpis = `<div class="kpis">${
|
|
1659
|
+
kpi("Needs a look", t.issues, t.issues ? "processes + worktrees" : "all clean", t.issues ? "hot" : "")
|
|
1660
|
+
}${kpi("Processes", t.processes, t.orphanedProcesses || t.deadProcesses ? `${t.orphanedProcesses} orphaned · ${t.deadProcesses} dead` : "all healthy", t.orphanedProcesses || t.deadProcesses ? "hot" : "")
|
|
1661
|
+
}${kpi("Worktrees", t.worktrees, t.staleWorktrees ? `${t.staleWorktrees} stale` : "none stale", t.staleWorktrees ? "warm" : "")
|
|
1662
|
+
}${kpi("Reclaimable", diskPending ? '<span class="dim">—</span>' : mb(t.reclaimableKb), diskPending ? `measuring ${h.worktrees.length} worktrees…` : `of ${mb(t.diskKb)} on disk`, !diskPending && t.reclaimableKb ? "warm" : "")}</div>`;
|
|
1663
|
+
|
|
1664
|
+
const pcols = [
|
|
1665
|
+
{ key: "issue", label: "state", width: 96, get: (p) => p.issue ?? "", cell: (p) => issueBadge(p.issue) },
|
|
1666
|
+
{ key: "name", label: "name", width: 130, get: (p) => p.name, cell: (p) => `<b>${esc(p.name)}</b>` },
|
|
1667
|
+
{ key: "kind", label: "kind", width: 64, get: (p) => p.kind, cell: (p) => `<span class="br">${esc(p.kind)}</span>` },
|
|
1668
|
+
{ key: "pid", label: "pid", width: 64, num: true, get: (p) => p.pid, cell: (p) => p.pid },
|
|
1669
|
+
{ key: "port", label: "port", width: 60, num: true, get: (p) => p.port ?? 0, cell: (p) => p.port ?? '<span class="dim">—</span>' },
|
|
1670
|
+
{ key: "cpu", label: "cpu", width: 60, num: true, get: (p) => p.cpuPct ?? -1, cell: (p) => (p.cpuPct === null ? '<span class="dim">—</span>' : `${p.cpuPct.toFixed(0)}%`) },
|
|
1671
|
+
{ key: "rss", label: "memory", width: 78, num: true, get: (p) => p.rssKb ?? -1, cell: (p) => mb(p.rssKb) },
|
|
1672
|
+
{ key: "note", label: "why", flex: true, get: (p) => p.note ?? "", cell: (p) => (p.note ? `<span class="now" title="${esc(p.note)}">${esc(p.note)}</span>` : '<span class="dim">—</span>') },
|
|
1673
|
+
{ key: "act", label: "", width: 70, sortable: false, filterable: false, get: () => null, cell: (p) => (p.reclaimable ? `<a href="#" class="mini-act" data-procstop="${esc(String(p.pid))}" data-procproj="${esc(p.projectId)}" title="Stop this process">Stop</a>` : "") },
|
|
1674
|
+
];
|
|
1675
|
+
const wcols = [
|
|
1676
|
+
{ key: "issue", label: "state", width: 106, get: (w) => w.issue ?? "", cell: (w) => issueBadge(w.issue) },
|
|
1677
|
+
{ key: "branch", label: "branch", width: 190, get: (w) => w.branch ?? w.path, cell: (w) => `<b>${esc(w.branch ?? "(detached)")}</b>${w.main ? ' <span class="badge">main</span>' : ""}` },
|
|
1678
|
+
{ key: "disk", label: "disk", width: 78, num: true, get: (w) => w.diskKb ?? -1, cell: (w) => mb(w.diskKb) },
|
|
1679
|
+
{ key: "idle", label: "untouched", width: 88, num: true, get: (w) => w.idleMs ?? -1, cell: (w) => (w.idleMs === null ? '<span class="dim">—</span>' : dur(w.idleMs)) },
|
|
1680
|
+
{ key: "state2", label: "work", width: 130, get: (w) => w.dirty * 1000 + w.ahead, cell: (w) => `${badge(w.dirty, "Dirty", "warn")}${badge(w.ahead, "Unpushed", "acc")}${w.dirty === 0 && w.ahead <= 0 ? (w.merged ? '<span class="badge ok">Merged</span>' : '<span class="badge">Clean</span>') : ""}` },
|
|
1681
|
+
{ key: "held", label: "in use", width: 110, get: (w) => w.heldByClaim ?? "", cell: (w) => (w.heldByClaim ? `<span class="br" title="Claimed">${esc(w.heldByClaim)}</span>` : w.liveSessions ? `<span class="badge acc">${w.liveSessions} live</span>` : '<span class="dim">—</span>') },
|
|
1682
|
+
{ key: "note", label: "why", flex: true, get: (w) => w.note ?? "", cell: (w) => (w.note ? `<span class="now" title="${esc(w.note)}">${esc(w.note)}</span>` : '<span class="dim">—</span>') },
|
|
1683
|
+
{ key: "act", label: "", width: 80, sortable: false, filterable: false, get: () => null, cell: (w) => (w.reclaimable ? `<a href="#" class="mini-act bad" data-wtrm="${esc(w.projectId)}:${esc(w.path)}" title="Remove this worktree">Remove</a>` : "") },
|
|
1684
|
+
];
|
|
1685
|
+
const sub = t.issues ? `<b class="navcount">${t.issues} need${t.issues === 1 ? "s" : ""} a look</b>` : "nothing to clean up";
|
|
1686
|
+
$("#main").innerHTML = head(sub) + kpis +
|
|
1687
|
+
`<h2 class="mt-sec">Processes <span>${h.processes.length} tracked · started through swarm, never matched by command pattern</span></h2>` +
|
|
1688
|
+
(h.processes.length ? dataTable({ id: "hyg-procs", columns: pcols, rows: h.processes, rerender: touch }) : `<div class="empty">${PX.idle()}No tracked processes.</div>`) +
|
|
1689
|
+
`<h2 class="mt-sec">Worktrees <span>${h.worktrees.length} on this machine · ${totalDisk}${diskPending ? "" : " on disk"}${sampled && sampled < h.worktrees.length ? ` · ${sampled}/${h.worktrees.length} measured` : ""}</span></h2>` +
|
|
1690
|
+
(h.worktrees.length ? dataTable({ id: "hyg-trees", columns: wcols, rows: h.worktrees, rerender: touch }) : `<div class="empty">${PX.idle()}No worktrees.</div>`) +
|
|
1691
|
+
`<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Only merged worktrees with nothing uncommitted, nothing unpushed and nobody working in them are offered for removal. Anything unmerged is listed but never called safe. Disk is sampled in the background, so sizes fill in a moment after the view opens.</p>`;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// M9.12: live file-collision graph — which live sessions touch which files, contested files
|
|
1695
|
+
// highlighted. Data from /v1/graphs/collisions (fetched by the poll while the view is open).
|
|
1696
|
+
function renderGraphs() {
|
|
1697
|
+
const tab = state.graphTab ?? "collisions";
|
|
1698
|
+
const chip = (k, label, n) => `<span class="chip ${tab === k ? "on" : ""}" data-graphtab="${k}">${label}${n ? ` <b>${n}</b>` : ""}</span>`;
|
|
1699
|
+
const tabs = `<div class="chips">${chip("collisions", "Collisions", state.collisions?.contested ?? 0)}${chip("lineage", "Lineage", state.lineage?.edges?.length ?? 0)}</div>`;
|
|
1700
|
+
const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>${tabs}`;
|
|
1701
|
+
if (tab === "lineage") return renderLineage(head);
|
|
1702
|
+
const g = state.collisions;
|
|
1703
|
+
const title = (s) => s.title ?? s.id.slice(0, 8);
|
|
1704
|
+
if (!g || !g.sessions.length) {
|
|
1705
|
+
$("#main").innerHTML = head("live file collisions") + `<div class="empty">${PX.idle()}No live sessions${state.sel ? " in this project" : ""}.<br>The collision graph shows who is touching what, the moment two agents run at once.</div>`;
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if (!g.files.length) {
|
|
1709
|
+
$("#main").innerHTML = head(`${g.sessions.length} live session${g.sessions.length === 1 ? "" : "s"}`) + `<div class="empty">${PX.idle()}No file touches recorded yet — the graph fills in as agents read and edit.</div>`;
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const sessions = g.sessions.map((s) => ({ ...s, label: title(s) }));
|
|
1713
|
+
const agents = [...new Set(sessions.map((s) => s.agent))].sort(viz.agentSort);
|
|
1714
|
+
const sub = `${sessions.length} live session${sessions.length === 1 ? "" : "s"} · ${g.files.length} file${g.files.length === 1 ? "" : "s"} · ${g.contested ? `<b class="navcount">${g.contested} contested</b>` : "no collisions"}`;
|
|
1715
|
+
$("#main").innerHTML = head(sub) +
|
|
1716
|
+
`<div class="card" style="padding:14px">${viz.bipartite(sessions, g.files)}</div>
|
|
1717
|
+
<div style="margin-top:10px;display:flex;gap:16px;align-items:center">${viz.legend(agents)}<span class="dim" style="font-size:var(--fs-sm)">solid edge = writing · faint edge = reading · <span style="color:var(--bad)">red file</span> = two sessions on it, at least one writing</span></div>`;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// M9.13: who started whom, who told whom, who picked up whose work. Every edge is a recorded
|
|
1721
|
+
// relationship — nothing is inferred from timing.
|
|
1722
|
+
const EDGE_LEGEND = [
|
|
1723
|
+
["subagent", "spawned a subagent", "var(--acc)", ""],
|
|
1724
|
+
["dispatch", "dispatched a run", "var(--c3,#5a9e6f)", ""],
|
|
1725
|
+
["message", "sent a message", "var(--warn)", "3 3"],
|
|
1726
|
+
["handoff", "handed the task on", "var(--dim)", "6 3"],
|
|
1727
|
+
];
|
|
1728
|
+
function renderLineage(head) {
|
|
1729
|
+
const g = state.lineage;
|
|
1730
|
+
if (!g) { $("#main").innerHTML = head("session lineage") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1731
|
+
if (!g.nodes.length) {
|
|
1732
|
+
$("#main").innerHTML = head("session lineage") + `<div class="empty">${PX.idle()}No relationships between sessions${state.sel ? " in this project" : ""} in the last 14 days.<br>Edges appear when a session spawns a subagent, dispatches a run, messages another agent, or hands a task on.</div>`;
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
const key = EDGE_LEGEND.filter(([k]) => g.byKind[k]).map(([k, label, color, dash]) =>
|
|
1736
|
+
`<span style="display:inline-flex;align-items:center;gap:6px"><svg width="22" height="8" aria-hidden="true"><line x1="0" y1="4" x2="22" y2="4" stroke="${color}" stroke-width="2"${dash ? ` stroke-dasharray="${dash}"` : ""}/></svg><span class="dim" style="font-size:var(--fs-sm)">${label} <b>${g.byKind[k]}</b></span></span>`).join("");
|
|
1737
|
+
const sub = `${g.nodes.length} session${g.nodes.length === 1 ? "" : "s"} · ${g.edges.length} link${g.edges.length === 1 ? "" : "s"} · ${g.roots} root${g.roots === 1 ? "" : "s"} · last 14 days${g.truncated ? ` · <b class="navcount" title="The best-connected ${g.nodes.length} are drawn; the rest would be an unreadable column">${g.truncated} not drawn</b>` : ""}`;
|
|
1738
|
+
$("#main").innerHTML = head(sub) +
|
|
1739
|
+
`<div class="card" style="padding:14px;overflow:auto;max-height:72vh">${viz.dag(g)}</div>
|
|
1740
|
+
<div style="margin-top:10px;display:flex;gap:18px;align-items:center;flex-wrap:wrap">${key}
|
|
1741
|
+
<span class="dim" style="font-size:var(--fs-sm)">a green pill is a collapsed group — click to open it · ring = outcome · thicker dot = more links · a bowed edge closed a loop</span></div>`;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1139
1744
|
function renderTimeline() {
|
|
1140
1745
|
loadTimelineDetail();
|
|
1141
1746
|
const now = Date.now();
|
|
@@ -1185,7 +1790,23 @@ async function openSession(id) {
|
|
|
1185
1790
|
// Rendered log rows, keyed per event seq / turn id (+ the mutable turn fields) so only new rows are formatted.
|
|
1186
1791
|
const rowCache = new Map();
|
|
1187
1792
|
let logRendered = null; // keys of the rows currently in #log, in order — enables append-only updates
|
|
1188
|
-
|
|
1793
|
+
// The kind column showed raw hook names — "pretooluse", "subagentstop" — which are long, repeat on
|
|
1794
|
+
// every row, and say nothing the row does not: a tool row already begins with the tool's name. Short
|
|
1795
|
+
// labels here buy the transcript back ~70px of width per row; the full name stays in the title.
|
|
1796
|
+
const EV_LABEL = {
|
|
1797
|
+
PreToolUse: "tool", PostToolUse: "result", UserPromptSubmit: "you", Stop: "stop",
|
|
1798
|
+
SubagentStart: "sub →", SubagentStop: "sub ←", Notification: "note",
|
|
1799
|
+
SessionStart: "start", SessionEnd: "end", PreCompact: "compact",
|
|
1800
|
+
assistant: "agent", subagent: "sub",
|
|
1801
|
+
// ledger events reach the transcript too, and their dotted type names are the longest of all
|
|
1802
|
+
"incident.opened": "rule", "question.asked": "asks", "question.answered": "answer",
|
|
1803
|
+
"message.sent": "msg", "gate.recorded": "gate", "session.stuck": "stuck",
|
|
1804
|
+
"permission.requested": "perm?", "permission.resolved": "perm",
|
|
1805
|
+
"claim.acquired": "claim", "claim.released": "release", "pr.opened": "pr",
|
|
1806
|
+
};
|
|
1807
|
+
// Anything unmapped keeps its last dotted segment rather than the whole `a.b` name.
|
|
1808
|
+
const evLabel = (k) => EV_LABEL[k] ?? String(k).split(".").at(-1) ?? String(k);
|
|
1809
|
+
const evRow = (i) => `<div class="ev ${i.cls}"><span class="t">${hhmm(i.ts)}</span><span class="k" title="${esc(i.kind)}">${esc(evLabel(i.kind))}</span><span class="m">${esc(i.text)}${i.out ? `<span class="dim"> · ${tok(i.out)} out${i.cost != null ? ` · $${i.cost.toFixed(3)}` : ""}</span>` : ""}</span></div>`;
|
|
1189
1810
|
// Merge the two ts-sorted inputs (events by seq ≈ ts, turns by ts) in one pass → [{key, html}].
|
|
1190
1811
|
function sessionStream() {
|
|
1191
1812
|
const out = [];
|
|
@@ -1263,16 +1884,37 @@ function replayGo(delta) {
|
|
|
1263
1884
|
}
|
|
1264
1885
|
|
|
1265
1886
|
// Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
|
|
1266
|
-
// M7.6: the session's message thread (sent + received) and a compose box.
|
|
1887
|
+
// M7.6: the session's message thread (sent + received) and a compose box. Messages are never an
|
|
1888
|
+
// interrupt: they ride along as context on the agent's next tool call, so the block says so.
|
|
1267
1889
|
function messageThread(s) {
|
|
1268
1890
|
const ms = (state.msgs ?? []).filter((m) => m.sessionId === s.id || m.fromSession === s.id).slice().reverse();
|
|
1891
|
+
const queued = ms.filter((m) => m.fromSession !== s.id && !m.deliveredAt).length;
|
|
1892
|
+
const ended = s.state === "ended";
|
|
1269
1893
|
const row = (m) => {
|
|
1270
1894
|
const out = m.fromSession === s.id;
|
|
1271
1895
|
return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
|
|
1272
1896
|
<span class="msg-f">${out ? `→ ${esc(m.task ?? m.toKind)}` : esc(m.from ?? "?")}${m.deliveredAt ? "" : ' <i class="dim">·queued</i>'}</span>${esc(m.text)}</div>`;
|
|
1273
1897
|
};
|
|
1274
|
-
|
|
1275
|
-
|
|
1898
|
+
const hint = ended
|
|
1899
|
+
? `${ic("warning", 12)} Session ended — there is nothing left to deliver to.`
|
|
1900
|
+
: queued
|
|
1901
|
+
? `${ic("clock", 12)} <b>${queued} queued</b> · delivered the next time this agent calls a tool.`
|
|
1902
|
+
: `${ic("comment-text", 12)} Delivered as context on this agent's next tool call — never an interrupt.`;
|
|
1903
|
+
return `<h4>messages${ms.length ? ` <span class="badge">${ms.length}</span>` : ""}</h4>
|
|
1904
|
+
${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : ""}
|
|
1905
|
+
<div class="msg-compose">
|
|
1906
|
+
<input id="msgText" placeholder="Message this agent…" aria-label="Message this agent" autocomplete="off"${ended ? " disabled" : ""}>
|
|
1907
|
+
<button id="msgSend" data-sid="${s.id}" data-pid="${s.projectId}" title="Send (Enter)"${ended ? " disabled" : ""}>${ic("arrow-right", 12)}Send</button>
|
|
1908
|
+
</div>
|
|
1909
|
+
<p class="msg-hint">${hint}</p>`;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// The transcript file, as one copyable row: the directory truncates, the file name always shows.
|
|
1913
|
+
function transcriptRow(s) {
|
|
1914
|
+
if (!s.transcriptPath) return "";
|
|
1915
|
+
const p = short(s.transcriptPath);
|
|
1916
|
+
const cut = p.lastIndexOf("/");
|
|
1917
|
+
return `<h4>transcript</h4><button class="pathrow" data-copy="${esc(s.transcriptPath)}" title="Copy path · ${esc(p)}">${ic("file-text", 12)}<span class="dir">${esc(cut < 0 ? "" : p.slice(0, cut + 1))}</span><b>${esc(cut < 0 ? p : p.slice(cut + 1))}</b>${ic("copy", 12, "cp")}</button>`;
|
|
1276
1918
|
}
|
|
1277
1919
|
|
|
1278
1920
|
// M7.7: questions this session is waiting on a human for
|
|
@@ -1307,6 +1949,8 @@ async function sendStdin() {
|
|
|
1307
1949
|
}
|
|
1308
1950
|
document.addEventListener("click", (ev) => {
|
|
1309
1951
|
if (ev.target.closest("#stdinSend")) return sendStdin();
|
|
1952
|
+
const cp = ev.target.closest("[data-copy]");
|
|
1953
|
+
if (cp) { ev.preventDefault(); copy(cp.dataset.copy); cp.classList.add("copied"); setTimeout(() => cp.classList.remove("copied"), 1000); return; }
|
|
1310
1954
|
const qa = ev.target.closest("[data-qanswer]");
|
|
1311
1955
|
if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
|
|
1312
1956
|
const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
|
|
@@ -1329,9 +1973,9 @@ function renderSession() {
|
|
|
1329
1973
|
const t = s.tokens;
|
|
1330
1974
|
const ctx = t.input + t.cacheRead + t.cacheWrite;
|
|
1331
1975
|
const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
|
|
1332
|
-
const STAT_ICON = { cost: "coin", model: "robot", turns: "arrows-clockwise", "tool calls": "wrench", output: "chart-bar",
|
|
1976
|
+
const STAT_ICON = { cost: "coin", model: "robot", turns: "arrows-clockwise", "tool calls": "wrench", output: "chart-bar", processed: "rows", started: "clock", "last seen": "eye", "subagent turns": "tree-structure" };
|
|
1333
1977
|
const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
|
|
1334
|
-
const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span><a href="#" class="nav" id="replay" style="margin-left:auto" title="Step through this session's tool calls">${ic("play", 13)} Replay</a>${(state.worktrees[s.projectId] ?? []).some((w) => !w.main && (s.cwd === w.path || s.cwd.startsWith(`${w.path}/`))) ? `<a href="#" class="nav" id="sessDiff" title="What this session's worktree changed">${ic("folders", 13)} Diff</a>` : ""}${s.state === "ended" ? `<a href="#" class="nav" id="resumeDead" title="Spawn a run that picks up this session's task from its handoff + last actions">${ic("
|
|
1978
|
+
const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span><a href="#" class="nav" id="replay" style="margin-left:auto" title="Step through this session's tool calls">${ic("play", 13)} Replay</a>${(state.worktrees[s.projectId] ?? []).some((w) => !w.main && (s.cwd === w.path || s.cwd.startsWith(`${w.path}/`))) ? `<a href="#" class="nav" id="sessDiff" title="What this session's worktree changed">${ic("folders", 13)} Diff</a>` : ""}${s.state === "ended" ? `<a href="#" class="nav" id="resumeDead" title="Spawn a run that picks up this session's task from its handoff + last actions">${ic("arrows-clockwise", 13)} Resume where it died</a>` : ""}</h2>`;
|
|
1335
1979
|
const side = `<div class="stats">
|
|
1336
1980
|
${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
|
|
1337
1981
|
${stat("output", `${tok(t.output)}${t.thinking ? `<small> · ${tok(t.thinking)} thinking</small>` : ""}`)}${stat("processed", `${tok(ctx)}<small> · ${ctx ? ((100 * t.cacheRead) / ctx).toFixed(0) : 0}% cached</small>`)}
|
|
@@ -1343,12 +1987,24 @@ function renderSession() {
|
|
|
1343
1987
|
<h4>tools</h4>${tools.length ? viz.hbars(tools.slice(0, 8).map(([k, v]) => [k.replace(/^mcp__[a-z0-9-]+__/i, ""), v])) : '<span class="dim">None yet</span>'}
|
|
1344
1988
|
${messageThread(s)}
|
|
1345
1989
|
${questionCards(s)}
|
|
1346
|
-
${
|
|
1990
|
+
${transcriptRow(s)}`;
|
|
1347
1991
|
if (logEl && isAppend(rows)) {
|
|
1348
1992
|
// Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
|
|
1349
1993
|
// scroll position (and its DOM) untouched.
|
|
1350
1994
|
$("#main > h2").outerHTML = head;
|
|
1995
|
+
// The message compose box lives inside .side, and this fast-path runs on every event while the
|
|
1996
|
+
// agent works — carry the draft (and the caret) across the swap instead of wiping what is
|
|
1997
|
+
// being typed.
|
|
1998
|
+
const msg = $("#msgText");
|
|
1999
|
+
const draft = msg?.value ? { v: msg.value, focused: document.activeElement === msg, pos: msg.selectionStart } : null;
|
|
1351
2000
|
$("#main .side").innerHTML = side;
|
|
2001
|
+
if (draft) {
|
|
2002
|
+
const el = $("#msgText");
|
|
2003
|
+
if (el) {
|
|
2004
|
+
el.value = draft.v;
|
|
2005
|
+
if (draft.focused) { el.focus(); el.setSelectionRange(draft.pos, draft.pos); }
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
1352
2008
|
const sb = stdinBox(s); const cur = $("#main .stdin");
|
|
1353
2009
|
if (cur && cur.outerHTML !== sb && document.activeElement?.id !== "stdinText") cur.outerHTML = sb;
|
|
1354
2010
|
else if (!cur && sb) $("#main").insertAdjacentHTML("beforeend", sb);
|
|
@@ -1508,7 +2164,7 @@ function menuSpec(kind, d) {
|
|
|
1508
2164
|
if (!p) return null;
|
|
1509
2165
|
const green = p.checks !== "fail" && p.mergeable && !p.draft;
|
|
1510
2166
|
return { title: `#${p.number}`, items: [
|
|
1511
|
-
{ label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () =>
|
|
2167
|
+
{ label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => openExternal(p.url) },
|
|
1512
2168
|
{ label: "Copy URL", icon: "copy", run: () => copy(p.url) },
|
|
1513
2169
|
{ divider: true },
|
|
1514
2170
|
{ label: "Squash-merge", icon: "git-pull-request", disabled: !green, caption: green ? (p.forge === "gitlab" ? "glab" : "gh") : p.draft ? "draft" : p.checks === "fail" ? "checks failing" : "not mergeable", run: () => act.merge(p.projectId, p.number) },
|
|
@@ -1550,8 +2206,8 @@ function menuSpec(kind, d) {
|
|
|
1550
2206
|
{ divider: true },
|
|
1551
2207
|
{ label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
|
|
1552
2208
|
{ label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
|
|
1553
|
-
{ label: "Documentation", icon: "book-open", caption: "getswarm", run: () =>
|
|
1554
|
-
{ label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () =>
|
|
2209
|
+
{ label: "Documentation", icon: "book-open", caption: "getswarm", run: () => openExternal("https://getswarm.vercel.app/docs/") },
|
|
2210
|
+
{ label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => openExternal(feedbackUrl()) },
|
|
1555
2211
|
] };
|
|
1556
2212
|
}
|
|
1557
2213
|
return null;
|
|
@@ -1587,6 +2243,10 @@ ${p.reason ?? ""}`.slice(0, 180);
|
|
|
1587
2243
|
title = "An agent has a question";
|
|
1588
2244
|
body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
|
|
1589
2245
|
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
2246
|
+
} else if (ev.type === "session.stuck") {
|
|
2247
|
+
title = "Session looks stuck";
|
|
2248
|
+
body = (p.reason ?? p.summary ?? "").slice(0, 180);
|
|
2249
|
+
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
1590
2250
|
} else if (ev.type === "claim.orphaned") {
|
|
1591
2251
|
title = "Claim orphaned";
|
|
1592
2252
|
body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
|
|
@@ -1600,9 +2260,13 @@ ${p.reason ?? ""}`.slice(0, 180);
|
|
|
1600
2260
|
// What's New: release notes for the running version, from window.RELEASE_NOTES (release-notes.js).
|
|
1601
2261
|
// The desktop menu calls window.swarmWhatsNew; the settings menu calls whatsNew(); it also opens
|
|
1602
2262
|
// itself once after an upgrade (localStorage remembers the last version the user saw).
|
|
1603
|
-
|
|
2263
|
+
// `strict` matters: the automatic post-upgrade panel must never fall back. Falling back showed
|
|
2264
|
+
// 0.10.0's notes under a "What's New" triggered by upgrading to 0.11.0 — the notes bundle was a
|
|
2265
|
+
// stale cached copy that had no 0.11.0 in it, and the fallback quietly hid that.
|
|
2266
|
+
function releaseNotesFor(version, { strict = false } = {}) {
|
|
1604
2267
|
const all = window.RELEASE_NOTES || {};
|
|
1605
2268
|
if (version && all[version]) return { version, ...all[version] };
|
|
2269
|
+
if (strict) return null;
|
|
1606
2270
|
const latest = Object.keys(all)[0];
|
|
1607
2271
|
return latest ? { version: latest, ...all[latest] } : null;
|
|
1608
2272
|
}
|
|
@@ -1631,9 +2295,12 @@ function maybeUpdateNudge(h) {
|
|
|
1631
2295
|
<div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
|
|
1632
2296
|
document.body.appendChild(el);
|
|
1633
2297
|
el.addEventListener("click", async (e) => {
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
e.target.
|
|
2298
|
+
// closest(), not e.target.id: the button holds an <svg> icon, so a click on the glyph itself
|
|
2299
|
+
// targets the svg/path and an id check would miss it.
|
|
2300
|
+
const btn = e.target.closest?.("button");
|
|
2301
|
+
if (btn?.id === "updLater") return el.remove();
|
|
2302
|
+
if (btn?.id !== "updRestart") return;
|
|
2303
|
+
btn.textContent = "restarting…";
|
|
1637
2304
|
await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
|
|
1638
2305
|
const t0 = Date.now();
|
|
1639
2306
|
const wait = setInterval(async () => {
|
|
@@ -1650,7 +2317,7 @@ function maybeWhatsNew() {
|
|
|
1650
2317
|
let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
|
|
1651
2318
|
if (seen === state.version) return;
|
|
1652
2319
|
if (!seen) { try { localStorage.setItem("swarm.seenVersion", state.version); } catch {} return; }
|
|
1653
|
-
if (releaseNotesFor(state.version)) whatsNew(state.version);
|
|
2320
|
+
if (releaseNotesFor(state.version, { strict: true })) whatsNew(state.version);
|
|
1654
2321
|
}
|
|
1655
2322
|
|
|
1656
2323
|
// Star nudge: once a month at most, never on first open, dismissable for good. Pure localStorage —
|
|
@@ -1675,7 +2342,7 @@ function maybeStarNudge() {
|
|
|
1675
2342
|
el.addEventListener("click", (ev) => {
|
|
1676
2343
|
const t = ev.target.closest("[data-star]"); if (!t) return;
|
|
1677
2344
|
ev.preventDefault();
|
|
1678
|
-
if (t.dataset.star === "go") { starSave({ done: now });
|
|
2345
|
+
if (t.dataset.star === "go") { starSave({ done: now }); openExternal(REPO_URL); }
|
|
1679
2346
|
else if (t.dataset.star === "never") starSave({ never: now });
|
|
1680
2347
|
el.remove();
|
|
1681
2348
|
});
|
|
@@ -1697,6 +2364,11 @@ function openMenu(kind, anchor, d) {
|
|
|
1697
2364
|
const spec = menuSpec(kind, d);
|
|
1698
2365
|
if (!spec) return;
|
|
1699
2366
|
if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
|
|
2367
|
+
// Once the menu is up the pointer is over *it*, not the row, so a :hover-only kebab vanishes
|
|
2368
|
+
// under its own menu. Mark the row (and the kebab) until menus:openchange reports the close.
|
|
2369
|
+
if (anchor?.closest) {
|
|
2370
|
+
for (const el of [anchor.closest(".proj"), anchor.closest("tr"), anchor.closest(".more")]) el?.classList.add("menu-open");
|
|
2371
|
+
}
|
|
1700
2372
|
window.menus.open(anchor, spec);
|
|
1701
2373
|
}
|
|
1702
2374
|
document.addEventListener("keydown", (e) => {
|
|
@@ -1718,13 +2390,16 @@ document.addEventListener("contextmenu", (ev) => {
|
|
|
1718
2390
|
});
|
|
1719
2391
|
|
|
1720
2392
|
// ---------- events
|
|
2393
|
+
// Every id / data-attr a branch below matches on MUST be in this selector, or the branch is
|
|
2394
|
+
// unreachable (closest() returns null and the click dies silently) — that is how Replay,
|
|
2395
|
+
// Resume-where-it-died and the dry-run Re-run button all shipped dead.
|
|
1721
2396
|
document.addEventListener("click", async (ev) => {
|
|
1722
|
-
const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop],[data-wtopen],[data-wtrm],[data-wtdiff],[data-wtpr],[data-dffile],#prGo,#sessDiff,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
|
|
2397
|
+
const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-graphtab],[data-group],#abNew,[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop],[data-wtopen],[data-wtrm],[data-wtdiff],[data-wtpr],[data-dffile],#prGo,#sessDiff,#replay,#resumeDead,#drRun,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
|
|
1723
2398
|
if (!t) return;
|
|
1724
2399
|
if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
|
|
1725
2400
|
if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
|
|
1726
|
-
if (t.id === "feedback") { ev.preventDefault(); return
|
|
1727
|
-
if (t.dataset.view) { ev.preventDefault();
|
|
2401
|
+
if (t.id === "feedback") { ev.preventDefault(); return openExternal(feedbackUrl()); }
|
|
2402
|
+
if (t.dataset.view) { ev.preventDefault(); return showView(t.dataset.view); }
|
|
1728
2403
|
if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
|
|
1729
2404
|
if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
|
|
1730
2405
|
if (t.dataset.emoji !== undefined) { $("#psIcon").value = t.dataset.emoji; $("#psImage").value = ""; setIconPreview(t.dataset.emoji); for (const e of $$(".emoji")) e.classList.toggle("on", e.dataset.emoji === t.dataset.emoji); return; }
|
|
@@ -1805,6 +2480,31 @@ document.addEventListener("click", async (ev) => {
|
|
|
1805
2480
|
if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
|
|
1806
2481
|
if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
|
|
1807
2482
|
if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
|
|
2483
|
+
if (t.id === "abNew") {
|
|
2484
|
+
ev.preventDefault();
|
|
2485
|
+
if (!state.sel) return;
|
|
2486
|
+
const task = prompt("Task id to trial (each arm claims task#model, so each gets its own worktree):");
|
|
2487
|
+
if (!task) return;
|
|
2488
|
+
const models = prompt("Models to compare, comma separated:", "opus-5, sonnet-5");
|
|
2489
|
+
const arms = (models ?? "").split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ model: m, label: m }));
|
|
2490
|
+
if (arms.length < 2) { alert("A trial needs at least two models."); return; }
|
|
2491
|
+
const r = await fetch("/v1/ab", {
|
|
2492
|
+
method: "POST",
|
|
2493
|
+
headers: { "content-type": "application/json" },
|
|
2494
|
+
body: JSON.stringify({ projectId: state.sel, task: task.trim(), arms }),
|
|
2495
|
+
}).then((x) => x.json()).catch(() => null);
|
|
2496
|
+
if (!r) return alert("Could not reach the daemon.");
|
|
2497
|
+
if (r?.failed?.length) alert(`Started ${r.started.length}. Could not start: ${r.failed.map((f) => `${f.arm} — ${f.reason}`).join("; ")}`);
|
|
2498
|
+
return refresh();
|
|
2499
|
+
}
|
|
2500
|
+
if (t.dataset.group) {
|
|
2501
|
+
ev.preventDefault();
|
|
2502
|
+
const open = new Set(state.lineageOpen ?? []);
|
|
2503
|
+
open.has(t.dataset.group) ? open.delete(t.dataset.group) : open.add(t.dataset.group);
|
|
2504
|
+
state.lineageOpen = [...open];
|
|
2505
|
+
return refresh();
|
|
2506
|
+
}
|
|
2507
|
+
if (t.dataset.graphtab) { state.graphTab = t.dataset.graphtab; localStorage.setItem("swarm.graphTab", state.graphTab); return refresh(); }
|
|
1808
2508
|
if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
|
|
1809
2509
|
if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
|
|
1810
2510
|
if (t.dataset.ackall) {
|
|
@@ -1855,6 +2555,57 @@ $("#sbToggle")?.addEventListener("click", () => {
|
|
|
1855
2555
|
});
|
|
1856
2556
|
sbApply();
|
|
1857
2557
|
|
|
2558
|
+
// ---------- ⌘K palette (M9.1): jump to any view, project or session; falls through to Search.
|
|
2559
|
+
const pal = { items: [], view: [], q: "", i: 0 };
|
|
2560
|
+
function palBuild() {
|
|
2561
|
+
const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => showView(v.id) }));
|
|
2562
|
+
for (const p of state.projects) items.push({ icon: "folder-simple", label: p.name, grp: "project", run: () => { state.sel = p.id; localStorage.setItem("swarm.sel", p.id); state.session = null; state.dirty = true; refresh(); } });
|
|
2563
|
+
const pname = (id) => state.projects.find((p) => p.id === id)?.name ?? "";
|
|
2564
|
+
for (const s of state.sessions) items.push({ icon: "terminal-window", label: s.title || s.id.slice(0, 8), sub: pname(s.projectId), live: isLive(s), grp: "session", run: () => openSession(s.id) });
|
|
2565
|
+
return items;
|
|
2566
|
+
}
|
|
2567
|
+
function palFilter() {
|
|
2568
|
+
const q = pal.q.trim().toLowerCase();
|
|
2569
|
+
const rank = (x) => Math.min(...[x.label, x.sub ?? ""].map((t) => { const i = t.toLowerCase().indexOf(q); return i < 0 ? 1e9 : i; }));
|
|
2570
|
+
const out = q
|
|
2571
|
+
? pal.items.map((x) => ({ x, r: rank(x) })).filter((h) => h.r < 1e9).sort((a, b) => a.r - b.r).map((h) => h.x).slice(0, 12)
|
|
2572
|
+
: pal.items.filter((x) => x.grp !== "session" || x.live).slice(0, 16); // idle: every view + project + live sessions
|
|
2573
|
+
if (q) out.push({ icon: "magnifying-glass", label: `Search Swarm for “${pal.q.trim()}”`, grp: "search", run: () => { srch.q = pal.q.trim(); state.view = "search"; localStorage.setItem("swarm.view", "search"); state.session = null; state.dirty = true; runSearch(); refresh(); } });
|
|
2574
|
+
return out;
|
|
2575
|
+
}
|
|
2576
|
+
function palRender() {
|
|
2577
|
+
pal.view = palFilter();
|
|
2578
|
+
if (pal.i >= pal.view.length) pal.i = Math.max(0, pal.view.length - 1);
|
|
2579
|
+
const row = (x, i) => `<div class="pk-row pal-row ${i === pal.i ? "on" : ""}" data-pal="${i}">${ic(x.icon, 14)}<span class="nm">${esc(x.label)}${x.sub ? ` <span class="dim">· ${esc(x.sub)}</span>` : ""}</span><span class="grp">${x.grp}</span></div>`;
|
|
2580
|
+
const el = $("#palList");
|
|
2581
|
+
if (el) el.innerHTML = pal.view.map(row).join("") || '<div class="empty" style="padding:16px">No matches.</div>';
|
|
2582
|
+
}
|
|
2583
|
+
function palRun(i) {
|
|
2584
|
+
const x = pal.view[i];
|
|
2585
|
+
if (!x) return;
|
|
2586
|
+
closePicker();
|
|
2587
|
+
x.run();
|
|
2588
|
+
}
|
|
2589
|
+
function openPalette() {
|
|
2590
|
+
pal.items = palBuild(); pal.q = ""; pal.i = 0;
|
|
2591
|
+
$("#picker").innerHTML = `<div class="pk pal" role="dialog" aria-modal="true">
|
|
2592
|
+
<div class="pk-h">${ic("magnifying-glass", 15)}<input id="palQ" placeholder="Jump to view, project or session…" spellcheck="false" autocomplete="off"></div>
|
|
2593
|
+
<div class="pk-list" id="palList"></div>
|
|
2594
|
+
</div>`;
|
|
2595
|
+
palRender();
|
|
2596
|
+
const inp = $("#palQ");
|
|
2597
|
+
inp.focus();
|
|
2598
|
+
inp.addEventListener("input", () => { pal.q = inp.value; pal.i = 0; palRender(); });
|
|
2599
|
+
inp.addEventListener("keydown", (ev) => {
|
|
2600
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); pal.i = Math.max(0, Math.min(pal.view.length - 1, pal.i + (ev.key === "ArrowDown" ? 1 : -1))); palRender(); }
|
|
2601
|
+
else if (ev.key === "Enter") { ev.preventDefault(); palRun(pal.i); }
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
$("#palBtn")?.addEventListener("click", openPalette);
|
|
2605
|
+
document.addEventListener("keydown", (ev) => {
|
|
2606
|
+
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") { ev.preventDefault(); if ($("#palQ")) closePicker(); else openPalette(); }
|
|
2607
|
+
});
|
|
2608
|
+
|
|
1858
2609
|
// ---------- folder picker
|
|
1859
2610
|
const picker = { path: null };
|
|
1860
2611
|
// Run drawer (M3.3): prompt prefilled from the task row; submit = POST /v1/runs.
|
|
@@ -1903,21 +2654,49 @@ const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊"
|
|
|
1903
2654
|
// (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
|
|
1904
2655
|
const EMOJI_BLOCKS = [["Smileys & people", 0x1f600, 0x1f64f], ["Gestures & body", 0x1f440, 0x1f4ff], ["Animals & nature", 0x1f400, 0x1f43f], ["Food", 0x1f32d, 0x1f37f], ["Activity & travel", 0x1f680, 0x1f6ff], ["Objects", 0x1f4a0, 0x1f4ff], ["Symbols", 0x1f300, 0x1f32c], ["More", 0x1f900, 0x1f9ff], ["Extended", 0x1fa70, 0x1faff], ["Misc", 0x2600, 0x26ff], ["Dingbats", 0x2700, 0x27bf]];
|
|
1905
2656
|
let emojiGrid = null;
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2657
|
+
// Which code points the platform font actually draws in colour is a per-machine answer, so it is
|
|
2658
|
+
// probed once and remembered. Two things made that probe cost ~150ms of blocked main thread:
|
|
2659
|
+
// it called getImageData once per code point (1536 GPU->CPU readbacks), and the blocks overlap,
|
|
2660
|
+
// so 96 code points were probed — and rendered — twice. Now it is one readback per block over a
|
|
2661
|
+
// grid of glyphs, deduped, and the answer is cached across reloads.
|
|
2662
|
+
const EMOJI_CACHE_KEY = "swarm.emoji.v1";
|
|
2663
|
+
function detectEmoji(a, b) {
|
|
2664
|
+
const S = 20, COLS = 32, n = b - a + 1, rows = Math.ceil(n / COLS);
|
|
2665
|
+
const cv = document.createElement("canvas");
|
|
2666
|
+
cv.width = COLS * S; cv.height = rows * S;
|
|
1910
2667
|
const c = cv.getContext("2d", { willReadFrequently: true });
|
|
1911
2668
|
c.font = `${S - 4}px system-ui`; c.textBaseline = "top";
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2669
|
+
for (let i = 0; i < n; i++) c.fillText(String.fromCodePoint(a + i), (i % COLS) * S, ((i / COLS) | 0) * S);
|
|
2670
|
+
const d = c.getImageData(0, 0, cv.width, cv.height).data, W = cv.width, out = [];
|
|
2671
|
+
// A code point counts as an emoji the platform can draw if its cell paints coloured pixels.
|
|
2672
|
+
for (let i = 0; i < n; i++) {
|
|
2673
|
+
const x0 = (i % COLS) * S, y0 = ((i / COLS) | 0) * S;
|
|
2674
|
+
let ok = false;
|
|
2675
|
+
for (let y = y0; y < y0 + S && !ok; y++)
|
|
2676
|
+
for (let x = x0; x < x0 + S; x++) {
|
|
2677
|
+
const p = (y * W + x) * 4;
|
|
2678
|
+
if (d[p + 3] > 40 && (Math.abs(d[p] - d[p + 1]) > 24 || Math.abs(d[p + 1] - d[p + 2]) > 24)) { ok = true; break; }
|
|
2679
|
+
}
|
|
2680
|
+
if (ok) out.push(String.fromCodePoint(a + i));
|
|
2681
|
+
}
|
|
2682
|
+
return out;
|
|
2683
|
+
}
|
|
2684
|
+
function buildEmojiGrid() {
|
|
2685
|
+
if (emojiGrid) return emojiGrid;
|
|
2686
|
+
// The cache is keyed by the UA (a font change is what would invalidate it) plus the block list.
|
|
2687
|
+
const sig = `${navigator.userAgent}|${EMOJI_BLOCKS.map((x) => x.join(":")).join(",")}`;
|
|
2688
|
+
let blocks = null;
|
|
2689
|
+
try {
|
|
2690
|
+
const hit = JSON.parse(localStorage.getItem(EMOJI_CACHE_KEY) ?? "null");
|
|
2691
|
+
if (hit?.sig === sig) blocks = hit.blocks;
|
|
2692
|
+
} catch { /* corrupt or unavailable cache: probe again */ }
|
|
2693
|
+
if (!blocks) {
|
|
2694
|
+
const seen = new Set();
|
|
2695
|
+
blocks = EMOJI_BLOCKS.map(([, a, b]) => detectEmoji(a, b).filter((e) => !seen.has(e) && seen.add(e)));
|
|
2696
|
+
try { localStorage.setItem(EMOJI_CACHE_KEY, JSON.stringify({ sig, blocks })); } catch { /* private mode / quota */ }
|
|
2697
|
+
}
|
|
2698
|
+
emojiGrid = EMOJI_BLOCKS.map(([name], i) => {
|
|
2699
|
+
const list = blocks[i] ?? [];
|
|
1921
2700
|
return list.length ? `<div class="emoji-sec">${esc(name)}</div><div class="emoji-row">${list.map((e) => `<span class="emoji" data-emoji="${e}">${e}</span>`).join("")}</div>` : "";
|
|
1922
2701
|
}).join("");
|
|
1923
2702
|
return emojiGrid;
|
|
@@ -2071,7 +2850,7 @@ async function submitPr() {
|
|
|
2071
2850
|
closePicker();
|
|
2072
2851
|
state.prs = [];
|
|
2073
2852
|
await refresh();
|
|
2074
|
-
if (r.url)
|
|
2853
|
+
if (r.url) openExternal(r.url);
|
|
2075
2854
|
}
|
|
2076
2855
|
|
|
2077
2856
|
async function openPicker(focusPath = false) {
|
|
@@ -2100,6 +2879,8 @@ async function pickerGo(path) {
|
|
|
2100
2879
|
const closePicker = () => { $("#picker").innerHTML = ""; };
|
|
2101
2880
|
$("#picker").addEventListener("click", (ev) => {
|
|
2102
2881
|
if (ev.target.id === "picker" || ev.target.closest("#pkCancel")) return closePicker();
|
|
2882
|
+
const pr = ev.target.closest("[data-pal]");
|
|
2883
|
+
if (pr) return palRun(Number(pr.dataset.pal));
|
|
2103
2884
|
const go = ev.target.closest("[data-go]");
|
|
2104
2885
|
if (go) return void pickerGo(go.dataset.go);
|
|
2105
2886
|
const ctoml = ev.target.closest("[data-copy-toml]"), cles = ev.target.closest("[data-copy-lesson]");
|
|
@@ -2147,7 +2928,7 @@ function connect() {
|
|
|
2147
2928
|
if (fresh) notifyForEvent(ev);
|
|
2148
2929
|
pollSoon();
|
|
2149
2930
|
};
|
|
2150
|
-
for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "worktree.bootstrapped", "worktree.created", "worktree.removed", "pr.opened", "question.asked", "question.answered", "message.sent", "dispatch.queued", "dispatch.started", "dispatch.finished", "workflow.started", "workflow.step", "workflow.finished", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
|
|
2931
|
+
for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "worktree.bootstrapped", "worktree.created", "worktree.removed", "pr.opened", "question.asked", "question.answered", "message.sent", "dispatch.queued", "dispatch.started", "dispatch.finished", "workflow.started", "workflow.step", "workflow.finished", "permission.requested", "permission.resolved", "session.stuck"]) es.addEventListener(t, onAny);
|
|
2151
2932
|
}
|
|
2152
2933
|
refresh().then(() => {
|
|
2153
2934
|
const sid = new URLSearchParams(location.search).get("session");
|