From 5be3e33c94ba7d34bb2bc3e7cdedcfe8b9915a47 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:50:24 +0100 Subject: [PATCH] feat(maestro): render sidebar sessions as two-line cards per docs/SIDEBAR.md --- web/src/catalog.test.ts | 119 ++++++++++++++++++++++++++++++++++++++++ web/src/catalog.ts | 78 +++++++++++++++++++++----- web/src/kit.ts | 84 ++++++++++++++++++++++------ web/src/styles.css | 101 +++++++++++++++++++++------------- 4 files changed, 311 insertions(+), 71 deletions(-) diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts index b502fd7..57fa9a1 100644 --- a/web/src/catalog.test.ts +++ b/web/src/catalog.test.ts @@ -104,3 +104,122 @@ describe("blocked-work indicator (#83)", () => { expect(confirmations).toEqual(['Kill session "claude"?']); }); }); + +describe("session cards (#107)", () => { + const ago = (ms: number): string => new Date(Date.now() - ms).toISOString(); + + it("keeps the name and the actions in the same flow line, actions reserving their width", () => { + const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" }); + const el = renderSidebar(storeWith([s])).el; + const line1 = rowFor(el, s.id)?.querySelector(".card-line1"); + expect(line1).not.toBeNull(); + // Same flex line ⇒ the name truncates before it can ever reach the buttons. + expect(line1?.querySelector(".leaf-name")).not.toBeNull(); + expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild); + }); + + it("stays a single line while freshly active", () => { + const s = session({ + id: "aaaaaaaa-0000-0000-0000-000000000011", + activity: "Active", + activity_since: ago(5_000), + }); + const el = renderSidebar(storeWith([s])).el; + expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull(); + }); + + it("shows dot + floored duration once the state has been held past 30s", () => { + const s = session({ + id: "aaaaaaaa-0000-0000-0000-000000000012", + activity: "Active", + activity_since: ago(4 * 60_000 + 30_000), + }); + const el = renderSidebar(storeWith([s])).el; + const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); + expect(line2?.dataset.activity).toBe("active"); + expect(line2?.querySelector(".activity-dot")).not.toBeNull(); + expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m"); + }); + + it("maps each activity state to its contract slug", () => { + const mk = (id: string, activity: string) => + session({ + id, + activity: activity as Session["activity"], + status: "Running", + activity_since: ago(90_000), + }); + const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle"); + const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput"); + const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error"); + const el = renderSidebar(storeWith([idle, wait, error])).el; + expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( + "idle", + ); + expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( + "awaiting-input", + ); + expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( + "error", + ); + }); + + it("treats an unknown activity variant as idle instead of failing to render", () => { + const s = session({ + id: "aaaaaaaa-0000-0000-0000-000000000016", + activity: "Teleported" as Session["activity"], + activity_since: ago(120_000), + }); + const el = renderSidebar(storeWith([s])).el; + const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); + expect(line2?.getAttribute("data-activity")).toBe("idle"); + expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m"); + }); + + it("moves blocked work onto line 2 in the shared phrasing", () => { + const s = session({ + id: "aaaaaaaa-0000-0000-0000-000000000017", + blocked_work: { uncommitted_files: 3, unpushed_commits: 2 }, + }); + const el = renderSidebar(storeWith([s])).el; + const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); + expect(line2?.textContent).toContain("⚠"); + expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits"); + // Line 1 carries no badges. + expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull(); + }); + + it("shows exited on line 2 for dead sessions", () => { + const s = session({ + id: "aaaaaaaa-0000-0000-0000-000000000018", + status: "Dead", + }); + const el = renderSidebar(storeWith([s])).el; + const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); + expect(line2?.textContent).toContain("exited"); + }); + + it("carries the state word only in tooltips and aria labels, never as text", () => { + const s = session({ + id: "aaaaaaaa-0000-0000-0000-000000000019", + command: "claude", + metadata: { name: "worker" }, + activity: "AwaitingInput", + activity_since: ago(90_000), + }); + const el = renderSidebar(storeWith([s])).el; + const row = rowFor(el, s.id) as HTMLElement; + const dot = row.querySelector(".activity-dot"); + expect(dot?.getAttribute("title")).toContain("Awaiting input"); + expect(dot?.getAttribute("aria-label")).toContain("Awaiting input"); + expect(row.textContent).not.toMatch(/awaiting.?input/i); + }); + + it("keeps the attach navigation and depth indentation", () => { + const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" }); + const el = renderSidebar(storeWith([s])).el; + const row = rowFor(el, s.id) as HTMLAnchorElement; + expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a"); + expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/); + }); +}); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index b9d4bf0..cefca49 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -4,6 +4,7 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js"; import { CatalogStore } from "./events.js"; import type { Session } from "./types.js"; import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js"; +import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js"; import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; import { createThemeToggle } from "./theme.js"; @@ -17,6 +18,41 @@ export function requestOpenSpawn(): void { const collapsed = new Set(); +// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries +// it. It survives only in tooltips/aria labels so meaning is not colour-only. +const STATE_WORDS: Record = { + active: "Active", + idle: "Idle", + "awaiting-input": "Awaiting input", + error: "Error", +}; + +function metaLine(s: Session, now: number): string { + const name = sessionName(s); + const dead = s.status === "Dead"; + const blockers = sessionBlockers(s); + const slug = activityColorSlug(s.activity ?? "Idle"); + const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle; + const held = activityHeldMs(s.activity_since, now); + const duration = held !== null ? activityDurationLabel(held) : null; + // The directory is the enclosing folder, so only surface the command — and + // only when it isn't already what the name shows. + const showCmd = s.command !== name; + + const fragments = [ + ``, + duration ? `${duration}` : "", + blockers + ? `⚠ ${escapeHtml(formatInTheWay(blockers))}` + : "", + dead ? `exited` : "", + showCmd + ? `${escapeHtml(s.command)}` + : "", + ]; + return `${fragments.join("")}`; +} + function renderNode(node: TreeNode, depth: number): HTMLElement { if (node.kind === "folder") { const wrap = document.createElement("div"); @@ -52,30 +88,34 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { } const s = node.session; + const now = Date.now(); const row = document.createElement("a"); - row.className = "tree-leaf"; + row.className = "tree-leaf session-card"; row.href = `#/attach/${encodeURIComponent(s.id)}`; row.style.paddingLeft = `${depth * 16 + 8}px`; row.dataset.sessionId = s.id; const running = s.status === "Running"; row.dataset.status = running ? "running" : "dead"; const name = sessionName(s); - row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`); - // The directory is the enclosing folder, so only surface the command — and - // only when it isn't already what the name shows. - const showCmd = s.command !== name; - // #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so - // "this will be refused" is visible before the kill is attempted. - const blockers = sessionBlockers(s); + const stateWord = + STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle; + row.setAttribute( + "aria-label", + `${name}, ${running ? "running" : "exited"}, ${stateWord}`, + ); + // docs/SIDEBAR.md line 1: name + top-right actions in the same flow line — + // the actions reserve their width, so the name truncates before it can ever + // reach them. Line 2 renders only when a contract trigger holds. + const showMeta = showsMetaLine(s, now); row.innerHTML = ` - ${escapeHtml(name)} - ${showCmd ? `${escapeHtml(s.command)}` : ""} - ${running ? "" : `exited`} - ${blockers ? `⚠ ${escapeHtml(formatInTheWay(blockers))}` : ""} - - - + + ${escapeHtml(name)} + + + + + ${showMeta ? metaLine(s, now) : ""} `; const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; similarBtn.addEventListener("click", (ev) => { @@ -395,6 +435,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S const unsubscribe = store.subscribe(refresh); + // Contract granularity: re-render at least every 30s while visible so the + // duration cutover points land within one tick. + const clock = setInterval(() => { + if (document.visibilityState !== "visible") return; + refresh(); + }, 30_000); + return { el, setActive(id: string | null) { @@ -410,6 +457,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S applyHighlights(); }, dispose() { + clearInterval(clock); document.removeEventListener("catalog-rerender", rerenderHandler); unsubscribe(); }, diff --git a/web/src/kit.ts b/web/src/kit.ts index f33759c..1d4386f 100644 --- a/web/src/kit.ts +++ b/web/src/kit.ts @@ -59,20 +59,28 @@ function leafRow(opts: { status: "running" | "dead"; active?: boolean; indent?: number; + activity?: "active" | "idle" | "awaiting-input" | "error"; + duration?: string; }): HTMLElement { - const row = h("a", "tree-leaf"); + const row = h("a", "tree-leaf session-card"); (row as HTMLAnchorElement).href = "#/kit"; row.dataset.status = opts.status; if (opts.active) row.classList.add("is-active"); row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`; + const flags = [ + opts.status === "dead" ? `exited` : "", + opts.cmd !== opts.name ? `${opts.cmd}` : "", + ].join(""); row.innerHTML = ` - ${opts.name} - ${opts.cmd !== opts.name ? `${opts.cmd}` : ""} - ${opts.status === "dead" ? `exited` : ""} - - - - `; + + ${opts.name} + + + + + + ${opts.activity ? `${opts.duration ?? ""}${flags}` : ""} + `; return row; } @@ -190,14 +198,17 @@ export function renderKit(): HTMLElement { // --- chips & badges --- { const { el, body } = section("Chips & badges", "Status-bearing pills and counts."); + const activeLine = h( + "span", + "card-line2", + `4m`, + ); + activeLine.setAttribute("data-activity", "active"); body.append( specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")), specimen("folder-count", h("span", "folder-count", "4")), - specimen("leaf-badge", h("span", "leaf-badge", "exited")), - specimen( - "attach-state · open", - h("span", "attach-state", "live"), - ), + specimen("card-line2 · active", activeLine), + specimen("attach-state · open", h("span", "attach-state", "live")), ); (body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute( "data-state", @@ -215,19 +226,56 @@ export function renderKit(): HTMLElement { { const { el, body } = section( "Session rows — nested by host, then directory", - "The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.", + "docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.", ); const list = h("div", "kit-tree"); list.append( folderHeader("~/git/maestro", 3, 0), folderHeader("web", 2, 1), leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }), - leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }), + leafRow({ + name: "dev-server", + cmd: "vite dev", + status: "running", + indent: 2, + active: true, + activity: "active", + duration: "4m", + }), folderHeader("crates/daemon", 1, 1), - leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }), + leafRow({ + name: "daemon-work", + cmd: "claude", + status: "running", + indent: 2, + activity: "awaiting-input", + duration: "12s", + }), + leafRow({ + name: "flaky-agent", + cmd: "claude", + status: "running", + indent: 2, + activity: "error", + duration: "2h", + }), folderHeader("~/work/acme", 2, 0), - leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }), - leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }), + leafRow({ + name: "billing-api", + cmd: "claude", + status: "running", + indent: 1, + activity: "idle", + duration: "26m", + }), + leafRow({ + name: "scratch", + cmd: "bash", + status: "dead", + indent: 1, + activity: "idle", + duration: "5h", + }), leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }), ); body.classList.add("kit-tree-host"); diff --git a/web/src/styles.css b/web/src/styles.css index cd89d81..4b533e9 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -787,60 +787,85 @@ textarea { font-weight: 400; } +/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions) + always, line 2 (meta) only when it has something to say. */ +.tree-leaf.session-card { + flex-direction: column; + align-items: stretch; + gap: 1px; +} + +.card-line1 { + display: flex; + align-items: center; + gap: var(--sp-2); + min-width: 0; +} + .leaf-name { font-weight: 500; font-size: var(--fs-13); - flex: 0 1 auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.leaf-meta { - font-size: var(--fs-11); - color: var(--muted); - margin-left: auto; - padding-left: var(--sp-2); + /* Grows to push the actions to the right edge but shrinks first, so the name + truncates before it can ever reach them — overlap is a defect (#107). */ + flex: 1 1 auto; min-width: 0; - flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - display: inline-flex; - align-items: baseline; +} + +/* Line 2 — dot + duration in the state's colour, then muted fragments. The + state word itself is never spelled out here; the colour carries it. */ +.card-line2 { + display: flex; + align-items: center; gap: var(--sp-2); -} - -.leaf-cmd { - color: var(--muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.leaf-badge { font-size: var(--fs-11); - letter-spacing: 0.02em; - text-transform: uppercase; + min-width: 0; color: var(--muted); - border: 1px solid var(--border); - border-radius: var(--r-pill); - padding: 1px 8px; +} + +.card-line2[data-activity="active"] { + color: var(--live); +} +.card-line2[data-activity="idle"] { + color: var(--muted); +} +.card-line2[data-activity="awaiting-input"] { + color: var(--warn); +} +.card-line2[data-activity="error"] { + color: var(--danger); +} + +.activity-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; flex: 0 0 auto; } -/* #83: the session row's blocked-work warning — an unforced kill will be - refused, and the operator should see that before pressing the button. */ -.leaf-badge.leaf-blocked { - color: var(--warn, #b45309); - border-color: var(--warn, #b45309); +.activity-duration { + color: inherit; + flex: 0 0 auto; } +/* Blocked-work / exited / command fragments recede: the ⚠ glyph already + signals severity and "exited" dims with the row. */ +.card-flag { + color: var(--faint); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Actions live in line 1's flow at its right edge — never overlaid on the + name (#107). They reserve their width even while hover-faded. */ .leaf-actions { - position: absolute; - right: var(--sp-1); - top: 50%; - transform: translateY(-50%); + flex: 0 0 auto; + margin-left: auto; display: inline-flex; gap: 2px; }