From d7b15a131f6ae15061492eafde0ff4af7e0ab828 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:42:33 +0100 Subject: [PATCH 1/3] feat(maestro): activity duration + colour helpers per the sidebar card contract --- web/src/activity.test.ts | 101 +++++++++++++++++++++++++++++++++++++++ web/src/activity.ts | 56 ++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 web/src/activity.test.ts create mode 100644 web/src/activity.ts diff --git a/web/src/activity.test.ts b/web/src/activity.test.ts new file mode 100644 index 0000000..206c97f --- /dev/null +++ b/web/src/activity.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + activityColorSlug, + activityDurationLabel, + activityHeldMs, + showsMetaLine, +} from "./activity.js"; +import type { Session } from "./types.js"; + +describe("activityHeldMs", () => { + it("measures time spent in the current state", () => { + const now = Date.now(); + const since = new Date(now - 90_000).toISOString(); + expect(activityHeldMs(since, now)).toBe(90_000); + }); + + it("returns null for payloads that predate #102 (epoch default)", () => { + expect(activityHeldMs(undefined, Date.now())).toBeNull(); + expect(activityHeldMs(new Date(0).toISOString(), Date.now())).toBeNull(); + }); + + it("returns null for unparseable timestamps", () => { + expect(activityHeldMs("not-a-time", Date.now())).toBeNull(); + }); + + it("never goes negative", () => { + const now = Date.now(); + expect(activityHeldMs(new Date(now + 5_000).toISOString(), now)).toBe(0); + }); +}); + +describe("activityDurationLabel (#102)", () => { + it("floors whole seconds under a minute", () => { + expect(activityDurationLabel(30_400)).toBe("30s"); + expect(activityDurationLabel(59_900)).toBe("59s"); + }); + + it("floors whole minutes under an hour", () => { + expect(activityDurationLabel(60_000)).toBe("1m"); + expect(activityDurationLabel(4 * 60_000 + 59_999)).toBe("4m"); + }); + + it("floors whole hours from an hour up, with no days unit", () => { + expect(activityDurationLabel(60 * 60_000)).toBe("1h"); + expect(activityDurationLabel(2 * 3_600_000 + 59 * 60_000 + 59_999)).toBe("2h"); + expect(activityDurationLabel(26 * 3_600_000)).toBe("26h"); + }); +}); + +describe("activityColorSlug", () => { + it("maps each known state to its contract slug", () => { + expect(activityColorSlug("Active")).toBe("active"); + expect(activityColorSlug("Idle")).toBe("idle"); + expect(activityColorSlug("AwaitingInput")).toBe("awaiting-input"); + expect(activityColorSlug("Error")).toBe("error"); + }); + + it("treats an unknown variant as idle so rendering never fails", () => { + 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 new file mode 100644 index 0000000..2d3aa83 --- /dev/null +++ b/web/src/activity.ts @@ -0,0 +1,56 @@ +// docs/SIDEBAR.md: the session card's meta line — activity colour mapping and +// 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"; + +/** A state held shorter than this keeps the card to one line (contract rule 1). */ +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. +const PLAUSIBLE_EPOCH_MS = Date.UTC(2010, 0, 1); + +export function activityHeldMs(since: IsoTimestamp | undefined, now: number): number | null { + if (!since) return null; + const t = Date.parse(since); + if (Number.isNaN(t) || t < PLAUSIBLE_EPOCH_MS) return null; + return Math.max(0, now - t); +} + +/** `<60s` → whole seconds, `<60m` → whole minutes, else whole hours. Floored + * everywhere so the counter never shows a duration the state hasn't reached. */ +export function activityDurationLabel(heldMs: number): string { + const s = Math.floor(heldMs / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + return `${Math.floor(m / 60)}h`; +} + +/** CSS hook for the state's colour (`styles.css` maps slugs to tokens). An + * unknown variant degrades to idle so rendering never fails (Precedence). */ +export function activityColorSlug(state: ActivityState | string): string { + switch (state) { + case "Active": + return "active"; + case "AwaitingInput": + return "awaiting-input"; + case "Error": + return "error"; + default: + 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" + ); +} From 5be3e33c94ba7d34bb2bc3e7cdedcfe8b9915a47 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:50:24 +0100 Subject: [PATCH 2/3] 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; } From f6a97c4695fbe902ea13bdae5a34d1fba18dfcc7 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:51:06 +0100 Subject: [PATCH 3/3] =?UTF-8?q?docs(maestro):=20settle=20line-2=20rulings?= =?UTF-8?q?=20=E2=80=94=20command=20fragment,=20pre-#102=20payloads,=20unc?= =?UTF-8?q?onditional=20dot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIDEBAR.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index 55cbd49..1ac2442 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -41,13 +41,20 @@ Otherwise line 2 is absent — never an empty placeholder row. When present, line 2 reads left to right: ``` - [ | exited] + [ | exited] [] ``` `` is a single filled glyph (`●` on both clients) carrying the activity state's colour, and `` follows in the same colour. There is no state word and no second badge: the dot exists so the state is still legible when a -row is dimmed or the terminal renders the duration in the default fg. +row is dimmed or the terminal renders the duration in the default fg. The dot +and duration are unconditional parts of line 2; the bracketed fragments appear +only when their trigger holds. A payload that predates #102 (no usable +`activity_since`) contributes no duration — the dot still renders. + +`` is optional and muted: the session's command line, shown only when +the rendered name came from `metadata.name` and differs from the command (the +web has always surfaced this; the TUI may omit it). ## Colour per activity state @@ -65,9 +72,9 @@ and `prefers-color-scheme` variants for each). The TUI uses the plain ratatui named colours above; do not introduce RGB values or a new palette module for this. -The duration text inherits its state's colour; the blocked-work and exited -fragments use the muted colour (`--faint` / default style), since the ⚠ glyph -already signals severity. +The duration text inherits its state's colour; the blocked-work, exited and +command fragments use the muted colour (`--faint` / default style), since the +⚠ glyph already signals severity. ## Duration format