@ra3orblade/swarm 0.7.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 +18 -2
- package/dist/swarm.js +430 -48
- package/dist/swarmd.js +956 -68
- package/package.json +1 -1
- package/web/app.js +388 -127
- package/web/index.html +63 -3
- package/web/menus.js +1 -1
- package/web/release-notes.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");
|
|
@@ -51,7 +71,13 @@ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (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
|
|
|
@@ -189,6 +215,9 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
|
|
|
189
215
|
for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
|
|
190
216
|
}
|
|
191
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; }
|
|
192
221
|
// Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
|
|
193
222
|
const af = document.activeElement;
|
|
194
223
|
const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
|
|
@@ -243,7 +272,7 @@ function renderProjects() {
|
|
|
243
272
|
const row = (p) => {
|
|
244
273
|
const act = `<span class="act more" data-menu="project" data-pid="${p.id}" title="Project actions">${ic("dots-three", 15)}</span>`;
|
|
245
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"'}>
|
|
246
|
-
<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>`;
|
|
247
276
|
};
|
|
248
277
|
const liveAll = live("");
|
|
249
278
|
$("#projects").innerHTML =
|
|
@@ -291,13 +320,17 @@ projectsEl.addEventListener("dragend", () => {
|
|
|
291
320
|
// ---------- fleet
|
|
292
321
|
// Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
|
|
293
322
|
const FLEET_COLS = [
|
|
294
|
-
{ key: "project", label: "project", width:
|
|
295
|
-
{ key: "agent", label: "agent", width:
|
|
296
|
-
{ key: "session", label: "session", width:
|
|
297
|
-
{ key: "branch", label: "branch", width:
|
|
298
|
-
{ key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) =>
|
|
299
|
-
|
|
300
|
-
|
|
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)) },
|
|
301
334
|
{ key: "out", label: "out", width: 66, num: true, get: (s) => s.tokens.output, cell: (s) => tok(s.tokens.output) },
|
|
302
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) },
|
|
303
336
|
{ key: "cost", label: "cost", width: 64, num: true, get: (s) => s.costUsd ?? 0, cell: (s) => usd(s.costUsd) },
|
|
@@ -361,18 +394,45 @@ function renderPRs() {
|
|
|
361
394
|
columns: cols,
|
|
362
395
|
rows,
|
|
363
396
|
leading: { width: 24, cell: (p) => `<span class="s ${p.checks === "fail" ? "waiting" : p.checks === "pass" ? "active" : "idle"}"></span>` },
|
|
364
|
-
trailing: { width:
|
|
365
|
-
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}"`,
|
|
366
399
|
rerender: touch,
|
|
367
400
|
})
|
|
368
401
|
: `<div class="empty">${PX.idle()}No open pull requests.<br>Agent branches land here the moment they're pushed.</div>`);
|
|
369
402
|
}
|
|
370
403
|
|
|
371
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
|
+
|
|
372
432
|
function renderBoard() {
|
|
373
|
-
const parts = [renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
|
|
433
|
+
const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
|
|
374
434
|
$("#main").innerHTML = parts.length
|
|
375
|
-
? 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
|
|
376
436
|
: `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
|
|
377
437
|
}
|
|
378
438
|
|
|
@@ -381,19 +441,20 @@ function incidentColumns(full) {
|
|
|
381
441
|
const sess = (id) => state.sessions.find((s) => s.id === id);
|
|
382
442
|
return [
|
|
383
443
|
{ key: "ts", label: "when", width: 76, get: (i) => i.ts, cell: (i) => `<span class="dim" title="${esc(i.ts)}">${ago(i.ts)}</span>` },
|
|
384
|
-
{ 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) },
|
|
385
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>') },
|
|
386
446
|
{ key: "rule", label: "rule", width: 150, get: (i) => i.rule, cell: (i) => `<span class="br">${esc(i.rule ?? "")}</span>` },
|
|
387
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>') },
|
|
388
|
-
{ 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>` },
|
|
389
449
|
...(full ? [
|
|
390
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>` },
|
|
391
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>') },
|
|
392
452
|
] : []),
|
|
393
453
|
].filter((c) => !(c.key === "project" && state.sel) && !(c.key === "session" && !full));
|
|
394
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;
|
|
395
457
|
const incidentDot = (i) => `<span class="s ${i.acked ? "ended" : i.action === "deny" || i.action === "orphaned" || i.action === "failed" ? "waiting" : "idle"}"></span>`;
|
|
396
|
-
const ackLink = (i) => (i.acked ? "" : `<a href="#" data-ack="${i.seq}" title="Mark as seen">Ack</a>`);
|
|
397
458
|
|
|
398
459
|
function renderIncidents() {
|
|
399
460
|
const rows = (state.incidents ?? []).filter((i) => !state.sel || i.projectId === state.sel);
|
|
@@ -405,8 +466,8 @@ function renderIncidents() {
|
|
|
405
466
|
columns: incidentColumns(false),
|
|
406
467
|
rows,
|
|
407
468
|
leading: { width: 24, cell: incidentDot },
|
|
408
|
-
trailing: { width:
|
|
409
|
-
rowAttrs: (i) =>
|
|
469
|
+
trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
|
|
470
|
+
rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
|
|
410
471
|
rerender: touch,
|
|
411
472
|
});
|
|
412
473
|
}
|
|
@@ -486,8 +547,8 @@ function renderIncidentsView() {
|
|
|
486
547
|
columns: incidentColumns(true),
|
|
487
548
|
rows,
|
|
488
549
|
leading: { width: 24, cell: incidentDot },
|
|
489
|
-
trailing: { width:
|
|
490
|
-
rowAttrs: () => ""
|
|
550
|
+
trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
|
|
551
|
+
rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
|
|
491
552
|
rerender: touch,
|
|
492
553
|
})
|
|
493
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>`);
|
|
@@ -500,7 +561,7 @@ function renderProcesses() {
|
|
|
500
561
|
const cols = [
|
|
501
562
|
{ key: "name", label: "process", width: 150, get: (r) => r.name, cell: (r) => `<b>${esc(r.name)}</b>` },
|
|
502
563
|
{ key: "kind", label: "kind", width: 80, get: (r) => r.kind, cell: (r) => `<span class="badge">${esc(r.kind)}</span>` },
|
|
503
|
-
{ 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) },
|
|
504
565
|
{ key: "pid", label: "pid", width: 76, num: true, get: (r) => r.pid, cell: (r) => r.pid },
|
|
505
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>') },
|
|
506
567
|
{ key: "owner", label: "owner", width: 110, get: (r) => r.owner, cell: (r) => esc(r.owner) },
|
|
@@ -513,8 +574,8 @@ function renderProcesses() {
|
|
|
513
574
|
columns: cols,
|
|
514
575
|
rows,
|
|
515
576
|
leading: { width: 24, cell: () => '<span class="s active"></span>' },
|
|
516
|
-
trailing: { width:
|
|
517
|
-
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 ?? "")}"`,
|
|
518
579
|
rerender: touch,
|
|
519
580
|
});
|
|
520
581
|
}
|
|
@@ -537,7 +598,8 @@ function renderResources() {
|
|
|
537
598
|
columns: cols,
|
|
538
599
|
rows,
|
|
539
600
|
leading: { width: 24, cell: () => '<span class="s active"></span>' },
|
|
540
|
-
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 ?? "")}"`,
|
|
541
603
|
rerender: touch,
|
|
542
604
|
});
|
|
543
605
|
}
|
|
@@ -600,16 +662,35 @@ function renderTasks() {
|
|
|
600
662
|
...(hasGates ? [{ key: "gates", label: "gates", width: 170, get: (t) => (t.gates ?? []).filter((g) => g.verdict === "pass").length, cell: (t) => gateChips(t.gates ?? []) }] : []),
|
|
601
663
|
];
|
|
602
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
|
+
};
|
|
603
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>` +
|
|
604
|
-
`<div class="chips">${chip("ready", "Ready", ready.length)
|
|
605
|
-
(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
|
|
606
687
|
? dataTable({
|
|
607
688
|
id: "tasks",
|
|
608
689
|
columns: cols,
|
|
609
690
|
rows,
|
|
610
691
|
leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
|
|
611
|
-
trailing: { width:
|
|
612
|
-
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)}"`,
|
|
613
694
|
rerender: touch,
|
|
614
695
|
})
|
|
615
696
|
: `<div class="empty">${PX.idle()}${state.taskFilter === "ready" ? "Nothing ready — every open task is blocked or held." : "No tasks."}</div>`);
|
|
@@ -623,7 +704,7 @@ function renderClaims() {
|
|
|
623
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>';
|
|
624
705
|
const orphans = rows.filter((c) => c.state === "orphaned").length;
|
|
625
706
|
const cols = [
|
|
626
|
-
{ 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) },
|
|
627
708
|
{ key: "task", label: "task", width: 140, get: (c) => c.task, cell: (c) => `<b>${esc(c.task)}</b>` },
|
|
628
709
|
{ key: "owner", label: "owner", width: 120, get: (c) => c.owner || "", cell: (c) => esc(c.owner || "—") },
|
|
629
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>` },
|
|
@@ -636,12 +717,8 @@ function renderClaims() {
|
|
|
636
717
|
columns: cols,
|
|
637
718
|
rows,
|
|
638
719
|
leading: { width: 24, cell: (c) => `<span class="s ${c.state === "orphaned" ? "waiting" : c.state === "expired" ? "idle" : "active"}"></span>` },
|
|
639
|
-
trailing: { width:
|
|
640
|
-
|
|
641
|
-
return c.state === "orphaned"
|
|
642
|
-
? `<a href="#" data-forcerelease="${key}" title="Discards the worktree AND its uncommitted work">Force release</a>`
|
|
643
|
-
: `<a href="#" data-release="${key}">Release</a>`;
|
|
644
|
-
} },
|
|
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)}"`,
|
|
645
722
|
rerender: touch,
|
|
646
723
|
});
|
|
647
724
|
}
|
|
@@ -660,7 +737,7 @@ function renderWorktrees() {
|
|
|
660
737
|
const inside = (w) => byPath.get(w.path);
|
|
661
738
|
const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
|
|
662
739
|
const cols = [
|
|
663
|
-
{ 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) },
|
|
664
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>' : ""}` },
|
|
665
742
|
{ key: "head", label: "head", width: 90, get: (w) => w.head, cell: (w) => `<span class="br">${esc(w.head)}</span>` },
|
|
666
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>` },
|
|
@@ -669,26 +746,30 @@ function renderWorktrees() {
|
|
|
669
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>' },
|
|
670
747
|
].filter((c) => !(c.key === "project" && state.sel));
|
|
671
748
|
const heldBy = new Map(state.claims ? state.claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]) : []);
|
|
672
|
-
const actions = (w) => {
|
|
673
|
-
const key = `${w.projectId}:${w.path}`;
|
|
674
|
-
const open = `<a href="#" data-wtopen="${esc(key)}" title="Open this worktree (editor / file manager; [worktree] open in .swarm.toml)">${ic("arrow-square-out", 12)} Open</a>`;
|
|
675
|
-
if (w.main) return open;
|
|
676
|
-
const diff = ` · <a href="#" data-wtdiff="${esc(key)}" title="What this worktree changed vs the main checkout's branch">${ic("folders", 12)} Diff</a>`;
|
|
677
|
-
const pr = w.branch && !w.merged ? ` · <a href="#" data-wtpr="${esc(key)}" title="Push the branch and open a PR prefilled from the task, handoff, gates and files">${ic("git-pull-request", 12)} PR</a>` : "";
|
|
678
|
-
if (heldBy.has(w.path)) return `${open}${diff}${pr}`;
|
|
679
|
-
return `${open}${diff}${pr} · <a href="#" data-wtrm="${esc(key)}" title="${w.dirty > 0 || w.ahead > 0 ? "Refuses while dirty / unpushed (you can force)" : "git worktree remove"}">${ic("trash", 12)} Remove</a>`;
|
|
680
|
-
};
|
|
681
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>` : "";
|
|
682
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>` : "";
|
|
683
|
-
|
|
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() :
|
|
684
764
|
dataTable({
|
|
685
765
|
id: "worktrees",
|
|
686
766
|
columns: cols,
|
|
687
767
|
rows,
|
|
688
768
|
leading: { width: 24, cell: (w) => `<span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span>` },
|
|
689
|
-
trailing: { width:
|
|
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)}"`,
|
|
690
771
|
rerender: touch,
|
|
691
|
-
});
|
|
772
|
+
}));
|
|
692
773
|
}
|
|
693
774
|
|
|
694
775
|
// ---------- dispatch (M7.5)
|
|
@@ -812,7 +893,6 @@ function renderAttribution() {
|
|
|
812
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>` },
|
|
813
894
|
],
|
|
814
895
|
rows: a.byTask,
|
|
815
|
-
leading: { width: 20, cell: () => "" },
|
|
816
896
|
trailing: { width: 8, cell: () => "" },
|
|
817
897
|
rerender: touch,
|
|
818
898
|
}));
|
|
@@ -829,7 +909,6 @@ function renderAttribution() {
|
|
|
829
909
|
{ key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns, cell: (r) => String(r.turns) },
|
|
830
910
|
],
|
|
831
911
|
rows: a.contextBudget,
|
|
832
|
-
leading: { width: 20, cell: () => "" },
|
|
833
912
|
trailing: { width: 8, cell: () => "" },
|
|
834
913
|
rerender: touch,
|
|
835
914
|
}));
|
|
@@ -882,6 +961,12 @@ async function runSearch() {
|
|
|
882
961
|
srch.hits = j.hits ?? [];
|
|
883
962
|
if (state.view === "search" && !state.session) renderSearch();
|
|
884
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()); } });
|
|
885
970
|
document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
|
|
886
971
|
function renderStats() {
|
|
887
972
|
const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
|
|
@@ -1159,7 +1244,7 @@ function renderSession() {
|
|
|
1159
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>`;
|
|
1160
1245
|
const side = `<div class="stats">
|
|
1161
1246
|
${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
|
|
1162
|
-
${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>`)}
|
|
1163
1248
|
${stat("started", `${ago(s.startedAt)} ago`)}${stat("last seen", `${ago(s.lastSeenAt)} ago`)}
|
|
1164
1249
|
${subTurns.length ? stat("subagent turns", subTurns.length) : ""}
|
|
1165
1250
|
</div>
|
|
@@ -1192,6 +1277,63 @@ function renderSession() {
|
|
|
1192
1277
|
// ---------- menus (fancy-menus island; see src/menus.tsx). Menus are plain data.
|
|
1193
1278
|
const pinProject = (id, pinned) => fetch(`/v1/projects/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ pinned }) }).then(refresh);
|
|
1194
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
|
+
|
|
1195
1337
|
function menuSpec(kind, d) {
|
|
1196
1338
|
if (kind === "project") {
|
|
1197
1339
|
const p = state.projects.find((x) => x.id === d.pid);
|
|
@@ -1204,7 +1346,8 @@ function menuSpec(kind, d) {
|
|
|
1204
1346
|
{ label: "Stats", icon: "chart-bar", run: () => { state.sel = p.id; state.view = "stats"; state.session = null; touch(); } },
|
|
1205
1347
|
{ divider: true },
|
|
1206
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) },
|
|
1207
|
-
{ 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) },
|
|
1208
1351
|
{ divider: true },
|
|
1209
1352
|
{ label: "Remove from Swarm", icon: "trash", danger: true, run: () => removeProject(p.id) },
|
|
1210
1353
|
] };
|
|
@@ -1218,9 +1361,91 @@ function menuSpec(kind, d) {
|
|
|
1218
1361
|
{ divider: true },
|
|
1219
1362
|
{ section: "Copy" },
|
|
1220
1363
|
{ label: "Session id", icon: "copy", caption: s.id.slice(0, 8), run: () => copy(s.id) },
|
|
1221
|
-
{ 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) },
|
|
1222
1365
|
...(s.transcriptPath ? [{ label: "Transcript path", icon: "file-text", run: () => copy(s.transcriptPath) }] : []),
|
|
1223
|
-
...(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) }]),
|
|
1224
1449
|
] };
|
|
1225
1450
|
}
|
|
1226
1451
|
if (kind === "settings") {
|
|
@@ -1356,6 +1581,14 @@ function openMenu(kind, anchor, d) {
|
|
|
1356
1581
|
if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
|
|
1357
1582
|
window.menus.open(anchor, spec);
|
|
1358
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
|
+
});
|
|
1359
1592
|
document.addEventListener("contextmenu", (ev) => {
|
|
1360
1593
|
const t = ev.target.closest("[data-ctx]");
|
|
1361
1594
|
if (!t) return;
|
|
@@ -1365,7 +1598,7 @@ document.addEventListener("contextmenu", (ev) => {
|
|
|
1365
1598
|
|
|
1366
1599
|
// ---------- events
|
|
1367
1600
|
document.addEventListener("click", async (ev) => {
|
|
1368
|
-
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],#dispatch,#dispatchGo,#dispatchClear");
|
|
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");
|
|
1369
1602
|
if (!t) return;
|
|
1370
1603
|
if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
|
|
1371
1604
|
if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
|
|
@@ -1373,27 +1606,22 @@ document.addEventListener("click", async (ev) => {
|
|
|
1373
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(); }
|
|
1374
1607
|
if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
|
|
1375
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(); }
|
|
1376
1614
|
if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
|
|
1377
1615
|
if (t.dataset.runstop) {
|
|
1378
1616
|
ev.preventDefault();
|
|
1379
1617
|
if (!confirm("Stop this run? Its stdin is closed, then the process is signalled by pid.")) return;
|
|
1380
1618
|
return fetch(`/v1/runs/${encodeURIComponent(t.dataset.runstop)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
|
|
1381
1619
|
}
|
|
1382
|
-
if (t.dataset.claim) {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
}
|
|
1388
|
-
if (t.dataset.wtopen) {
|
|
1389
|
-
ev.preventDefault();
|
|
1390
|
-
const i = t.dataset.wtopen.indexOf(":");
|
|
1391
|
-
const r = await fetch("/v1/worktrees/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: t.dataset.wtopen.slice(0, i), worktree: t.dataset.wtopen.slice(i + 1) }) }).then((x) => x.json());
|
|
1392
|
-
if (!r.ok) alert(r.error);
|
|
1393
|
-
return;
|
|
1394
|
-
}
|
|
1395
|
-
if (t.dataset.wtdiff) { ev.preventDefault(); const i = t.dataset.wtdiff.indexOf(":"); return openDiffDrawer(t.dataset.wtdiff.slice(0, i), t.dataset.wtdiff.slice(i + 1)); }
|
|
1396
|
-
if (t.dataset.wtpr) { ev.preventDefault(); const i = t.dataset.wtpr.indexOf(":"); return openPrDrawer(t.dataset.wtpr.slice(0, i), t.dataset.wtpr.slice(i + 1)); }
|
|
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)); }
|
|
1397
1625
|
if (t.dataset.dffile !== undefined) { ev.preventDefault(); return loadDiffFile(t.dataset.dffile); }
|
|
1398
1626
|
if (t.id === "prGo") { ev.preventDefault(); return submitPr(); }
|
|
1399
1627
|
if (t.id === "sessDiff") {
|
|
@@ -1403,19 +1631,7 @@ document.addEventListener("click", async (ev) => {
|
|
|
1403
1631
|
const w = (state.worktrees[s.projectId] ?? []).find((x) => !x.main && (s.cwd === x.path || s.cwd.startsWith(`${x.path}/`)));
|
|
1404
1632
|
return w ? openDiffDrawer(s.projectId, w.path) : null;
|
|
1405
1633
|
}
|
|
1406
|
-
if (t.dataset.wtrm) {
|
|
1407
|
-
ev.preventDefault();
|
|
1408
|
-
const i = t.dataset.wtrm.indexOf(":");
|
|
1409
|
-
const projectId = t.dataset.wtrm.slice(0, i), worktree = t.dataset.wtrm.slice(i + 1);
|
|
1410
|
-
const rm = (force) => fetch("/v1/worktrees/remove", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, worktree, force }) }).then((x) => x.json());
|
|
1411
|
-
if (!confirm(`Remove worktree ${short(worktree)}?`)) return;
|
|
1412
|
-
const r = await rm(false);
|
|
1413
|
-
if (!r.ok && (r.refused === "dirty" || r.refused === "unpushed")) {
|
|
1414
|
-
if (confirm(`${r.error}\n\nRemove anyway (discards the work)?`)) await rm(true);
|
|
1415
|
-
} else if (!r.ok) alert(r.error);
|
|
1416
|
-
state.worktrees[projectId] = null;
|
|
1417
|
-
return refresh();
|
|
1418
|
-
}
|
|
1634
|
+
if (t.dataset.wtrm) { ev.preventDefault(); return act.wtRemove(...split(t.dataset.wtrm)); }
|
|
1419
1635
|
if (t.id === "wtnew") {
|
|
1420
1636
|
ev.preventDefault();
|
|
1421
1637
|
const name = prompt("Worktree name (folder under ~/.swarm/worktrees/<project>/; branch wt/<name>):");
|
|
@@ -1445,25 +1661,13 @@ document.addEventListener("click", async (ev) => {
|
|
|
1445
1661
|
state.dispatch = null;
|
|
1446
1662
|
return refresh();
|
|
1447
1663
|
}
|
|
1448
|
-
if (t.dataset.gaterun) {
|
|
1449
|
-
ev.preventDefault();
|
|
1450
|
-
const task = t.dataset.gaterun;
|
|
1451
|
-
t.textContent = "running…";
|
|
1452
|
-
const r = await fetch("/v1/gates/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, task }) }).then((x) => x.json());
|
|
1453
|
-
if (!r.started?.length) alert(r.error ?? r.skipped?.[0]?.reason ?? "nothing ran");
|
|
1454
|
-
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(", ")}` : ""}`);
|
|
1455
|
-
state.tasks = null;
|
|
1456
|
-
return refresh();
|
|
1457
|
-
}
|
|
1664
|
+
if (t.dataset.gaterun) { ev.preventDefault(); return act.gateRun(t.dataset.gaterun); }
|
|
1458
1665
|
if (t.dataset.codify) { ev.preventDefault(); return codifyIncident(t.dataset.codify); }
|
|
1459
1666
|
if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
|
|
1460
1667
|
if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
|
|
1461
1668
|
if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
|
|
1462
1669
|
if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
|
|
1463
|
-
if (t.dataset.ack) {
|
|
1464
|
-
ev.preventDefault(); ev.stopPropagation();
|
|
1465
|
-
return fetch(`/v1/incidents/${t.dataset.ack}/ack`, { method: "POST" }).then(refresh);
|
|
1466
|
-
}
|
|
1670
|
+
if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
|
|
1467
1671
|
if (t.dataset.ackall) {
|
|
1468
1672
|
return fetch("/v1/incidents/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project: state.sel || undefined }) }).then(refresh);
|
|
1469
1673
|
}
|
|
@@ -1471,37 +1675,13 @@ document.addEventListener("click", async (ev) => {
|
|
|
1471
1675
|
if (t.dataset.sdays) { ev.preventDefault(); state.statsDays = Number(t.dataset.sdays); return touch(); }
|
|
1472
1676
|
if (t.dataset.release || t.dataset.forcerelease) {
|
|
1473
1677
|
ev.preventDefault();
|
|
1474
|
-
const force = Boolean(t.dataset.forcerelease);
|
|
1475
1678
|
const [projectId, task] = (t.dataset.release || t.dataset.forcerelease).split(":");
|
|
1476
|
-
|
|
1477
|
-
const r = await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force }) }).then((x) => x.json());
|
|
1478
|
-
if (!r.ok) {
|
|
1479
|
-
if (confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) {
|
|
1480
|
-
await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force: true }) });
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
return refresh();
|
|
1679
|
+
return act.releaseClaim(projectId, task, Boolean(t.dataset.forcerelease));
|
|
1484
1680
|
}
|
|
1485
1681
|
if (t.dataset.agent !== undefined && t.classList.contains("chip")) { state.agentFilter = t.dataset.agent || null; return touch(); }
|
|
1486
|
-
if (t.dataset.merge !== undefined) {
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
if (!confirm(`Squash-merge #${number}?`)) return;
|
|
1490
|
-
return fetch("/v1/prs/merge", {
|
|
1491
|
-
method: "POST", headers: { "content-type": "application/json" },
|
|
1492
|
-
body: JSON.stringify({ projectId, number: Number(number) }),
|
|
1493
|
-
}).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
|
|
1494
|
-
}
|
|
1495
|
-
if (t.dataset.procstop) {
|
|
1496
|
-
ev.preventDefault();
|
|
1497
|
-
if (!confirm(`Stop pid ${t.dataset.procstop}?`)) return;
|
|
1498
|
-
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(); });
|
|
1499
|
-
}
|
|
1500
|
-
if (t.dataset.resrelease !== undefined) {
|
|
1501
|
-
ev.preventDefault();
|
|
1502
|
-
const q = new URLSearchParams({ force: "1" }); if (t.dataset.resproj) q.set("project", t.dataset.resproj);
|
|
1503
|
-
return fetch(`/v1/resources/${encodeURIComponent(t.dataset.resrelease)}?${q}`, { method: "DELETE" }).then(refresh);
|
|
1504
|
-
}
|
|
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); }
|
|
1505
1685
|
if (t.id === "back") { ev.preventDefault(); state.session = null; return touch(); }
|
|
1506
1686
|
if (t.id === "replay") { ev.preventDefault(); return openReplay(); }
|
|
1507
1687
|
if (t.id === "resumeDead") { ev.preventDefault(); return resumeDead(); }
|
|
@@ -1578,6 +1758,87 @@ async function submitRun(taskId) {
|
|
|
1578
1758
|
openSession(r.run.sessionId);
|
|
1579
1759
|
}
|
|
1580
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
|
+
|
|
1581
1842
|
// ---------- dispatch drawer (M7.5)
|
|
1582
1843
|
function openDispatchDrawer() {
|
|
1583
1844
|
const ready = (state.tasks?.tasks ?? []).filter((t) => t.ready);
|
|
@@ -1728,7 +1989,7 @@ let pending = false;
|
|
|
1728
1989
|
const pollSoon = () => { if (!pending) { pending = true; setTimeout(() => { pending = false; poll(); }, 400); } };
|
|
1729
1990
|
let backoff = 1500;
|
|
1730
1991
|
function connect() {
|
|
1731
|
-
const es = new EventSource(`/v1/events?since=${state.seq}`);
|
|
1992
|
+
const es = new EventSource(`/v1/events?since=${state.seq}${TOKEN ? `&token=${TOKEN}` : ""}`);
|
|
1732
1993
|
const on = () => { backoff = 1500; $("#daemon .dot").classList.add("on"); };
|
|
1733
1994
|
es.addEventListener("open", on);
|
|
1734
1995
|
es.addEventListener("ping", on);
|