From 10a25e5b837300f5ca1f384b85350c1cdbe248a5 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 13:06:23 +0100 Subject: [PATCH] feat(maestro): keep web session cards two lines --- web/src/activity.test.ts | 41 ---------------------------------------- web/src/activity.ts | 18 +++--------------- web/src/catalog.test.ts | 18 ++++++++++++++++-- web/src/catalog.ts | 25 +++++++++++------------- web/src/styles.css | 5 ++--- 5 files changed, 32 insertions(+), 75 deletions(-) diff --git a/web/src/activity.test.ts b/web/src/activity.test.ts index 206c97f..86ba3cf 100644 --- a/web/src/activity.test.ts +++ b/web/src/activity.test.ts @@ -4,9 +4,7 @@ import { activityColorSlug, activityDurationLabel, activityHeldMs, - showsMetaLine, } from "./activity.js"; -import type { Session } from "./types.js"; describe("activityHeldMs", () => { it("measures time spent in the current state", () => { @@ -60,42 +58,3 @@ describe("activityColorSlug", () => { expect(activityColorSlug("Teleported")).toBe("idle"); }); }); - -function session(overrides: Partial): Session { - return { - id: "11111111-2222-3333-4444-555555555555", - cwd: "/home/naps/tea/maestro", - command: "claude", - env: {}, - metadata: {}, - status: "Running", - created_at: "2026-08-24T10:00:00Z", - activity: "Active", - ...overrides, - }; -} - -describe("showsMetaLine", () => { - const fresh = new Date(Date.now() - 10_000).toISOString(); - - it("stays hidden for a freshly-active session", () => { - expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false); - }); - - it("appears once the state has been held past 30 seconds", () => { - const held = new Date(Date.now() - 31_000).toISOString(); - expect(showsMetaLine(session({ activity_since: held }))).toBe(true); - }); - - it("appears for blocked work, exited sessions and reported errors", () => { - expect( - showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })), - ).toBe(true); - expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true); - expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true); - }); - - it("needs no activity timestamp when another trigger holds", () => { - expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true); - }); -}); diff --git a/web/src/activity.ts b/web/src/activity.ts index 2d3aa83..b2805fa 100644 --- a/web/src/activity.ts +++ b/web/src/activity.ts @@ -2,11 +2,10 @@ // the shared duration format. Both clients compute it identically; the TUI // mirrors this in crates/maestro-tui. -import type { ActivityState, IsoTimestamp, Session } from "./types.js"; -import { sessionBlockers } from "./types.js"; +import type { ActivityState, IsoTimestamp } from "./types.js"; -/** A state held shorter than this keeps the card to one line (contract rule 1). */ -const META_LINE_MIN_HOLD_MS = 30_000; +/** A state held shorter than this has no duration fragment. */ +export const META_LINE_MIN_HOLD_MS = 30_000; // Payloads predating #102 default `activity_since` to the Unix epoch; anything // implausibly old is treated as "never observed" rather than a huge duration. @@ -43,14 +42,3 @@ export function activityColorSlug(state: ActivityState | string): string { return "idle"; } } - -/** Line 2 renders only when at least one contract trigger holds. */ -export function showsMetaLine(s: Session, now: number = Date.now()): boolean { - const held = activityHeldMs(s.activity_since, now); - return ( - (held !== null && held >= META_LINE_MIN_HOLD_MS) || - sessionBlockers(s) !== null || - s.status === "Dead" || - s.activity === "Error" - ); -} diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts index 560ee31..8990206 100644 --- a/web/src/catalog.test.ts +++ b/web/src/catalog.test.ts @@ -150,14 +150,18 @@ describe("session cards (#107)", () => { expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild); }); - it("stays a single line while freshly active", () => { + it("always renders line 2 for a freshly spawned session", () => { 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(); + const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); + expect(line2).not.toBeNull(); + expect(line2?.querySelector(".activity-dot")).not.toBeNull(); + expect(line2?.querySelector(".activity-duration")).toBeNull(); + expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude"); }); it("shows dot + floored duration once the state has been held past 30s", () => { @@ -173,6 +177,16 @@ describe("session cards (#107)", () => { expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m"); }); + it("renders the command when the session name equals it", () => { + const s = session({ + id: "aaaaaaaa-0000-0000-0000-00000000001b", + command: "claude", + metadata: { name: "claude" }, + }); + const el = renderSidebar(storeWith([s])).el; + expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude"); + }); + it("maps each activity state to its contract slug", () => { const mk = (id: string, activity: string) => session({ diff --git a/web/src/catalog.ts b/web/src/catalog.ts index a4d517a..bd5c399 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -4,7 +4,12 @@ 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 { + activityColorSlug, + activityDurationLabel, + activityHeldMs, + META_LINE_MIN_HOLD_MS, +} from "./activity.js"; import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; import { createThemeToggle } from "./theme.js"; @@ -28,16 +33,13 @@ const STATE_WORDS: Record = { }; 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 duration = + held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null; const fragments = [ ``, @@ -49,9 +51,7 @@ function metaLine(s: Session, now: number): string { ? `⚠ ${escapeHtml(formatInTheWay(blockers))}` : "", dead ? `exited` : "", - showCmd - ? `${escapeHtml(s.command)}` - : "", + `${escapeHtml(s.command)}`, ]; return `${fragments.join("")}`; } @@ -106,10 +106,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { "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); + // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. row.innerHTML = ` ${escapeHtml(name)} @@ -118,7 +115,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { - ${showMeta ? metaLine(s, now) : ""} + ${metaLine(s, now)} `; const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement; similarBtn.addEventListener("click", (ev) => { diff --git a/web/src/styles.css b/web/src/styles.css index ea4060e..f7e99f9 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -736,7 +736,6 @@ textarea { display: flex; align-items: center; gap: var(--sp-2); - min-height: 30px; margin: 1px var(--sp-2); padding: 4px var(--sp-3); border-radius: var(--r-1); @@ -787,12 +786,12 @@ 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. */ +/* #120: every session card has two lines, so its row height is stable. */ .tree-leaf.session-card { flex-direction: column; align-items: stretch; gap: 1px; + height: 40px; } .card-line1 {