@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/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) => ({ "&": "&amp;", "<
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
- const projName = (id) => state.projects.find((p) => p.id === id)?.name ?? (id === "p_unknown" ? "?" : id);
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 = 24) => { const t = short(p); return t.length > n ? `…${t.slice(-(n - 1))}` : t; };
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>${ic("folder-simple", 14)}<span class="nm">${disamb(p)}${esc(p.name)}</span><small>${live(p.id) || ""}</small>${act}</div>`;
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: 104, get: (s) => projName(s.projectId), cell: (s) => esc(projName(s.projectId)) },
295
- { key: "agent", label: "agent", width: 84, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
296
- { key: "session", label: "session", width: 236, 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>' : ""}` },
297
- { key: "branch", label: "branch", width: 134, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
298
- { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => `<span class="now" title="${esc(s.last)}">${esc(s.state === "waiting" ? (s.lastText ? s.lastText.split("\n")[0] : s.last) : s.last)}</span>` },
299
- { key: "model", label: "model", width: 96, 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>` },
300
- { key: "trend", label: "trend", width: 100, sortable: false, filterable: false, get: () => null, cell: (s) => viz.sparkline(s.spark.map((p) => p[0]), viz.agentColor(s.agent)) },
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: 96, cell: (p) => (green(p) ? `<a href="#" data-merge="${p.projectId}:${p.number}" title="Squash-merge via ${p.forge === "gitlab" ? "glab" : "gh"}">Merge</a>` : "") },
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) => esc(projName(i.projectId)) },
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.reason ?? "")}">${esc(i.command ?? "")}</span>` },
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: 44, cell: ackLink },
409
- rowAttrs: (i) => (i.sessionId ? `data-s="${i.sessionId}"` : ""),
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: 120, cell: (i) => `${i.suggestion ? `<a href="#" data-codify="${i.seq}" title="Turn this into a rule / lesson">${ic("shield", 12)} Codify</a> ` : ""}${ackLink(i)}` },
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) => esc(projName(r.projectId)) },
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: 60, cell: (r) => `<a href="#" data-procstop="${r.pid}" data-procproj="${esc(r.projectId)}" title="SIGTERM, then SIGKILL after 3 s">Stop</a>` },
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: 90, cell: (r) => `<a href="#" data-resrelease="${esc(r.name)}" data-resproj="${esc(r.projectId ?? "")}">Release</a>` },
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)}${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>` : ""}</div>` +
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: 170, cell: (t) => (t.ready ? `<a href="#" data-run="${esc(t.id)}" title="Claim and spawn claude -p in a worktree">${ic("play", 12)} Run</a> · <a href="#" data-claim="${esc(t.id)}" title="Claim into a fresh worktree">Claim</a>` : t.claimedBy ? `<a href="#" data-run="${esc(t.id)}" title="Spawn claude -p in the held worktree">${ic("play", 12)} Run</a>${(state.gates?.executable ?? []).length ? ` · <a href="#" data-gaterun="${esc(t.id)}" title="Execute the repo's [gates.<name>] cmd gates in this task's worktree: ${esc((state.gates.executable ?? []).join(", "))}">${ic("check", 12)} Gates</a>` : ""}` : "") },
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) => esc(projName(c.projectId)) },
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: 120, cell: (c) => {
640
- const key = `${c.projectId}:${c.task}`;
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) => esc(projName(w.projectId)) },
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
- return `<h2 class="mt-sec hrow">Worktrees <span>${rows.length}</span>${newBtn}${gcBtn}</h2>` +
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: 230, cell: actions },
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("context", `${tok(ctx)}<small> · ${ctx ? ((100 * t.cacheRead) / ctx).toFixed(0) : 0}% cached</small>`)}
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: "Copy path", icon: "copy", caption: tail(p.root), run: () => copy(p.root) },
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, 18), run: () => copy(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, 18), run: () => copy(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
- ev.preventDefault();
1384
- const r = await fetch("/v1/claims", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, task: t.dataset.claim, owner: "dashboard" }) }).then((x) => x.json());
1385
- if (!r.ok) alert(r.error); else state.tasks = null;
1386
- return refresh();
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
- if (force && !confirm(`Force-release ${task}? This permanently discards its worktree and any uncommitted work.`)) return;
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
- ev.preventDefault();
1488
- const [projectId, number] = t.dataset.merge.split(":");
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);