@ra3orblade/swarm 0.9.0 → 0.10.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 +8 -1
- package/dist/swarm-hook.js +2 -0
- package/dist/swarm.js +171 -2
- package/dist/swarmd.js +1179 -20
- package/package.json +1 -1
- package/web/app.js +184 -18
- package/web/index.html +21 -12
- package/web/release-notes.js +1 -1
- package/web/viz.js +47 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ra3orblade/swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Local-first control plane for AI-agent development: watch every Claude Code / Codex / Grok session on your machine, ledger tasks and worktrees, enforce rules as hook denials.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/web/app.js
CHANGED
|
@@ -65,7 +65,7 @@ document.addEventListener("keydown", (ev) => {
|
|
|
65
65
|
window.swarmZoom(dir);
|
|
66
66
|
});
|
|
67
67
|
// `dirty`: a UI-side change (selection, view, filter) needs a render even when the daemon snapshot is unchanged.
|
|
68
|
-
const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, dispatch: null, questions: [], budget: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, dirty: true };
|
|
68
|
+
const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, dispatch: null, questions: [], budget: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, collisions: null, outcomes: null, dirty: true };
|
|
69
69
|
|
|
70
70
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
71
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`; };
|
|
@@ -203,9 +203,39 @@ async function refresh() {
|
|
|
203
203
|
incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
|
|
204
204
|
state.allIncidents = inc;
|
|
205
205
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
206
|
+
let colChanged = false;
|
|
207
|
+
if (state.view === "graphs" && !state.session) {
|
|
208
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
209
|
+
const col = await fetch(`/v1/graphs/collisions${q}`).then((r) => r.json()).catch(() => state.collisions);
|
|
210
|
+
colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
|
|
211
|
+
state.collisions = col;
|
|
212
|
+
}
|
|
213
|
+
let outChanged = false;
|
|
214
|
+
if (state.view === "outcomes" && !state.session) {
|
|
215
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
216
|
+
const o = await fetch(`/v1/outcomes${q}`).then((r) => r.json()).catch(() => state.outcomes);
|
|
217
|
+
outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
|
|
218
|
+
state.outcomes = o;
|
|
219
|
+
}
|
|
220
|
+
if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || outChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
|
|
221
|
+
}
|
|
222
|
+
// M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
|
|
223
|
+
// deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
|
|
224
|
+
const VIEW_DEFS = [
|
|
225
|
+
{ id: "fleet", label: "Fleet", icon: "squares-four", group: "Observe", render: () => renderFleet() },
|
|
226
|
+
{ id: "timeline", label: "Timeline", icon: "clock-counter-clockwise", group: "Observe", render: () => renderTimeline() },
|
|
227
|
+
{ id: "graphs", label: "Graphs", icon: "tree-structure", group: "Observe", render: () => renderGraphs(), badge: () => state.collisions?.contested ?? 0 },
|
|
228
|
+
{ id: "board", label: "Board", icon: "stack", group: "Work", render: () => renderBoard() },
|
|
229
|
+
{ id: "prs", label: "PRs", icon: "git-pull-request", group: "Work", render: () => renderPRs() },
|
|
230
|
+
{ id: "outcomes", label: "Outcomes", icon: "check", group: "Insight", render: () => renderOutcomes() },
|
|
231
|
+
{ id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
|
|
232
|
+
{ id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
|
|
233
|
+
{ id: "search", label: "Search", icon: "magnifying-glass", group: "Insight", render: () => renderSearch() },
|
|
234
|
+
{ id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
|
|
235
|
+
];
|
|
236
|
+
const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
|
|
237
|
+
const VIEWS = VIEW_DEFS.map((v) => v.id);
|
|
238
|
+
let navHtml = ""; // last-rendered nav html; declared before the restore block below calls renderNav()
|
|
209
239
|
// restore last view + project selection (persisted UI state)
|
|
210
240
|
{
|
|
211
241
|
const v = localStorage.getItem("swarm.view");
|
|
@@ -217,7 +247,7 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
|
|
|
217
247
|
if (VIEWS.includes(q.get("view"))) state.view = q.get("view");
|
|
218
248
|
if (q.has("project")) state.sel = q.get("project") || null;
|
|
219
249
|
// Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
|
|
220
|
-
|
|
250
|
+
renderNav();
|
|
221
251
|
}
|
|
222
252
|
function render() {
|
|
223
253
|
// A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
|
|
@@ -231,14 +261,7 @@ function render() {
|
|
|
231
261
|
if (!dragPid) renderProjects(); // a re-render mid-drag would yank the row out from under the cursor
|
|
232
262
|
renderHeader();
|
|
233
263
|
if (state.session) renderSession();
|
|
234
|
-
else
|
|
235
|
-
else if (state.view === "stats") { loadStats(); renderStats(); } // loadStats is a no-op while the cache is fresh
|
|
236
|
-
else if (state.view === "search") renderSearch();
|
|
237
|
-
else if (state.view === "timeline") renderTimeline();
|
|
238
|
-
else if (state.view === "board") renderBoard();
|
|
239
|
-
else if (state.view === "incidents") renderIncidentsView();
|
|
240
|
-
else if (state.view === "prs") renderPRs();
|
|
241
|
-
else renderFleet();
|
|
264
|
+
else (viewDef(state.view)?.render ?? viewDef("fleet").render)();
|
|
242
265
|
if (keep) {
|
|
243
266
|
const el = document.querySelector(`input[data-filter="${keep.key}"][data-tid="${keep.tid}"]`);
|
|
244
267
|
if (el) { el.focus(); el.setSelectionRange(keep.pos, keep.pos); }
|
|
@@ -249,9 +272,22 @@ function renderHeader() {
|
|
|
249
272
|
const today = state.spend ? sumBy(state.spend.byProjectToday, (x) => x.cost) : 0;
|
|
250
273
|
const html = `Today <b>${usd(today)}</b>`;
|
|
251
274
|
if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
275
|
+
renderNav();
|
|
276
|
+
}
|
|
277
|
+
// M9.1: grouped view nav in the sidebar, generated from VIEW_DEFS. Rebuilt only when the
|
|
278
|
+
// html changes (active view, badges) so the 5s poll doesn't churn the DOM.
|
|
279
|
+
function renderNav() {
|
|
280
|
+
const groups = [];
|
|
281
|
+
for (const v of VIEW_DEFS) {
|
|
282
|
+
const g = groups.find((x) => x.name === v.group) ?? groups[groups.push({ name: v.group, views: [] }) - 1];
|
|
283
|
+
g.views.push(v);
|
|
284
|
+
}
|
|
285
|
+
const link = (v) => {
|
|
286
|
+
const n = v.badge?.() ?? 0;
|
|
287
|
+
return `<a href="#" data-view="${v.id}" class="nav ${!state.session && state.view === v.id ? "on" : ""}" title="${v.label}">${ic(v.icon, 14)}<span class="nm">${v.label}</span>${n ? `<b class="navcount">${n > 99 ? "99+" : n}</b>` : ""}</a>`;
|
|
288
|
+
};
|
|
289
|
+
const html = groups.map((g) => `<h4>${g.name}</h4>${g.views.map(link).join("")}`).join("");
|
|
290
|
+
if (html !== navHtml) { navHtml = html; $("#viewnav").innerHTML = html; }
|
|
255
291
|
}
|
|
256
292
|
|
|
257
293
|
const isLive = (s) => s.state === "active" || s.state === "waiting";
|
|
@@ -348,7 +384,7 @@ function onboarding() {
|
|
|
348
384
|
const FLEET_COLS = [
|
|
349
385
|
{ key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
|
|
350
386
|
{ 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>' : ""}` },
|
|
387
|
+
{ key: "session", label: "session", width: 210, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}${(state.questions ?? []).some((q) => q.sessionId === s.id) ? ' <span class="badge warn" title="This agent asked a question only a human can answer — open the session">Asking</span>' : ""}${s.stuck ? ` <span class="badge bad" title="${esc(s.stuck)} — heuristic, nothing was interrupted; open the session to judge">Stuck</span>` : ""}` },
|
|
352
388
|
{ key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
|
|
353
389
|
{ key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
|
|
354
390
|
const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
|
|
@@ -1136,6 +1172,76 @@ async function loadTimelineDetail() {
|
|
|
1136
1172
|
if (state.view === "timeline" && !state.session) touch();
|
|
1137
1173
|
} finally { tlDetail.busy = false; }
|
|
1138
1174
|
}
|
|
1175
|
+
// M9.2: Outcomes — did the agent's work survive? Branch → PR → merged / reverted, with
|
|
1176
|
+
// scorecards per model and per agent. Data from /v1/outcomes (fetched by the poll while open).
|
|
1177
|
+
const outBadge = (o) => ({ merged: '<span class="badge ok">merged</span>', reverted: '<span class="badge bad">reverted</span>', open: '<span class="badge acc">open</span>', "no-pr": '<span class="badge">no PR</span>' })[o] ?? esc(o);
|
|
1178
|
+
const ratePct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
|
|
1179
|
+
const hrs = (x) => (x == null || x < 0 ? "—" : x < 1 ? `${Math.round(x * 60)}m` : x < 48 ? `${x.toFixed(1)}h` : `${(x / 24).toFixed(1)}d`);
|
|
1180
|
+
const scoreCols = (label) => [
|
|
1181
|
+
{ key: "key", label, width: 150, get: (r) => r.key, cell: (r) => `<b>${esc(label === "model" ? model(r.key) : viz.agentName(r.key))}</b>` },
|
|
1182
|
+
{ key: "branches", label: "branches", width: 80, num: true, get: (r) => r.branches, cell: (r) => String(r.branches) },
|
|
1183
|
+
{ key: "merged", label: "merged", width: 72, num: true, get: (r) => r.merged, cell: (r) => String(r.merged) },
|
|
1184
|
+
{ key: "reverted", label: "reverted", width: 78, num: true, get: (r) => r.reverted, cell: (r) => (r.reverted ? `<b style="color:var(--bad)">${r.reverted}</b>` : "0") },
|
|
1185
|
+
{ key: "open", label: "open", width: 60, num: true, get: (r) => r.open, cell: (r) => String(r.open) },
|
|
1186
|
+
{ key: "nopr", label: "no PR", width: 64, num: true, get: (r) => r.noPr, cell: (r) => String(r.noPr) },
|
|
1187
|
+
{ key: "rate", label: "merge rate", width: 92, num: true, get: (r) => r.mergeRate ?? -1, cell: (r) => ratePct(r.mergeRate) },
|
|
1188
|
+
{ key: "lead", label: "median lead", width: 98, num: true, get: (r) => r.medianLeadHours ?? -1, cell: (r) => hrs(r.medianLeadHours) },
|
|
1189
|
+
{ key: "cpm", label: "$ / merge", width: 84, num: true, get: (r) => r.costPerMerge ?? -1, cell: (r) => (r.costPerMerge == null ? "—" : usd(r.costPerMerge)) },
|
|
1190
|
+
];
|
|
1191
|
+
const BRANCH_COLS = [
|
|
1192
|
+
{ key: "branch", label: "branch", width: 190, get: (r) => r.branch, cell: (r) => `<span class="br">${esc(r.branch)}</span>` },
|
|
1193
|
+
{ key: "outcome", label: "outcome", width: 92, cls: "td-badge", get: (r) => r.outcome, cell: (r) => outBadge(r.outcome) },
|
|
1194
|
+
{ key: "pr", label: "PR", flex: true, get: (r) => r.title ?? "", cell: (r) => (r.prNumber ? `<a href="${esc(r.url ?? "#")}" target="_blank" rel="noreferrer">#${r.prNumber}</a> <span class="dim">${esc(r.title ?? "")}</span>` : '<span class="faint">—</span>') },
|
|
1195
|
+
{ key: "model", label: "model", width: 92, get: (r) => model(r.model), cell: (r) => `<span class="br">${esc(model(r.model))}</span>` },
|
|
1196
|
+
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (r) => agentLabel(r.agent), cell: (r) => agentBadge(r.agent) },
|
|
1197
|
+
{ key: "sessions", label: "sessions", width: 76, num: true, get: (r) => r.sessions.length, cell: (r) => String(r.sessions.length) },
|
|
1198
|
+
{ key: "cost", label: "cost", width: 64, num: true, get: (r) => r.costUsd, cell: (r) => usd(r.costUsd) },
|
|
1199
|
+
{ key: "lead", label: "lead", width: 64, num: true, get: (r) => r.leadHours ?? -1, cell: (r) => hrs(r.leadHours) },
|
|
1200
|
+
];
|
|
1201
|
+
function renderOutcomes() {
|
|
1202
|
+
const o = state.outcomes;
|
|
1203
|
+
const head = (sub) => `<h2>Outcomes <span>${sub}</span></h2>`;
|
|
1204
|
+
if (!o) {
|
|
1205
|
+
$("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}Loading…</div>`;
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (!o.branches?.length) {
|
|
1209
|
+
$("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}No agent branches yet${state.sel ? " in this project" : ""}.<br>Outcomes fill in as sessions work on branches and their PRs merge — or get reverted.</div>`;
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const n = (k) => o.branches.filter((b) => b.outcome === k).length;
|
|
1213
|
+
const rev = n("reverted");
|
|
1214
|
+
$("#main").innerHTML =
|
|
1215
|
+
head(`${o.branches.length} branch${o.branches.length === 1 ? "" : "es"} · ${n("merged")} merged · ${rev ? `<b style="color:var(--bad)">${rev} reverted</b>` : "0 reverted"} · ${n("open")} open`) +
|
|
1216
|
+
`<h2 class="mt-sec">By model <span>who ships work that survives</span></h2>` +
|
|
1217
|
+
dataTable({ id: "outcomes-model", columns: scoreCols("model"), rows: o.byModel }) +
|
|
1218
|
+
(o.byAgent.length > 1 ? `<h2 class="mt-sec">By agent</h2>${dataTable({ id: "outcomes-agent", columns: scoreCols("agent"), rows: o.byAgent })}` : "") +
|
|
1219
|
+
`<h2 class="mt-sec">Branches <span>latest first</span></h2>` +
|
|
1220
|
+
dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100) });
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// M9.12: live file-collision graph — which live sessions touch which files, contested files
|
|
1224
|
+
// highlighted. Data from /v1/graphs/collisions (fetched by the poll while the view is open).
|
|
1225
|
+
function renderGraphs() {
|
|
1226
|
+
const g = state.collisions;
|
|
1227
|
+
const title = (s) => s.title ?? s.id.slice(0, 8);
|
|
1228
|
+
const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>`;
|
|
1229
|
+
if (!g || !g.sessions.length) {
|
|
1230
|
+
$("#main").innerHTML = head("live file collisions") + `<div class="empty">${PX.idle()}No live sessions${state.sel ? " in this project" : ""}.<br>The collision graph shows who is touching what, the moment two agents run at once.</div>`;
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (!g.files.length) {
|
|
1234
|
+
$("#main").innerHTML = head(`${g.sessions.length} live session${g.sessions.length === 1 ? "" : "s"}`) + `<div class="empty">${PX.idle()}No file touches recorded yet — the graph fills in as agents read and edit.</div>`;
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const sessions = g.sessions.map((s) => ({ ...s, label: title(s) }));
|
|
1238
|
+
const agents = [...new Set(sessions.map((s) => s.agent))].sort(viz.agentSort);
|
|
1239
|
+
const sub = `${sessions.length} live session${sessions.length === 1 ? "" : "s"} · ${g.files.length} file${g.files.length === 1 ? "" : "s"} · ${g.contested ? `<b class="navcount">${g.contested} contested</b>` : "no collisions"}`;
|
|
1240
|
+
$("#main").innerHTML = head(sub) +
|
|
1241
|
+
`<div class="card" style="padding:14px">${viz.bipartite(sessions, g.files)}</div>
|
|
1242
|
+
<div style="margin-top:10px;display:flex;gap:16px;align-items:center">${viz.legend(agents)}<span class="dim" style="font-size:var(--fs-sm)">solid edge = writing · faint edge = reading · <span style="color:var(--bad)">red file</span> = two sessions on it, at least one writing</span></div>`;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1139
1245
|
function renderTimeline() {
|
|
1140
1246
|
loadTimelineDetail();
|
|
1141
1247
|
const now = Date.now();
|
|
@@ -1587,6 +1693,10 @@ ${p.reason ?? ""}`.slice(0, 180);
|
|
|
1587
1693
|
title = "An agent has a question";
|
|
1588
1694
|
body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
|
|
1589
1695
|
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
1696
|
+
} else if (ev.type === "session.stuck") {
|
|
1697
|
+
title = "Session looks stuck";
|
|
1698
|
+
body = (p.reason ?? p.summary ?? "").slice(0, 180);
|
|
1699
|
+
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
1590
1700
|
} else if (ev.type === "claim.orphaned") {
|
|
1591
1701
|
title = "Claim orphaned";
|
|
1592
1702
|
body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
|
|
@@ -1718,8 +1828,11 @@ document.addEventListener("contextmenu", (ev) => {
|
|
|
1718
1828
|
});
|
|
1719
1829
|
|
|
1720
1830
|
// ---------- events
|
|
1831
|
+
// Every id / data-attr a branch below matches on MUST be in this selector, or the branch is
|
|
1832
|
+
// unreachable (closest() returns null and the click dies silently) — that is how Replay,
|
|
1833
|
+
// Resume-where-it-died and the dry-run Re-run button all shipped dead.
|
|
1721
1834
|
document.addEventListener("click", async (ev) => {
|
|
1722
|
-
const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop],[data-wtopen],[data-wtrm],[data-wtdiff],[data-wtpr],[data-dffile],#prGo,#sessDiff,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
|
|
1835
|
+
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,#replay,#resumeDead,#drRun,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
|
|
1723
1836
|
if (!t) return;
|
|
1724
1837
|
if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
|
|
1725
1838
|
if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
|
|
@@ -1855,6 +1968,57 @@ $("#sbToggle")?.addEventListener("click", () => {
|
|
|
1855
1968
|
});
|
|
1856
1969
|
sbApply();
|
|
1857
1970
|
|
|
1971
|
+
// ---------- ⌘K palette (M9.1): jump to any view, project or session; falls through to Search.
|
|
1972
|
+
const pal = { items: [], view: [], q: "", i: 0 };
|
|
1973
|
+
function palBuild() {
|
|
1974
|
+
const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => { state.view = v.id; localStorage.setItem("swarm.view", v.id); state.session = null; state.dirty = true; refresh(); } }));
|
|
1975
|
+
for (const p of state.projects) items.push({ icon: "folder-simple", label: p.name, grp: "project", run: () => { state.sel = p.id; localStorage.setItem("swarm.sel", p.id); state.session = null; state.dirty = true; refresh(); } });
|
|
1976
|
+
const pname = (id) => state.projects.find((p) => p.id === id)?.name ?? "";
|
|
1977
|
+
for (const s of state.sessions) items.push({ icon: "terminal-window", label: s.title || s.id.slice(0, 8), sub: pname(s.projectId), live: isLive(s), grp: "session", run: () => openSession(s.id) });
|
|
1978
|
+
return items;
|
|
1979
|
+
}
|
|
1980
|
+
function palFilter() {
|
|
1981
|
+
const q = pal.q.trim().toLowerCase();
|
|
1982
|
+
const rank = (x) => Math.min(...[x.label, x.sub ?? ""].map((t) => { const i = t.toLowerCase().indexOf(q); return i < 0 ? 1e9 : i; }));
|
|
1983
|
+
const out = q
|
|
1984
|
+
? pal.items.map((x) => ({ x, r: rank(x) })).filter((h) => h.r < 1e9).sort((a, b) => a.r - b.r).map((h) => h.x).slice(0, 12)
|
|
1985
|
+
: pal.items.filter((x) => x.grp !== "session" || x.live).slice(0, 16); // idle: every view + project + live sessions
|
|
1986
|
+
if (q) out.push({ icon: "magnifying-glass", label: `Search Swarm for “${pal.q.trim()}”`, grp: "search", run: () => { srch.q = pal.q.trim(); state.view = "search"; localStorage.setItem("swarm.view", "search"); state.session = null; state.dirty = true; runSearch(); refresh(); } });
|
|
1987
|
+
return out;
|
|
1988
|
+
}
|
|
1989
|
+
function palRender() {
|
|
1990
|
+
pal.view = palFilter();
|
|
1991
|
+
if (pal.i >= pal.view.length) pal.i = Math.max(0, pal.view.length - 1);
|
|
1992
|
+
const row = (x, i) => `<div class="pk-row pal-row ${i === pal.i ? "on" : ""}" data-pal="${i}">${ic(x.icon, 14)}<span class="nm">${esc(x.label)}${x.sub ? ` <span class="dim">· ${esc(x.sub)}</span>` : ""}</span><span class="grp">${x.grp}</span></div>`;
|
|
1993
|
+
const el = $("#palList");
|
|
1994
|
+
if (el) el.innerHTML = pal.view.map(row).join("") || '<div class="empty" style="padding:16px">No matches.</div>';
|
|
1995
|
+
}
|
|
1996
|
+
function palRun(i) {
|
|
1997
|
+
const x = pal.view[i];
|
|
1998
|
+
if (!x) return;
|
|
1999
|
+
closePicker();
|
|
2000
|
+
x.run();
|
|
2001
|
+
}
|
|
2002
|
+
function openPalette() {
|
|
2003
|
+
pal.items = palBuild(); pal.q = ""; pal.i = 0;
|
|
2004
|
+
$("#picker").innerHTML = `<div class="pk pal" role="dialog" aria-modal="true">
|
|
2005
|
+
<div class="pk-h">${ic("magnifying-glass", 15)}<input id="palQ" placeholder="Jump to view, project or session…" spellcheck="false" autocomplete="off"></div>
|
|
2006
|
+
<div class="pk-list" id="palList"></div>
|
|
2007
|
+
</div>`;
|
|
2008
|
+
palRender();
|
|
2009
|
+
const inp = $("#palQ");
|
|
2010
|
+
inp.focus();
|
|
2011
|
+
inp.addEventListener("input", () => { pal.q = inp.value; pal.i = 0; palRender(); });
|
|
2012
|
+
inp.addEventListener("keydown", (ev) => {
|
|
2013
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); pal.i = Math.max(0, Math.min(pal.view.length - 1, pal.i + (ev.key === "ArrowDown" ? 1 : -1))); palRender(); }
|
|
2014
|
+
else if (ev.key === "Enter") { ev.preventDefault(); palRun(pal.i); }
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
$("#palBtn")?.addEventListener("click", openPalette);
|
|
2018
|
+
document.addEventListener("keydown", (ev) => {
|
|
2019
|
+
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") { ev.preventDefault(); if ($("#palQ")) closePicker(); else openPalette(); }
|
|
2020
|
+
});
|
|
2021
|
+
|
|
1858
2022
|
// ---------- folder picker
|
|
1859
2023
|
const picker = { path: null };
|
|
1860
2024
|
// Run drawer (M3.3): prompt prefilled from the task row; submit = POST /v1/runs.
|
|
@@ -2100,6 +2264,8 @@ async function pickerGo(path) {
|
|
|
2100
2264
|
const closePicker = () => { $("#picker").innerHTML = ""; };
|
|
2101
2265
|
$("#picker").addEventListener("click", (ev) => {
|
|
2102
2266
|
if (ev.target.id === "picker" || ev.target.closest("#pkCancel")) return closePicker();
|
|
2267
|
+
const pr = ev.target.closest("[data-pal]");
|
|
2268
|
+
if (pr) return palRun(Number(pr.dataset.pal));
|
|
2103
2269
|
const go = ev.target.closest("[data-go]");
|
|
2104
2270
|
if (go) return void pickerGo(go.dataset.go);
|
|
2105
2271
|
const ctoml = ev.target.closest("[data-copy-toml]"), cles = ev.target.closest("[data-copy-lesson]");
|
package/web/index.html
CHANGED
|
@@ -88,9 +88,11 @@
|
|
|
88
88
|
kbd{font:var(--fs-mono) var(--mono);background:var(--panel-2);border:1px solid var(--line);border-radius:var(--r-xs);padding:1px 5px;color:var(--fg-2)}
|
|
89
89
|
code{font:var(--fs-md) var(--mono);color:var(--fg-2)}
|
|
90
90
|
|
|
91
|
-
/* collapsed sidebar */
|
|
92
|
-
body.nosb{grid-template-columns:
|
|
93
|
-
body.nosb>aside{
|
|
91
|
+
/* collapsed sidebar = icon rail (M9.1): views stay one click away; projects via ⌘K */
|
|
92
|
+
body.nosb{grid-template-columns:46px 1fr}
|
|
93
|
+
body.nosb>aside{padding:12px 5px;overflow:hidden}
|
|
94
|
+
body.nosb #viewnav a.nav{justify-content:center;padding:7px 0}
|
|
95
|
+
body.nosb #viewnav .nm,body.nosb #viewnav h4,body.nosb #viewnav .navcount,body.nosb #projects{display:none}
|
|
94
96
|
body>aside{grid-column:1;grid-row:2}
|
|
95
97
|
body>main{grid-column:2;grid-row:2}
|
|
96
98
|
#projects h4{display:flex;align-items:center;gap:5px}
|
|
@@ -124,6 +126,13 @@
|
|
|
124
126
|
|
|
125
127
|
/* sidebar */
|
|
126
128
|
body>aside{border-right:1px solid var(--line);background:var(--panel);padding:12px 10px;overflow:auto;display:flex;flex-direction:column}
|
|
129
|
+
/* view nav (M9.1): grouped views in the sidebar, generated from VIEW_DEFS; header holds only chrome */
|
|
130
|
+
#viewnav{display:flex;flex-direction:column;gap:1px;flex:none;margin-bottom:4px}
|
|
131
|
+
#viewnav a.nav{display:flex;align-items:center;gap:9px;color:var(--dim);padding:5px 8px;border-radius:var(--r-sm);font-weight:500;transition:color var(--t-fast),background var(--t-fast)}
|
|
132
|
+
#viewnav a.nav:hover{color:var(--fg-2);background:var(--panel-2)}
|
|
133
|
+
#viewnav a.nav.on{color:var(--fg);background:var(--panel-2)}
|
|
134
|
+
#viewnav a.nav .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
135
|
+
#viewnav .navcount{margin-left:auto}
|
|
127
136
|
body>aside h4{margin:14px 8px 6px;color:var(--faint);font-size:var(--fs-xs);font-weight:600;text-transform:uppercase;letter-spacing:.09em}
|
|
128
137
|
body>aside h4:first-child{margin-top:2px}
|
|
129
138
|
.proj{padding:var(--pad-row);border-radius:var(--r-sm);cursor:pointer;display:flex;align-items:center;gap:9px;
|
|
@@ -153,6 +162,11 @@
|
|
|
153
162
|
.dr-rule select{background:var(--panel-2);color:var(--fg);border:1px solid var(--line);border-radius:var(--r-sm);padding:2px 4px}
|
|
154
163
|
.dr-flaky{padding:6px 8px;border:1px solid var(--line);border-radius:var(--r-sm);margin:6px 0;background:var(--panel-2)}
|
|
155
164
|
table.plain{width:100%;border-collapse:collapse;font-size:var(--fs-sm)} table.plain td{padding:3px 6px;border-top:1px solid var(--line);vertical-align:top}
|
|
165
|
+
/* ⌘K palette (M9.1): top-anchored .pk variant; rows reuse .pk-row */
|
|
166
|
+
.pk.pal{align-self:start;margin-top:9vh}
|
|
167
|
+
.pal .pk-list{min-height:120px;max-height:min(440px,62vh)}
|
|
168
|
+
.pal .pk-row.on{background:var(--acc-soft);color:var(--fg)}
|
|
169
|
+
.pal .pk-row .grp{margin-left:auto;color:var(--faint);font-size:var(--fs-xs);text-transform:uppercase;letter-spacing:.07em}
|
|
156
170
|
#picker:empty{display:none}
|
|
157
171
|
#picker{position:fixed;inset:0;background:var(--overlay);display:grid;place-items:center;z-index:50}
|
|
158
172
|
.pk{width:min(560px,92vw);max-height:78vh;display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--shadow-pop);overflow:hidden}
|
|
@@ -298,6 +312,7 @@
|
|
|
298
312
|
.badge.warn{color:var(--warn);background:var(--warn-soft)}
|
|
299
313
|
.badge.acc{color:var(--acc);background:var(--acc-soft)}
|
|
300
314
|
.badge.ok{color:var(--ok);background:var(--ok-soft)}
|
|
315
|
+
.badge.bad{color:var(--bad);background:var(--bad-soft)}
|
|
301
316
|
.badge.agent{color:var(--violet);background:color-mix(in srgb,var(--violet) 14%,transparent);font-weight:600}
|
|
302
317
|
|
|
303
318
|
/* empty */
|
|
@@ -426,7 +441,7 @@
|
|
|
426
441
|
/* icons (Phosphor regular, inline) */
|
|
427
442
|
.ph{display:inline-block;vertical-align:middle;flex:none}
|
|
428
443
|
header .nav{display:inline-flex;align-items:center;gap:6px}
|
|
429
|
-
|
|
444
|
+
.navcount{font:600 var(--fs-xs) var(--mono);color:var(--warn);background:var(--warn-soft);border-radius:var(--r-pill);padding:1px 6px;line-height:1.4}
|
|
430
445
|
header .nav .ph{opacity:.75}
|
|
431
446
|
header .nav.on .ph{opacity:1;color:var(--acc)}
|
|
432
447
|
.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border-radius:var(--r-sm);
|
|
@@ -578,21 +593,15 @@
|
|
|
578
593
|
<header>
|
|
579
594
|
<button class="icon-btn" id="sbToggle" title="Toggle sidebar" aria-label="Toggle sidebar"><i data-icon="arrow-bar-left"></i></button>
|
|
580
595
|
<span class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 11" shape-rendering="crispEdges"><rect x="2" y="1" width="1" height="1" fill="#a3e635"/><rect x="6" y="1" width="1" height="1" fill="#7cc02f"/><rect x="7" y="1" width="1" height="1" fill="#7cc02f"/><rect x="8" y="1" width="1" height="1" fill="#7cc02f"/><rect x="9" y="1" width="1" height="1" fill="#4f7d24"/><rect x="4" y="3" width="1" height="1" fill="#a3e635"/><rect x="7" y="3" width="1" height="1" fill="#7cc02f"/><rect x="8" y="3" width="1" height="1" fill="#7cc02f"/><rect x="9" y="3" width="1" height="1" fill="#7cc02f"/><rect x="10" y="3" width="1" height="1" fill="#7cc02f"/><rect x="11" y="3" width="1" height="1" fill="#7cc02f"/><rect x="12" y="3" width="1" height="1" fill="#4f7d24"/><rect x="1" y="5" width="1" height="1" fill="#a3e635"/><rect x="5" y="5" width="1" height="1" fill="#a3e635"/><rect x="6" y="5" width="1" height="1" fill="#a3e635"/><rect x="7" y="5" width="1" height="1" fill="#a3e635"/><rect x="8" y="5" width="1" height="1" fill="#a3e635"/><rect x="9" y="5" width="1" height="1" fill="#a3e635"/><rect x="10" y="5" width="1" height="1" fill="#a3e635"/><rect x="11" y="5" width="1" height="1" fill="#a3e635"/><rect x="12" y="5" width="1" height="1" fill="#a3e635"/><rect x="13" y="5" width="1" height="1" fill="#4f7d24"/><rect x="4" y="7" width="1" height="1" fill="#a3e635"/><rect x="7" y="7" width="1" height="1" fill="#7cc02f"/><rect x="8" y="7" width="1" height="1" fill="#7cc02f"/><rect x="9" y="7" width="1" height="1" fill="#7cc02f"/><rect x="10" y="7" width="1" height="1" fill="#7cc02f"/><rect x="11" y="7" width="1" height="1" fill="#4f7d24"/><rect x="2" y="9" width="1" height="1" fill="#a3e635"/><rect x="6" y="9" width="1" height="1" fill="#7cc02f"/><rect x="7" y="9" width="1" height="1" fill="#7cc02f"/><rect x="8" y="9" width="1" height="1" fill="#7cc02f"/><rect x="9" y="9" width="1" height="1" fill="#4f7d24"/></svg>Swarm</span>
|
|
581
|
-
<a href="#" data-view="fleet" class="nav on"><i data-icon="squares-four"></i>Fleet</a>
|
|
582
|
-
<a href="#" data-view="board" class="nav"><i data-icon="stack"></i>Board</a>
|
|
583
|
-
<a href="#" data-view="incidents" class="nav"><i data-icon="warning"></i>Incidents<b id="incCount" class="navcount" hidden></b></a>
|
|
584
|
-
<a href="#" data-view="prs" class="nav"><i data-icon="git-pull-request"></i>PRs</a>
|
|
585
|
-
<a href="#" data-view="timeline" class="nav"><i data-icon="clock-counter-clockwise"></i>Timeline</a>
|
|
586
|
-
<a href="#" data-view="spend" class="nav"><i data-icon="coins"></i>Spend</a>
|
|
587
|
-
<a href="#" data-view="stats" class="nav"><i data-icon="chart-bar"></i>Stats</a>
|
|
588
|
-
<a href="#" data-view="search" class="nav"><i data-icon="magnifying-glass"></i>Search</a>
|
|
589
596
|
<span class="sp"></span>
|
|
590
597
|
<span id="today"></span>
|
|
591
598
|
<span id="daemon"><span class="dot"></span><span class="lbl">Daemon</span></span>
|
|
599
|
+
<button class="icon-btn" id="palBtn" title="Jump to view, project or session (⌘K)" aria-label="Open command palette"><i data-icon="magnifying-glass"></i></button>
|
|
592
600
|
<button class="icon-btn" id="feedback" title="Send feedback (opens a GitHub issue)" aria-label="Send feedback"><i data-icon="comment-text"></i></button>
|
|
593
601
|
<button class="icon-btn" id="settings" title="Settings" aria-label="Settings"><i data-icon="sliders"></i></button>
|
|
594
602
|
</header>
|
|
595
603
|
<aside>
|
|
604
|
+
<nav id="viewnav"></nav>
|
|
596
605
|
<div id="projects"></div>
|
|
597
606
|
</aside>
|
|
598
607
|
<main id="main"></main>
|