Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/web/agent-roster.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
});
});
20 changes: 20 additions & 0 deletions src/web/agent-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 6 additions & 2 deletions src/web/island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!doctype html>
<html lang="en">
Expand Down Expand Up @@ -587,6 +587,7 @@ export const ISLAND_HTML = `<!doctype 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
Expand Down Expand Up @@ -937,7 +938,9 @@ export const ISLAND_HTML = `<!doctype 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);
Expand Down Expand Up @@ -978,6 +981,7 @@ export const ISLAND_HTML = `<!doctype 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', () => {
Expand Down
13 changes: 11 additions & 2 deletions src/web/lisa-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/web/lisa-html-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down