From 416aa3fd91adccfcc58d85c80ce8c4d97056b448 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 18 Jun 2026 16:37:59 +0800 Subject: [PATCH] feat(dispatch): label agent rows by git branch, not the worktree-hash cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The island/GUI Claude monitor showed cwd basenames like "2cffa8" / "wf_17f778a2-74c" — worktree hashes, not meaningful. Claude Code's human session TITLE isn't in the transcript JSONL the observer reads (verified: 0 of 368 local sessions carry a summary/ai-title/custom-title record — those live in the app's own index, not a source LISA taps). So the best title-like label we actually have is the git branch the observer already captures. - agent-roster.ts: new pure rosterLabel(s) — prefer activity.gitBranch (strip the ubiquitous "claude/" prefix), fall back to project. Self-contained + source-injected into the island; covered by the injection-safety test + unit tests. - island.ts: inject ${rosterLabel}, use it for the row label; the project/cwd stays visible in the row tooltip so no info is lost. - lisa-client.ts (GUI sidebar): same branch-preferring label + project in the tooltip. Uses string ops (indexOf/slice), NOT a regex — a /\// here gets mangled by the outer template literal wrapping the client script (the bug the MAIN_HTML-parses guard caught). - snapshot constants updated for the intentional GUI change. Privacy: unchanged — the branch is already captured (no new content surfaced); this is option B (vs. reading prompt text). 695 tests pass; typecheck + build clean; shipped dist verified (rosterLabel injected, regex intact, no __name). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/web/agent-roster.test.ts | 18 ++++++++++++++++-- src/web/agent-roster.ts | 20 ++++++++++++++++++++ src/web/island.ts | 8 ++++++-- src/web/lisa-client.ts | 13 +++++++++++-- src/web/lisa-html-snapshot.test.ts | 6 +++--- 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/web/agent-roster.test.ts b/src/web/agent-roster.test.ts index 3b15ff7..1881833 100644 --- a/src/web/agent-roster.test.ts +++ b/src/web/agent-roster.test.ts @@ -1,6 +1,6 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { mergeAgentSession, aggregateAgentState, type RosterSession } from "./agent-roster.js"; +import { mergeAgentSession, aggregateAgentState, rosterLabel, type RosterSession } from "./agent-roster.js"; const NOW = 1_700_000_000_000; const WINDOW = 30 * 60_000; @@ -57,16 +57,30 @@ describe("source-injection safety (island injects these verbatim)", () => { // island.ts embeds `${mergeAgentSession}` into the page; the browser eval's // the function source. Assert each is self-contained: its source eval's to a // working function with no external references. - for (const fn of [mergeAgentSession, aggregateAgentState]) { + for (const fn of [mergeAgentSession, aggregateAgentState, rosterLabel]) { test(`${fn.name} source eval's to a working function`, () => { // eslint-disable-next-line @typescript-eslint/no-implied-eval const rebuilt = new Function(`return (${fn.toString()})`)() as (...a: unknown[]) => unknown; assert.equal(typeof rebuilt, "function"); if (fn === aggregateAgentState) { assert.equal(rebuilt([s({ state: "error" })], NOW, WINDOW), "error"); + } else if (fn === rosterLabel) { + assert.equal(rebuilt(s({ project: "p" })), "p"); } else { assert.equal((rebuilt([], s(), NOW, WINDOW) as RosterSession[]).length, 1); } }); } }); + +describe("rosterLabel", () => { + test("prefers the git branch, stripping the claude/ prefix", () => { + assert.equal(rosterLabel(s({ activity: { gitBranch: "claude/fix-sentry-build-upload" } })), "fix-sentry-build-upload"); + assert.equal(rosterLabel(s({ activity: { gitBranch: "feature/foo" } })), "feature/foo"); + }); + test("falls back to project when there's no branch", () => { + assert.equal(rosterLabel(s({ project: "kind-bhaskara-2cffa8", activity: undefined })), "kind-bhaskara-2cffa8"); + assert.equal(rosterLabel(s({ project: "p", activity: { lastTools: [] } })), "p"); + assert.equal(rosterLabel(s({ project: "p", activity: { gitBranch: "" } })), "p"); + }); +}); diff --git a/src/web/agent-roster.ts b/src/web/agent-roster.ts index 159f61f..7bbc6dd 100644 --- a/src/web/agent-roster.ts +++ b/src/web/agent-roster.ts @@ -63,3 +63,23 @@ export function aggregateAgentState( if (recent.some((x) => x.state === "working")) return "working"; return null; } + +/** + * Display label for a roster row. Prefers the git branch — far more meaningful + * than a worktree-hash cwd basename like "2cffa8" — with the ubiquitous + * "claude/" prefix stripped; falls back to the project name. Pure + + * self-contained (the island injects this function's source; the + * injection-safety test guards it, so no imports / closure refs). + * + * (Claude Code's human-written session TITLE isn't in the transcript JSONL the + * observer reads, so the branch is the best title-like label we actually have.) + */ +export function rosterLabel(s: RosterSession): string { + const a = s.activity; + const branch = + a && typeof a === "object" ? (a as { gitBranch?: unknown }).gitBranch : undefined; + if (typeof branch === "string" && branch) { + return branch.replace(/^claude\//, ""); + } + return s.project; +} diff --git a/src/web/island.ts b/src/web/island.ts index 1fad14f..7085a4c 100644 --- a/src/web/island.ts +++ b/src/web/island.ts @@ -13,7 +13,7 @@ * so the browser runs the exact unit-tested code instead of a drifting copy. */ -import { mergeAgentSession, aggregateAgentState } from "./agent-roster.js"; +import { mergeAgentSession, aggregateAgentState, rosterLabel } from "./agent-roster.js"; export const ISLAND_HTML = ` @@ -587,6 +587,7 @@ export const ISLAND_HTML = ` // injection-safety test in agent-roster.test.ts guards that. ${mergeAgentSession} ${aggregateAgentState} + ${rosterLabel} // Composite identity for a roster row: the same sessionId seen under two // agents (e.g. a git + a shell observer) are DISTINCT rows. Mirrors the // key mergeAgentSession uses; the UI keys per-row history, open-state, and @@ -937,7 +938,9 @@ export const ISLAND_HTML = ` pip.className = 'pip ' + (s.state || 'unknown'); const proj = document.createElement('span'); proj.className = 'proj'; - proj.textContent = s.project; + // Label by git branch when available (more meaningful than a worktree + // hash); the project/cwd stays visible in the row tooltip below. + proj.textContent = rosterLabel(s); const when = document.createElement('span'); when.className = 'when'; when.textContent = relativeTime(s.lastMtime); @@ -978,6 +981,7 @@ export const ISLAND_HTML = ` li.appendChild(actions); li.title = s.state + (s.stateReason ? ' (' + s.stateReason + ')' : '') + + ' · ' + s.project + ' · ' + s.sessionId + '\\nclick: expand timeline · double-click: copy sessionId'; li.addEventListener('click', () => { diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index d87824b..49a8f3c 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -1050,14 +1050,23 @@ if ('serviceWorker' in navigator) { badge.title = s.agent; name.appendChild(badge); } - name.appendChild(document.createTextNode(s.project)); + // Label by git branch when available (more meaningful than a worktree + // hash), stripping the claude/ prefix; fall back to the project name. + // (String ops, not a regex — a /\// here would be mangled by the outer + // template literal that wraps this client script.) + let label = s.project; + if (s.activity && s.activity.gitBranch) { + const br = String(s.activity.gitBranch); + label = br.indexOf('claude/') === 0 ? br.slice(7) : br; + } + name.appendChild(document.createTextNode(label)); const when = document.createElement('div'); when.className = 'when'; when.textContent = relativeTime(s.lastMtime); row.appendChild(pip); row.appendChild(name); row.appendChild(when); - row.title = (s.stateReason ? s.state + ' · ' + s.stateReason : s.state) + ' · ' + s.sessionId; + row.title = (s.stateReason ? s.state + ' · ' + s.stateReason : s.state) + ' · ' + s.project + ' · ' + s.sessionId; sbClaudeRows.appendChild(row); } } diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index fc7de42..1e8ac35 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -16,11 +16,11 @@ import { MAIN_HTML } from "./lisa-html.js"; * change the GUI markup/CSS/JS, recompute them: * node --import tsx -e 'import("./src/web/lisa-html.ts").then(async m=>{const {createHash}=await import("node:crypto");console.log(m.MAIN_HTML.length, createHash("sha256").update(m.MAIN_HTML).digest("hex"))})' * - * Last updated: D4a multi-agent sidebar (agent-kind badge CSS + JS, /api/agents/sessions). + * Last updated: sidebar rows labelled by git branch (rosterLabel) + project in tooltip. */ -const EXPECTED_LENGTH = 77174; +const EXPECTED_LENGTH = 77677; const EXPECTED_SHA256 = - "f02520ca19159dc033c4b1f5f96e2ea7d90cb8b328d9207043a17e46d28f62e5"; + "2c806033b5bf4ec839088a35e220d1214a2b328785f406197646042132f88195"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH);