@ra3orblade/swarm 0.7.0 → 0.9.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 +283 -1
- package/dist/swarm-mcp.js +53 -8
- package/dist/swarm.js +606 -49
- package/dist/swarmd.js +1899 -132
- package/package.json +1 -1
- package/web/app.js +533 -133
- package/web/index.html +106 -3
- package/web/menus.js +1 -1
- package/web/release-notes.js +1 -1
- package/web/viz.js +20 -3
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
|
|
|
@@ -129,7 +155,7 @@ async function refresh() {
|
|
|
129
155
|
const txt = await (await fetch("/v1/state")).text();
|
|
130
156
|
const same = txt === lastSnap;
|
|
131
157
|
if (!same) { lastSnap = txt; Object.assign(state, JSON.parse(txt)); }
|
|
132
|
-
if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; maybeWhatsNew(); }).catch(() => {});
|
|
158
|
+
if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; state.hooksInstalled = h.hooksInstalled !== false; maybeUpdateNudge(h); maybeWhatsNew(); }).catch(() => {});
|
|
133
159
|
let prsChanged = false;
|
|
134
160
|
if (state.view === "prs" && !state.session) {
|
|
135
161
|
const prs = await (await fetch("/v1/prs")).json().catch(() => state.prs ?? []);
|
|
@@ -149,6 +175,10 @@ async function refresh() {
|
|
|
149
175
|
state.attribution = null;
|
|
150
176
|
}
|
|
151
177
|
let runsChanged = false;
|
|
178
|
+
if (state.session) {
|
|
179
|
+
const ms = await fetch(`/v1/messages?session=${encodeURIComponent(state.session)}&limit=50`).then((r) => r.json()).catch(() => state.msgs ?? []);
|
|
180
|
+
if (JSON.stringify(ms) !== JSON.stringify(state.msgs)) { state.msgs = ms; state.dirty = true; }
|
|
181
|
+
}
|
|
152
182
|
const openSpawned = state.session && state.sessions.find((x) => x.id === state.session)?.kind === "spawned";
|
|
153
183
|
if (openSpawned || (state.view === "board" && !state.session) || (state.view === "fleet" && !state.session)) {
|
|
154
184
|
const runs = await fetch("/v1/runs").then((r) => r.json()).catch(() => state.runs ?? []);
|
|
@@ -157,13 +187,14 @@ async function refresh() {
|
|
|
157
187
|
}
|
|
158
188
|
let tasksChanged = false;
|
|
159
189
|
if (state.view === "board" && state.sel && !state.session) {
|
|
160
|
-
const [t, g, d] = await Promise.all([
|
|
190
|
+
const [t, g, d, wf] = await Promise.all([
|
|
161
191
|
fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
|
|
162
192
|
fetch(`/v1/gates?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.gates),
|
|
163
193
|
fetch(`/v1/dispatch?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.dispatch),
|
|
194
|
+
fetch(`/v1/workflows?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.workflows),
|
|
164
195
|
]);
|
|
165
|
-
tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch);
|
|
166
|
-
state.tasks = t; state.gates = g; state.dispatch = d;
|
|
196
|
+
tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch) || JSON.stringify(wf) !== JSON.stringify(state.workflows);
|
|
197
|
+
state.tasks = t; state.gates = g; state.dispatch = d; state.workflows = wf;
|
|
167
198
|
}
|
|
168
199
|
let incChanged = false;
|
|
169
200
|
if (state.view === "incidents" && !state.session) {
|
|
@@ -189,6 +220,9 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
|
|
|
189
220
|
for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
|
|
190
221
|
}
|
|
191
222
|
function render() {
|
|
223
|
+
// A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
|
|
224
|
+
// hold the frame while one is open; the next poll or interaction paints it.
|
|
225
|
+
if (window.menus?.isOpen()) { state.dirty = true; return; }
|
|
192
226
|
// Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
|
|
193
227
|
const af = document.activeElement;
|
|
194
228
|
const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
|
|
@@ -227,6 +261,12 @@ function liveCounts() {
|
|
|
227
261
|
for (const s of state.sessions) if (isLive(s)) { m.set(s.projectId, (m.get(s.projectId) ?? 0) + 1); m.set("", (m.get("") ?? 0) + 1); }
|
|
228
262
|
return m;
|
|
229
263
|
}
|
|
264
|
+
// M5.7: 14-day spend sparkline per pinned project; hidden when the fortnight cost is ~zero.
|
|
265
|
+
function spendSpark(pid) {
|
|
266
|
+
const pts = state.spendSparks?.[pid];
|
|
267
|
+
if (!pts || pts.reduce((a, b) => a + b, 0) < 0.5) return "";
|
|
268
|
+
return `<span class="proj-spark" title="last 14 days · $${pts.reduce((a, b) => a + b, 0).toFixed(0)}">${viz.sparkline(pts, "var(--c1)")}</span>`;
|
|
269
|
+
}
|
|
230
270
|
function renderProjects() {
|
|
231
271
|
const lc = liveCounts();
|
|
232
272
|
const live = (pid) => lc.get(pid) ?? 0;
|
|
@@ -243,7 +283,7 @@ function renderProjects() {
|
|
|
243
283
|
const row = (p) => {
|
|
244
284
|
const act = `<span class="act more" data-menu="project" data-pid="${p.id}" title="Project actions">${ic("dots-three", 15)}</span>`;
|
|
245
285
|
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>${
|
|
286
|
+
<span class="st ${live(p.id) ? "live" : ""}"></span>${projGlyph(p)}<span class="nm">${disamb(p)}${esc(p.name)}</span>${spendSpark(p.id)}<small>${live(p.id) || ""}</small>${act}</div>`;
|
|
247
287
|
};
|
|
248
288
|
const liveAll = live("");
|
|
249
289
|
$("#projects").innerHTML =
|
|
@@ -288,16 +328,35 @@ projectsEl.addEventListener("dragend", () => {
|
|
|
288
328
|
fetch("/v1/projects/order", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }) }).then(refresh);
|
|
289
329
|
});
|
|
290
330
|
|
|
331
|
+
// First run: no sessions have ever been seen. Say exactly what to do next, and whether hooks are in.
|
|
332
|
+
function onboarding() {
|
|
333
|
+
const hooksOk = state.hooksInstalled !== false;
|
|
334
|
+
const step = (n, done, html) => `<div class="ob-step ${done ? "done" : ""}"><span class="ob-n">${done ? "✓" : n}</span><div>${html}</div></div>`;
|
|
335
|
+
return `<div class="onboard">${PX.idle()}
|
|
336
|
+
<h3>Swarm is running and watching this machine.</h3>
|
|
337
|
+
<div class="ob-steps">
|
|
338
|
+
${step(1, hooksOk, `<b>Hook into Claude Code</b> — <code>swarm install</code> once${hooksOk ? "" : " <span class='badge warn'>not installed</span>"}. Codex and Grok are picked up automatically, nothing to configure.`)}
|
|
339
|
+
${step(2, false, `<b>Open any agent session</b> — run <code>claude</code> in any repository, in any terminal. No changes to the repo, the agent doesn't know Swarm is there.`)}
|
|
340
|
+
${step(3, false, `<b>Watch it appear here</b> — live status, branch, tokens and cost per session; Board, Timeline and Spend fill up as you work.`)}
|
|
341
|
+
</div>
|
|
342
|
+
<div class="dim">Something off? <code>swarm doctor</code> checks every piece and prints the fix.</div>
|
|
343
|
+
</div>`;
|
|
344
|
+
}
|
|
345
|
+
|
|
291
346
|
// ---------- fleet
|
|
292
347
|
// Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
|
|
293
348
|
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
|
-
|
|
349
|
+
{ key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
|
|
350
|
+
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
|
|
351
|
+
{ key: "session", label: "session", width: 210, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}${(state.questions ?? []).some((q) => q.sessionId === s.id) ? ' <span class="badge warn" title="This agent asked a question only a human can answer — open the session">Asking</span>' : ""}` },
|
|
352
|
+
{ key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
|
|
353
|
+
{ key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
|
|
354
|
+
const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
|
|
355
|
+
if (s.state === "ended") return line ? `<span class="now dim" title="${esc(line)}">${esc(line)}</span>` : '<span class="dim">ended</span>';
|
|
356
|
+
return `<span class="now" title="${esc(s.last)}">${esc(s.state === "waiting" && line ? line : s.last)}</span>`;
|
|
357
|
+
} },
|
|
358
|
+
{ 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>` },
|
|
359
|
+
{ 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
360
|
{ key: "out", label: "out", width: 66, num: true, get: (s) => s.tokens.output, cell: (s) => tok(s.tokens.output) },
|
|
302
361
|
{ 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
362
|
{ key: "cost", label: "cost", width: 64, num: true, get: (s) => s.costUsd ?? 0, cell: (s) => usd(s.costUsd) },
|
|
@@ -330,7 +389,7 @@ function renderFleet() {
|
|
|
330
389
|
: "";
|
|
331
390
|
$("#main").innerHTML = chips +
|
|
332
391
|
`<h2>Live <span>${live.length} sessions · ${usd(sumBy(live, (s) => s.costUsd))}</span></h2>` +
|
|
333
|
-
(live.length ? table(live, "fleet-live") : `<div class="empty">${PX.idle()}Nothing running
|
|
392
|
+
(live.length ? table(live, "fleet-live") : state.sessions.length ? `<div class="empty">${PX.idle()}Nothing running.</div>` : onboarding()) +
|
|
334
393
|
(rest.length ? `<h2 class="mt-sec">Earlier <span>${rest.length}</span></h2>${table(rest.slice(0, 30), "fleet-earlier")}` : "") +
|
|
335
394
|
"";
|
|
336
395
|
}
|
|
@@ -361,18 +420,45 @@ function renderPRs() {
|
|
|
361
420
|
columns: cols,
|
|
362
421
|
rows,
|
|
363
422
|
leading: { width: 24, cell: (p) => `<span class="s ${p.checks === "fail" ? "waiting" : p.checks === "pass" ? "active" : "idle"}"></span>` },
|
|
364
|
-
trailing: { width:
|
|
365
|
-
rowAttrs: () => ""
|
|
423
|
+
trailing: { width: 34, cell: (p) => more("pr", `data-pid="${esc(p.projectId)}" data-num="${p.number}"`) },
|
|
424
|
+
rowAttrs: (p) => `data-ctx="pr" data-pid="${esc(p.projectId)}" data-num="${p.number}"`,
|
|
366
425
|
rerender: touch,
|
|
367
426
|
})
|
|
368
427
|
: `<div class="empty">${PX.idle()}No open pull requests.<br>Agent branches land here the moment they're pushed.</div>`);
|
|
369
428
|
}
|
|
370
429
|
|
|
371
430
|
// ---------- board (coordination: claims, worktrees, incidents)
|
|
431
|
+
// Board representation toggles (cards vs table), persisted per section.
|
|
432
|
+
const boardMode = (k) => localStorage.getItem(`swarm.board.${k}`) ?? "cards";
|
|
433
|
+
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>`;
|
|
434
|
+
|
|
435
|
+
// KPI strip: the board at a glance — what is live, held, dirty, failing, waiting.
|
|
436
|
+
function renderBoardKpis() {
|
|
437
|
+
const inSel = (pid) => !state.sel || pid === state.sel;
|
|
438
|
+
const live = state.sessions.filter((s) => inSel(s.projectId) && (s.state === "active" || s.state === "waiting"));
|
|
439
|
+
const waiting = live.filter((s) => s.state === "waiting").length;
|
|
440
|
+
const claims = (state.claims ?? []).filter((c) => c.state !== "released" && inSel(c.projectId));
|
|
441
|
+
const orphaned = claims.filter((c) => c.state === "orphaned").length;
|
|
442
|
+
const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
|
|
443
|
+
const dirty = wts.filter((w) => w.dirty > 0).length, merged = wts.filter((w) => !w.main && w.merged).length;
|
|
444
|
+
const inc = (state.incidents ?? []).filter((i) => inSel(i.projectId) && !i.acked).length;
|
|
445
|
+
const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
|
|
446
|
+
const ready = tasks ? tasks.filter((t) => t.ready).length : null;
|
|
447
|
+
const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
|
|
448
|
+
if (!live.length && !claims.length && !wts.length && !inc && !tasks) return "";
|
|
449
|
+
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>`;
|
|
450
|
+
return `<div class="kpis kpis-5">${
|
|
451
|
+
kpi("Live", live.length, waiting ? `${waiting} waiting on you` : live.length ? "sessions working" : "no sessions", waiting ? "hot" : "")
|
|
452
|
+
}${kpi("Held", claims.length, orphaned ? `${orphaned} orphaned` : claims.length ? "claims with a lease" : "nothing claimed", orphaned ? "hot" : "")
|
|
453
|
+
}${kpi("Worktrees", wts.length, dirty || merged ? `${dirty ? `${dirty} dirty` : ""}${dirty && merged ? " · " : ""}${merged ? `${merged} merged` : ""}` : "all clean", dirty ? "warm" : "")
|
|
454
|
+
}${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")
|
|
455
|
+
}${kpi("Incidents", inc, inc ? "need a look" : "all acknowledged", inc ? "hot" : "")}</div>`;
|
|
456
|
+
}
|
|
457
|
+
|
|
372
458
|
function renderBoard() {
|
|
373
|
-
const parts = [renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
|
|
459
|
+
const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderWorkflowRuns(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
|
|
374
460
|
$("#main").innerHTML = parts.length
|
|
375
|
-
? parts.join("").replace(/^(<h2) class="mt-sec"/, "$1") // first section needs no top gap
|
|
461
|
+
? parts.join("").replace(/^(<div class="kpis[^>]*>[\s\S]*?<\/div><\/div>|)(<h2) class="mt-sec"/, "$1$2") // first section needs no top gap
|
|
376
462
|
: `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
|
|
377
463
|
}
|
|
378
464
|
|
|
@@ -381,19 +467,20 @@ function incidentColumns(full) {
|
|
|
381
467
|
const sess = (id) => state.sessions.find((s) => s.id === id);
|
|
382
468
|
return [
|
|
383
469
|
{ 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) =>
|
|
470
|
+
{ key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) => projCell(i.projectId) },
|
|
385
471
|
{ 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
472
|
{ key: "rule", label: "rule", width: 150, get: (i) => i.rule, cell: (i) => `<span class="br">${esc(i.rule ?? "")}</span>` },
|
|
387
473
|
{ 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.
|
|
474
|
+
{ 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
475
|
...(full ? [
|
|
390
476
|
{ 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
477
|
{ 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
478
|
] : []),
|
|
393
479
|
].filter((c) => !(c.key === "project" && state.sel) && !(c.key === "session" && !full));
|
|
394
480
|
}
|
|
481
|
+
/** The part of a shell command worth reading in a cell: drop a leading `cd <dir> &&` / `;`. */
|
|
482
|
+
const cmdGist = (c) => c.replace(/^\s*cd\s+\S+\s*(&&|;)\s*/, "").replace(/\s+/g, " ").trim() || c;
|
|
395
483
|
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
484
|
|
|
398
485
|
function renderIncidents() {
|
|
399
486
|
const rows = (state.incidents ?? []).filter((i) => !state.sel || i.projectId === state.sel);
|
|
@@ -405,8 +492,8 @@ function renderIncidents() {
|
|
|
405
492
|
columns: incidentColumns(false),
|
|
406
493
|
rows,
|
|
407
494
|
leading: { width: 24, cell: incidentDot },
|
|
408
|
-
trailing: { width:
|
|
409
|
-
rowAttrs: (i) =>
|
|
495
|
+
trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
|
|
496
|
+
rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
|
|
410
497
|
rerender: touch,
|
|
411
498
|
});
|
|
412
499
|
}
|
|
@@ -486,8 +573,8 @@ function renderIncidentsView() {
|
|
|
486
573
|
columns: incidentColumns(true),
|
|
487
574
|
rows,
|
|
488
575
|
leading: { width: 24, cell: incidentDot },
|
|
489
|
-
trailing: { width:
|
|
490
|
-
rowAttrs: () => ""
|
|
576
|
+
trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
|
|
577
|
+
rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
|
|
491
578
|
rerender: touch,
|
|
492
579
|
})
|
|
493
580
|
: `<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 +587,7 @@ function renderProcesses() {
|
|
|
500
587
|
const cols = [
|
|
501
588
|
{ key: "name", label: "process", width: 150, get: (r) => r.name, cell: (r) => `<b>${esc(r.name)}</b>` },
|
|
502
589
|
{ 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) =>
|
|
590
|
+
{ key: "project", label: "project", width: 104, get: (r) => projName(r.projectId), cell: (r) => projCell(r.projectId) },
|
|
504
591
|
{ key: "pid", label: "pid", width: 76, num: true, get: (r) => r.pid, cell: (r) => r.pid },
|
|
505
592
|
{ 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
593
|
{ key: "owner", label: "owner", width: 110, get: (r) => r.owner, cell: (r) => esc(r.owner) },
|
|
@@ -513,8 +600,8 @@ function renderProcesses() {
|
|
|
513
600
|
columns: cols,
|
|
514
601
|
rows,
|
|
515
602
|
leading: { width: 24, cell: () => '<span class="s active"></span>' },
|
|
516
|
-
trailing: { width:
|
|
517
|
-
rowAttrs: () => ""
|
|
603
|
+
trailing: { width: 34, cell: (r) => more("process", `data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`) },
|
|
604
|
+
rowAttrs: (r) => `data-ctx="process" data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`,
|
|
518
605
|
rerender: touch,
|
|
519
606
|
});
|
|
520
607
|
}
|
|
@@ -537,7 +624,8 @@ function renderResources() {
|
|
|
537
624
|
columns: cols,
|
|
538
625
|
rows,
|
|
539
626
|
leading: { width: 24, cell: () => '<span class="s active"></span>' },
|
|
540
|
-
trailing: { width:
|
|
627
|
+
trailing: { width: 34, cell: (r) => more("resource", `data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`) },
|
|
628
|
+
rowAttrs: (r) => `data-ctx="resource" data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`,
|
|
541
629
|
rerender: touch,
|
|
542
630
|
});
|
|
543
631
|
}
|
|
@@ -565,7 +653,13 @@ function renderGates() {
|
|
|
565
653
|
{ key: "evidence", label: "evidence", width: 220, get: (r) => r.evidence ?? "", cell: (r) => (r.evidence ? `<span class="dim now" title="${esc(r.evidence)}">${esc(r.evidence)}</span>` : '<span class="dim">—</span>') },
|
|
566
654
|
{ key: "session", label: "session", width: 140, get: (r) => sess(r.sessionId)?.title ?? "", cell: (r) => (r.sessionId ? `<a href="#" data-s="${r.sessionId}">${esc(sess(r.sessionId)?.title ?? r.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
|
|
567
655
|
];
|
|
568
|
-
|
|
656
|
+
const history = (gate) => {
|
|
657
|
+
const rs = runs.filter((r) => r.gate === gate).slice(0, 12).reverse();
|
|
658
|
+
if (!rs.length) return "";
|
|
659
|
+
return `<span class="gh" title="${esc(gate)} — last ${rs.length} run${rs.length === 1 ? "" : "s"}, oldest first">${esc(gate)} ${rs.map((r) => `<i class="${r.verdict === "pass" ? "ok" : "bad"}" title="${esc(r.rubric)}"></i>`).join("")}</span>`;
|
|
660
|
+
};
|
|
661
|
+
const gateNames = [...new Set(runs.map((r) => r.gate))];
|
|
662
|
+
return `<h2 class="mt-sec">Recent gates <span>${runs.length} run${runs.length === 1 ? "" : "s"}${required.length ? ` · required: ${required.map(esc).join(", ")}` : ""} · latest run per gate decides</span>${gateNames.length ? `<span class="grow"></span><span class="gh-strip">${gateNames.map(history).join("")}</span>` : ""}</h2>` +
|
|
569
663
|
(runs.length
|
|
570
664
|
? dataTable({
|
|
571
665
|
id: "gates",
|
|
@@ -600,16 +694,35 @@ function renderTasks() {
|
|
|
600
694
|
...(hasGates ? [{ key: "gates", label: "gates", width: 170, get: (t) => (t.gates ?? []).filter((g) => g.verdict === "pass").length, cell: (t) => gateChips(t.gates ?? []) }] : []),
|
|
601
695
|
];
|
|
602
696
|
const srcLabel = state.tasks.source === "github" ? "GitHub Issues" : state.tasks.source === "linear" ? "Linear" : state.tasks.source;
|
|
697
|
+
const lane = (t) => (t.claimedBy ? "held" : t.status === "done" ? "done" : t.ready ? "ready" : t.status === "active" ? "held" : "blocked");
|
|
698
|
+
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)}">
|
|
699
|
+
<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>
|
|
700
|
+
<div class="tc-t">${esc(t.title)}</div>
|
|
701
|
+
${t.milestone ? `<div class="tc-m">${esc(t.milestone.split(" — ")[0])}</div>` : ""}
|
|
702
|
+
${(t.gates ?? []).some((g) => g.verdict) ? `<div class="tc-g">${gateChips(t.gates)}</div>` : ""}
|
|
703
|
+
</div>`;
|
|
704
|
+
const kanban = () => {
|
|
705
|
+
const lanes = [["ready", "Ready"], ["held", "In progress"], ["blocked", "Blocked"], ["done", "Done"]];
|
|
706
|
+
const by = Object.fromEntries(lanes.map(([k]) => [k, []]));
|
|
707
|
+
for (const t of all) by[lane(t)].push(t);
|
|
708
|
+
by.done.reverse();
|
|
709
|
+
const CAP = 6;
|
|
710
|
+
return `<div class="kanban">${lanes.map(([k, label]) => {
|
|
711
|
+
const list = by[k];
|
|
712
|
+
const shown = k === "done" ? list.slice(0, CAP) : list;
|
|
713
|
+
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>`;
|
|
714
|
+
}).join("")}</div>`;
|
|
715
|
+
};
|
|
603
716
|
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
|
|
717
|
+
`<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>` +
|
|
718
|
+
(all.length && boardMode("tasks") === "cards" ? kanban() : rows.length
|
|
606
719
|
? dataTable({
|
|
607
720
|
id: "tasks",
|
|
608
721
|
columns: cols,
|
|
609
722
|
rows,
|
|
610
723
|
leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
|
|
611
|
-
trailing: { width:
|
|
612
|
-
rowAttrs: () => ""
|
|
724
|
+
trailing: { width: 34, cell: (t) => (t.ready || t.claimedBy ? more("task", `data-task="${esc(t.id)}"`) : "") },
|
|
725
|
+
rowAttrs: (t) => `data-ctx="task" data-task="${esc(t.id)}"`,
|
|
613
726
|
rerender: touch,
|
|
614
727
|
})
|
|
615
728
|
: `<div class="empty">${PX.idle()}${state.taskFilter === "ready" ? "Nothing ready — every open task is blocked or held." : "No tasks."}</div>`);
|
|
@@ -623,7 +736,7 @@ function renderClaims() {
|
|
|
623
736
|
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
737
|
const orphans = rows.filter((c) => c.state === "orphaned").length;
|
|
625
738
|
const cols = [
|
|
626
|
-
{ key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) =>
|
|
739
|
+
{ key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) => projCell(c.projectId) },
|
|
627
740
|
{ key: "task", label: "task", width: 140, get: (c) => c.task, cell: (c) => `<b>${esc(c.task)}</b>` },
|
|
628
741
|
{ key: "owner", label: "owner", width: 120, get: (c) => c.owner || "", cell: (c) => esc(c.owner || "—") },
|
|
629
742
|
{ 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 +749,8 @@ function renderClaims() {
|
|
|
636
749
|
columns: cols,
|
|
637
750
|
rows,
|
|
638
751
|
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
|
-
} },
|
|
752
|
+
trailing: { width: 34, cell: (c) => more("claim", `data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`) },
|
|
753
|
+
rowAttrs: (c) => `data-ctx="claim" data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`,
|
|
645
754
|
rerender: touch,
|
|
646
755
|
});
|
|
647
756
|
}
|
|
@@ -660,7 +769,7 @@ function renderWorktrees() {
|
|
|
660
769
|
const inside = (w) => byPath.get(w.path);
|
|
661
770
|
const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
|
|
662
771
|
const cols = [
|
|
663
|
-
{ key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) =>
|
|
772
|
+
{ key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) => projCell(w.projectId) },
|
|
664
773
|
{ 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
774
|
{ key: "head", label: "head", width: 90, get: (w) => w.head, cell: (w) => `<span class="br">${esc(w.head)}</span>` },
|
|
666
775
|
{ 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,24 +778,59 @@ function renderWorktrees() {
|
|
|
669
778
|
{ 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
779
|
].filter((c) => !(c.key === "project" && state.sel));
|
|
671
780
|
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
781
|
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
782
|
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
|
-
|
|
783
|
+
const stateOf = (w) => (inside(w).length ? "live" : w.dirty > 0 ? "dirty" : w.ahead > 0 ? "ahead" : w.merged ? "merged" : "clean");
|
|
784
|
+
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)}">
|
|
785
|
+
<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>
|
|
786
|
+
<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>
|
|
787
|
+
</div>`;
|
|
788
|
+
const map = () => {
|
|
789
|
+
const groups = new Map();
|
|
790
|
+
for (const w of rows) (groups.get(w.projectId) ?? groups.set(w.projectId, []).get(w.projectId)).push(w);
|
|
791
|
+
const order = { live: 0, dirty: 1, ahead: 2, clean: 3, merged: 4 };
|
|
792
|
+
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>`;
|
|
793
|
+
};
|
|
794
|
+
return `<h2 class="mt-sec hrow">Worktrees <span>${rows.length}</span>${newBtn}${gcBtn}<span class="grow"></span>${modeSeg("worktrees", "Map", "Table")}</h2>` +
|
|
795
|
+
(boardMode("worktrees") === "cards" ? map() :
|
|
684
796
|
dataTable({
|
|
685
797
|
id: "worktrees",
|
|
686
798
|
columns: cols,
|
|
687
799
|
rows,
|
|
688
800
|
leading: { width: 24, cell: (w) => `<span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span>` },
|
|
689
|
-
trailing: { width:
|
|
801
|
+
trailing: { width: 34, cell: (w) => more("worktree", `data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`) },
|
|
802
|
+
rowAttrs: (w) => `data-ctx="worktree" data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`,
|
|
803
|
+
rerender: touch,
|
|
804
|
+
}));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ---------- workflows (M7.8)
|
|
808
|
+
function renderWorkflowRuns() {
|
|
809
|
+
const w = state.workflows;
|
|
810
|
+
if (!state.sel || !w?.runs?.length) return "";
|
|
811
|
+
const chip = (r, i) => {
|
|
812
|
+
const label = esc(r.steps[i]);
|
|
813
|
+
if (i < r.step || (r.state === "done" && i <= r.step)) return `<span class="wfs ok" title="${label}">✓ ${label}</span>`;
|
|
814
|
+
if (i === r.step) return r.state === "running" ? `<span class="wfs run" title="${label}">● ${label}</span>` : r.state === "failed" ? `<span class="wfs bad" title="${label}">✗ ${label}</span>` : `<span class="wfs" title="${label}">◦ ${label}</span>`;
|
|
815
|
+
return `<span class="wfs" title="${label}">○ ${label}</span>`;
|
|
816
|
+
};
|
|
817
|
+
const badge = (r) => r.state === "running" ? '<span class="badge acc">Running</span>' : r.state === "done" ? '<span class="badge ok">Done</span>' : r.state === "failed" ? '<span class="badge warn">Failed</span>' : '<span class="badge">Stopped</span>';
|
|
818
|
+
const cols = [
|
|
819
|
+
{ key: "task", label: "task", width: 90, get: (r) => r.task, cell: (r) => `<b>${esc(r.task)}</b>` },
|
|
820
|
+
{ key: "workflow", label: "workflow", width: 100, get: (r) => r.workflow, cell: (r) => `<span class="br">${esc(r.workflow)}</span>` },
|
|
821
|
+
{ key: "steps", label: "steps", flex: true, sortable: false, get: (r) => r.step, cell: (r) => `<span class="wf-steps">${r.steps.map((_, i) => chip(r, i)).join("")}</span>` },
|
|
822
|
+
{ key: "state", label: "state", width: 90, get: (r) => r.state, cell: badge },
|
|
823
|
+
{ key: "detail", label: "detail", width: 260, get: (r) => r.detail ?? "", cell: (r) => `<span class="dim now" title="${esc(r.detail ?? "")}">${esc(r.detail ?? "")}</span>` },
|
|
824
|
+
{ key: "when", label: "updated", width: 76, get: (r) => r.updatedAt, cell: (r) => `<span class="dim">${ago(r.updatedAt)}</span>` },
|
|
825
|
+
];
|
|
826
|
+
const running = w.runs.filter((r) => r.state === "running").length;
|
|
827
|
+
return `<h2 class="mt-sec">Workflows <span>${running ? `${running} running · ` : ""}${Object.keys(w.defs ?? {}).map(esc).join(", ") || "none declared"}</span></h2>` +
|
|
828
|
+
dataTable({
|
|
829
|
+
id: "workflows",
|
|
830
|
+
columns: cols,
|
|
831
|
+
rows: w.runs.slice(0, 20),
|
|
832
|
+
leading: { width: 24, cell: (r) => `<span class="s ${r.state === "running" ? "active" : r.state === "failed" ? "waiting" : "ended"}"></span>` },
|
|
833
|
+
trailing: { width: 60, cell: (r) => (r.state === "running" ? `<a href="#" data-wfstop="${esc(r.task)}">Stop</a>` : "") },
|
|
690
834
|
rerender: touch,
|
|
691
835
|
});
|
|
692
836
|
}
|
|
@@ -812,7 +956,6 @@ function renderAttribution() {
|
|
|
812
956
|
{ 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
957
|
],
|
|
814
958
|
rows: a.byTask,
|
|
815
|
-
leading: { width: 20, cell: () => "" },
|
|
816
959
|
trailing: { width: 8, cell: () => "" },
|
|
817
960
|
rerender: touch,
|
|
818
961
|
}));
|
|
@@ -829,7 +972,6 @@ function renderAttribution() {
|
|
|
829
972
|
{ key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns, cell: (r) => String(r.turns) },
|
|
830
973
|
],
|
|
831
974
|
rows: a.contextBudget,
|
|
832
|
-
leading: { width: 20, cell: () => "" },
|
|
833
975
|
trailing: { width: 8, cell: () => "" },
|
|
834
976
|
rerender: touch,
|
|
835
977
|
}));
|
|
@@ -882,6 +1024,12 @@ async function runSearch() {
|
|
|
882
1024
|
srch.hits = j.hits ?? [];
|
|
883
1025
|
if (state.view === "search" && !state.session) renderSearch();
|
|
884
1026
|
}
|
|
1027
|
+
document.addEventListener("change", async (ev) => {
|
|
1028
|
+
if (ev.target.id !== "psFile" || !ev.target.files?.[0]) return;
|
|
1029
|
+
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"); }
|
|
1030
|
+
catch (e) { alert(e.message); }
|
|
1031
|
+
});
|
|
1032
|
+
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
1033
|
document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
|
|
886
1034
|
function renderStats() {
|
|
887
1035
|
const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
|
|
@@ -974,7 +1122,22 @@ function renderStats() {
|
|
|
974
1122
|
}
|
|
975
1123
|
|
|
976
1124
|
// ---------- timeline
|
|
1125
|
+
let tlDetail = { key: "", data: null, busy: false };
|
|
1126
|
+
async function loadTimelineDetail() {
|
|
1127
|
+
const hours = state.tlHours ?? 12;
|
|
1128
|
+
const key = `${hours}:${state.sel ?? ""}`;
|
|
1129
|
+
if (tlDetail.busy || (tlDetail.key === key && tlDetail.at && Date.now() - tlDetail.at < 15_000)) return;
|
|
1130
|
+
tlDetail.busy = true;
|
|
1131
|
+
try {
|
|
1132
|
+
const q = new URLSearchParams({ hours: String(hours) });
|
|
1133
|
+
if (state.sel) q.set("project", state.sel);
|
|
1134
|
+
const data = await (await fetch(`/v1/timeline?${q}`)).json();
|
|
1135
|
+
tlDetail = { key, at: Date.now(), data, busy: false };
|
|
1136
|
+
if (state.view === "timeline" && !state.session) touch();
|
|
1137
|
+
} finally { tlDetail.busy = false; }
|
|
1138
|
+
}
|
|
977
1139
|
function renderTimeline() {
|
|
1140
|
+
loadTimelineDetail();
|
|
978
1141
|
const now = Date.now();
|
|
979
1142
|
const hours = state.tlHours ?? 12;
|
|
980
1143
|
const from = now - hours * 3.6e6, to = now + 0.25 * 3.6e6;
|
|
@@ -983,7 +1146,7 @@ function renderTimeline() {
|
|
|
983
1146
|
const chip = (h) => `<a href="#" class="nav ${hours === h ? "on" : ""}" data-tl="${h}">${h}h</a>`;
|
|
984
1147
|
$("#main").innerHTML =
|
|
985
1148
|
`<h2>Timeline <span>${rows.length} sessions · last ${hours}h · ${usd(sumBy(rows, (s) => s.costUsd))}</span><span style="margin-left:auto;display:flex;gap:2px">${[3, 6, 12, 24, 72].map(chip).join("")}</span></h2>
|
|
986
|
-
${rows.length ? viz.timeline(rows, { from, to, projName, now }) : `<div class="empty">${PX.clock()}No sessions in the last ${hours}h.</div>`}
|
|
1149
|
+
${rows.length ? viz.timeline(rows, { from, to, projName, now, detail: tlDetail.key === `${hours}:${state.sel ?? ""}` ? tlDetail.data : null }) : `<div class="empty">${PX.clock()}No sessions in the last ${hours}h.</div>`}
|
|
987
1150
|
${agents.length ? `<div style="margin-top:10px">${viz.legend(agents)}</div>` : ""}`;
|
|
988
1151
|
}
|
|
989
1152
|
|
|
@@ -1100,6 +1263,18 @@ function replayGo(delta) {
|
|
|
1100
1263
|
}
|
|
1101
1264
|
|
|
1102
1265
|
// Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
|
|
1266
|
+
// M7.6: the session's message thread (sent + received) and a compose box.
|
|
1267
|
+
function messageThread(s) {
|
|
1268
|
+
const ms = (state.msgs ?? []).filter((m) => m.sessionId === s.id || m.fromSession === s.id).slice().reverse();
|
|
1269
|
+
const row = (m) => {
|
|
1270
|
+
const out = m.fromSession === s.id;
|
|
1271
|
+
return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
|
|
1272
|
+
<span class="msg-f">${out ? `→ ${esc(m.task ?? m.toKind)}` : esc(m.from ?? "?")}${m.deliveredAt ? "" : ' <i class="dim">·queued</i>'}</span>${esc(m.text)}</div>`;
|
|
1273
|
+
};
|
|
1274
|
+
return `<h4>messages</h4>${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : '<span class="dim">None yet.</span>'}
|
|
1275
|
+
<div class="msg-compose"><input id="msgText" placeholder="Message this agent… (delivered on its next tool call)" autocomplete="off"><button id="msgSend" data-sid="${s.id}" data-pid="${s.projectId}">${ic("arrow-right", 13)}</button></div>`;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1103
1278
|
// M7.7: questions this session is waiting on a human for
|
|
1104
1279
|
function questionCards(s) {
|
|
1105
1280
|
const qs = (state.questions ?? []).filter((q) => q.sessionId === s.id);
|
|
@@ -1159,13 +1334,14 @@ function renderSession() {
|
|
|
1159
1334
|
const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span><a href="#" class="nav" id="replay" style="margin-left:auto" title="Step through this session's tool calls">${ic("play", 13)} Replay</a>${(state.worktrees[s.projectId] ?? []).some((w) => !w.main && (s.cwd === w.path || s.cwd.startsWith(`${w.path}/`))) ? `<a href="#" class="nav" id="sessDiff" title="What this session's worktree changed">${ic("folders", 13)} Diff</a>` : ""}${s.state === "ended" ? `<a href="#" class="nav" id="resumeDead" title="Spawn a run that picks up this session's task from its handoff + last actions">${ic("reload", 13)} Resume where it died</a>` : ""}</h2>`;
|
|
1160
1335
|
const side = `<div class="stats">
|
|
1161
1336
|
${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("
|
|
1337
|
+
${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
1338
|
${stat("started", `${ago(s.startedAt)} ago`)}${stat("last seen", `${ago(s.lastSeenAt)} ago`)}
|
|
1164
1339
|
${subTurns.length ? stat("subagent turns", subTurns.length) : ""}
|
|
1165
1340
|
</div>
|
|
1166
1341
|
<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 }])}
|
|
1167
1342
|
${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
|
|
1168
1343
|
<h4>tools</h4>${tools.length ? viz.hbars(tools.slice(0, 8).map(([k, v]) => [k.replace(/^mcp__[a-z0-9-]+__/i, ""), v])) : '<span class="dim">None yet</span>'}
|
|
1344
|
+
${messageThread(s)}
|
|
1169
1345
|
${questionCards(s)}
|
|
1170
1346
|
${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
|
|
1171
1347
|
if (logEl && isAppend(rows)) {
|
|
@@ -1192,6 +1368,63 @@ function renderSession() {
|
|
|
1192
1368
|
// ---------- menus (fancy-menus island; see src/menus.tsx). Menus are plain data.
|
|
1193
1369
|
const pinProject = (id, pinned) => fetch(`/v1/projects/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ pinned }) }).then(refresh);
|
|
1194
1370
|
const removeProject = (id) => fetch(`/v1/projects/${id}`, { method: "DELETE" }).then(refresh);
|
|
1371
|
+
// ---------- row actions (shared by the row menus, right-click, and any remaining links)
|
|
1372
|
+
const post = (url, body) => fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((x) => x.json());
|
|
1373
|
+
const act = {
|
|
1374
|
+
async wtOpen(projectId, worktree) { const r = await post("/v1/worktrees/open", { projectId, worktree }); if (!r.ok) alert(r.error); },
|
|
1375
|
+
wtDiff(projectId, worktree) { openDiffDrawer(projectId, worktree); },
|
|
1376
|
+
wtPr(projectId, worktree) { openPrDrawer(projectId, worktree); },
|
|
1377
|
+
async wtRemove(projectId, worktree) {
|
|
1378
|
+
const rm = (force) => post("/v1/worktrees/remove", { projectId, worktree, force });
|
|
1379
|
+
if (!confirm(`Remove worktree ${short(worktree)}?`)) return;
|
|
1380
|
+
const r = await rm(false);
|
|
1381
|
+
if (!r.ok && (r.refused === "dirty" || r.refused === "unpushed")) {
|
|
1382
|
+
if (confirm(`${r.error}\n\nRemove anyway (discards the work)?`)) await rm(true);
|
|
1383
|
+
} else if (!r.ok) alert(r.error);
|
|
1384
|
+
state.worktrees[projectId] = null;
|
|
1385
|
+
refresh();
|
|
1386
|
+
},
|
|
1387
|
+
async claimTask(task) {
|
|
1388
|
+
const r = await post("/v1/claims", { projectId: state.sel, task, owner: "dashboard" });
|
|
1389
|
+
if (!r.ok) alert(r.error); else state.tasks = null;
|
|
1390
|
+
refresh();
|
|
1391
|
+
},
|
|
1392
|
+
runTask(task) { openRunDrawer(task); },
|
|
1393
|
+
async gateRun(task) {
|
|
1394
|
+
const r = await post("/v1/gates/run", { projectId: state.sel, task });
|
|
1395
|
+
if (!r.started?.length) alert(r.error ?? r.skipped?.[0]?.reason ?? "nothing ran");
|
|
1396
|
+
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(", ")}` : ""}`);
|
|
1397
|
+
state.tasks = null;
|
|
1398
|
+
refresh();
|
|
1399
|
+
},
|
|
1400
|
+
async releaseClaim(projectId, task, force) {
|
|
1401
|
+
if (force && !confirm(`Force-release ${task}? This permanently discards its worktree and any uncommitted work.`)) return;
|
|
1402
|
+
const r = await post("/v1/claims/release", { projectId, task, force });
|
|
1403
|
+
if (!r.ok && confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) await post("/v1/claims/release", { projectId, task, force: true });
|
|
1404
|
+
refresh();
|
|
1405
|
+
},
|
|
1406
|
+
async merge(projectId, number) {
|
|
1407
|
+
if (!confirm(`Squash-merge #${number}?`)) return;
|
|
1408
|
+
const r = await post("/v1/prs/merge", { projectId, number: Number(number) });
|
|
1409
|
+
if (r.ok === false || r.error) alert(r.error);
|
|
1410
|
+
refresh();
|
|
1411
|
+
},
|
|
1412
|
+
async procStop(pid, projectId) {
|
|
1413
|
+
if (!confirm(`Stop pid ${pid}?`)) return;
|
|
1414
|
+
const r = await fetch(`/v1/processes/${pid}?project=${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
|
1415
|
+
if (!r.ok) alert((await r.json()).error);
|
|
1416
|
+
refresh();
|
|
1417
|
+
},
|
|
1418
|
+
resRelease(name, projectId) {
|
|
1419
|
+
const q = new URLSearchParams({ force: "1" }); if (projectId) q.set("project", projectId);
|
|
1420
|
+
return fetch(`/v1/resources/${encodeURIComponent(name)}?${q}`, { method: "DELETE" }).then(refresh);
|
|
1421
|
+
},
|
|
1422
|
+
ack(seq) { return fetch(`/v1/incidents/${seq}/ack`, { method: "POST" }).then(refresh); },
|
|
1423
|
+
codify(seq) { codifyIncident(seq); },
|
|
1424
|
+
};
|
|
1425
|
+
/** Hover kebab that opens the row menu `kind`; `attrs` are the data-* the menu needs. */
|
|
1426
|
+
const more = (kind, attrs, title = "Actions") => `<span class="more" tabindex="0" role="button" data-menu="${kind}" ${attrs} title="${title}">${ic("dots-three", 15)}</span>`;
|
|
1427
|
+
|
|
1195
1428
|
function menuSpec(kind, d) {
|
|
1196
1429
|
if (kind === "project") {
|
|
1197
1430
|
const p = state.projects.find((x) => x.id === d.pid);
|
|
@@ -1204,7 +1437,8 @@ function menuSpec(kind, d) {
|
|
|
1204
1437
|
{ label: "Stats", icon: "chart-bar", run: () => { state.sel = p.id; state.view = "stats"; state.session = null; touch(); } },
|
|
1205
1438
|
{ divider: true },
|
|
1206
1439
|
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: "
|
|
1440
|
+
{ label: "Settings…", icon: "sliders", caption: "name · icon · color", run: () => openProjectSettings(p.id) },
|
|
1441
|
+
{ label: "Copy path", icon: "copy", caption: tail(p.root, 16), run: () => copy(p.root) },
|
|
1208
1442
|
{ divider: true },
|
|
1209
1443
|
{ label: "Remove from Swarm", icon: "trash", danger: true, run: () => removeProject(p.id) },
|
|
1210
1444
|
] };
|
|
@@ -1218,9 +1452,91 @@ function menuSpec(kind, d) {
|
|
|
1218
1452
|
{ divider: true },
|
|
1219
1453
|
{ section: "Copy" },
|
|
1220
1454
|
{ 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,
|
|
1455
|
+
{ label: "Working directory", icon: "folder-simple", caption: tail(s.cwd, 16), run: () => copy(s.cwd) },
|
|
1222
1456
|
...(s.transcriptPath ? [{ label: "Transcript path", icon: "file-text", run: () => copy(s.transcriptPath) }] : []),
|
|
1223
|
-
...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch,
|
|
1457
|
+
...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch, 16), run: () => copy(s.branch) }] : []),
|
|
1458
|
+
] };
|
|
1459
|
+
}
|
|
1460
|
+
if (kind === "worktree") {
|
|
1461
|
+
const w = (state.worktrees[d.pid] ?? []).find((x) => x.path === d.path);
|
|
1462
|
+
if (!w) return null;
|
|
1463
|
+
const held = (state.claims ?? []).some((c) => c.state === "held" && c.worktree === w.path);
|
|
1464
|
+
const sess = state.sessions.filter((x) => x.state !== "ended" && (x.cwd === w.path || x.cwd.startsWith(`${w.path}/`)));
|
|
1465
|
+
return { title: w.branch ?? "(detached)", items: [
|
|
1466
|
+
{ label: "Open", icon: "arrow-square-out", caption: "editor", run: () => act.wtOpen(d.pid, w.path) },
|
|
1467
|
+
...(w.main ? [] : [{ label: "Diff", icon: "folders", caption: "vs main", run: () => act.wtDiff(d.pid, w.path) }]),
|
|
1468
|
+
...(w.branch && !w.merged && !w.main ? [{ label: "Open PR", icon: "git-pull-request", run: () => act.wtPr(d.pid, w.path) }] : []),
|
|
1469
|
+
...(sess.length ? [{ divider: true }, { section: "Sessions" }, ...sess.map((x) => ({ label: x.title ?? x.id.slice(0, 8), icon: "terminal-window", run: () => openSession(x.id) }))] : []),
|
|
1470
|
+
{ divider: true },
|
|
1471
|
+
{ label: "Copy path", icon: "copy", caption: tail(w.path, 14), run: () => copy(w.path) },
|
|
1472
|
+
...(w.branch ? [{ label: "Copy branch", icon: "git-branch", caption: tail(w.branch, 14), run: () => copy(w.branch) }] : []),
|
|
1473
|
+
...(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) }]),
|
|
1474
|
+
] };
|
|
1475
|
+
}
|
|
1476
|
+
if (kind === "task") {
|
|
1477
|
+
const t = (state.tasks?.tasks ?? []).find((x) => x.id === d.task);
|
|
1478
|
+
if (!t) return null;
|
|
1479
|
+
const exec = state.gates?.executable ?? [];
|
|
1480
|
+
return { title: t.id, items: [
|
|
1481
|
+
...(t.ready ? [
|
|
1482
|
+
{ label: "Run", icon: "play", caption: "claim + claude -p", run: () => act.runTask(t.id) },
|
|
1483
|
+
{ label: "Claim", icon: "folders", caption: "fresh worktree", run: () => act.claimTask(t.id) },
|
|
1484
|
+
] : t.claimedBy ? [
|
|
1485
|
+
{ label: "Run in worktree", icon: "play", run: () => act.runTask(t.id) },
|
|
1486
|
+
...(exec.length ? [{ label: "Run gates", icon: "check", caption: exec.join(", "), run: () => act.gateRun(t.id) }] : []),
|
|
1487
|
+
] : [{ label: t.status === "done" ? "Done" : "Blocked", disabled: true }]),
|
|
1488
|
+
{ divider: true },
|
|
1489
|
+
{ label: "Copy id", icon: "copy", caption: t.id, run: () => copy(t.id) },
|
|
1490
|
+
{ label: "Copy title", icon: "file-text", run: () => copy(`${t.id} — ${t.title}`) },
|
|
1491
|
+
] };
|
|
1492
|
+
}
|
|
1493
|
+
if (kind === "claim") {
|
|
1494
|
+
const c = (state.claims ?? []).find((x) => x.projectId === d.pid && x.task === d.task);
|
|
1495
|
+
if (!c) return null;
|
|
1496
|
+
const w = (state.worktrees[c.projectId] ?? []).find((x) => x.path === c.worktree);
|
|
1497
|
+
return { title: c.task, items: [
|
|
1498
|
+
...(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) }] : []),
|
|
1499
|
+
{ label: "Copy path", icon: "copy", caption: tail(c.worktree, 14), run: () => copy(c.worktree) },
|
|
1500
|
+
{ divider: true },
|
|
1501
|
+
c.state === "orphaned"
|
|
1502
|
+
? { label: "Force release", icon: "trash", danger: true, caption: "discards work", run: () => act.releaseClaim(c.projectId, c.task, true) }
|
|
1503
|
+
: { label: "Release claim", icon: "x", run: () => act.releaseClaim(c.projectId, c.task, false) },
|
|
1504
|
+
] };
|
|
1505
|
+
}
|
|
1506
|
+
if (kind === "pr") {
|
|
1507
|
+
const p = (state.prs ?? []).find((x) => String(x.projectId) === d.pid && String(x.number) === d.num);
|
|
1508
|
+
if (!p) return null;
|
|
1509
|
+
const green = p.checks !== "fail" && p.mergeable && !p.draft;
|
|
1510
|
+
return { title: `#${p.number}`, items: [
|
|
1511
|
+
{ label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => window.open(p.url, "_blank") },
|
|
1512
|
+
{ label: "Copy URL", icon: "copy", run: () => copy(p.url) },
|
|
1513
|
+
{ divider: true },
|
|
1514
|
+
{ 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) },
|
|
1515
|
+
] };
|
|
1516
|
+
}
|
|
1517
|
+
if (kind === "process") {
|
|
1518
|
+
return { items: [
|
|
1519
|
+
{ label: "Copy pid", icon: "copy", caption: d.pid, run: () => copy(d.pid) },
|
|
1520
|
+
...(d.cwd ? [{ label: "Copy cwd", icon: "folder-simple", caption: tail(d.cwd, 16), run: () => copy(d.cwd) }] : []),
|
|
1521
|
+
{ divider: true },
|
|
1522
|
+
{ label: "Stop", icon: "stop", danger: true, caption: "SIGTERM → SIGKILL", run: () => act.procStop(d.pid, d.proj) },
|
|
1523
|
+
] };
|
|
1524
|
+
}
|
|
1525
|
+
if (kind === "resource") {
|
|
1526
|
+
return { title: d.name, items: [
|
|
1527
|
+
{ label: "Copy name", icon: "copy", run: () => copy(d.name) },
|
|
1528
|
+
{ divider: true },
|
|
1529
|
+
{ label: "Release", icon: "x", danger: true, caption: "force", run: () => act.resRelease(d.name, d.proj) },
|
|
1530
|
+
] };
|
|
1531
|
+
}
|
|
1532
|
+
if (kind === "incident") {
|
|
1533
|
+
const i = [...(state.incidents ?? []), ...(state.allIncidents ?? [])].find((x) => String(x.seq) === d.seq);
|
|
1534
|
+
if (!i) return null;
|
|
1535
|
+
return { items: [
|
|
1536
|
+
...(i.sessionId ? [{ label: "Open session", icon: "terminal-window", run: () => openSession(i.sessionId) }] : []),
|
|
1537
|
+
...(i.suggestion ? [{ label: "Codify", icon: "shield", caption: "rule / lesson", run: () => act.codify(i.seq) }] : []),
|
|
1538
|
+
{ label: "Copy command", icon: "copy", run: () => copy(i.command ?? "") },
|
|
1539
|
+
...(i.acked ? [] : [{ divider: true }, { label: "Acknowledge", icon: "check", run: () => act.ack(i.seq) }]),
|
|
1224
1540
|
] };
|
|
1225
1541
|
}
|
|
1226
1542
|
if (kind === "settings") {
|
|
@@ -1302,6 +1618,33 @@ function whatsNew(version) {
|
|
|
1302
1618
|
}
|
|
1303
1619
|
window.swarmWhatsNew = (v) => whatsNew(v);
|
|
1304
1620
|
// auto-open once per version, but never on the very first run (nothing to compare against)
|
|
1621
|
+
// M-launch: after an update the running daemon is the old build until it restarts. The daemon
|
|
1622
|
+
// reports the version on disk; when it differs, offer a one-click restart, then reload.
|
|
1623
|
+
let updateNudged = false;
|
|
1624
|
+
setInterval(() => { fetch("/v1/health").then((r) => r.json()).then(maybeUpdateNudge).catch(() => {}); }, 300_000);
|
|
1625
|
+
function maybeUpdateNudge(h) {
|
|
1626
|
+
if (!h?.disk || !h.version || h.disk === h.version || updateNudged) return;
|
|
1627
|
+
updateNudged = true;
|
|
1628
|
+
const el = document.createElement("div");
|
|
1629
|
+
el.className = "nudge";
|
|
1630
|
+
el.innerHTML = `${ic("arrows-clockwise", 18, "ic")}<div><b>Swarm ${esc(h.disk)} is installed</b>The daemon is still running ${esc(h.version)} — restart it to switch. Sessions and history are unaffected.
|
|
1631
|
+
<div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
|
|
1632
|
+
document.body.appendChild(el);
|
|
1633
|
+
el.addEventListener("click", async (e) => {
|
|
1634
|
+
if (e.target.id === "updLater") return el.remove();
|
|
1635
|
+
if (e.target.id !== "updRestart") return;
|
|
1636
|
+
e.target.textContent = "restarting…";
|
|
1637
|
+
await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
|
|
1638
|
+
const t0 = Date.now();
|
|
1639
|
+
const wait = setInterval(async () => {
|
|
1640
|
+
try {
|
|
1641
|
+
const j = await (await fetch("/v1/health")).json();
|
|
1642
|
+
if (j.version === h.disk) { clearInterval(wait); location.reload(); }
|
|
1643
|
+
} catch {}
|
|
1644
|
+
if (Date.now() - t0 > 30_000) { clearInterval(wait); el.remove(); }
|
|
1645
|
+
}, 800);
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1305
1648
|
function maybeWhatsNew() {
|
|
1306
1649
|
if (!state.version || !window.RELEASE_NOTES) return;
|
|
1307
1650
|
let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
|
|
@@ -1356,6 +1699,17 @@ function openMenu(kind, anchor, d) {
|
|
|
1356
1699
|
if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
|
|
1357
1700
|
window.menus.open(anchor, spec);
|
|
1358
1701
|
}
|
|
1702
|
+
document.addEventListener("keydown", (e) => {
|
|
1703
|
+
if (e.key === "Enter" && e.target.id === "msgText") { e.preventDefault(); $("#msgSend")?.click(); }
|
|
1704
|
+
});
|
|
1705
|
+
// Enter / Space on a focused card, tile or kebab opens its menu like a click.
|
|
1706
|
+
document.addEventListener("keydown", (ev) => {
|
|
1707
|
+
if (ev.key !== "Enter" && ev.key !== " ") return;
|
|
1708
|
+
const t = ev.target.closest?.("[data-menu]");
|
|
1709
|
+
if (!t || t.tagName === "INPUT") return;
|
|
1710
|
+
ev.preventDefault();
|
|
1711
|
+
openMenu(t.dataset.menu, t, t.dataset);
|
|
1712
|
+
});
|
|
1359
1713
|
document.addEventListener("contextmenu", (ev) => {
|
|
1360
1714
|
const t = ev.target.closest("[data-ctx]");
|
|
1361
1715
|
if (!t) return;
|
|
@@ -1365,7 +1719,7 @@ document.addEventListener("contextmenu", (ev) => {
|
|
|
1365
1719
|
|
|
1366
1720
|
// ---------- events
|
|
1367
1721
|
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");
|
|
1722
|
+
const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop],[data-wtopen],[data-wtrm],[data-wtdiff],[data-wtpr],[data-dffile],#prGo,#sessDiff,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
|
|
1369
1723
|
if (!t) return;
|
|
1370
1724
|
if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
|
|
1371
1725
|
if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
|
|
@@ -1373,27 +1727,40 @@ document.addEventListener("click", async (ev) => {
|
|
|
1373
1727
|
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
1728
|
if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
|
|
1375
1729
|
if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
|
|
1376
|
-
if (t.dataset.
|
|
1377
|
-
if (t.
|
|
1730
|
+
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; }
|
|
1731
|
+
if (t.id === "psAllEmoji") { const all = $("#psEmojiAll"); if (all.hidden) { all.innerHTML = buildEmojiGrid(); all.hidden = false; } else all.hidden = true; return; }
|
|
1732
|
+
if (t.dataset.color !== undefined && t.classList.contains("swatch")) { for (const e of $$(".swatch")) e.classList.toggle("on", e === t); return; }
|
|
1733
|
+
if (t.id === "msgSend") {
|
|
1378
1734
|
ev.preventDefault();
|
|
1379
|
-
|
|
1380
|
-
|
|
1735
|
+
const text = $("#msgText")?.value.trim();
|
|
1736
|
+
if (!text) return;
|
|
1737
|
+
const r = await post("/v1/messages", { projectId: t.dataset.pid, to: t.dataset.sid, text, from: "dashboard" });
|
|
1738
|
+
if (!r.ok) return alert(r.error);
|
|
1739
|
+
$("#msgText").value = "";
|
|
1740
|
+
state.msgs = null;
|
|
1741
|
+
return refresh();
|
|
1381
1742
|
}
|
|
1382
|
-
if (t.dataset.
|
|
1743
|
+
if (t.id === "psSave") { ev.preventDefault(); return saveProjectSettings(t.dataset.pid); }
|
|
1744
|
+
if (t.dataset.wfstop !== undefined) {
|
|
1383
1745
|
ev.preventDefault();
|
|
1384
|
-
|
|
1385
|
-
|
|
1746
|
+
if (!confirm(`Stop the workflow on ${t.dataset.wfstop}? A live step's run is stopped too.`)) return;
|
|
1747
|
+
const r = await post("/v1/workflows/stop", { projectId: state.sel, task: t.dataset.wfstop });
|
|
1748
|
+
if (!r.ok) alert(r.error);
|
|
1749
|
+
state.workflows = null;
|
|
1386
1750
|
return refresh();
|
|
1387
1751
|
}
|
|
1388
|
-
if (t.dataset.
|
|
1752
|
+
if (t.dataset.bmode) { ev.preventDefault(); const [k, v] = t.dataset.bmode.split(":"); localStorage.setItem(`swarm.board.${k}`, v); return touch(); }
|
|
1753
|
+
if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
|
|
1754
|
+
if (t.dataset.runstop) {
|
|
1389
1755
|
ev.preventDefault();
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
if (!r.ok) alert(r.error);
|
|
1393
|
-
return;
|
|
1756
|
+
if (!confirm("Stop this run? Its stdin is closed, then the process is signalled by pid.")) return;
|
|
1757
|
+
return fetch(`/v1/runs/${encodeURIComponent(t.dataset.runstop)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
|
|
1394
1758
|
}
|
|
1395
|
-
if (t.dataset.
|
|
1396
|
-
|
|
1759
|
+
if (t.dataset.claim) { ev.preventDefault(); return act.claimTask(t.dataset.claim); }
|
|
1760
|
+
const split = (v) => { const i = v.indexOf(":"); return [v.slice(0, i), v.slice(i + 1)]; };
|
|
1761
|
+
if (t.dataset.wtopen) { ev.preventDefault(); return act.wtOpen(...split(t.dataset.wtopen)); }
|
|
1762
|
+
if (t.dataset.wtdiff) { ev.preventDefault(); return act.wtDiff(...split(t.dataset.wtdiff)); }
|
|
1763
|
+
if (t.dataset.wtpr) { ev.preventDefault(); return act.wtPr(...split(t.dataset.wtpr)); }
|
|
1397
1764
|
if (t.dataset.dffile !== undefined) { ev.preventDefault(); return loadDiffFile(t.dataset.dffile); }
|
|
1398
1765
|
if (t.id === "prGo") { ev.preventDefault(); return submitPr(); }
|
|
1399
1766
|
if (t.id === "sessDiff") {
|
|
@@ -1403,19 +1770,7 @@ document.addEventListener("click", async (ev) => {
|
|
|
1403
1770
|
const w = (state.worktrees[s.projectId] ?? []).find((x) => !x.main && (s.cwd === x.path || s.cwd.startsWith(`${x.path}/`)));
|
|
1404
1771
|
return w ? openDiffDrawer(s.projectId, w.path) : null;
|
|
1405
1772
|
}
|
|
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
|
-
}
|
|
1773
|
+
if (t.dataset.wtrm) { ev.preventDefault(); return act.wtRemove(...split(t.dataset.wtrm)); }
|
|
1419
1774
|
if (t.id === "wtnew") {
|
|
1420
1775
|
ev.preventDefault();
|
|
1421
1776
|
const name = prompt("Worktree name (folder under ~/.swarm/worktrees/<project>/; branch wt/<name>):");
|
|
@@ -1445,25 +1800,13 @@ document.addEventListener("click", async (ev) => {
|
|
|
1445
1800
|
state.dispatch = null;
|
|
1446
1801
|
return refresh();
|
|
1447
1802
|
}
|
|
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
|
-
}
|
|
1803
|
+
if (t.dataset.gaterun) { ev.preventDefault(); return act.gateRun(t.dataset.gaterun); }
|
|
1458
1804
|
if (t.dataset.codify) { ev.preventDefault(); return codifyIncident(t.dataset.codify); }
|
|
1459
1805
|
if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
|
|
1460
1806
|
if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
|
|
1461
1807
|
if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
|
|
1462
1808
|
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
|
-
}
|
|
1809
|
+
if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
|
|
1467
1810
|
if (t.dataset.ackall) {
|
|
1468
1811
|
return fetch("/v1/incidents/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project: state.sel || undefined }) }).then(refresh);
|
|
1469
1812
|
}
|
|
@@ -1471,37 +1814,13 @@ document.addEventListener("click", async (ev) => {
|
|
|
1471
1814
|
if (t.dataset.sdays) { ev.preventDefault(); state.statsDays = Number(t.dataset.sdays); return touch(); }
|
|
1472
1815
|
if (t.dataset.release || t.dataset.forcerelease) {
|
|
1473
1816
|
ev.preventDefault();
|
|
1474
|
-
const force = Boolean(t.dataset.forcerelease);
|
|
1475
1817
|
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();
|
|
1818
|
+
return act.releaseClaim(projectId, task, Boolean(t.dataset.forcerelease));
|
|
1484
1819
|
}
|
|
1485
1820
|
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
|
-
}
|
|
1821
|
+
if (t.dataset.merge !== undefined) { ev.preventDefault(); return act.merge(...t.dataset.merge.split(":")); }
|
|
1822
|
+
if (t.dataset.procstop) { ev.preventDefault(); return act.procStop(t.dataset.procstop, t.dataset.procproj); }
|
|
1823
|
+
if (t.dataset.resrelease !== undefined) { ev.preventDefault(); return act.resRelease(t.dataset.resrelease, t.dataset.resproj); }
|
|
1505
1824
|
if (t.id === "back") { ev.preventDefault(); state.session = null; return touch(); }
|
|
1506
1825
|
if (t.id === "replay") { ev.preventDefault(); return openReplay(); }
|
|
1507
1826
|
if (t.id === "resumeDead") { ev.preventDefault(); return resumeDead(); }
|
|
@@ -1578,6 +1897,87 @@ async function submitRun(taskId) {
|
|
|
1578
1897
|
openSession(r.run.sessionId);
|
|
1579
1898
|
}
|
|
1580
1899
|
|
|
1900
|
+
// ---------- project settings drawer
|
|
1901
|
+
const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊", "🤖", "🧠", "🎨", "🔒", "📚", "💬", "🏗️", "🧩", "⚡"];
|
|
1902
|
+
// Every emoji the platform font can draw, by Unicode block — no names, but browseable; the OS picker
|
|
1903
|
+
// (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
|
|
1904
|
+
const EMOJI_BLOCKS = [["Smileys & people", 0x1f600, 0x1f64f], ["Gestures & body", 0x1f440, 0x1f4ff], ["Animals & nature", 0x1f400, 0x1f43f], ["Food", 0x1f32d, 0x1f37f], ["Activity & travel", 0x1f680, 0x1f6ff], ["Objects", 0x1f4a0, 0x1f4ff], ["Symbols", 0x1f300, 0x1f32c], ["More", 0x1f900, 0x1f9ff], ["Extended", 0x1fa70, 0x1faff], ["Misc", 0x2600, 0x26ff], ["Dingbats", 0x2700, 0x27bf]];
|
|
1905
|
+
let emojiGrid = null;
|
|
1906
|
+
function buildEmojiGrid() {
|
|
1907
|
+
if (emojiGrid) return emojiGrid;
|
|
1908
|
+
// A code point counts as an emoji the platform can draw if it paints colored pixels.
|
|
1909
|
+
const S = 20, cv = document.createElement("canvas"); cv.width = S; cv.height = S;
|
|
1910
|
+
const c = cv.getContext("2d", { willReadFrequently: true });
|
|
1911
|
+
c.font = `${S - 4}px system-ui`; c.textBaseline = "top";
|
|
1912
|
+
const colored = (ch) => {
|
|
1913
|
+
c.clearRect(0, 0, S, S); c.fillText(ch, 0, 0);
|
|
1914
|
+
const d = c.getImageData(0, 0, S, S).data;
|
|
1915
|
+
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;
|
|
1916
|
+
return false;
|
|
1917
|
+
};
|
|
1918
|
+
emojiGrid = EMOJI_BLOCKS.map(([name, a, b]) => {
|
|
1919
|
+
const list = [];
|
|
1920
|
+
for (let cp = a; cp <= b; cp++) { const ch = String.fromCodePoint(cp); if (colored(ch)) list.push(ch); }
|
|
1921
|
+
return list.length ? `<div class="emoji-sec">${esc(name)}</div><div class="emoji-row">${list.map((e) => `<span class="emoji" data-emoji="${e}">${e}</span>`).join("")}</div>` : "";
|
|
1922
|
+
}).join("");
|
|
1923
|
+
return emojiGrid;
|
|
1924
|
+
}
|
|
1925
|
+
function openProjectSettings(pid) {
|
|
1926
|
+
const p = state.projects.find((x) => x.id === pid);
|
|
1927
|
+
if (!p) return;
|
|
1928
|
+
const slots = ["", "c1", "c2", "c3", "c4", "c5", "c6", "c7"];
|
|
1929
|
+
$("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
|
|
1930
|
+
<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>
|
|
1931
|
+
<div class="pk-b">
|
|
1932
|
+
<label>name<input id="psName" value="${esc(p.name)}" maxlength="60" spellcheck="false"></label>
|
|
1933
|
+
<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>
|
|
1934
|
+
<input type="hidden" id="psImage" value="${esc(p.icon?.startsWith("data:image/") ? p.icon : "")}">
|
|
1935
|
+
<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>
|
|
1936
|
+
<div class="emoji-all" id="psEmojiAll" hidden></div>
|
|
1937
|
+
<label>color</label>
|
|
1938
|
+
<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>
|
|
1939
|
+
<label class="chk"><input type="checkbox" id="psPinned" ${p.discovered ? "" : "checked"}> pinned — always in the sidebar, drag to reorder</label>
|
|
1940
|
+
</div>
|
|
1941
|
+
<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>
|
|
1942
|
+
</div>`;
|
|
1943
|
+
$("#psName").focus();
|
|
1944
|
+
}
|
|
1945
|
+
/** Downsize an image file to a square 64px PNG data URL (center-cropped). */
|
|
1946
|
+
function fileToIconDataUrl(file) {
|
|
1947
|
+
return new Promise((resolve, reject) => {
|
|
1948
|
+
const url = URL.createObjectURL(file);
|
|
1949
|
+
const img = new Image();
|
|
1950
|
+
img.onload = () => {
|
|
1951
|
+
// square: center-crop the shorter side (cover), never letterbox
|
|
1952
|
+
const S = 64, cv = document.createElement("canvas"); cv.width = S; cv.height = S;
|
|
1953
|
+
const side = Math.min(img.width, img.height), sx = (img.width - side) / 2, sy = (img.height - side) / 2;
|
|
1954
|
+
cv.getContext("2d").drawImage(img, sx, sy, side, side, 0, 0, S, S);
|
|
1955
|
+
URL.revokeObjectURL(url);
|
|
1956
|
+
resolve(cv.toDataURL("image/png"));
|
|
1957
|
+
};
|
|
1958
|
+
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("not an image the browser can decode")); };
|
|
1959
|
+
img.src = url;
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
function setIconPreview(icon) {
|
|
1963
|
+
const el = $("#psPreview");
|
|
1964
|
+
if (!el) return;
|
|
1965
|
+
el.innerHTML = icon ? (icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(icon)}" alt="">` : esc(icon)) : ic("folder-simple", 16);
|
|
1966
|
+
}
|
|
1967
|
+
async function saveProjectSettings(pid) {
|
|
1968
|
+
const body = {
|
|
1969
|
+
name: $("#psName").value.trim() || undefined,
|
|
1970
|
+
icon: $("#psImage").value || $("#psIcon").value.trim(),
|
|
1971
|
+
color: $(".swatch.on")?.dataset.color ?? "",
|
|
1972
|
+
pinned: $("#psPinned").checked,
|
|
1973
|
+
};
|
|
1974
|
+
const r = await fetch(`/v1/projects/${encodeURIComponent(pid)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
1975
|
+
if (!r.ok) return alert((await r.json()).error ?? "could not save");
|
|
1976
|
+
closePicker();
|
|
1977
|
+
state.dirty = true;
|
|
1978
|
+
refresh();
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1581
1981
|
// ---------- dispatch drawer (M7.5)
|
|
1582
1982
|
function openDispatchDrawer() {
|
|
1583
1983
|
const ready = (state.tasks?.tasks ?? []).filter((t) => t.ready);
|
|
@@ -1728,7 +2128,7 @@ let pending = false;
|
|
|
1728
2128
|
const pollSoon = () => { if (!pending) { pending = true; setTimeout(() => { pending = false; poll(); }, 400); } };
|
|
1729
2129
|
let backoff = 1500;
|
|
1730
2130
|
function connect() {
|
|
1731
|
-
const es = new EventSource(`/v1/events?since=${state.seq}`);
|
|
2131
|
+
const es = new EventSource(`/v1/events?since=${state.seq}${TOKEN ? `&token=${TOKEN}` : ""}`);
|
|
1732
2132
|
const on = () => { backoff = 1500; $("#daemon .dot").classList.add("on"); };
|
|
1733
2133
|
es.addEventListener("open", on);
|
|
1734
2134
|
es.addEventListener("ping", on);
|
|
@@ -1747,7 +2147,7 @@ function connect() {
|
|
|
1747
2147
|
if (fresh) notifyForEvent(ev);
|
|
1748
2148
|
pollSoon();
|
|
1749
2149
|
};
|
|
1750
|
-
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);
|
|
2150
|
+
for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "worktree.bootstrapped", "worktree.created", "worktree.removed", "pr.opened", "question.asked", "question.answered", "message.sent", "dispatch.queued", "dispatch.started", "dispatch.finished", "workflow.started", "workflow.step", "workflow.finished", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
|
|
1751
2151
|
}
|
|
1752
2152
|
refresh().then(() => {
|
|
1753
2153
|
const sid = new URLSearchParams(location.search).get("session");
|