@ra3orblade/swarm 0.6.0 → 0.8.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 +63 -10
- package/dist/swarm-hook.js +281 -1
- package/dist/swarm-mcp.js +140 -3
- package/dist/swarm.js +869 -36
- package/dist/swarmd.js +2821 -349
- package/package.json +1 -1
- package/web/app.js +601 -97
- package/web/index.html +73 -3
- package/web/menus.js +1 -1
- package/web/release-notes.js +1 -1
- package/web/table.js +1 -1
package/web/app.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
const $ = (s) => document.querySelector(s);
|
|
2
|
+
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
|
3
|
+
// M8.2b daemon token: `swarm ui` (and the desktop app) open the dashboard with ?token=…; it is kept
|
|
4
|
+
// in sessionStorage, stripped from the URL, and sent on every /v1 request. Loopback without a token
|
|
5
|
+
// still works while `[daemon] auth = "loopback-optional"`.
|
|
6
|
+
const TOKEN = (() => {
|
|
7
|
+
const q = new URLSearchParams(location.search);
|
|
8
|
+
const t = q.get("token");
|
|
9
|
+
if (t) { try { sessionStorage.setItem("swarm.token", t); } catch {} q.delete("token"); history.replaceState(null, "", `${location.pathname}${q.size ? `?${q}` : ""}${location.hash}`); return t; }
|
|
10
|
+
try { return sessionStorage.getItem("swarm.token"); } catch { return null; }
|
|
11
|
+
})();
|
|
12
|
+
if (TOKEN) {
|
|
13
|
+
const rawFetch = window.fetch.bind(window);
|
|
14
|
+
window.fetch = (input, init = {}) => {
|
|
15
|
+
const url = typeof input === "string" ? input : input.url;
|
|
16
|
+
if (!url.startsWith("/v1/")) return rawFetch(input, init);
|
|
17
|
+
const headers = new Headers(init.headers || {});
|
|
18
|
+
headers.set("authorization", `Bearer ${TOKEN}`);
|
|
19
|
+
return rawFetch(input, { ...init, headers });
|
|
20
|
+
};
|
|
21
|
+
}
|
|
2
22
|
// macOS desktop app signals its overlay title bar via ?chrome=inset (see src-tauri/lib.rs).
|
|
3
23
|
if (new URLSearchParams(location.search).get("chrome") === "inset") {
|
|
4
24
|
document.documentElement.classList.add("chrome-inset");
|
|
@@ -45,13 +65,19 @@ document.addEventListener("keydown", (ev) => {
|
|
|
45
65
|
window.swarmZoom(dir);
|
|
46
66
|
});
|
|
47
67
|
// `dirty`: a UI-side change (selection, view, filter) needs a render even when the daemon snapshot is unchanged.
|
|
48
|
-
const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, dirty: true };
|
|
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 };
|
|
49
69
|
|
|
50
70
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
51
71
|
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`; };
|
|
52
72
|
// p2 (zero-pad) is defined in viz.js, which loads first
|
|
53
73
|
const hhmm = (iso) => { const d = new Date(iso); return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`; };
|
|
54
|
-
|
|
74
|
+
/** The project's glyph: its emoji icon, or the folder icon, tinted with its color slot. */
|
|
75
|
+
const projGlyph = (p, size = 14) => p?.icon
|
|
76
|
+
? `<span class="pg ${p.color ? `pg-${p.color}` : ""}">${p.icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(p.icon)}" alt="">` : esc(p.icon)}</span>`
|
|
77
|
+
: `<span class="pg ${p?.color ? `pg-${p.color}` : ""}">${ic("folder-simple", size)}</span>`;
|
|
78
|
+
/** Project cell for tables: glyph + name. */
|
|
79
|
+
const projCell = (id) => { const p = state.projects.find((x) => x.id === id); return p ? `${projGlyph(p, 12)} ${esc(p.name)}` : esc(projName(id)); };
|
|
80
|
+
const projName = (id) => state.projects.find((p) => p.id === id)?.name ?? (id === "p_unknown" ? "?" : "(removed)");
|
|
55
81
|
const short = (p) => String(p ?? "").replace(/^\/Users\/[^/]+/, "~");
|
|
56
82
|
const tok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(0)}k` : String(n | 0));
|
|
57
83
|
const usd = (n) => (n == null ? '<span class="dim">—</span>' : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`);
|
|
@@ -114,7 +140,7 @@ const getTheme = () => localStorage.getItem("swarm.theme") ?? "system";
|
|
|
114
140
|
const setTheme = (t) => { localStorage.setItem("swarm.theme", t); if (t === "system") delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t; };
|
|
115
141
|
setTheme(getTheme());
|
|
116
142
|
const copy = (text) => navigator.clipboard?.writeText(String(text ?? ""));
|
|
117
|
-
const tail = (p, n =
|
|
143
|
+
const tail = (p, n = 16) => { const t = short(p); return t.length > n ? `…${t.slice(-(n - 1))}` : t; };
|
|
118
144
|
const agentLabel = (a) => viz.agentName(a);
|
|
119
145
|
const agentBadge = (a) => (a ? `<span class="badge agent" style="color:${viz.agentColor(a)};background:color-mix(in srgb,${viz.agentColor(a)} 14%,transparent)">${esc(agentLabel(a))}</span>` : "");
|
|
120
146
|
|
|
@@ -138,9 +164,12 @@ async function refresh() {
|
|
|
138
164
|
}
|
|
139
165
|
let attrChanged = false;
|
|
140
166
|
if (state.view === "spend" && state.sel && !state.session) {
|
|
141
|
-
const a = await
|
|
142
|
-
|
|
143
|
-
|
|
167
|
+
const [a, bd] = await Promise.all([
|
|
168
|
+
fetch(`/v1/attribution?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.attribution),
|
|
169
|
+
fetch(`/v1/budget?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.budget),
|
|
170
|
+
]);
|
|
171
|
+
attrChanged = JSON.stringify(a) !== JSON.stringify(state.attribution) || JSON.stringify(bd) !== JSON.stringify(state.budget);
|
|
172
|
+
state.attribution = a; state.budget = bd;
|
|
144
173
|
} else if (state.view === "spend" && !state.sel) {
|
|
145
174
|
if (state.attribution) attrChanged = true;
|
|
146
175
|
state.attribution = null;
|
|
@@ -154,12 +183,13 @@ async function refresh() {
|
|
|
154
183
|
}
|
|
155
184
|
let tasksChanged = false;
|
|
156
185
|
if (state.view === "board" && state.sel && !state.session) {
|
|
157
|
-
const [t, g] = await Promise.all([
|
|
186
|
+
const [t, g, d] = await Promise.all([
|
|
158
187
|
fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
|
|
159
188
|
fetch(`/v1/gates?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.gates),
|
|
189
|
+
fetch(`/v1/dispatch?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.dispatch),
|
|
160
190
|
]);
|
|
161
|
-
tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates);
|
|
162
|
-
state.tasks = t; state.gates = g;
|
|
191
|
+
tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch);
|
|
192
|
+
state.tasks = t; state.gates = g; state.dispatch = d;
|
|
163
193
|
}
|
|
164
194
|
let incChanged = false;
|
|
165
195
|
if (state.view === "incidents" && !state.session) {
|
|
@@ -185,6 +215,9 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
|
|
|
185
215
|
for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
|
|
186
216
|
}
|
|
187
217
|
function render() {
|
|
218
|
+
// A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
|
|
219
|
+
// hold the frame while one is open; the next poll or interaction paints it.
|
|
220
|
+
if (window.menus?.isOpen()) { state.dirty = true; return; }
|
|
188
221
|
// Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
|
|
189
222
|
const af = document.activeElement;
|
|
190
223
|
const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
|
|
@@ -239,7 +272,7 @@ function renderProjects() {
|
|
|
239
272
|
const row = (p) => {
|
|
240
273
|
const act = `<span class="act more" data-menu="project" data-pid="${p.id}" title="Project actions">${ic("dots-three", 15)}</span>`;
|
|
241
274
|
return `<div class="proj ${state.sel === p.id ? "sel" : ""}" data-id="${p.id}" data-ctx="project" data-pid="${p.id}" title="${esc(p.root)}"${p.discovered ? "" : ' draggable="true"'}>
|
|
242
|
-
<span class="st ${live(p.id) ? "live" : ""}"></span>${
|
|
275
|
+
<span class="st ${live(p.id) ? "live" : ""}"></span>${projGlyph(p)}<span class="nm">${disamb(p)}${esc(p.name)}</span><small>${live(p.id) || ""}</small>${act}</div>`;
|
|
243
276
|
};
|
|
244
277
|
const liveAll = live("");
|
|
245
278
|
$("#projects").innerHTML =
|
|
@@ -287,13 +320,17 @@ projectsEl.addEventListener("dragend", () => {
|
|
|
287
320
|
// ---------- fleet
|
|
288
321
|
// Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
|
|
289
322
|
const FLEET_COLS = [
|
|
290
|
-
{ key: "project", label: "project", width:
|
|
291
|
-
{ key: "agent", label: "agent", width:
|
|
292
|
-
{ key: "session", label: "session", width:
|
|
293
|
-
{ key: "branch", label: "branch", width:
|
|
294
|
-
{ key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) =>
|
|
295
|
-
|
|
296
|
-
|
|
323
|
+
{ key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
|
|
324
|
+
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
|
|
325
|
+
{ 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>' : ""}` },
|
|
326
|
+
{ key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
|
|
327
|
+
{ key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
|
|
328
|
+
const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
|
|
329
|
+
if (s.state === "ended") return line ? `<span class="now dim" title="${esc(line)}">${esc(line)}</span>` : '<span class="dim">ended</span>';
|
|
330
|
+
return `<span class="now" title="${esc(s.last)}">${esc(s.state === "waiting" && line ? line : s.last)}</span>`;
|
|
331
|
+
} },
|
|
332
|
+
{ key: "model", label: "model", width: 84, get: (s) => model(s.model), cell: (s) => `<span class="br">${esc(model(s.model))}${s.models > 1 ? ` <span class="faint">+${s.models - 1}</span>` : ""}</span>` },
|
|
333
|
+
{ key: "trend", label: "trend", width: 84, sortable: false, filterable: false, get: () => null, cell: (s) => viz.sparkline(s.spark.map((p) => p[0]), viz.agentColor(s.agent)) },
|
|
297
334
|
{ key: "out", label: "out", width: 66, num: true, get: (s) => s.tokens.output, cell: (s) => tok(s.tokens.output) },
|
|
298
335
|
{ key: "ctx", label: "ctx", width: 72, num: true, get: (s) => s.tokens.cacheRead + s.tokens.input + s.tokens.cacheWrite, cell: (s) => tok(s.tokens.cacheRead + s.tokens.input + s.tokens.cacheWrite) },
|
|
299
336
|
{ key: "cost", label: "cost", width: 64, num: true, get: (s) => s.costUsd ?? 0, cell: (s) => usd(s.costUsd) },
|
|
@@ -357,18 +394,45 @@ function renderPRs() {
|
|
|
357
394
|
columns: cols,
|
|
358
395
|
rows,
|
|
359
396
|
leading: { width: 24, cell: (p) => `<span class="s ${p.checks === "fail" ? "waiting" : p.checks === "pass" ? "active" : "idle"}"></span>` },
|
|
360
|
-
trailing: { width:
|
|
361
|
-
rowAttrs: () => ""
|
|
397
|
+
trailing: { width: 34, cell: (p) => more("pr", `data-pid="${esc(p.projectId)}" data-num="${p.number}"`) },
|
|
398
|
+
rowAttrs: (p) => `data-ctx="pr" data-pid="${esc(p.projectId)}" data-num="${p.number}"`,
|
|
362
399
|
rerender: touch,
|
|
363
400
|
})
|
|
364
401
|
: `<div class="empty">${PX.idle()}No open pull requests.<br>Agent branches land here the moment they're pushed.</div>`);
|
|
365
402
|
}
|
|
366
403
|
|
|
367
404
|
// ---------- board (coordination: claims, worktrees, incidents)
|
|
405
|
+
// Board representation toggles (cards vs table), persisted per section.
|
|
406
|
+
const boardMode = (k) => localStorage.getItem(`swarm.board.${k}`) ?? "cards";
|
|
407
|
+
const modeSeg = (k, a = "Cards", b = "Table") => `<span class="seg"><a href="#" data-bmode="${k}:cards" class="${boardMode(k) === "cards" ? "on" : ""}">${a}</a><a href="#" data-bmode="${k}:table" class="${boardMode(k) === "table" ? "on" : ""}">${b}</a></span>`;
|
|
408
|
+
|
|
409
|
+
// KPI strip: the board at a glance — what is live, held, dirty, failing, waiting.
|
|
410
|
+
function renderBoardKpis() {
|
|
411
|
+
const inSel = (pid) => !state.sel || pid === state.sel;
|
|
412
|
+
const live = state.sessions.filter((s) => inSel(s.projectId) && (s.state === "active" || s.state === "waiting"));
|
|
413
|
+
const waiting = live.filter((s) => s.state === "waiting").length;
|
|
414
|
+
const claims = (state.claims ?? []).filter((c) => c.state !== "released" && inSel(c.projectId));
|
|
415
|
+
const orphaned = claims.filter((c) => c.state === "orphaned").length;
|
|
416
|
+
const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
|
|
417
|
+
const dirty = wts.filter((w) => w.dirty > 0).length, merged = wts.filter((w) => !w.main && w.merged).length;
|
|
418
|
+
const inc = (state.incidents ?? []).filter((i) => inSel(i.projectId) && !i.acked).length;
|
|
419
|
+
const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
|
|
420
|
+
const ready = tasks ? tasks.filter((t) => t.ready).length : null;
|
|
421
|
+
const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
|
|
422
|
+
if (!live.length && !claims.length && !wts.length && !inc && !tasks) return "";
|
|
423
|
+
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>`;
|
|
424
|
+
return `<div class="kpis kpis-5">${
|
|
425
|
+
kpi("Live", live.length, waiting ? `${waiting} waiting on you` : live.length ? "sessions working" : "no sessions", waiting ? "hot" : "")
|
|
426
|
+
}${kpi("Held", claims.length, orphaned ? `${orphaned} orphaned` : claims.length ? "claims with a lease" : "nothing claimed", orphaned ? "hot" : "")
|
|
427
|
+
}${kpi("Worktrees", wts.length, dirty || merged ? `${dirty ? `${dirty} dirty` : ""}${dirty && merged ? " · " : ""}${merged ? `${merged} merged` : ""}` : "all clean", dirty ? "warm" : "")
|
|
428
|
+
}${tasks ? kpi("Ready", ready, gateFails ? `${gateFails} with failing gates` : `${tasks.filter((t) => t.status !== "done").length} open`, gateFails ? "hot" : "") : kpi("Projects", state.sel ? 1 : state.projects.length, "on the board")
|
|
429
|
+
}${kpi("Incidents", inc, inc ? "need a look" : "all acknowledged", inc ? "hot" : "")}</div>`;
|
|
430
|
+
}
|
|
431
|
+
|
|
368
432
|
function renderBoard() {
|
|
369
|
-
const parts = [renderTasks(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
|
|
433
|
+
const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
|
|
370
434
|
$("#main").innerHTML = parts.length
|
|
371
|
-
? parts.join("").replace(/^(<h2) class="mt-sec"/, "$1") // first section needs no top gap
|
|
435
|
+
? parts.join("").replace(/^(<div class="kpis[^>]*>[\s\S]*?<\/div><\/div>|)(<h2) class="mt-sec"/, "$1$2") // first section needs no top gap
|
|
372
436
|
: `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
|
|
373
437
|
}
|
|
374
438
|
|
|
@@ -377,19 +441,20 @@ function incidentColumns(full) {
|
|
|
377
441
|
const sess = (id) => state.sessions.find((s) => s.id === id);
|
|
378
442
|
return [
|
|
379
443
|
{ key: "ts", label: "when", width: 76, get: (i) => i.ts, cell: (i) => `<span class="dim" title="${esc(i.ts)}">${ago(i.ts)}</span>` },
|
|
380
|
-
{ key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) =>
|
|
444
|
+
{ key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) => projCell(i.projectId) },
|
|
381
445
|
{ key: "session", label: "session", width: 150, get: (i) => sess(i.sessionId)?.title ?? i.sessionId ?? "", cell: (i) => (i.sessionId ? `<a href="#" data-s="${i.sessionId}">${esc(sess(i.sessionId)?.title ?? i.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
|
|
382
446
|
{ key: "rule", label: "rule", width: 150, get: (i) => i.rule, cell: (i) => `<span class="br">${esc(i.rule ?? "")}</span>` },
|
|
383
447
|
{ key: "action", label: "action", width: 80, get: (i) => i.action, cell: (i) => (i.action === "deny" ? '<span class="badge warn">Denied</span>' : i.action === "orphaned" ? '<span class="badge warn">Orphaned</span>' : i.action === "failed" ? '<span class="badge warn">Failed</span>' : '<span class="badge acc">Asked</span>') },
|
|
384
|
-
{ key: "command", label: "command", flex: true, get: (i) => i.command, cell: (i) => `<span class="now" title="${esc(i.
|
|
448
|
+
{ key: "command", label: "command", flex: true, get: (i) => i.command, cell: (i) => `<span class="now" title="${esc(i.command ?? "")}${i.reason ? `\n\n${esc(i.reason)}` : ""}">${esc(cmdGist(i.command ?? ""))}</span>` },
|
|
385
449
|
...(full ? [
|
|
386
450
|
{ key: "reason", label: "reason", width: 260, get: (i) => i.reason ?? "", cell: (i) => `<span class="dim now" title="${esc(i.reason ?? "")}">${esc(i.reason ?? "")}</span>` },
|
|
387
451
|
{ key: "acked", label: "acked", width: 80, get: (i) => i.acked ?? "", cell: (i) => (i.acked ? `<span class="dim" title="${esc(i.acked)}">${ago(i.acked)}</span>` : '<span class="badge warn">Open</span>') },
|
|
388
452
|
] : []),
|
|
389
453
|
].filter((c) => !(c.key === "project" && state.sel) && !(c.key === "session" && !full));
|
|
390
454
|
}
|
|
455
|
+
/** The part of a shell command worth reading in a cell: drop a leading `cd <dir> &&` / `;`. */
|
|
456
|
+
const cmdGist = (c) => c.replace(/^\s*cd\s+\S+\s*(&&|;)\s*/, "").replace(/\s+/g, " ").trim() || c;
|
|
391
457
|
const incidentDot = (i) => `<span class="s ${i.acked ? "ended" : i.action === "deny" || i.action === "orphaned" || i.action === "failed" ? "waiting" : "idle"}"></span>`;
|
|
392
|
-
const ackLink = (i) => (i.acked ? "" : `<a href="#" data-ack="${i.seq}" title="Mark as seen">Ack</a>`);
|
|
393
458
|
|
|
394
459
|
function renderIncidents() {
|
|
395
460
|
const rows = (state.incidents ?? []).filter((i) => !state.sel || i.projectId === state.sel);
|
|
@@ -401,8 +466,8 @@ function renderIncidents() {
|
|
|
401
466
|
columns: incidentColumns(false),
|
|
402
467
|
rows,
|
|
403
468
|
leading: { width: 24, cell: incidentDot },
|
|
404
|
-
trailing: { width:
|
|
405
|
-
rowAttrs: (i) =>
|
|
469
|
+
trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
|
|
470
|
+
rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
|
|
406
471
|
rerender: touch,
|
|
407
472
|
});
|
|
408
473
|
}
|
|
@@ -482,8 +547,8 @@ function renderIncidentsView() {
|
|
|
482
547
|
columns: incidentColumns(true),
|
|
483
548
|
rows,
|
|
484
549
|
leading: { width: 24, cell: incidentDot },
|
|
485
|
-
trailing: { width:
|
|
486
|
-
rowAttrs: () => ""
|
|
550
|
+
trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
|
|
551
|
+
rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
|
|
487
552
|
rerender: touch,
|
|
488
553
|
})
|
|
489
554
|
: `<div class="empty">${PX.idle()}${state.incFilter === "open" ? "No open incidents." : "No incidents yet."}<br>Every <code>ask</code> or <code>deny</code> a rule makes lands here; ack it once you've seen it.</div>`);
|
|
@@ -496,7 +561,7 @@ function renderProcesses() {
|
|
|
496
561
|
const cols = [
|
|
497
562
|
{ key: "name", label: "process", width: 150, get: (r) => r.name, cell: (r) => `<b>${esc(r.name)}</b>` },
|
|
498
563
|
{ key: "kind", label: "kind", width: 80, get: (r) => r.kind, cell: (r) => `<span class="badge">${esc(r.kind)}</span>` },
|
|
499
|
-
{ key: "project", label: "project", width: 104, get: (r) => projName(r.projectId), cell: (r) =>
|
|
564
|
+
{ key: "project", label: "project", width: 104, get: (r) => projName(r.projectId), cell: (r) => projCell(r.projectId) },
|
|
500
565
|
{ key: "pid", label: "pid", width: 76, num: true, get: (r) => r.pid, cell: (r) => r.pid },
|
|
501
566
|
{ key: "port", label: "port", width: 70, num: true, get: (r) => r.port ?? 0, cell: (r) => (r.port != null ? `<a href="http://127.0.0.1:${r.port}/" target="_blank" rel="noopener">:${r.port}</a>` : '<span class="dim">—</span>') },
|
|
502
567
|
{ key: "owner", label: "owner", width: 110, get: (r) => r.owner, cell: (r) => esc(r.owner) },
|
|
@@ -509,8 +574,8 @@ function renderProcesses() {
|
|
|
509
574
|
columns: cols,
|
|
510
575
|
rows,
|
|
511
576
|
leading: { width: 24, cell: () => '<span class="s active"></span>' },
|
|
512
|
-
trailing: { width:
|
|
513
|
-
rowAttrs: () => ""
|
|
577
|
+
trailing: { width: 34, cell: (r) => more("process", `data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`) },
|
|
578
|
+
rowAttrs: (r) => `data-ctx="process" data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`,
|
|
514
579
|
rerender: touch,
|
|
515
580
|
});
|
|
516
581
|
}
|
|
@@ -533,7 +598,8 @@ function renderResources() {
|
|
|
533
598
|
columns: cols,
|
|
534
599
|
rows,
|
|
535
600
|
leading: { width: 24, cell: () => '<span class="s active"></span>' },
|
|
536
|
-
trailing: { width:
|
|
601
|
+
trailing: { width: 34, cell: (r) => more("resource", `data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`) },
|
|
602
|
+
rowAttrs: (r) => `data-ctx="resource" data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`,
|
|
537
603
|
rerender: touch,
|
|
538
604
|
});
|
|
539
605
|
}
|
|
@@ -596,16 +662,35 @@ function renderTasks() {
|
|
|
596
662
|
...(hasGates ? [{ key: "gates", label: "gates", width: 170, get: (t) => (t.gates ?? []).filter((g) => g.verdict === "pass").length, cell: (t) => gateChips(t.gates ?? []) }] : []),
|
|
597
663
|
];
|
|
598
664
|
const srcLabel = state.tasks.source === "github" ? "GitHub Issues" : state.tasks.source === "linear" ? "Linear" : state.tasks.source;
|
|
665
|
+
const lane = (t) => (t.claimedBy ? "held" : t.status === "done" ? "done" : t.ready ? "ready" : t.status === "active" ? "held" : "blocked");
|
|
666
|
+
const card = (t) => `<div class="tcard ${lane(t)}" tabindex="0" role="button" data-menu="task" data-ctx="task" data-task="${esc(t.id)}" title="${esc(t.statusText)}">
|
|
667
|
+
<div class="tc-h"><b>${esc(t.id)}</b>${t.claimedBy ? `<span class="badge ok">${esc(t.claimedBy)}</span>` : ""}${t.depends.length && lane(t) === "blocked" ? `<span class="dim">← ${esc(t.depends.join(" "))}</span>` : ""}</div>
|
|
668
|
+
<div class="tc-t">${esc(t.title)}</div>
|
|
669
|
+
${t.milestone ? `<div class="tc-m">${esc(t.milestone.split(" — ")[0])}</div>` : ""}
|
|
670
|
+
${(t.gates ?? []).some((g) => g.verdict) ? `<div class="tc-g">${gateChips(t.gates)}</div>` : ""}
|
|
671
|
+
</div>`;
|
|
672
|
+
const kanban = () => {
|
|
673
|
+
const lanes = [["ready", "Ready"], ["held", "In progress"], ["blocked", "Blocked"], ["done", "Done"]];
|
|
674
|
+
const by = Object.fromEntries(lanes.map(([k]) => [k, []]));
|
|
675
|
+
for (const t of all) by[lane(t)].push(t);
|
|
676
|
+
by.done.reverse();
|
|
677
|
+
const CAP = 6;
|
|
678
|
+
return `<div class="kanban">${lanes.map(([k, label]) => {
|
|
679
|
+
const list = by[k];
|
|
680
|
+
const shown = k === "done" ? list.slice(0, CAP) : list;
|
|
681
|
+
return `<div class="lane ${k}"><div class="lane-h">${label} <span>${list.length}</span></div>${shown.map(card).join("") || '<div class="lane-empty">—</div>'}${list.length > shown.length ? `<div class="lane-more dim">+${list.length - shown.length} more in the table</div>` : ""}</div>`;
|
|
682
|
+
}).join("")}</div>`;
|
|
683
|
+
};
|
|
599
684
|
return `<h2 class="mt-sec">Tasks <span>${ready.length} ready · ${all.length} in ${esc(srcLabel)}${state.tasks.error ? ` · <span class="badge warn" title="${esc(state.tasks.error)}">${ic("warning", 12)} ${esc(state.tasks.error)}</span>` : ""}</span></h2>` +
|
|
600
|
-
`<div class="chips">${chip("ready", "Ready", ready.length)
|
|
601
|
-
(rows.length
|
|
685
|
+
`<div class="chips">${boardMode("tasks") === "cards" ? "" : chip("ready", "Ready", ready.length) + chip("open", "Open", all.filter((t) => t.status !== "done").length) + chip("all", "All", all.length)}${ready.length ? `<span class="chip" id="dispatch" title="Claim a worktree per ready task and spawn a run in each, ${state.dispatch?.config?.max_parallel ?? 2} at a time">${ic("play", 12)} Dispatch</span>` : ""}<span class="grow"></span>${modeSeg("tasks")}</div>` +
|
|
686
|
+
(all.length && boardMode("tasks") === "cards" ? kanban() : rows.length
|
|
602
687
|
? dataTable({
|
|
603
688
|
id: "tasks",
|
|
604
689
|
columns: cols,
|
|
605
690
|
rows,
|
|
606
691
|
leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
|
|
607
|
-
trailing: { width:
|
|
608
|
-
rowAttrs: () => ""
|
|
692
|
+
trailing: { width: 34, cell: (t) => (t.ready || t.claimedBy ? more("task", `data-task="${esc(t.id)}"`) : "") },
|
|
693
|
+
rowAttrs: (t) => `data-ctx="task" data-task="${esc(t.id)}"`,
|
|
609
694
|
rerender: touch,
|
|
610
695
|
})
|
|
611
696
|
: `<div class="empty">${PX.idle()}${state.taskFilter === "ready" ? "Nothing ready — every open task is blocked or held." : "No tasks."}</div>`);
|
|
@@ -619,7 +704,7 @@ function renderClaims() {
|
|
|
619
704
|
const badge = (st) => st === "orphaned" ? '<span class="badge warn">Orphaned · holds work</span>' : st === "expired" ? '<span class="badge acc">Expired</span>' : '<span class="badge ok">Held</span>';
|
|
620
705
|
const orphans = rows.filter((c) => c.state === "orphaned").length;
|
|
621
706
|
const cols = [
|
|
622
|
-
{ key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) =>
|
|
707
|
+
{ key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) => projCell(c.projectId) },
|
|
623
708
|
{ key: "task", label: "task", width: 140, get: (c) => c.task, cell: (c) => `<b>${esc(c.task)}</b>` },
|
|
624
709
|
{ key: "owner", label: "owner", width: 120, get: (c) => c.owner || "", cell: (c) => esc(c.owner || "—") },
|
|
625
710
|
{ key: "lease", label: "lease", width: 130, get: (c) => (c.state === "held" ? new Date(c.expiresAt).getTime() : 0), cell: (c) => `<span class="dim">${c.state === "held" ? leaseLeft(c.expiresAt) : "—"}</span>` },
|
|
@@ -632,12 +717,8 @@ function renderClaims() {
|
|
|
632
717
|
columns: cols,
|
|
633
718
|
rows,
|
|
634
719
|
leading: { width: 24, cell: (c) => `<span class="s ${c.state === "orphaned" ? "waiting" : c.state === "expired" ? "idle" : "active"}"></span>` },
|
|
635
|
-
trailing: { width:
|
|
636
|
-
|
|
637
|
-
return c.state === "orphaned"
|
|
638
|
-
? `<a href="#" data-forcerelease="${key}" title="Discards the worktree AND its uncommitted work">Force release</a>`
|
|
639
|
-
: `<a href="#" data-release="${key}">Release</a>`;
|
|
640
|
-
} },
|
|
720
|
+
trailing: { width: 34, cell: (c) => more("claim", `data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`) },
|
|
721
|
+
rowAttrs: (c) => `data-ctx="claim" data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`,
|
|
641
722
|
rerender: touch,
|
|
642
723
|
});
|
|
643
724
|
}
|
|
@@ -656,24 +737,80 @@ function renderWorktrees() {
|
|
|
656
737
|
const inside = (w) => byPath.get(w.path);
|
|
657
738
|
const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
|
|
658
739
|
const cols = [
|
|
659
|
-
{ key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) =>
|
|
740
|
+
{ key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) => projCell(w.projectId) },
|
|
660
741
|
{ key: "branch", label: "branch", width: 240, get: (w) => w.branch ?? "", cell: (w) => `<span class="br">${esc(w.branch ?? "(detached)")}</span>${w.main ? ' <span class="badge">Main tree</span>' : ""}` },
|
|
661
742
|
{ key: "head", label: "head", width: 90, get: (w) => w.head, cell: (w) => `<span class="br">${esc(w.head)}</span>` },
|
|
662
743
|
{ key: "path", label: "path", flex: true, get: (w) => w.path, cell: (w) => `<span class="now" title="${esc(w.path)}">${esc(short(w.path))}</span>` },
|
|
663
744
|
{ key: "state", label: "state", width: 170, 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 ? '<span class="badge">Clean</span>' : ""}` },
|
|
745
|
+
{ key: "drift", label: "drift", width: 120, get: (w) => (w.main ? -1 : w.behind), cell: (w) => (w.main ? "" : w.merged ? '<span class="badge" title="This branch is already in the main checkout\'s branch">Merged</span>' : w.behind > 0 ? `<span class="badge warn" title="Commits on the main checkout\'s branch this worktree lacks">${w.behind} behind</span>` : w.behind === 0 ? '<span class="badge">Up to date</span>' : '<span class="dim">—</span>') },
|
|
664
746
|
{ key: "sessions", label: "sessions", width: 160, get: (w) => inside(w).length, cell: (w) => inside(w).map((x) => `<a href="#" data-s="${x.id}">${esc(x.title ?? x.id.slice(0, 8))}</a>`).join(", ") || '<span class="dim">—</span>' },
|
|
665
747
|
].filter((c) => !(c.key === "project" && state.sel));
|
|
666
|
-
|
|
748
|
+
const heldBy = new Map(state.claims ? state.claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]) : []);
|
|
749
|
+
const gcBtn = state.sel ? ` <a href="#" class="nav" id="wtgc" title="Find worktrees whose branch is merged or whose claim is gone">${ic("trash", 12)} Collect stale</a>` : "";
|
|
750
|
+
const newBtn = state.sel ? ` <a href="#" class="nav" id="wtnew" title="Create a task-less worktree (spike, review checkout)">${ic("plus", 12)} New worktree</a>` : "";
|
|
751
|
+
const stateOf = (w) => (inside(w).length ? "live" : w.dirty > 0 ? "dirty" : w.ahead > 0 ? "ahead" : w.merged ? "merged" : "clean");
|
|
752
|
+
const tile = (w) => `<div class="wt ${stateOf(w)}${w.main ? " main" : ""}${heldBy.has(w.path) ? " held" : ""}" tabindex="0" role="button" data-menu="worktree" data-ctx="worktree" data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}" title="${esc(w.path)}">
|
|
753
|
+
<div class="wt-b"><span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span><span class="br">${esc(w.branch ?? "(detached)")}</span></div>
|
|
754
|
+
<div class="wt-m">${w.main ? "main tree" : w.merged ? "merged" : w.behind > 0 ? `${w.behind} behind` : w.behind === 0 ? "up to date" : ""}${w.dirty ? ` · <i class="warn">${w.dirty} dirty</i>` : ""}${w.ahead > 0 ? ` · <i class="acc">${w.ahead} unpushed</i>` : ""}${heldBy.has(w.path) ? ` · held: ${esc(heldBy.get(w.path))}` : ""}${inside(w).length ? ` · ${inside(w).map((x) => esc(x.title ?? x.id.slice(0, 8))).join(", ")}` : ""}</div>
|
|
755
|
+
</div>`;
|
|
756
|
+
const map = () => {
|
|
757
|
+
const groups = new Map();
|
|
758
|
+
for (const w of rows) (groups.get(w.projectId) ?? groups.set(w.projectId, []).get(w.projectId)).push(w);
|
|
759
|
+
const order = { live: 0, dirty: 1, ahead: 2, clean: 3, merged: 4 };
|
|
760
|
+
return `<div class="wtmap">${[...groups].map(([pid, list]) => `<div class="wt-group"><div class="wt-proj">${projCell(pid)} <span>${list.length}</span></div><div class="wt-tiles">${list.sort((a, b) => (b.main - a.main) || order[stateOf(a)] - order[stateOf(b)]).map(tile).join("")}</div></div>`).join("")}</div>`;
|
|
761
|
+
};
|
|
762
|
+
return `<h2 class="mt-sec hrow">Worktrees <span>${rows.length}</span>${newBtn}${gcBtn}<span class="grow"></span>${modeSeg("worktrees", "Map", "Table")}</h2>` +
|
|
763
|
+
(boardMode("worktrees") === "cards" ? map() :
|
|
667
764
|
dataTable({
|
|
668
765
|
id: "worktrees",
|
|
669
766
|
columns: cols,
|
|
670
767
|
rows,
|
|
671
768
|
leading: { width: 24, cell: (w) => `<span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span>` },
|
|
672
|
-
trailing: { width: 34, cell: () => "" },
|
|
769
|
+
trailing: { width: 34, cell: (w) => more("worktree", `data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`) },
|
|
770
|
+
rowAttrs: (w) => `data-ctx="worktree" data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`,
|
|
771
|
+
rerender: touch,
|
|
772
|
+
}));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ---------- dispatch (M7.5)
|
|
776
|
+
function renderDispatch() {
|
|
777
|
+
const d = state.dispatch;
|
|
778
|
+
if (!state.sel || !d?.entries?.length) return "";
|
|
779
|
+
const rows = d.entries;
|
|
780
|
+
const oc = (e) => e.state === "queued" ? '<span class="badge">Queued</span>'
|
|
781
|
+
: e.state === "running" ? '<span class="badge acc">Running</span>'
|
|
782
|
+
: e.outcome === "done" ? '<span class="badge ok">Done</span>'
|
|
783
|
+
: e.outcome === "stopped" ? '<span class="badge">Stopped</span>'
|
|
784
|
+
: `<span class="badge warn">${esc(e.outcome ?? "?")}</span>`;
|
|
785
|
+
const cols = [
|
|
786
|
+
{ key: "task", label: "task", width: 90, get: (e) => e.task, cell: (e) => `<b>${esc(e.task)}</b>` },
|
|
787
|
+
{ key: "title", label: "title", flex: true, get: (e) => e.title, cell: (e) => esc(e.title) },
|
|
788
|
+
{ key: "state", label: "state", width: 110, get: (e) => (e.state === "running" ? 0 : e.state === "queued" ? 1 : 2), cell: oc },
|
|
789
|
+
{ key: "cost", label: "cost", width: 70, num: true, get: (e) => e.costUsd ?? -1, cell: (e) => (e.costUsd != null ? usd(e.costUsd) : '<span class="dim">—</span>') },
|
|
790
|
+
{ key: "detail", label: "detail", width: 360, get: (e) => e.detail ?? "", cell: (e) => `<span class="dim" title="${esc(e.detail ?? "")}">${esc(e.detail ?? "")}</span>` },
|
|
791
|
+
];
|
|
792
|
+
const running = rows.filter((e) => e.state === "running").length, queued = rows.filter((e) => e.state === "queued").length;
|
|
793
|
+
return `<h2 class="mt-sec hrow">Dispatch <span>${running} running · ${queued} queued · cap ${d.config?.max_parallel ?? 2}</span><a href="#" class="nav" id="dispatchClear" title="Drop queued tasks and clear finished rows (running ones keep going)">${ic("trash", 12)} Clear</a></h2>` +
|
|
794
|
+
dataTable({
|
|
795
|
+
id: "dispatch",
|
|
796
|
+
columns: cols,
|
|
797
|
+
rows,
|
|
798
|
+
leading: { width: 24, cell: (e) => `<span class="s ${e.state === "running" ? "active" : e.state === "queued" ? "waiting" : e.outcome === "done" ? "ended" : "waiting"}"></span>` },
|
|
799
|
+
trailing: { width: 90, cell: (e) => (e.sessionId ? `<a href="#" data-s="${esc(e.sessionId)}">session</a>` : "") },
|
|
673
800
|
rerender: touch,
|
|
674
801
|
});
|
|
675
802
|
}
|
|
676
803
|
|
|
804
|
+
// 0.7.0: the project's [budget] ceiling against what it spent
|
|
805
|
+
function budgetKpi(kpi) {
|
|
806
|
+
const b = state.sel ? state.budget : null;
|
|
807
|
+
if (!b?.status) return state.sel ? kpi("budget", "—", "no [budget] in .swarm.toml") : "";
|
|
808
|
+
const s = b.status;
|
|
809
|
+
const pct = Math.round(s.pct * 100);
|
|
810
|
+
const cls = s.level === "exceeded" ? "warn" : s.level === "warn" ? "acc" : "";
|
|
811
|
+
return kpi(`${s.kind} budget`, `<span class="${cls}">${pct}%</span>`, `${usd(s.spent)} of ${usd(s.limit)} · past it: ${b.config.on_exceed}`);
|
|
812
|
+
}
|
|
813
|
+
|
|
677
814
|
// ---------- spend
|
|
678
815
|
function renderSpend() {
|
|
679
816
|
const sp = state.spend;
|
|
@@ -723,7 +860,7 @@ function renderSpend() {
|
|
|
723
860
|
const hm = sp.hourly.filter(inSel).map((c) => ({ dow: c.dow, hour: c.hour, v: c.cost ?? 0 }));
|
|
724
861
|
$("#main").innerHTML =
|
|
725
862
|
`<h2>Spend <span>${state.sel ? esc(projName(state.sel)) : "all projects"}</span>${rangeChips}</h2>
|
|
726
|
-
<div class="kpis">${kpi("today", usd(todayCost), `${todayTurns} turns`)}${kpi(`${N}-day total`, usd(total14), `${activeDays} active day${activeDays === 1 ? "" : "s"}`)}${kpi("today vs avg", prevDays ? `${todayCost >= avg ? "+" : ""}${(((todayCost - avg) / avg) * 100).toFixed(0)}%` : "—", prevDays ? `vs ${usd(avg)} / active day` : "no earlier days to compare")}${kpi("agents", agents.length, agents.map(agentLabel).join(" · ") || "—")}</div>
|
|
863
|
+
<div class="kpis">${kpi("today", usd(todayCost), `${todayTurns} turns`)}${kpi(`${N}-day total`, usd(total14), `${activeDays} active day${activeDays === 1 ? "" : "s"}`)}${kpi("today vs avg", prevDays ? `${todayCost >= avg ? "+" : ""}${(((todayCost - avg) / avg) * 100).toFixed(0)}%` : "—", prevDays ? `vs ${usd(avg)} / active day` : "no earlier days to compare")}${kpi("agents", agents.length, agents.map(agentLabel).join(" · ") || "—")}${budgetKpi(kpi)}</div>
|
|
727
864
|
<div class="chart-card"><h3>Daily cost · last ${N} days <span>stacked by agent</span></h3>${viz.stackedColumns(days, series)}${agents.length > 1 ? viz.legend(agents) : ""}</div>
|
|
728
865
|
<div class="cols">
|
|
729
866
|
<div class="chart-card" style="margin:0"><h3>When the agents work <span>cost by weekday × hour · last 4 weeks · local time</span></h3>${viz.heatmap(hm)}</div>
|
|
@@ -756,7 +893,6 @@ function renderAttribution() {
|
|
|
756
893
|
{ key: "worktree", label: "worktree", flex: true, get: (t) => t.worktree, cell: (t) => `<span class="now dim" title="${esc(t.worktree)}">${esc(short(t.worktree))}</span>` },
|
|
757
894
|
],
|
|
758
895
|
rows: a.byTask,
|
|
759
|
-
leading: { width: 20, cell: () => "" },
|
|
760
896
|
trailing: { width: 8, cell: () => "" },
|
|
761
897
|
rerender: touch,
|
|
762
898
|
}));
|
|
@@ -773,7 +909,6 @@ function renderAttribution() {
|
|
|
773
909
|
{ key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns, cell: (r) => String(r.turns) },
|
|
774
910
|
],
|
|
775
911
|
rows: a.contextBudget,
|
|
776
|
-
leading: { width: 20, cell: () => "" },
|
|
777
912
|
trailing: { width: 8, cell: () => "" },
|
|
778
913
|
rerender: touch,
|
|
779
914
|
}));
|
|
@@ -826,6 +961,12 @@ async function runSearch() {
|
|
|
826
961
|
srch.hits = j.hits ?? [];
|
|
827
962
|
if (state.view === "search" && !state.session) renderSearch();
|
|
828
963
|
}
|
|
964
|
+
document.addEventListener("change", async (ev) => {
|
|
965
|
+
if (ev.target.id !== "psFile" || !ev.target.files?.[0]) return;
|
|
966
|
+
try { const d = await fileToIconDataUrl(ev.target.files[0]); $("#psImage").value = d; $("#psIcon").value = ""; setIconPreview(d); for (const e of $$(".emoji")) e.classList.remove("on"); }
|
|
967
|
+
catch (e) { alert(e.message); }
|
|
968
|
+
});
|
|
969
|
+
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()); } });
|
|
829
970
|
document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
|
|
830
971
|
function renderStats() {
|
|
831
972
|
const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
|
|
@@ -1044,6 +1185,19 @@ function replayGo(delta) {
|
|
|
1044
1185
|
}
|
|
1045
1186
|
|
|
1046
1187
|
// Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
|
|
1188
|
+
// M7.7: questions this session is waiting on a human for
|
|
1189
|
+
function questionCards(s) {
|
|
1190
|
+
const qs = (state.questions ?? []).filter((q) => q.sessionId === s.id);
|
|
1191
|
+
if (!qs.length) return "";
|
|
1192
|
+
return `<h4>waiting on you</h4>${qs.map((q) => `<div class="perm"><div class="perm-t">${ic("warning", 13)} <b>Question #${q.id}</b>${q.task ? `<span class="dim"> · ${esc(q.task)}</span>` : ""}</div><div class="perm-c">${esc(q.text)}</div><div class="perm-b">${(q.options ?? []).map((o) => `<button class="ok" data-qanswer="${q.id}" data-text="${esc(o)}">${esc(o)}</button>`).join("")}<button data-qanswer="${q.id}">Answer…</button></div></div>`).join("")}`;
|
|
1193
|
+
}
|
|
1194
|
+
async function answerQuestion(id, preset) {
|
|
1195
|
+
const text = preset ?? prompt(`Answer to question #${id}:`);
|
|
1196
|
+
if (!text) return;
|
|
1197
|
+
const r = await fetch(`/v1/questions/${id}/answer`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, by: "dashboard" }) }).then((x) => x.json());
|
|
1198
|
+
if (!r.ok) alert(r.error);
|
|
1199
|
+
return refresh();
|
|
1200
|
+
}
|
|
1047
1201
|
function stdinBox(s) {
|
|
1048
1202
|
if (s.kind !== "spawned") return "";
|
|
1049
1203
|
const run = (state.runs ?? []).find((r) => r.sessionId === s.id);
|
|
@@ -1063,6 +1217,8 @@ async function sendStdin() {
|
|
|
1063
1217
|
}
|
|
1064
1218
|
document.addEventListener("click", (ev) => {
|
|
1065
1219
|
if (ev.target.closest("#stdinSend")) return sendStdin();
|
|
1220
|
+
const qa = ev.target.closest("[data-qanswer]");
|
|
1221
|
+
if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
|
|
1066
1222
|
const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
|
|
1067
1223
|
const key = a?.dataset.permAllow || d?.dataset.permDeny;
|
|
1068
1224
|
if (key) {
|
|
@@ -1085,16 +1241,17 @@ function renderSession() {
|
|
|
1085
1241
|
const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
|
|
1086
1242
|
const STAT_ICON = { cost: "coin", model: "robot", turns: "arrows-clockwise", "tool calls": "wrench", output: "chart-bar", context: "rows", started: "clock", "last seen": "eye", "subagent turns": "tree-structure" };
|
|
1087
1243
|
const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
|
|
1088
|
-
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>${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("reload", 13)} Resume where it died</a>` : ""}</h2>`;
|
|
1244
|
+
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("reload", 13)} Resume where it died</a>` : ""}</h2>`;
|
|
1089
1245
|
const side = `<div class="stats">
|
|
1090
1246
|
${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
|
|
1091
|
-
${stat("output", `${tok(t.output)}${t.thinking ? `<small> · ${tok(t.thinking)} thinking</small>` : ""}`)}${stat("
|
|
1247
|
+
${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>`)}
|
|
1092
1248
|
${stat("started", `${ago(s.startedAt)} ago`)}${stat("last seen", `${ago(s.lastSeenAt)} ago`)}
|
|
1093
1249
|
${subTurns.length ? stat("subagent turns", subTurns.length) : ""}
|
|
1094
1250
|
</div>
|
|
1095
1251
|
<h4>tokens</h4>${viz.compositionBar([{ label: "cache read", v: t.cacheRead }, { label: "cache write", v: t.cacheWrite }, { label: "input", v: t.input }, { label: "thinking", v: t.thinking }, { label: "output", v: t.output }])}
|
|
1096
1252
|
${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
|
|
1097
1253
|
<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>'}
|
|
1254
|
+
${questionCards(s)}
|
|
1098
1255
|
${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
|
|
1099
1256
|
if (logEl && isAppend(rows)) {
|
|
1100
1257
|
// Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
|
|
@@ -1120,6 +1277,63 @@ function renderSession() {
|
|
|
1120
1277
|
// ---------- menus (fancy-menus island; see src/menus.tsx). Menus are plain data.
|
|
1121
1278
|
const pinProject = (id, pinned) => fetch(`/v1/projects/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ pinned }) }).then(refresh);
|
|
1122
1279
|
const removeProject = (id) => fetch(`/v1/projects/${id}`, { method: "DELETE" }).then(refresh);
|
|
1280
|
+
// ---------- row actions (shared by the row menus, right-click, and any remaining links)
|
|
1281
|
+
const post = (url, body) => fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((x) => x.json());
|
|
1282
|
+
const act = {
|
|
1283
|
+
async wtOpen(projectId, worktree) { const r = await post("/v1/worktrees/open", { projectId, worktree }); if (!r.ok) alert(r.error); },
|
|
1284
|
+
wtDiff(projectId, worktree) { openDiffDrawer(projectId, worktree); },
|
|
1285
|
+
wtPr(projectId, worktree) { openPrDrawer(projectId, worktree); },
|
|
1286
|
+
async wtRemove(projectId, worktree) {
|
|
1287
|
+
const rm = (force) => post("/v1/worktrees/remove", { projectId, worktree, force });
|
|
1288
|
+
if (!confirm(`Remove worktree ${short(worktree)}?`)) return;
|
|
1289
|
+
const r = await rm(false);
|
|
1290
|
+
if (!r.ok && (r.refused === "dirty" || r.refused === "unpushed")) {
|
|
1291
|
+
if (confirm(`${r.error}\n\nRemove anyway (discards the work)?`)) await rm(true);
|
|
1292
|
+
} else if (!r.ok) alert(r.error);
|
|
1293
|
+
state.worktrees[projectId] = null;
|
|
1294
|
+
refresh();
|
|
1295
|
+
},
|
|
1296
|
+
async claimTask(task) {
|
|
1297
|
+
const r = await post("/v1/claims", { projectId: state.sel, task, owner: "dashboard" });
|
|
1298
|
+
if (!r.ok) alert(r.error); else state.tasks = null;
|
|
1299
|
+
refresh();
|
|
1300
|
+
},
|
|
1301
|
+
runTask(task) { openRunDrawer(task); },
|
|
1302
|
+
async gateRun(task) {
|
|
1303
|
+
const r = await post("/v1/gates/run", { projectId: state.sel, task });
|
|
1304
|
+
if (!r.started?.length) alert(r.error ?? r.skipped?.[0]?.reason ?? "nothing ran");
|
|
1305
|
+
else alert(`${task}: ${r.runs.map((x) => `${x.verdict === "pass" ? "✓" : "✗"} ${x.gate} — ${x.rubric}`).join("\n")}${r.skipped.length ? `\n\nskipped: ${r.skipped.map((x) => `${x.gate} (${x.reason})`).join(", ")}` : ""}`);
|
|
1306
|
+
state.tasks = null;
|
|
1307
|
+
refresh();
|
|
1308
|
+
},
|
|
1309
|
+
async releaseClaim(projectId, task, force) {
|
|
1310
|
+
if (force && !confirm(`Force-release ${task}? This permanently discards its worktree and any uncommitted work.`)) return;
|
|
1311
|
+
const r = await post("/v1/claims/release", { projectId, task, force });
|
|
1312
|
+
if (!r.ok && confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) await post("/v1/claims/release", { projectId, task, force: true });
|
|
1313
|
+
refresh();
|
|
1314
|
+
},
|
|
1315
|
+
async merge(projectId, number) {
|
|
1316
|
+
if (!confirm(`Squash-merge #${number}?`)) return;
|
|
1317
|
+
const r = await post("/v1/prs/merge", { projectId, number: Number(number) });
|
|
1318
|
+
if (r.ok === false || r.error) alert(r.error);
|
|
1319
|
+
refresh();
|
|
1320
|
+
},
|
|
1321
|
+
async procStop(pid, projectId) {
|
|
1322
|
+
if (!confirm(`Stop pid ${pid}?`)) return;
|
|
1323
|
+
const r = await fetch(`/v1/processes/${pid}?project=${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
|
1324
|
+
if (!r.ok) alert((await r.json()).error);
|
|
1325
|
+
refresh();
|
|
1326
|
+
},
|
|
1327
|
+
resRelease(name, projectId) {
|
|
1328
|
+
const q = new URLSearchParams({ force: "1" }); if (projectId) q.set("project", projectId);
|
|
1329
|
+
return fetch(`/v1/resources/${encodeURIComponent(name)}?${q}`, { method: "DELETE" }).then(refresh);
|
|
1330
|
+
},
|
|
1331
|
+
ack(seq) { return fetch(`/v1/incidents/${seq}/ack`, { method: "POST" }).then(refresh); },
|
|
1332
|
+
codify(seq) { codifyIncident(seq); },
|
|
1333
|
+
};
|
|
1334
|
+
/** Hover kebab that opens the row menu `kind`; `attrs` are the data-* the menu needs. */
|
|
1335
|
+
const more = (kind, attrs, title = "Actions") => `<span class="more" tabindex="0" role="button" data-menu="${kind}" ${attrs} title="${title}">${ic("dots-three", 15)}</span>`;
|
|
1336
|
+
|
|
1123
1337
|
function menuSpec(kind, d) {
|
|
1124
1338
|
if (kind === "project") {
|
|
1125
1339
|
const p = state.projects.find((x) => x.id === d.pid);
|
|
@@ -1132,7 +1346,8 @@ function menuSpec(kind, d) {
|
|
|
1132
1346
|
{ label: "Stats", icon: "chart-bar", run: () => { state.sel = p.id; state.view = "stats"; state.session = null; touch(); } },
|
|
1133
1347
|
{ divider: true },
|
|
1134
1348
|
p.discovered ? { label: "Pin project", icon: "push-pin", run: () => pinProject(p.id, true) } : { label: "Unpin project", icon: "push-pin-slash", run: () => pinProject(p.id, false) },
|
|
1135
|
-
{ label: "
|
|
1349
|
+
{ label: "Settings…", icon: "sliders", caption: "name · icon · color", run: () => openProjectSettings(p.id) },
|
|
1350
|
+
{ label: "Copy path", icon: "copy", caption: tail(p.root, 16), run: () => copy(p.root) },
|
|
1136
1351
|
{ divider: true },
|
|
1137
1352
|
{ label: "Remove from Swarm", icon: "trash", danger: true, run: () => removeProject(p.id) },
|
|
1138
1353
|
] };
|
|
@@ -1146,9 +1361,91 @@ function menuSpec(kind, d) {
|
|
|
1146
1361
|
{ divider: true },
|
|
1147
1362
|
{ section: "Copy" },
|
|
1148
1363
|
{ label: "Session id", icon: "copy", caption: s.id.slice(0, 8), run: () => copy(s.id) },
|
|
1149
|
-
{ label: "Working directory", icon: "folder-simple", caption: tail(s.cwd,
|
|
1364
|
+
{ label: "Working directory", icon: "folder-simple", caption: tail(s.cwd, 16), run: () => copy(s.cwd) },
|
|
1150
1365
|
...(s.transcriptPath ? [{ label: "Transcript path", icon: "file-text", run: () => copy(s.transcriptPath) }] : []),
|
|
1151
|
-
...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch,
|
|
1366
|
+
...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch, 16), run: () => copy(s.branch) }] : []),
|
|
1367
|
+
] };
|
|
1368
|
+
}
|
|
1369
|
+
if (kind === "worktree") {
|
|
1370
|
+
const w = (state.worktrees[d.pid] ?? []).find((x) => x.path === d.path);
|
|
1371
|
+
if (!w) return null;
|
|
1372
|
+
const held = (state.claims ?? []).some((c) => c.state === "held" && c.worktree === w.path);
|
|
1373
|
+
const sess = state.sessions.filter((x) => x.state !== "ended" && (x.cwd === w.path || x.cwd.startsWith(`${w.path}/`)));
|
|
1374
|
+
return { title: w.branch ?? "(detached)", items: [
|
|
1375
|
+
{ label: "Open", icon: "arrow-square-out", caption: "editor", run: () => act.wtOpen(d.pid, w.path) },
|
|
1376
|
+
...(w.main ? [] : [{ label: "Diff", icon: "folders", caption: "vs main", run: () => act.wtDiff(d.pid, w.path) }]),
|
|
1377
|
+
...(w.branch && !w.merged && !w.main ? [{ label: "Open PR", icon: "git-pull-request", run: () => act.wtPr(d.pid, w.path) }] : []),
|
|
1378
|
+
...(sess.length ? [{ divider: true }, { section: "Sessions" }, ...sess.map((x) => ({ label: x.title ?? x.id.slice(0, 8), icon: "terminal-window", run: () => openSession(x.id) }))] : []),
|
|
1379
|
+
{ divider: true },
|
|
1380
|
+
{ label: "Copy path", icon: "copy", caption: tail(w.path, 14), run: () => copy(w.path) },
|
|
1381
|
+
...(w.branch ? [{ label: "Copy branch", icon: "git-branch", caption: tail(w.branch, 14), run: () => copy(w.branch) }] : []),
|
|
1382
|
+
...(w.main || held ? [] : [{ divider: true }, { label: "Remove", icon: "trash", danger: true, caption: w.dirty > 0 ? "dirty" : w.ahead > 0 ? "unpushed" : undefined, run: () => act.wtRemove(d.pid, w.path) }]),
|
|
1383
|
+
] };
|
|
1384
|
+
}
|
|
1385
|
+
if (kind === "task") {
|
|
1386
|
+
const t = (state.tasks?.tasks ?? []).find((x) => x.id === d.task);
|
|
1387
|
+
if (!t) return null;
|
|
1388
|
+
const exec = state.gates?.executable ?? [];
|
|
1389
|
+
return { title: t.id, items: [
|
|
1390
|
+
...(t.ready ? [
|
|
1391
|
+
{ label: "Run", icon: "play", caption: "claim + claude -p", run: () => act.runTask(t.id) },
|
|
1392
|
+
{ label: "Claim", icon: "folders", caption: "fresh worktree", run: () => act.claimTask(t.id) },
|
|
1393
|
+
] : t.claimedBy ? [
|
|
1394
|
+
{ label: "Run in worktree", icon: "play", run: () => act.runTask(t.id) },
|
|
1395
|
+
...(exec.length ? [{ label: "Run gates", icon: "check", caption: exec.join(", "), run: () => act.gateRun(t.id) }] : []),
|
|
1396
|
+
] : [{ label: t.status === "done" ? "Done" : "Blocked", disabled: true }]),
|
|
1397
|
+
{ divider: true },
|
|
1398
|
+
{ label: "Copy id", icon: "copy", caption: t.id, run: () => copy(t.id) },
|
|
1399
|
+
{ label: "Copy title", icon: "file-text", run: () => copy(`${t.id} — ${t.title}`) },
|
|
1400
|
+
] };
|
|
1401
|
+
}
|
|
1402
|
+
if (kind === "claim") {
|
|
1403
|
+
const c = (state.claims ?? []).find((x) => x.projectId === d.pid && x.task === d.task);
|
|
1404
|
+
if (!c) return null;
|
|
1405
|
+
const w = (state.worktrees[c.projectId] ?? []).find((x) => x.path === c.worktree);
|
|
1406
|
+
return { title: c.task, items: [
|
|
1407
|
+
...(w ? [{ label: "Open worktree", icon: "arrow-square-out", run: () => act.wtOpen(c.projectId, c.worktree) }, { label: "Diff", icon: "folders", run: () => act.wtDiff(c.projectId, c.worktree) }] : []),
|
|
1408
|
+
{ label: "Copy path", icon: "copy", caption: tail(c.worktree, 14), run: () => copy(c.worktree) },
|
|
1409
|
+
{ divider: true },
|
|
1410
|
+
c.state === "orphaned"
|
|
1411
|
+
? { label: "Force release", icon: "trash", danger: true, caption: "discards work", run: () => act.releaseClaim(c.projectId, c.task, true) }
|
|
1412
|
+
: { label: "Release claim", icon: "x", run: () => act.releaseClaim(c.projectId, c.task, false) },
|
|
1413
|
+
] };
|
|
1414
|
+
}
|
|
1415
|
+
if (kind === "pr") {
|
|
1416
|
+
const p = (state.prs ?? []).find((x) => String(x.projectId) === d.pid && String(x.number) === d.num);
|
|
1417
|
+
if (!p) return null;
|
|
1418
|
+
const green = p.checks !== "fail" && p.mergeable && !p.draft;
|
|
1419
|
+
return { title: `#${p.number}`, items: [
|
|
1420
|
+
{ label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => window.open(p.url, "_blank") },
|
|
1421
|
+
{ label: "Copy URL", icon: "copy", run: () => copy(p.url) },
|
|
1422
|
+
{ divider: true },
|
|
1423
|
+
{ 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) },
|
|
1424
|
+
] };
|
|
1425
|
+
}
|
|
1426
|
+
if (kind === "process") {
|
|
1427
|
+
return { items: [
|
|
1428
|
+
{ label: "Copy pid", icon: "copy", caption: d.pid, run: () => copy(d.pid) },
|
|
1429
|
+
...(d.cwd ? [{ label: "Copy cwd", icon: "folder-simple", caption: tail(d.cwd, 16), run: () => copy(d.cwd) }] : []),
|
|
1430
|
+
{ divider: true },
|
|
1431
|
+
{ label: "Stop", icon: "stop", danger: true, caption: "SIGTERM → SIGKILL", run: () => act.procStop(d.pid, d.proj) },
|
|
1432
|
+
] };
|
|
1433
|
+
}
|
|
1434
|
+
if (kind === "resource") {
|
|
1435
|
+
return { title: d.name, items: [
|
|
1436
|
+
{ label: "Copy name", icon: "copy", run: () => copy(d.name) },
|
|
1437
|
+
{ divider: true },
|
|
1438
|
+
{ label: "Release", icon: "x", danger: true, caption: "force", run: () => act.resRelease(d.name, d.proj) },
|
|
1439
|
+
] };
|
|
1440
|
+
}
|
|
1441
|
+
if (kind === "incident") {
|
|
1442
|
+
const i = [...(state.incidents ?? []), ...(state.allIncidents ?? [])].find((x) => String(x.seq) === d.seq);
|
|
1443
|
+
if (!i) return null;
|
|
1444
|
+
return { items: [
|
|
1445
|
+
...(i.sessionId ? [{ label: "Open session", icon: "terminal-window", run: () => openSession(i.sessionId) }] : []),
|
|
1446
|
+
...(i.suggestion ? [{ label: "Codify", icon: "shield", caption: "rule / lesson", run: () => act.codify(i.seq) }] : []),
|
|
1447
|
+
{ label: "Copy command", icon: "copy", run: () => copy(i.command ?? "") },
|
|
1448
|
+
...(i.acked ? [] : [{ divider: true }, { label: "Acknowledge", icon: "check", run: () => act.ack(i.seq) }]),
|
|
1152
1449
|
] };
|
|
1153
1450
|
}
|
|
1154
1451
|
if (kind === "settings") {
|
|
@@ -1160,7 +1457,7 @@ function menuSpec(kind, d) {
|
|
|
1160
1457
|
{ label: "Refresh pricing", icon: "arrows-clockwise", caption: "LiteLLM", run: async () => { const r = await fetch("/v1/pricing/refresh", { method: "POST" }); if (!r.ok) console.warn("pricing refresh failed", r.status); refresh(); } },
|
|
1161
1458
|
{ label: "Copy dashboard URL", icon: "copy", run: () => copy(location.origin) },
|
|
1162
1459
|
{ divider: true },
|
|
1163
|
-
{ label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "
|
|
1460
|
+
{ label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
|
|
1164
1461
|
{ label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
|
|
1165
1462
|
{ label: "Documentation", icon: "book-open", caption: "getswarm", run: () => window.open("https://getswarm.vercel.app/docs/", "_blank") },
|
|
1166
1463
|
{ label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => window.open(feedbackUrl(), "_blank") },
|
|
@@ -1185,7 +1482,7 @@ function disableNotifications() { try { localStorage.setItem(NOTIFY_KEY, "off");
|
|
|
1185
1482
|
let lastNotifyAt = 0;
|
|
1186
1483
|
function notifyForEvent(ev) {
|
|
1187
1484
|
if (!notifyOn() || !("Notification" in window) || Notification.permission !== "granted") return;
|
|
1188
|
-
if (!document.hidden && ev.type !== "permission.requested") return; // only
|
|
1485
|
+
if (!document.hidden && ev.type !== "permission.requested" && ev.type !== "question.asked") return; // only prompts that block an agent interrupt while you're looking
|
|
1189
1486
|
const now = Date.now();
|
|
1190
1487
|
if (now - lastNotifyAt < 1500) return; // don't stack
|
|
1191
1488
|
const p = ev.payload || {};
|
|
@@ -1195,6 +1492,10 @@ function notifyForEvent(ev) {
|
|
|
1195
1492
|
body = `${p.display ?? ""}
|
|
1196
1493
|
${p.reason ?? ""}`.slice(0, 180);
|
|
1197
1494
|
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
1495
|
+
} else if (ev.type === "question.asked") {
|
|
1496
|
+
title = "An agent has a question";
|
|
1497
|
+
body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
|
|
1498
|
+
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
1198
1499
|
} else if (ev.type === "claim.orphaned") {
|
|
1199
1500
|
title = "Claim orphaned";
|
|
1200
1501
|
body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
|
|
@@ -1280,6 +1581,14 @@ function openMenu(kind, anchor, d) {
|
|
|
1280
1581
|
if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
|
|
1281
1582
|
window.menus.open(anchor, spec);
|
|
1282
1583
|
}
|
|
1584
|
+
// Enter / Space on a focused card, tile or kebab opens its menu like a click.
|
|
1585
|
+
document.addEventListener("keydown", (ev) => {
|
|
1586
|
+
if (ev.key !== "Enter" && ev.key !== " ") return;
|
|
1587
|
+
const t = ev.target.closest?.("[data-menu]");
|
|
1588
|
+
if (!t || t.tagName === "INPUT") return;
|
|
1589
|
+
ev.preventDefault();
|
|
1590
|
+
openMenu(t.dataset.menu, t, t.dataset);
|
|
1591
|
+
});
|
|
1283
1592
|
document.addEventListener("contextmenu", (ev) => {
|
|
1284
1593
|
const t = ev.target.closest("[data-ctx]");
|
|
1285
1594
|
if (!t) return;
|
|
@@ -1289,7 +1598,7 @@ document.addEventListener("contextmenu", (ev) => {
|
|
|
1289
1598
|
|
|
1290
1599
|
// ---------- events
|
|
1291
1600
|
document.addEventListener("click", async (ev) => {
|
|
1292
|
-
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]");
|
|
1601
|
+
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-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#dispatch,#dispatchGo,#dispatchClear");
|
|
1293
1602
|
if (!t) return;
|
|
1294
1603
|
if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
|
|
1295
1604
|
if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
|
|
@@ -1297,27 +1606,68 @@ document.addEventListener("click", async (ev) => {
|
|
|
1297
1606
|
if (t.dataset.view) { ev.preventDefault(); state.view = t.dataset.view; localStorage.setItem("swarm.view", state.view); state.session = null; state.dirty = true; return refresh(); }
|
|
1298
1607
|
if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
|
|
1299
1608
|
if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
|
|
1609
|
+
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; }
|
|
1610
|
+
if (t.id === "psAllEmoji") { const all = $("#psEmojiAll"); if (all.hidden) { all.innerHTML = buildEmojiGrid(); all.hidden = false; } else all.hidden = true; return; }
|
|
1611
|
+
if (t.dataset.color !== undefined && t.classList.contains("swatch")) { for (const e of $$(".swatch")) e.classList.toggle("on", e === t); return; }
|
|
1612
|
+
if (t.id === "psSave") { ev.preventDefault(); return saveProjectSettings(t.dataset.pid); }
|
|
1613
|
+
if (t.dataset.bmode) { ev.preventDefault(); const [k, v] = t.dataset.bmode.split(":"); localStorage.setItem(`swarm.board.${k}`, v); return touch(); }
|
|
1300
1614
|
if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
|
|
1301
1615
|
if (t.dataset.runstop) {
|
|
1302
1616
|
ev.preventDefault();
|
|
1303
1617
|
if (!confirm("Stop this run? Its stdin is closed, then the process is signalled by pid.")) return;
|
|
1304
1618
|
return fetch(`/v1/runs/${encodeURIComponent(t.dataset.runstop)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
|
|
1305
1619
|
}
|
|
1306
|
-
if (t.dataset.claim) {
|
|
1620
|
+
if (t.dataset.claim) { ev.preventDefault(); return act.claimTask(t.dataset.claim); }
|
|
1621
|
+
const split = (v) => { const i = v.indexOf(":"); return [v.slice(0, i), v.slice(i + 1)]; };
|
|
1622
|
+
if (t.dataset.wtopen) { ev.preventDefault(); return act.wtOpen(...split(t.dataset.wtopen)); }
|
|
1623
|
+
if (t.dataset.wtdiff) { ev.preventDefault(); return act.wtDiff(...split(t.dataset.wtdiff)); }
|
|
1624
|
+
if (t.dataset.wtpr) { ev.preventDefault(); return act.wtPr(...split(t.dataset.wtpr)); }
|
|
1625
|
+
if (t.dataset.dffile !== undefined) { ev.preventDefault(); return loadDiffFile(t.dataset.dffile); }
|
|
1626
|
+
if (t.id === "prGo") { ev.preventDefault(); return submitPr(); }
|
|
1627
|
+
if (t.id === "sessDiff") {
|
|
1307
1628
|
ev.preventDefault();
|
|
1308
|
-
const
|
|
1309
|
-
if (!
|
|
1629
|
+
const s = state.sessions.find((x) => x.id === state.session);
|
|
1630
|
+
if (!s) return;
|
|
1631
|
+
const w = (state.worktrees[s.projectId] ?? []).find((x) => !x.main && (s.cwd === x.path || s.cwd.startsWith(`${x.path}/`)));
|
|
1632
|
+
return w ? openDiffDrawer(s.projectId, w.path) : null;
|
|
1633
|
+
}
|
|
1634
|
+
if (t.dataset.wtrm) { ev.preventDefault(); return act.wtRemove(...split(t.dataset.wtrm)); }
|
|
1635
|
+
if (t.id === "wtnew") {
|
|
1636
|
+
ev.preventDefault();
|
|
1637
|
+
const name = prompt("Worktree name (folder under ~/.swarm/worktrees/<project>/; branch wt/<name>):");
|
|
1638
|
+
if (!name) return;
|
|
1639
|
+
const r = await fetch("/v1/worktrees", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, name }) }).then((x) => x.json());
|
|
1640
|
+
if (!r.ok) alert(r.error);
|
|
1641
|
+
state.worktrees[state.sel] = null;
|
|
1642
|
+
return refresh();
|
|
1643
|
+
}
|
|
1644
|
+
if (t.id === "wtgc") {
|
|
1645
|
+
ev.preventDefault();
|
|
1646
|
+
const r = await fetch("/v1/worktrees/gc", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel }) }).then((x) => x.json());
|
|
1647
|
+
if (!r.candidates.length) return alert("Nothing to collect — no merged branches or released claims with a worktree left behind.");
|
|
1648
|
+
const lines = r.candidates.map((c) => `${c.removable ? "•" : "✗"} ${c.branch ?? "(detached)"} — ${c.why}${c.blocker ? ` (blocked: ${c.blocker})` : ""}`).join("\n");
|
|
1649
|
+
const n = r.candidates.filter((c) => c.removable).length;
|
|
1650
|
+
if (!n) return alert(`Stale worktrees, none removable without force:\n\n${lines}`);
|
|
1651
|
+
if (!confirm(`Stale worktrees:\n\n${lines}\n\nRemove the ${n} removable one${n === 1 ? "" : "s"}?`)) return;
|
|
1652
|
+
await fetch("/v1/worktrees/gc", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, apply: true }) });
|
|
1653
|
+
state.worktrees[state.sel] = null;
|
|
1310
1654
|
return refresh();
|
|
1311
1655
|
}
|
|
1656
|
+
if (t.id === "dispatch") { ev.preventDefault(); return openDispatchDrawer(); }
|
|
1657
|
+
if (t.id === "dispatchGo") { ev.preventDefault(); return submitDispatch(); }
|
|
1658
|
+
if (t.id === "dispatchClear") {
|
|
1659
|
+
ev.preventDefault();
|
|
1660
|
+
await fetch("/v1/dispatch", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel }) });
|
|
1661
|
+
state.dispatch = null;
|
|
1662
|
+
return refresh();
|
|
1663
|
+
}
|
|
1664
|
+
if (t.dataset.gaterun) { ev.preventDefault(); return act.gateRun(t.dataset.gaterun); }
|
|
1312
1665
|
if (t.dataset.codify) { ev.preventDefault(); return codifyIncident(t.dataset.codify); }
|
|
1313
1666
|
if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
|
|
1314
1667
|
if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
|
|
1315
1668
|
if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
|
|
1316
1669
|
if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
|
|
1317
|
-
if (t.dataset.ack) {
|
|
1318
|
-
ev.preventDefault(); ev.stopPropagation();
|
|
1319
|
-
return fetch(`/v1/incidents/${t.dataset.ack}/ack`, { method: "POST" }).then(refresh);
|
|
1320
|
-
}
|
|
1670
|
+
if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
|
|
1321
1671
|
if (t.dataset.ackall) {
|
|
1322
1672
|
return fetch("/v1/incidents/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project: state.sel || undefined }) }).then(refresh);
|
|
1323
1673
|
}
|
|
@@ -1325,37 +1675,13 @@ document.addEventListener("click", async (ev) => {
|
|
|
1325
1675
|
if (t.dataset.sdays) { ev.preventDefault(); state.statsDays = Number(t.dataset.sdays); return touch(); }
|
|
1326
1676
|
if (t.dataset.release || t.dataset.forcerelease) {
|
|
1327
1677
|
ev.preventDefault();
|
|
1328
|
-
const force = Boolean(t.dataset.forcerelease);
|
|
1329
1678
|
const [projectId, task] = (t.dataset.release || t.dataset.forcerelease).split(":");
|
|
1330
|
-
|
|
1331
|
-
const r = await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force }) }).then((x) => x.json());
|
|
1332
|
-
if (!r.ok) {
|
|
1333
|
-
if (confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) {
|
|
1334
|
-
await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force: true }) });
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
return refresh();
|
|
1679
|
+
return act.releaseClaim(projectId, task, Boolean(t.dataset.forcerelease));
|
|
1338
1680
|
}
|
|
1339
1681
|
if (t.dataset.agent !== undefined && t.classList.contains("chip")) { state.agentFilter = t.dataset.agent || null; return touch(); }
|
|
1340
|
-
if (t.dataset.merge !== undefined) {
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
if (!confirm(`Squash-merge #${number}?`)) return;
|
|
1344
|
-
return fetch("/v1/prs/merge", {
|
|
1345
|
-
method: "POST", headers: { "content-type": "application/json" },
|
|
1346
|
-
body: JSON.stringify({ projectId, number: Number(number) }),
|
|
1347
|
-
}).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
|
|
1348
|
-
}
|
|
1349
|
-
if (t.dataset.procstop) {
|
|
1350
|
-
ev.preventDefault();
|
|
1351
|
-
if (!confirm(`Stop pid ${t.dataset.procstop}?`)) return;
|
|
1352
|
-
return fetch(`/v1/processes/${t.dataset.procstop}?project=${encodeURIComponent(t.dataset.procproj)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
|
|
1353
|
-
}
|
|
1354
|
-
if (t.dataset.resrelease !== undefined) {
|
|
1355
|
-
ev.preventDefault();
|
|
1356
|
-
const q = new URLSearchParams({ force: "1" }); if (t.dataset.resproj) q.set("project", t.dataset.resproj);
|
|
1357
|
-
return fetch(`/v1/resources/${encodeURIComponent(t.dataset.resrelease)}?${q}`, { method: "DELETE" }).then(refresh);
|
|
1358
|
-
}
|
|
1682
|
+
if (t.dataset.merge !== undefined) { ev.preventDefault(); return act.merge(...t.dataset.merge.split(":")); }
|
|
1683
|
+
if (t.dataset.procstop) { ev.preventDefault(); return act.procStop(t.dataset.procstop, t.dataset.procproj); }
|
|
1684
|
+
if (t.dataset.resrelease !== undefined) { ev.preventDefault(); return act.resRelease(t.dataset.resrelease, t.dataset.resproj); }
|
|
1359
1685
|
if (t.id === "back") { ev.preventDefault(); state.session = null; return touch(); }
|
|
1360
1686
|
if (t.id === "replay") { ev.preventDefault(); return openReplay(); }
|
|
1361
1687
|
if (t.id === "resumeDead") { ev.preventDefault(); return resumeDead(); }
|
|
@@ -1410,6 +1736,7 @@ function openRunDrawer(taskId) {
|
|
|
1410
1736
|
<label>model<input id="rnModel" placeholder="default" value="${esc(last.model ?? "")}"></label>
|
|
1411
1737
|
<label>max turns<input id="rnTurns" type="number" min="1" placeholder="∞" value="${esc(last.turns ?? "")}"></label>
|
|
1412
1738
|
</div>
|
|
1739
|
+
<label>profile<select id="rnProfile" title="full: every tool · no-edits: commands but no file edits · read-only: read and search only">${["full", "no-edits", "read-only"].map((m) => opt(m, last.profile ?? "full")).join("")}</select></label>
|
|
1413
1740
|
<div class="dim" style="font-size:var(--fs-sm)">Claims <b>${esc(taskId)}</b> (or reuses your held worktree) and spawns <code>claude -p</code> there. The session appears in Fleet; steer it from its page.</div>
|
|
1414
1741
|
</div>
|
|
1415
1742
|
<div class="pk-f"><span class="grow"></span><button id="rnCancel">Cancel</button><button class="primary" id="rnGo" data-task="${esc(taskId)}">${ic("play", 13)} Run</button></div>
|
|
@@ -1419,11 +1746,11 @@ function openRunDrawer(taskId) {
|
|
|
1419
1746
|
async function submitRun(taskId) {
|
|
1420
1747
|
const prompt = $("#rnPrompt")?.value.trim();
|
|
1421
1748
|
if (!prompt) return alert("A prompt is required.");
|
|
1422
|
-
const mode = $("#rnMode")?.value, model = $("#rnModel")?.value.trim(), turns = $("#rnTurns")?.value;
|
|
1423
|
-
localStorage.setItem("swarm.runOpts", JSON.stringify({ mode, model, turns }));
|
|
1749
|
+
const mode = $("#rnMode")?.value, model = $("#rnModel")?.value.trim(), turns = $("#rnTurns")?.value, profile = $("#rnProfile")?.value;
|
|
1750
|
+
localStorage.setItem("swarm.runOpts", JSON.stringify({ mode, model, turns, profile }));
|
|
1424
1751
|
closePicker();
|
|
1425
1752
|
const r = await fetch("/v1/runs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({
|
|
1426
|
-
projectId: state.sel, task: taskId, prompt, owner: "dashboard", permissionMode: mode, model: model || undefined, maxTurns: turns ? Number(turns) : undefined,
|
|
1753
|
+
projectId: state.sel, task: taskId, prompt, owner: "dashboard", permissionMode: mode, model: model || undefined, maxTurns: turns ? Number(turns) : undefined, profile: profile && profile !== "full" ? profile : undefined,
|
|
1427
1754
|
}) }).then((x) => x.json());
|
|
1428
1755
|
if (!r.ok) return alert(r.error);
|
|
1429
1756
|
state.tasks = null;
|
|
@@ -1431,6 +1758,183 @@ async function submitRun(taskId) {
|
|
|
1431
1758
|
openSession(r.run.sessionId);
|
|
1432
1759
|
}
|
|
1433
1760
|
|
|
1761
|
+
// ---------- project settings drawer
|
|
1762
|
+
const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊", "🤖", "🧠", "🎨", "🔒", "📚", "💬", "🏗️", "🧩", "⚡"];
|
|
1763
|
+
// Every emoji the platform font can draw, by Unicode block — no names, but browseable; the OS picker
|
|
1764
|
+
// (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
|
|
1765
|
+
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]];
|
|
1766
|
+
let emojiGrid = null;
|
|
1767
|
+
function buildEmojiGrid() {
|
|
1768
|
+
if (emojiGrid) return emojiGrid;
|
|
1769
|
+
// A code point counts as an emoji the platform can draw if it paints colored pixels.
|
|
1770
|
+
const S = 20, cv = document.createElement("canvas"); cv.width = S; cv.height = S;
|
|
1771
|
+
const c = cv.getContext("2d", { willReadFrequently: true });
|
|
1772
|
+
c.font = `${S - 4}px system-ui`; c.textBaseline = "top";
|
|
1773
|
+
const colored = (ch) => {
|
|
1774
|
+
c.clearRect(0, 0, S, S); c.fillText(ch, 0, 0);
|
|
1775
|
+
const d = c.getImageData(0, 0, S, S).data;
|
|
1776
|
+
for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 40 && (Math.abs(d[i] - d[i + 1]) > 24 || Math.abs(d[i + 1] - d[i + 2]) > 24)) return true;
|
|
1777
|
+
return false;
|
|
1778
|
+
};
|
|
1779
|
+
emojiGrid = EMOJI_BLOCKS.map(([name, a, b]) => {
|
|
1780
|
+
const list = [];
|
|
1781
|
+
for (let cp = a; cp <= b; cp++) { const ch = String.fromCodePoint(cp); if (colored(ch)) list.push(ch); }
|
|
1782
|
+
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>` : "";
|
|
1783
|
+
}).join("");
|
|
1784
|
+
return emojiGrid;
|
|
1785
|
+
}
|
|
1786
|
+
function openProjectSettings(pid) {
|
|
1787
|
+
const p = state.projects.find((x) => x.id === pid);
|
|
1788
|
+
if (!p) return;
|
|
1789
|
+
const slots = ["", "c1", "c2", "c3", "c4", "c5", "c6", "c7"];
|
|
1790
|
+
$("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
|
|
1791
|
+
<div class="pk-h">${ic("sliders", 15)}<b>Project settings</b><span class="dim now" style="flex:1;margin-left:8px">${esc(p.root)}</span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
|
|
1792
|
+
<div class="pk-b">
|
|
1793
|
+
<label>name<input id="psName" value="${esc(p.name)}" maxlength="60" spellcheck="false"></label>
|
|
1794
|
+
<label>icon<div class="icon-row"><span class="pg pg-lg" id="psPreview">${p.icon ? (p.icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(p.icon)}" alt="">` : esc(p.icon)) : ic("folder-simple", 16)}</span><input id="psIcon" value="${esc(p.icon?.startsWith("data:image/") ? "" : (p.icon ?? ""))}" maxlength="4" placeholder="emoji or 1–2 letters · ${navigator.platform.startsWith("Mac") ? "⌃⌘Space" : "Win+."} opens the OS emoji picker" spellcheck="false" autocomplete="off"><label class="btn" title="PNG / JPEG / SVG / WebP — downsized to 64px and stored with the project">${ic("file-text", 13)} Image…<input type="file" id="psFile" accept="image/*" hidden></label></div></label>
|
|
1795
|
+
<input type="hidden" id="psImage" value="${esc(p.icon?.startsWith("data:image/") ? p.icon : "")}">
|
|
1796
|
+
<div class="emoji-row">${PROJECT_EMOJI.map((e) => `<span class="emoji ${p.icon === e ? "on" : ""}" data-emoji="${e}">${e}</span>`).join("")}<span class="emoji ${!p.icon ? "on" : ""}" data-emoji="" title="No icon">${ic("folder-simple", 14)}</span><span class="emoji more-emoji" id="psAllEmoji" title="Browse every emoji">…</span></div>
|
|
1797
|
+
<div class="emoji-all" id="psEmojiAll" hidden></div>
|
|
1798
|
+
<label>color</label>
|
|
1799
|
+
<div class="swatches">${slots.map((c) => `<span class="swatch ${c ? `pg-${c}` : "none"} ${(p.color ?? "") === c ? "on" : ""}" data-color="${c}" title="${c || "none"}"></span>`).join("")}</div>
|
|
1800
|
+
<label class="chk"><input type="checkbox" id="psPinned" ${p.discovered ? "" : "checked"}> pinned — always in the sidebar, drag to reorder</label>
|
|
1801
|
+
</div>
|
|
1802
|
+
<div class="pk-f"><span class="grow"></span><button id="pkCancel">Cancel</button><button class="primary" id="psSave" data-pid="${esc(p.id)}">Save</button></div>
|
|
1803
|
+
</div>`;
|
|
1804
|
+
$("#psName").focus();
|
|
1805
|
+
}
|
|
1806
|
+
/** Downsize an image file to a square 64px PNG data URL (center-cropped). */
|
|
1807
|
+
function fileToIconDataUrl(file) {
|
|
1808
|
+
return new Promise((resolve, reject) => {
|
|
1809
|
+
const url = URL.createObjectURL(file);
|
|
1810
|
+
const img = new Image();
|
|
1811
|
+
img.onload = () => {
|
|
1812
|
+
// square: center-crop the shorter side (cover), never letterbox
|
|
1813
|
+
const S = 64, cv = document.createElement("canvas"); cv.width = S; cv.height = S;
|
|
1814
|
+
const side = Math.min(img.width, img.height), sx = (img.width - side) / 2, sy = (img.height - side) / 2;
|
|
1815
|
+
cv.getContext("2d").drawImage(img, sx, sy, side, side, 0, 0, S, S);
|
|
1816
|
+
URL.revokeObjectURL(url);
|
|
1817
|
+
resolve(cv.toDataURL("image/png"));
|
|
1818
|
+
};
|
|
1819
|
+
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("not an image the browser can decode")); };
|
|
1820
|
+
img.src = url;
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
function setIconPreview(icon) {
|
|
1824
|
+
const el = $("#psPreview");
|
|
1825
|
+
if (!el) return;
|
|
1826
|
+
el.innerHTML = icon ? (icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(icon)}" alt="">` : esc(icon)) : ic("folder-simple", 16);
|
|
1827
|
+
}
|
|
1828
|
+
async function saveProjectSettings(pid) {
|
|
1829
|
+
const body = {
|
|
1830
|
+
name: $("#psName").value.trim() || undefined,
|
|
1831
|
+
icon: $("#psImage").value || $("#psIcon").value.trim(),
|
|
1832
|
+
color: $(".swatch.on")?.dataset.color ?? "",
|
|
1833
|
+
pinned: $("#psPinned").checked,
|
|
1834
|
+
};
|
|
1835
|
+
const r = await fetch(`/v1/projects/${encodeURIComponent(pid)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
1836
|
+
if (!r.ok) return alert((await r.json()).error ?? "could not save");
|
|
1837
|
+
closePicker();
|
|
1838
|
+
state.dirty = true;
|
|
1839
|
+
refresh();
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// ---------- dispatch drawer (M7.5)
|
|
1843
|
+
function openDispatchDrawer() {
|
|
1844
|
+
const ready = (state.tasks?.tasks ?? []).filter((t) => t.ready);
|
|
1845
|
+
const cfg = state.dispatch?.config ?? {};
|
|
1846
|
+
const last = (() => { try { return JSON.parse(localStorage.getItem("swarm.runOpts") || "{}"); } catch { return {}; } })();
|
|
1847
|
+
const opt = (v, cur) => `<option value="${v}" ${v === cur ? "selected" : ""}>${v || "default"}</option>`;
|
|
1848
|
+
$("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
|
|
1849
|
+
<div class="pk-h">${ic("play", 15)}<b>Dispatch</b><span class="dim now" style="flex:1;margin-left:8px">${ready.length} ready task${ready.length === 1 ? "" : "s"}</span></div>
|
|
1850
|
+
<div class="pk-b">
|
|
1851
|
+
<div class="df-files" style="max-height:30vh">${ready.map((t) => `<label style="display:flex;gap:8px;padding:4px 8px;align-items:center"><input type="checkbox" class="dpTask" value="${esc(t.id)}" checked style="width:auto"><b>${esc(t.id)}</b><span class="pa dim">${esc(t.title)}</span></label>`).join("")}</div>
|
|
1852
|
+
<div class="row">
|
|
1853
|
+
<label>at a time<input id="dpPar" type="number" min="1" max="16" value="${cfg.max_parallel ?? 2}"></label>
|
|
1854
|
+
<label>permission mode<select id="dpMode">${["acceptEdits", "auto", "plan", "dontAsk", "manual", "bypassPermissions"].map((m) => opt(m, cfg.permission_mode ?? last.mode ?? "acceptEdits")).join("")}</select></label>
|
|
1855
|
+
<label>max turns<input id="dpTurns" type="number" min="1" placeholder="∞" value="${esc(cfg.max_turns ?? last.turns ?? "")}"></label>
|
|
1856
|
+
</div>
|
|
1857
|
+
<label>profile<select id="dpProfile">${["full", "no-edits", "read-only"].map((m) => opt(m, cfg.profile ?? "full")).join("")}</select></label>
|
|
1858
|
+
<div class="dim" style="font-size:var(--fs-sm)">Each task gets its own claim + worktree and a <code>claude -p</code> run told to work there, run the gates, hand off and open a PR. The rest queue until a slot frees. Swarm derives the outcome from gates and PRs — a task is never flipped done by an agent.</div>
|
|
1859
|
+
</div>
|
|
1860
|
+
<div class="pk-f"><span class="grow"></span><button id="pkClose">Cancel</button><button class="primary" id="dispatchGo">${ic("play", 13)} Dispatch</button></div>
|
|
1861
|
+
</div>`;
|
|
1862
|
+
$("#pkClose")?.addEventListener("click", closePicker);
|
|
1863
|
+
}
|
|
1864
|
+
async function submitDispatch() {
|
|
1865
|
+
const tasks = [...document.querySelectorAll(".dpTask:checked")].map((i) => i.value);
|
|
1866
|
+
if (!tasks.length) return alert("Pick at least one task.");
|
|
1867
|
+
const prof = $("#dpProfile")?.value;
|
|
1868
|
+
const body = { projectId: state.sel, tasks, maxParallel: Number($("#dpPar")?.value) || undefined, permissionMode: $("#dpMode")?.value, maxTurns: Number($("#dpTurns")?.value) || undefined, profile: prof && prof !== "full" ? prof : undefined, owner: "dashboard" };
|
|
1869
|
+
closePicker();
|
|
1870
|
+
const r = await fetch("/v1/dispatch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((x) => x.json());
|
|
1871
|
+
if (!r.ok) return alert(r.error);
|
|
1872
|
+
if (r.rejected?.length) alert(`Not dispatched:\n${r.rejected.map((x) => `${x.id} — ${x.reason}`).join("\n")}`);
|
|
1873
|
+
state.tasks = null; state.dispatch = null;
|
|
1874
|
+
return refresh();
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// ---------- worktree diff + PR drawers (M7.3)
|
|
1878
|
+
const diffState = { projectId: null, worktree: null, base: null, files: [] };
|
|
1879
|
+
function colorPatch(patch) {
|
|
1880
|
+
return esc(patch).split("\n").map((l) => {
|
|
1881
|
+
const c = l.startsWith("+++") || l.startsWith("---") ? "m" : l.startsWith("@@") ? "h" : l.startsWith("+") ? "a" : l.startsWith("-") ? "d" : l.startsWith("diff ") ? "m" : "";
|
|
1882
|
+
return c ? `<span class="${c}">${l}</span>` : l;
|
|
1883
|
+
}).join("\n");
|
|
1884
|
+
}
|
|
1885
|
+
async function openDiffDrawer(projectId, worktree) {
|
|
1886
|
+
const q = new URLSearchParams({ project: projectId, worktree });
|
|
1887
|
+
const d = await fetch(`/v1/worktrees/diff?${q}`).then((x) => x.json());
|
|
1888
|
+
if (d.error) return alert(d.error);
|
|
1889
|
+
Object.assign(diffState, { projectId, worktree: d.worktree, base: d.base, files: d.files });
|
|
1890
|
+
const files = d.files.map((f) => `<a href="#" data-dffile="${esc(f.path)}"><span class="st">${esc(f.status)}</span><span class="pa" title="${esc(f.path)}">${esc(f.path)}</span>${f.added >= 0 ? `<span class="pl">+${f.added}</span><span class="mi">−${f.deleted}</span>` : '<span class="dim">bin</span>'}</a>`).join("");
|
|
1891
|
+
$("#picker").innerHTML = `<div class="pk wide" role="dialog" aria-modal="true">
|
|
1892
|
+
<div class="pk-h">${ic("folders", 15)}<b>Diff</b><span class="dim now" style="flex:1;margin-left:8px">${esc(short(d.worktree))} · vs ${esc(d.baseRef ?? "HEAD")} · ${d.commits.length} commit${d.commits.length === 1 ? "" : "s"} · ${d.files.length} file${d.files.length === 1 ? "" : "s"}${d.dirty ? ' · <span class="badge warn">dirty</span>' : ""}</span></div>
|
|
1893
|
+
<div class="pk-b">
|
|
1894
|
+
${d.commits.length ? `<div class="dim" style="font-size:var(--fs-sm)">${d.commits.slice(0, 8).map(esc).join("<br>")}${d.commits.length > 8 ? `<br>… ${d.commits.length - 8} more` : ""}</div>` : ""}
|
|
1895
|
+
${d.files.length ? `<div class="df-files">${files}</div><pre class="df-patch" id="dfPatch"><span class="m">select a file — or view everything below</span></pre>` : '<div class="empty">Nothing changed.</div>'}
|
|
1896
|
+
</div>
|
|
1897
|
+
<div class="pk-f">${d.files.length ? `<a href="#" class="nav" data-dffile="">${ic("folders", 12)} Whole diff</a>` : ""}<span class="grow"></span><button id="pkClose">Close</button></div>
|
|
1898
|
+
</div>`;
|
|
1899
|
+
$("#pkClose")?.addEventListener("click", closePicker);
|
|
1900
|
+
}
|
|
1901
|
+
async function loadDiffFile(file) {
|
|
1902
|
+
const q = new URLSearchParams({ project: diffState.projectId, worktree: diffState.worktree });
|
|
1903
|
+
if (file) q.set("file", file); else q.set("patch", "1");
|
|
1904
|
+
for (const a of document.querySelectorAll(".df-files a")) a.classList.toggle("on", a.dataset.dffile === file);
|
|
1905
|
+
const el = $("#dfPatch"); if (el) el.innerHTML = '<span class="m">loading…</span>';
|
|
1906
|
+
const d = await fetch(`/v1/worktrees/diff?${q}`).then((x) => x.json());
|
|
1907
|
+
if (el) el.innerHTML = d.patch ? colorPatch(d.patch) : '<span class="m">(empty)</span>';
|
|
1908
|
+
}
|
|
1909
|
+
async function openPrDrawer(projectId, worktree) {
|
|
1910
|
+
const q = new URLSearchParams({ project: projectId, worktree });
|
|
1911
|
+
const d = await fetch(`/v1/prs/draft?${q}`).then((x) => x.json());
|
|
1912
|
+
if (!d.ok) return alert(d.error);
|
|
1913
|
+
$("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
|
|
1914
|
+
<div class="pk-h">${ic("git-pull-request", 15)}<b>Open PR</b><span class="dim now" style="flex:1;margin-left:8px">${esc(d.task)} · ${esc(d.worktree.branch ?? "")}${d.diff.dirty ? ' · <span class="badge warn">uncommitted changes — commit first</span>' : ""}</span></div>
|
|
1915
|
+
<div class="pk-b">
|
|
1916
|
+
<label>title<input id="prTitle" value="${esc(d.title)}"></label>
|
|
1917
|
+
<label>body<textarea id="prBody" style="min-height:220px">${esc(d.body)}</textarea></label>
|
|
1918
|
+
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" id="prDraft" style="width:auto"> draft</label>
|
|
1919
|
+
<div class="dim" style="font-size:var(--fs-sm)">Pushes <code>${esc(d.worktree.branch ?? "")}</code> to origin and runs <code>gh pr create</code> / <code>glab mr create</code> with your local login. Swarm never commits for you.</div>
|
|
1920
|
+
</div>
|
|
1921
|
+
<div class="pk-f"><span class="grow"></span><button id="pkClose">Cancel</button><button class="primary" id="prGo" data-project="${esc(projectId)}" data-worktree="${esc(d.worktree.path)}" ${d.diff.dirty ? "disabled" : ""}>${ic("git-pull-request", 13)} Open PR</button></div>
|
|
1922
|
+
</div>`;
|
|
1923
|
+
$("#pkClose")?.addEventListener("click", closePicker);
|
|
1924
|
+
}
|
|
1925
|
+
async function submitPr() {
|
|
1926
|
+
const b = $("#prGo"); if (!b) return;
|
|
1927
|
+
const projectId = b.dataset.project, worktree = b.dataset.worktree;
|
|
1928
|
+
const title = $("#prTitle")?.value.trim(), body = $("#prBody")?.value, draft = $("#prDraft")?.checked;
|
|
1929
|
+
b.disabled = true; b.textContent = "opening…";
|
|
1930
|
+
const r = await fetch("/v1/prs/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, worktree, title, body, draft }) }).then((x) => x.json());
|
|
1931
|
+
if (!r.ok) { b.disabled = false; b.textContent = "Open PR"; return alert(r.error); }
|
|
1932
|
+
closePicker();
|
|
1933
|
+
state.prs = [];
|
|
1934
|
+
await refresh();
|
|
1935
|
+
if (r.url) window.open(r.url, "_blank");
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1434
1938
|
async function openPicker(focusPath = false) {
|
|
1435
1939
|
await pickerGo("");
|
|
1436
1940
|
if (focusPath) { const i = $("#pkPath"); if (i) { i.focus(); i.select(); } }
|
|
@@ -1485,7 +1989,7 @@ let pending = false;
|
|
|
1485
1989
|
const pollSoon = () => { if (!pending) { pending = true; setTimeout(() => { pending = false; poll(); }, 400); } };
|
|
1486
1990
|
let backoff = 1500;
|
|
1487
1991
|
function connect() {
|
|
1488
|
-
const es = new EventSource(`/v1/events?since=${state.seq}`);
|
|
1992
|
+
const es = new EventSource(`/v1/events?since=${state.seq}${TOKEN ? `&token=${TOKEN}` : ""}`);
|
|
1489
1993
|
const on = () => { backoff = 1500; $("#daemon .dot").classList.add("on"); };
|
|
1490
1994
|
es.addEventListener("open", on);
|
|
1491
1995
|
es.addEventListener("ping", on);
|
|
@@ -1504,7 +2008,7 @@ function connect() {
|
|
|
1504
2008
|
if (fresh) notifyForEvent(ev);
|
|
1505
2009
|
pollSoon();
|
|
1506
2010
|
};
|
|
1507
|
-
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", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
|
|
2011
|
+
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", "dispatch.queued", "dispatch.started", "dispatch.finished", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
|
|
1508
2012
|
}
|
|
1509
2013
|
refresh().then(() => {
|
|
1510
2014
|
const sid = new URLSearchParams(location.search).get("session");
|