From 2f4d691261524504baa2a86b3ccd29f070739d58 Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 21:11:39 +0400 Subject: [PATCH 01/19] feat(bridge): keep two timestamps per pane, so the dashboard can sort by attention and recency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Herdr reports no timestamps at all — not on panes, tabs, or workspaces — so Collie derives and owns them. An ActivityLedger persisted to the state dir (alongside snooze.json / notify-prefs.json) records, per session and pane: activeAt — the last agent status transition the state engine observed seenAt — the last time you opened or drove the pane through Collie That is enough for the whole feature. "Unseen" needs no stored flag: an agent is newly-finished- and-unread exactly when `status === "done" && activeAt > seenAt`, so opening the pane clears it by construction. A first sighting seeds activeAt = seenAt, matching the rule the engine already applies to notifications — a fresh start must not open on a screen of alerts you were never shown. Writes are debounced to one per 10s (an open pane polls ~1/s and each poll marks it seen) plus one on shutdown; entries are reconciled against the live pane set each successful poll, which reaps bare shells too — the engine's removal event is agent-derived and never fires for them. Also denormalises the pane's tab label onto AgentView, exactly as workspaceLabel already is, so no client has to join tabs[]. meaningfulTabLabel drops Herdr's positional default ("1") in a single-tab space, where it would render as "project · 1" and read as a bug. Co-Authored-By: Claude Opus 5 (1M context) --- bridge/activity.test.ts | 284 ++++++++++++++++++++++++++++++++++++++++ bridge/activity.ts | 243 ++++++++++++++++++++++++++++++++++ bridge/index.ts | 22 +++- bridge/server.ts | 21 ++- bridge/state-engine.ts | 6 + bridge/types.ts | 18 +++ 6 files changed, 590 insertions(+), 4 deletions(-) create mode 100644 bridge/activity.test.ts create mode 100644 bridge/activity.ts diff --git a/bridge/activity.test.ts b/bridge/activity.test.ts new file mode 100644 index 0000000..90a9da8 --- /dev/null +++ b/bridge/activity.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + ActivityLedger, + coerceActivityFile, + meaningfulTabLabel, + PRUNE_AFTER_MS, +} from "./activity.ts"; + +// A ledger over a throwaway state dir with a controllable clock. The debounce is set absurdly high +// so no test ever races a background write — the ones that care about disk call flush() explicitly. +function ledger(start = 1_000_000) { + let now = start; + const stateDir = mkdtempSync(join(tmpdir(), "collie-activity-")); + const l = new ActivityLedger({ stateDir }, () => now, 60 * 60 * 1000); + return { + l, + stateDir, + at: (t: number) => { + now = t; + }, + advance: (ms: number) => { + now += ms; + }, + }; +} + +/** The one derivation the whole feature rests on. Mirrors the client's `isUnseen`. */ +const unseen = (a: { activeAt: number; seenAt: number } | undefined) => + !!a && a.activeAt > a.seenAt; + +describe("meaningfulTabLabel", () => { + test("keeps a real name", () => { + expect(meaningfulTabLabel("fix-auth", 1)).toBe("fix-auth"); + expect(meaningfulTabLabel("fix-auth", 4)).toBe("fix-auth"); + }); + + test("drops herdr's positional default in a single-tab space", () => { + expect(meaningfulTabLabel("1", 1)).toBeUndefined(); + expect(meaningfulTabLabel("7", 1)).toBeUndefined(); + }); + + test("keeps the positional label once there is something to disambiguate", () => { + expect(meaningfulTabLabel("1", 2)).toBe("1"); + expect(meaningfulTabLabel("2", 9)).toBe("2"); + }); + + test("treats blank and whitespace-only labels as absent", () => { + expect(meaningfulTabLabel("", 3)).toBeUndefined(); + expect(meaningfulTabLabel(" ", 3)).toBeUndefined(); + expect(meaningfulTabLabel(undefined, 3)).toBeUndefined(); + }); + + test("trims, so a padded label doesn't render with its padding", () => { + expect(meaningfulTabLabel(" deploy ", 2)).toBe("deploy"); + // …and a padded positional default is still recognised as positional. + expect(meaningfulTabLabel(" 1 ", 1)).toBeUndefined(); + }); + + test("a zero tab count (workspace missing from the poll) is treated as single-tab", () => { + expect(meaningfulTabLabel("1", 0)).toBeUndefined(); + expect(meaningfulTabLabel("build", 0)).toBe("build"); + }); +}); + +describe("ActivityLedger — first sighting", () => { + test("seeds activeAt === seenAt, so a fresh install shows nothing unseen", () => { + const { l } = ledger(); + l.ensure("default", "w0:p1"); + const a = l.get("default", "w0:p1")!; + expect(a.activeAt).toBe(1_000_000); + expect(a.seenAt).toBe(1_000_000); + expect(unseen(a)).toBe(false); + }); + + test("is idempotent — a second sighting never re-stamps", () => { + const { l, advance } = ledger(); + l.ensure("default", "w0:p1"); + advance(5000); + l.ensure("default", "w0:p1"); + expect(l.get("default", "w0:p1")!.seenAt).toBe(1_000_000); + }); +}); + +describe("ActivityLedger — the unseen derivation", () => { + test("a transition after the last look marks the pane unseen", () => { + const { l, advance } = ledger(); + l.ensure("default", "w0:p1"); + advance(60_000); + l.noteActive("default", "w0:p1"); + expect(unseen(l.get("default", "w0:p1"))).toBe(true); + }); + + test("opening the pane clears it", () => { + const { l, advance } = ledger(); + l.ensure("default", "w0:p1"); + advance(60_000); + l.noteActive("default", "w0:p1"); + advance(1000); + l.noteSeen("default", "w0:p1"); + expect(unseen(l.get("default", "w0:p1"))).toBe(false); + }); + + test("finishing AGAIN after you looked makes it unseen again", () => { + const { l, advance } = ledger(); + l.ensure("default", "w0:p1"); + advance(10_000); + l.noteSeen("default", "w0:p1"); + advance(10_000); + l.noteActive("default", "w0:p1"); + expect(unseen(l.get("default", "w0:p1"))).toBe(true); + }); + + test("noteActive preserves seenAt and noteSeen preserves activeAt", () => { + const { l, at } = ledger(); + l.ensure("default", "w0:p1"); + at(2_000_000); + l.noteActive("default", "w0:p1"); + at(3_000_000); + l.noteSeen("default", "w0:p1"); + expect(l.get("default", "w0:p1")).toEqual({ activeAt: 2_000_000, seenAt: 3_000_000 }); + }); + + test("noteActive on an unknown pane seeds it as seen, not as an alert", () => { + // Defensive: a transition should always follow a sighting, but if the ledger somehow missed the + // seed, inventing an unread alert out of nothing is the worse failure. + const { l } = ledger(); + l.noteActive("default", "w0:p9"); + expect(unseen(l.get("default", "w0:p9"))).toBe(false); + }); +}); + +describe("ActivityLedger — sessions are isolated", () => { + test("the same pane id in two sessions keeps separate state", () => { + const { l, at } = ledger(); + l.ensure("default", "w0:p1"); + l.ensure("demo", "w0:p1"); + at(2_000_000); + l.noteActive("default", "w0:p1"); + + expect(unseen(l.get("default", "w0:p1"))).toBe(true); + expect(unseen(l.get("demo", "w0:p1"))).toBe(false); + }); + + test("forgetting a pane in one session leaves the other alone", () => { + const { l } = ledger(); + l.ensure("default", "w0:p1"); + l.ensure("demo", "w0:p1"); + l.forget("default", "w0:p1"); + expect(l.get("default", "w0:p1")).toBeUndefined(); + expect(l.get("demo", "w0:p1")).toBeDefined(); + }); +}); + +describe("ActivityLedger — reconcile", () => { + test("seeds new panes and reaps gone ones", () => { + const { l } = ledger(); + l.reconcile("default", ["w0:p1", "w0:p2"]); + expect(l.get("default", "w0:p1")).toBeDefined(); + expect(l.get("default", "w0:p2")).toBeDefined(); + + l.reconcile("default", ["w0:p2"]); + expect(l.get("default", "w0:p1")).toBeUndefined(); + expect(l.get("default", "w0:p2")).toBeDefined(); + }); + + test("does not disturb a surviving pane's timestamps", () => { + const { l, at } = ledger(); + l.reconcile("default", ["w0:p1"]); + at(2_000_000); + l.noteActive("default", "w0:p1"); + at(3_000_000); + l.reconcile("default", ["w0:p1", "w0:p2"]); + expect(l.get("default", "w0:p1")).toEqual({ activeAt: 2_000_000, seenAt: 1_000_000 }); + }); + + test("an empty herd clears the session, and a reused pane id starts clean", () => { + const { l, at } = ledger(); + l.reconcile("default", ["w0:p1"]); + at(2_000_000); + l.noteActive("default", "w0:p1"); + l.reconcile("default", []); + expect(l.get("default", "w0:p1")).toBeUndefined(); + + at(3_000_000); + l.reconcile("default", ["w0:p1"]); + expect(l.get("default", "w0:p1")).toEqual({ activeAt: 3_000_000, seenAt: 3_000_000 }); + }); + + test("reconciling one session never touches another", () => { + const { l } = ledger(); + l.reconcile("default", ["w0:p1"]); + l.reconcile("demo", ["w0:p1"]); + l.reconcile("default", []); + expect(l.get("demo", "w0:p1")).toBeDefined(); + }); +}); + +describe("coerceActivityFile", () => { + const now = 1_000_000_000; + + test("keeps well-formed entries", () => { + const raw = { default: { "w0:p1": { activeAt: now - 1000, seenAt: now - 500 } } }; + expect(coerceActivityFile(raw, now)).toEqual(raw); + }); + + test("drops entries past the prune horizon", () => { + const raw = { + default: { + fresh: { activeAt: now - 1000, seenAt: now - 1000 }, + stale: { activeAt: now - PRUNE_AFTER_MS - 1, seenAt: now - PRUNE_AFTER_MS - 1 }, + }, + }; + expect(coerceActivityFile(raw, now)).toEqual({ + default: { fresh: { activeAt: now - 1000, seenAt: now - 1000 } }, + }); + }); + + test("keeps an entry whose newest timestamp is inside the horizon", () => { + // Old activity but a recent look — still live. + const raw = { + default: { p: { activeAt: now - PRUNE_AFTER_MS - 5000, seenAt: now - 1000 } }, + }; + expect(coerceActivityFile(raw, now).default!.p).toBeDefined(); + }); + + test("drops a session left empty by pruning", () => { + const old = now - PRUNE_AFTER_MS - 1; + const raw = { default: { stale: { activeAt: old, seenAt: old } } }; + expect(coerceActivityFile(raw, now)).toEqual({}); + }); + + test("survives garbage without throwing", () => { + expect(coerceActivityFile(null, now)).toEqual({}); + expect(coerceActivityFile("nope", now)).toEqual({}); + expect(coerceActivityFile({ default: 42 }, now)).toEqual({}); + expect(coerceActivityFile({ default: { p: { activeAt: "x", seenAt: 1 } } }, now)).toEqual({}); + expect(coerceActivityFile({ default: { p: { activeAt: NaN, seenAt: 1 } } }, now)).toEqual({}); + expect(coerceActivityFile({ default: { p: null } }, now)).toEqual({}); + }); +}); + +describe("ActivityLedger — persistence", () => { + test("a flushed ledger reloads identically", async () => { + const { l, stateDir } = ledger(); + l.reconcile("default", ["w0:p1", "w0:p2"]); + l.noteActive("default", "w0:p1"); + await l.flush(); + + const reloaded = new ActivityLedger({ stateDir }, () => 1_000_000); + await reloaded.load(); + expect(reloaded.snapshot()).toEqual(l.snapshot()); + reloaded.stop(); + }); + + test("a missing file loads as empty rather than throwing", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "collie-activity-")); + const l = new ActivityLedger({ stateDir }, () => 1); + await l.load(); + expect(l.snapshot()).toEqual({}); + l.stop(); + }); + + test("flush with nothing dirty is a no-op", async () => { + const { l } = ledger(); + await l.flush(); // must not throw or write + expect(l.snapshot()).toEqual({}); + }); + + test("prunes stale entries on load", async () => { + const { l, stateDir, at } = ledger(); + l.ensure("default", "w0:p1"); + await l.flush(); + + const later = new ActivityLedger({ stateDir }, () => 1_000_000 + PRUNE_AFTER_MS + 1); + await later.load(); + expect(later.get("default", "w0:p1")).toBeUndefined(); + later.stop(); + at(0); + }); +}); diff --git a/bridge/activity.ts b/bridge/activity.ts new file mode 100644 index 0000000..4c33c28 --- /dev/null +++ b/bridge/activity.ts @@ -0,0 +1,243 @@ +import { mkdir, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Config } from "./config.ts"; + +// When did each pane last DO something, and when did you last LOOK at it? Herdr answers neither — +// its pane records carry no timestamps at all (HERDR_API.md) — so Collie derives and owns both. +// +// Two numbers per pane are enough for the whole dashboard: +// • activeAt — the last agent status transition this bridge observed +// • seenAt — the last time you opened or drove the pane THROUGH COLLIE +// +// "Unseen" is then a comparison, not a stored fact: an agent is newly-finished-and-unread exactly +// when `status === "done" && activeAt > seenAt`. Opening the pane sets seenAt = now, and the row +// leaves the section on its own — nothing to mark read, nothing to keep in sync. +// +// Bridge-side and shared across devices on purpose, and deliberately blind to what you do at the +// desk in Herdr itself — see .adr/0003-one-shared-seen.md. Persisted to the state dir like Snooze +// and NotifyPrefsStore, so it survives the `systemctl restart` every backend change needs. + +/** The two timestamps Collie keeps for a pane. Epoch ms. */ +export interface PaneActivity { + /** Last agent status transition observed by the state engine. */ + activeAt: number; + /** Last time you opened or drove this pane through Collie. */ + seenAt: number; +} + +/** Disk shape: session name → pane id → activity. */ +export type ActivityFile = Record>; + +/** Entries untouched for this long are dropped when the file loads — a backstop for panes whose + * removal we missed (an unclean shutdown), since {@link ActivityLedger.forget} is the real reaper. */ +export const PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; + +/** At most one disk write per this window. An open pane polls ~1/s and every poll marks it seen; + * in memory that's free, on disk it would be a write per second forever. Losing ≤10s of "seen" + * precision to a crash is imperceptible in a feature whose finest unit is "just now". */ +export const FLUSH_DEBOUNCE_MS = 10_000; + +/** + * A tab label worth putting on screen. Herdr labels an unlabelled tab **positionally** — it returns + * `"1"`, `"2"` (HERDR_API.md § Rename methods) — so a naive join renders `moonward_os · 1`, which + * reads as a bug rather than a name. When the space has only one tab there is nothing to + * disambiguate, so the positional default is dropped and the row shows the project alone. + * + * With two or more tabs the number is kept: it's weak, but it's the only thing telling two panes in + * the same project apart, and it matches what the desktop TUI shows. + * + * Pure + exported so the rule is unit-tested and lives in ONE place — every client surface then + * only has to know "render `tabLabel` if it's there". + */ +export function meaningfulTabLabel( + label: string | undefined, + tabCount: number, +): string | undefined { + const trimmed = label?.trim(); + if (!trimmed) return undefined; + if (tabCount <= 1 && /^\d+$/.test(trimmed)) return undefined; + return trimmed; +} + +/** + * Coerce an untrusted parsed value into an {@link ActivityFile}, dropping anything malformed and + * anything older than {@link PRUNE_AFTER_MS}. Pure + exported so the file-shape and prune handling + * are unit-testable without touching disk. + */ +export function coerceActivityFile(raw: unknown, now: number): ActivityFile { + const out: ActivityFile = {}; + if (typeof raw !== "object" || raw === null) return out; + for (const [session, panes] of Object.entries(raw as Record)) { + if (typeof panes !== "object" || panes === null) continue; + const kept: Record = {}; + for (const [paneId, entry] of Object.entries(panes as Record)) { + if (typeof entry !== "object" || entry === null) continue; + const e = entry as Record; + if (typeof e.activeAt !== "number" || typeof e.seenAt !== "number") continue; + if (!Number.isFinite(e.activeAt) || !Number.isFinite(e.seenAt)) continue; + if (now - Math.max(e.activeAt, e.seenAt) > PRUNE_AFTER_MS) continue; + kept[paneId] = { activeAt: e.activeAt, seenAt: e.seenAt }; + } + if (Object.keys(kept).length > 0) out[session] = kept; + } + return out; +} + +export class ActivityLedger { + private readonly bySession = new Map>(); + private readonly file: string; + private readonly dir: string; + private dirty = false; + private timer: ReturnType | null = null; + private writing: Promise = Promise.resolve(); + + constructor( + cfg: Pick, + private readonly now: () => number = Date.now, + private readonly debounceMs: number = FLUSH_DEBOUNCE_MS, + ) { + this.dir = cfg.stateDir; + this.file = join(cfg.stateDir, "activity.json"); + } + + async load(): Promise { + try { + const parsed = coerceActivityFile(await Bun.file(this.file).json(), this.now()); + for (const [session, panes] of Object.entries(parsed)) { + this.bySession.set(session, new Map(Object.entries(panes))); + } + } catch { + /* none saved yet, or unreadable — start empty */ + } + } + + /** + * First sighting of a pane: seed `activeAt = seenAt = now`, so it starts out exactly "seen". + * No-op once the pane is known. + * + * A fresh install must not open on a screen full of unread alerts, and only transitions observed + * AFTER we first saw a pane should be able to mark it unread. This is the same rule the state + * engine already applies to notifications — a first sighting never fires a transition, so we + * don't notify for agents that were already blocked when the bridge started. + */ + ensure(session: string, paneId: string): void { + const panes = this.panesFor(session); + if (panes.has(paneId)) return; + const t = this.now(); + panes.set(paneId, { activeAt: t, seenAt: t }); + this.markDirty(); + } + + /** The agent moved (a status transition). This is the only thing that can make a pane unread. */ + noteActive(session: string, paneId: string): void { + const panes = this.panesFor(session); + const t = this.now(); + const prev = panes.get(paneId); + panes.set(paneId, { activeAt: t, seenAt: prev?.seenAt ?? t }); + this.markDirty(); + } + + /** You opened or drove the pane through Collie. Clears its unread state by construction. */ + noteSeen(session: string, paneId: string): void { + const panes = this.panesFor(session); + const t = this.now(); + const prev = panes.get(paneId); + panes.set(paneId, { activeAt: prev?.activeAt ?? t, seenAt: t }); + this.markDirty(); + } + + get(session: string, paneId: string): PaneActivity | undefined { + return this.bySession.get(session)?.get(paneId); + } + + /** + * Bring a session's entries in line with the panes that actually exist: seed anything new + * ({@link ensure}) and drop anything gone ({@link forget}). + * + * Reconciling wholesale — rather than listening for per-pane removals — is what makes this + * correct for BARE SHELL panes too. The state engine's removal event is derived from its agent + * status map, so it never fires for a shell; a shell's entry would otherwise linger until the + * 30-day prune. Only ever called from a SUCCESSFUL poll, so a transient Herdr outage (which + * skips the update listeners entirely) can't be mistaken for "every pane closed". + */ + reconcile(session: string, livePaneIds: Iterable): void { + const live = new Set(livePaneIds); + for (const paneId of live) this.ensure(session, paneId); + const panes = this.bySession.get(session); + if (!panes) return; + for (const paneId of [...panes.keys()]) { + if (!live.has(paneId)) this.forget(session, paneId); + } + } + + /** The pane is gone. Drop it so a reused pane id can never inherit a dead pane's history. */ + forget(session: string, paneId: string): void { + const panes = this.bySession.get(session); + if (!panes?.delete(paneId)) return; + if (panes.size === 0) this.bySession.delete(session); + this.markDirty(); + } + + /** The current in-memory state as the on-disk shape. Exposed for tests and for {@link flush}. */ + snapshot(): ActivityFile { + const out: ActivityFile = {}; + for (const [session, panes] of this.bySession) { + out[session] = Object.fromEntries(panes); + } + return out; + } + + /** + * Write now, cancelling any pending debounce. Writes are serialised through `this.writing` so two + * flushes can't interleave their temp-file rename. Called on shutdown; otherwise the debounce + * timer drives it. + */ + async flush(): Promise { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + if (!this.dirty) return this.writing; + this.dirty = false; + const payload = JSON.stringify(this.snapshot()); + this.writing = this.writing.then(() => this.write(payload)).catch(() => {}); + return this.writing; + } + + /** Stop the debounce timer without writing. For tests and disposal paths. */ + stop(): void { + if (this.timer) clearTimeout(this.timer); + this.timer = null; + } + + private panesFor(session: string): Map { + let panes = this.bySession.get(session); + if (!panes) { + panes = new Map(); + this.bySession.set(session, panes); + } + return panes; + } + + private markDirty(): void { + this.dirty = true; + if (this.timer) return; + this.timer = setTimeout(() => { + this.timer = null; + void this.flush(); + }, this.debounceMs); + // Never hold the process open just to persist a "last seen" — shutdown flushes explicitly. + this.timer.unref?.(); + } + + private async write(payload: string): Promise { + try { + await mkdir(this.dir, { recursive: true, mode: 0o700 }); + const tmp = `${this.file}.tmp`; + await writeFile(tmp, payload, { mode: 0o600 }); + await rename(tmp, this.file); + } catch (err) { + console.warn(`[activity] could not persist ${this.file}: ${(err as Error).message}`); + } + } +} diff --git a/bridge/index.ts b/bridge/index.ts index 022d3db..7d2613d 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; +import { ActivityLedger } from "./activity.ts"; import { AuditLog, fileAuditAppender } from "./audit.ts"; import { loadConfig } from "./config.ts"; import { EventPoker } from "./event-poker.ts"; @@ -50,6 +51,12 @@ await snooze.load(); const notifyPrefs = new NotifyPrefsStore(cfg); await notifyPrefs.load(); +// When each pane last moved, and when you last looked at it — the two numbers the dashboard sorts +// and triages by (see activity.ts). Process-global and keyed by session name, because pane ids are +// session-scoped and collide across sessions. +const activity = new ActivityLedger(cfg); +await activity.load(); + // Append-only audit trail of write-level actions (see audit.ts). A write failure here is swallowed // inside record() so it can never break the user action it's auditing. const audit = new AuditLog(fileAuditAppender(join(cfg.stateDir, "audit.log"))); @@ -116,6 +123,15 @@ const makeSession: SessionFactory = (name, socketPath, isPrimary) => { poker.onHealth((h) => engine.setCadence(h ? cfg.pollIdleMs : cfg.pollMs)); engine.onUpdate((s) => poker.setAgentPanes(s.agents.map((a) => a.paneId))); + // Activity bookkeeping. A status change stamps `activeAt` (the only thing that can make a pane + // read as unseen); every successful poll reconciles the ledger against the panes that exist, which + // seeds first sightings as already-seen and reaps closed ones. Reconciling covers bare shells too, + // which the engine's agent-derived removal event never reports. + engine.onTransition((agent) => activity.noteActive(name, agent.paneId)); + engine.onUpdate((s) => + activity.reconcile(name, [...s.agents, ...s.shellPanes].map((p) => p.paneId)), + ); + // Background notifications on lifecycle transitions (foreground toasts are computed client-side by // diffing snapshots). Each session gets its own coordinator + notification slot: the primary keeps // the bare `collie:herd` tag (so pre-feature notifications don't orphan) and omits the session name @@ -187,7 +203,7 @@ const sweepTimer = setInterval(() => { }, SWEEP_INTERVAL_MS); sweepTimer.unref(); -const server = startServer({ cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit }); +const server = startServer({ cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit, activity }); const shutdown = async () => { console.log("\n[bridge] shutting down"); @@ -196,6 +212,10 @@ const shutdown = async () => { await server.stop(); clearInterval(refreshTimer); registry.disposeAll(); + // Writes are debounced, so the last few seconds of "you looked at this" live only in memory — + // persist them before exiting, or every restart quietly resurrects alerts you'd already cleared. + activity.stop(); + await activity.flush(); clearInterval(sweepTimer); clearTimeout(updateFirstCheck); clearInterval(updateTimer); diff --git a/bridge/server.ts b/bridge/server.ts index 11b8a7d..16d1830 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -1,6 +1,7 @@ import { mkdir } from "node:fs/promises"; import { homedir } from "node:os"; import { extname, join, normalize, sep } from "node:path"; +import type { ActivityLedger } from "./activity.ts"; import type { AuditLog } from "./audit.ts"; import type { Config } from "./config.ts"; import type { HerdrClient, PaneRead } from "./herdr-client.ts"; @@ -19,6 +20,7 @@ import type { StateEngine } from "./state-engine.ts"; import { ClaudeTranscriptSource, TranscriptStore } from "./transcript.ts"; import type { ActionResponse, + AgentView, BridgeConfig, CreateResponse, DeviceAuth, @@ -104,8 +106,9 @@ export function startServer(opts: { notifyPrefs: NotifyPrefsStore; updateMonitor: UpdateMonitor; audit: AuditLog; + activity: ActivityLedger; }) { - const { cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit } = opts; + const { cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit, activity } = opts; // One transcript store for the process: it caches parsed session logs across requests, and the // cache is keyed by absolute path, so sharing it across herdr sessions is correct (two sessions // can front panes whose agents write into the same ~/.claude/projects root). @@ -142,6 +145,13 @@ export function startServer(opts: { if (!rt) return unknownSession(); const { agents, shellPanes, workspaces, tabs, bridge } = rt.engine.current(); const device = deviceAuth(req, cfg); + // Attach each pane's activity timestamps. Done here rather than in the state engine so the + // engine stays a pure Herdr-poller with no knowledge of the ledger — and so the two numbers + // are read at serialise time, i.e. as fresh as the request. + const withActivity = (p: AgentView): AgentView => { + const a = activity.get(rt.name, p.paneId); + return a ? { ...p, lastActiveAt: a.activeAt, lastSeenAt: a.seenAt } : p; + }; // Tag every snapshot poll with the on-disk build id so an open client notices a live rebuild // between polls — the no-service-worker self-update path (web/src/lib/self-update.ts). return withBuildHeader( @@ -149,8 +159,8 @@ export function startServer(opts: { bridge, // Only report device state when the feature is on, so an off deployment sends nothing new. ...(device.enforced ? { device } : {}), - agents, - shellPanes, + agents: agents.map(withActivity), + shellPanes: shellPanes.map(withActivity), workspaces, tabs, sessions: registry.list(), @@ -205,6 +215,11 @@ export function startServer(opts: { const rt = registry.get(sessionName); if (!rt) return unknownSession(); const { herdr, name: session } = rt; + // You are in this pane: reading it, replying, sending keys, browsing its history. That is + // the whole definition of "seen" (.adr/0003), and this is the one place every such request + // passes through. It cannot false-positive from background polling — the dashboard loader + // only ever fetches /api/snapshot; paneLoader is the sole reader of pane text. + activity.noteSeen(session, paneId); // Every action is a write; attribute it to the authorised device for the audit trail. // `history` is a read, so it gets no device attribution (nothing is written to attribute). const device = action && action !== "history" ? deviceAuth(req, cfg).device : null; diff --git a/bridge/state-engine.ts b/bridge/state-engine.ts index 8e5c5fe..1181aeb 100644 --- a/bridge/state-engine.ts +++ b/bridge/state-engine.ts @@ -1,3 +1,4 @@ +import { meaningfulTabLabel } from "./activity.ts"; import type { HerdrClient } from "./herdr-client.ts"; import { type AgentStatus, @@ -193,6 +194,7 @@ export class StateEngine { try { const { workspaces, panes, tabs } = await this.fetchWire(); const wsById = new Map(workspaces.map((w) => [w.workspace_id, w])); + const tabById = new Map(tabs.map((t) => [t.tab_id, t])); const toView = ( p: (typeof panes)[number], @@ -200,6 +202,9 @@ export class StateEngine { kind: "agent" | "shell", ): AgentView => { const ws = wsById.get(p.workspace_id); + // The tab's label, denormalised alongside workspaceLabel so no client has to join tabs[]. + // Dropped when it's Herdr's positional default in a single-tab space — see meaningfulTabLabel. + const tabLabel = meaningfulTabLabel(tabById.get(p.tab_id)?.label, ws?.tab_count ?? 0); return { paneId: p.pane_id, workspaceId: p.workspace_id, @@ -213,6 +218,7 @@ export class StateEngine { kind, // A user-set pane label (herdr pane.rename); omitted when unset so "absent stays absent". ...(typeof p.label === "string" && p.label.length > 0 ? { paneLabel: p.label } : {}), + ...(tabLabel ? { tabLabel } : {}), // The agent's own session id — only the "id" kind names an on-disk transcript. Omitted // otherwise, so "no history for this pane" is simply the field being absent. ...(p.agent_session?.kind === "id" && typeof p.agent_session.value === "string" diff --git a/bridge/types.ts b/bridge/types.ts index 74e139e..aca1538 100644 --- a/bridge/types.ts +++ b/bridge/types.ts @@ -49,6 +49,24 @@ export interface AgentView { * here, because the alt screen keeps no scrollback ring at all. Absent on older Herdr servers. */ readableLines?: number; + /** + * The pane's tab label, denormalised from `tab.list` exactly as `workspaceLabel` already is — so + * every client surface (card, sidebar, palette, space view) gets it without joining `tabs[]`. + * Absent when the label carries no information: an unlabelled tab in a single-tab space is named + * positionally by Herdr ("1"), which would render as `project · 1`. See `meaningfulTabLabel`. + */ + tabLabel?: string; + /** + * Epoch ms of this agent's last observed status transition (bridge/activity.ts). The only thing + * that can make a pane read as unseen. Absent until the ledger has an entry, and on the very + * first poll after a fresh install. + */ + lastActiveAt?: number; + /** + * Epoch ms you last opened or drove this pane through Collie. `lastActiveAt > lastSeenAt` on a + * `done` agent IS the "finished while you weren't looking" state — there is no stored seen flag. + */ + lastSeenAt?: number; } /** A Herdr workspace ("space") — a project-scoped container of tabs. From `workspace.list`. */ From da4f44c41d8ce2ebed700cd6aba3301c2ba0f3d9 Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 21:11:53 +0400 Subject: [PATCH 02/19] feat(web): triage the dashboard by attention then recency, and stop calling every agent "claude" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three complaints, one cause: the dashboard was ordered by how Herdr stores things, not by what needs doing. 45 spaces rendered in creation order above the agents; agents sorted by status then creation order, with nothing distinguishing one that finished ten seconds ago from one dealt with yesterday; and every row titled "claude", because the title fell back to the agent name. Ordering (lib/triage.ts, replacing lib/agent-groups.ts): Needs you → Ready · unseen → Working → Recent The first three are pinned — they never move, never invert, and never fold. Recent runs by when you last used each pane and is the only section the direction toggle reaches. Recent and Spaces fold and remember it; fold both and the page is the triaged herd and nothing else. Naming (lib/pane-name.ts): a row is titled `project · tab`, with the pane's own name (a herdr label or Claude's /rename session name) on the second line where the cwd used to be. Nothing is lost — the agent's identity was always the avatar, never the text. In the space detail view, which already groups under a per-tab heading, the pane's own name leads instead (scope="tab"): repeating the heading would say nothing, and two panes in one tab would stop being distinguishable. Spaces moves BELOW every agent section — it's a navigator, not a work queue — and gains recency ordering plus a filter box. That retires the reason home.tsx split the agent list in two to hoist "Needs you" above it; AgentList now renders once. With no timestamps (an older bridge) this degrades to today's dashboard with no branch: the unseen test is false, every comparator returns 0, and Array.sort is stable, so each section keeps the bridge's own order. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/agent-card.tsx | 48 +++- web/src/components/agent-list.test.tsx | 258 +++++++++++++++++++++ web/src/components/agent-list.tsx | 114 ++++++--- web/src/components/agent-sidebar.test.tsx | 21 +- web/src/components/agent-sidebar.tsx | 29 ++- web/src/components/section-header.tsx | 96 ++++++++ web/src/components/space-overview.test.tsx | 160 +++++++++++-- web/src/components/space-overview.tsx | 206 ++++++++++------ web/src/components/space-view.tsx | 9 +- web/src/hooks/use-dash-prefs.test.ts | 83 +++++++ web/src/hooks/use-dash-prefs.ts | 92 ++++++++ web/src/lib/agent-groups.test.ts | 38 --- web/src/lib/agent-groups.ts | 19 -- web/src/lib/pane-name.test.ts | 86 +++++++ web/src/lib/pane-name.ts | 62 +++++ web/src/lib/spaces.test.ts | 107 ++++++++- web/src/lib/spaces.ts | 50 +++- web/src/lib/triage.test.ts | 188 +++++++++++++++ web/src/lib/triage.ts | 101 ++++++++ web/src/lib/types.ts | 19 ++ web/src/routes/home.tsx | 36 +-- 21 files changed, 1586 insertions(+), 236 deletions(-) create mode 100644 web/src/components/agent-list.test.tsx create mode 100644 web/src/components/section-header.tsx create mode 100644 web/src/hooks/use-dash-prefs.test.ts create mode 100644 web/src/hooks/use-dash-prefs.ts delete mode 100644 web/src/lib/agent-groups.test.ts delete mode 100644 web/src/lib/agent-groups.ts create mode 100644 web/src/lib/pane-name.test.ts create mode 100644 web/src/lib/pane-name.ts create mode 100644 web/src/lib/triage.test.ts create mode 100644 web/src/lib/triage.ts diff --git a/web/src/components/agent-card.tsx b/web/src/components/agent-card.tsx index 833db05..8d0b49a 100644 --- a/web/src/components/agent-card.tsx +++ b/web/src/components/agent-card.tsx @@ -4,15 +4,39 @@ import { cn } from "@/lib/utils"; import { Card } from "@/components/ui/card"; import { ShellBadge, StatusBadge } from "@/components/status-badge"; import { AgentIcon } from "@/components/agent-icon"; -import { shortCwd } from "@/lib/format"; -import { paneDisplayName } from "@/lib/types"; +import { timeAgo } from "@/lib/format"; +import { paneTitle, paneTitleInTab } from "@/lib/pane-name"; import type { AgentView } from "@/lib/types"; +interface AgentCardProps { + agent: AgentView; + onClick: () => void; + /** + * Show "how long ago" beside the badge, and which timestamp it means: "seen" for the Recent + * section (when you last opened it), "active" for Ready · unseen (when it finished). Omitted + * elsewhere — a blocked agent's age is noise next to the fact that it's blocked. + */ + age?: "seen" | "active"; + /** + * Where the row is being shown. "herd" (default) is a flat list across every space, so the title + * carries `project · tab`. "tab" is a list already grouped under its space and tab — repeating + * them would say nothing, so the pane's own name leads instead. + */ + scope?: "herd" | "tab"; +} + // A pane row, used by the triage home and the space view. Usually an agent; for a bare shell pane // (kind:"shell") it shows a terminal glyph and a muted "shell" tag instead of a status badge. -export function AgentCard({ agent, onClick }: { agent: AgentView; onClick: () => void }) { +// +// The title is `project · tab` (see paneTitle) — NOT the agent name, which every row would otherwise +// share. The agent's identity lives in the avatar; the pane's own name, when it has one, sits on the +// second line where the cwd used to be. +export function AgentCard({ agent, onClick, age, scope = "herd" }: AgentCardProps) { const isShell = agent.kind === "shell"; const blocked = agent.status === "blocked"; + const { primary, secondary } = scope === "tab" ? paneTitleInTab(agent) : paneTitle(agent); + const stamp = age === "seen" ? agent.lastSeenAt : age === "active" ? agent.lastActiveAt : undefined; + return ( + ); +} diff --git a/web/src/components/agent-sidebar.test.tsx b/web/src/components/agent-sidebar.test.tsx index 244330d..fbae62f 100644 --- a/web/src/components/agent-sidebar.test.tsx +++ b/web/src/components/agent-sidebar.test.tsx @@ -23,30 +23,31 @@ describe("ThreadSidebar", () => { expect(screen.getByText("No agents running.")).toBeInTheDocument(); }); - it("groups agents into the triage sections it has members for", () => { + it("groups agents into the same triage sections the dashboard uses", () => { render( , ); - // blocked → Needs you, working → Working, idle → Idle · done + // blocked → Needs you, working → Working, idle → Recent (lib/triage.ts) expect(screen.getByText("Needs you")).toBeInTheDocument(); expect(screen.getByText("Working")).toBeInTheDocument(); - expect(screen.getByText("Idle · done")).toBeInTheDocument(); + expect(screen.getByText("Recent")).toBeInTheDocument(); }); it("omits groups that have no members", () => { - // Only a blocked agent → no Working / Idle headers. + // Only a blocked agent → no Working / Recent headers. render(); expect(screen.getByText("Needs you")).toBeInTheDocument(); expect(screen.queryByText("Working")).toBeNull(); - expect(screen.queryByText("Idle · done")).toBeNull(); + expect(screen.queryByText("Recent")).toBeNull(); }); it("marks the current pane with aria-current='page'", () => { render(); const current = screen.getByRole("button", { current: "page" }); - // w2:p1 is the codex agent in the "collie" workspace. - expect(current).toHaveTextContent("codex"); + // w2:p1 lives in the "collie" workspace. The row is titled by where the work IS, not by which + // agent is doing it — "codex" is carried by the avatar (see paneTitle). expect(current).toHaveTextContent("collie"); + expect(current).not.toHaveTextContent("codex"); }); it("does not mark any pane current when the id matches nothing", () => { @@ -58,7 +59,7 @@ describe("ThreadSidebar", () => { const user = userEvent.setup(); const onSelect = vi.fn(); render(); - await user.click(screen.getByRole("button", { name: /claude/ })); + await user.click(screen.getByRole("button", { name: /webapp/ })); expect(onSelect).toHaveBeenCalledExactlyOnceWith("w1:p1"); }); @@ -87,7 +88,9 @@ describe("ThreadSidebar", () => { />, ); expect(screen.getByText("Shells")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /shell/ })); + // The shell row is titled by its space like every other row; the terminal glyph is what marks + // it as a shell. It's the only pane in "sandbox" here, so the name is unambiguous. + await user.click(screen.getByRole("button", { name: /sandbox/ })); expect(onSelect).toHaveBeenCalledExactlyOnceWith("w3:p2"); }); diff --git a/web/src/components/agent-sidebar.tsx b/web/src/components/agent-sidebar.tsx index 79ebfcc..66e38be 100644 --- a/web/src/components/agent-sidebar.tsx +++ b/web/src/components/agent-sidebar.tsx @@ -2,9 +2,8 @@ import { TerminalSquare } from "lucide-react"; import { cn } from "@/lib/utils"; import { AgentIcon } from "@/components/agent-icon"; -import { AGENT_GROUPS } from "@/lib/agent-groups"; -import { shortCwd } from "@/lib/format"; -import { paneDisplayName } from "@/lib/types"; +import { paneTitle } from "@/lib/pane-name"; +import { triage } from "@/lib/triage"; import type { AgentView } from "@/lib/types"; interface ThreadSidebarProps { @@ -37,8 +36,11 @@ export function ThreadSidebar({ return (
- {AGENT_GROUPS.map((g) => { - const members = agents.filter((a) => g.match(a.status)); + {/* The same triage the dashboard uses (lib/triage.ts) — the two lists must not disagree about + what needs you. Recent renders newest-first here and doesn't fold: the sidebar is a + switcher you're already inside, not a page you're triaging. */} + {triage(agents).map((g) => { + const members = g.agents; if (members.length === 0) return null; return (
@@ -111,9 +113,9 @@ function PaneRow({ onSelect: (paneId: string) => void; }) { const isShell = pane.kind === "shell"; - // A user label leads, then Claude's /rename session name, then the agent/shell name (the icon still - // conveys the agent). See paneDisplayName. - const name = paneDisplayName(pane); + // project · tab, with the pane's own name (or cwd) beneath — see paneTitle. The agent's identity + // stays in the icon, which is why the title line is free to say where the work is. + const { primary, secondary } = paneTitle(pane); return ( ); diff --git a/web/src/components/section-header.tsx b/web/src/components/section-header.tsx new file mode 100644 index 0000000..af462d9 --- /dev/null +++ b/web/src/components/section-header.tsx @@ -0,0 +1,96 @@ +import { ChevronRight } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +interface SectionHeaderProps { + /** Section name, e.g. "Recent". Rendered uppercase. */ + label: string; + /** Item count shown beside the label. Omit to show no count. */ + count?: number; + /** Bullet colour class from the status palette (e.g. "bg-status-blocked"). */ + dot?: string; + /** Render the label in the alert colour — the "Needs you" section. */ + accent?: boolean; + /** + * Fold state. Omit entirely for a section that can't fold: the header then renders as a plain + * heading with no caret and no button, so there is nothing to press and nothing to announce. + */ + open?: boolean; + onToggle?: (open: boolean) => void; + /** Id of the element this header folds — wired to `aria-controls`. Required when foldable. */ + controls?: string; + /** + * The section's own control (a sort toggle, a "new" button). Rendered as a SIBLING of the fold + * button, never inside it: nesting would be invalid markup and would make pressing the control + * also fold the section. + */ + trailing?: ReactNode; + className?: string; +} + +// The one section heading the dashboard uses, foldable or not. Recent and Spaces fold; the three +// attention sections don't (collapsing an alert defeats the alert), and they pass no `open` at all +// rather than a disabled toggle — a control that never does anything shouldn't be in the a11y tree. +// +// Keeping both cases in one component is deliberate: the two foldable sections behaved identically +// in the design, and two copies of "caret + aria-expanded + trailing slot" would drift. +export function SectionHeader({ + label, + count, + dot, + accent, + open, + onToggle, + controls, + trailing, + className, +}: SectionHeaderProps) { + const foldable = open !== undefined && onToggle !== undefined; + + const inner = ( + <> + {foldable && ( + + )} + {dot && } + {label} + {count !== undefined && ({count})} + + ); + + // Always an

, foldable or not — the sections are the page's outline, and a section that can + // be folded shouldn't vanish from it. When foldable the heading WRAPS the button (the standard + // disclosure pattern) instead of being replaced by one. + const tone = accent + ? "text-status-blocked" + : cn("text-muted-foreground", foldable && "hover:text-foreground"); + + return ( +
+

+ {foldable ? ( + + ) : ( + {inner} + )} +

+ {trailing && {trailing}} +
+ ); +} diff --git a/web/src/components/space-overview.test.tsx b/web/src/components/space-overview.test.tsx index 1ab0b82..0184d08 100644 --- a/web/src/components/space-overview.test.tsx +++ b/web/src/components/space-overview.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { SpaceOverview } from "./space-overview"; -import type { WorkspaceView } from "@/lib/types"; +import type { AgentView, WorkspaceView } from "@/lib/types"; function ws(workspaceId: string, label: string, tabCount: number, paneCount: number): WorkspaceView { return { @@ -16,39 +16,51 @@ function ws(workspaceId: string, label: string, tabCount: number, paneCount: num }; } +function pane(over: Partial & { paneId: string; workspaceId: string }): AgentView { + return { + workspaceLabel: "ws", + workspaceNumber: 1, + tabId: `${over.workspaceId}:t1`, + agent: "claude", + status: "idle", + cwd: "/home/you/demo", + focused: false, + ...over, + }; +} + +/** The section is foldable now, so every test declares the state it wants to exercise. */ +function view(props: Partial[0]> = {}) { + return ( + + ); +} + describe("SpaceOverview", () => { it("shows an empty state when there are no spaces", () => { - render(); + render(view()); expect(screen.getByText(/no spaces yet/i)).toBeInTheDocument(); }); - it("renders each space with its tab and pane counts (pluralized)", () => { - render( - , - ); + it("renders each space with its pane count (pluralized)", () => { + render(view({ workspaces: [ws("w1", "anchorgenius", 2, 3), ws("w2", "tgl", 1, 1)] })); expect(screen.getByText("anchorgenius")).toBeInTheDocument(); - expect(screen.getByLabelText("2 tabs")).toBeInTheDocument(); expect(screen.getByLabelText("3 panes")).toBeInTheDocument(); - expect(screen.getByLabelText("1 tab")).toBeInTheDocument(); // singular - expect(screen.getByLabelText("1 pane")).toBeInTheDocument(); + expect(screen.getByLabelText("1 pane")).toBeInTheDocument(); // singular }); it("opens a space when its card is tapped", async () => { const user = userEvent.setup(); const onOpen = vi.fn(); - render( - , - ); + render(view({ workspaces: [ws("w1", "anchorgenius", 2, 3)], onOpen })); await user.click(screen.getByRole("button", { name: /anchorgenius/ })); expect(onOpen).toHaveBeenCalledExactlyOnceWith("w1"); }); @@ -56,8 +68,110 @@ describe("SpaceOverview", () => { it("creates a new space from the header button", async () => { const user = userEvent.setup(); const onNewSpace = vi.fn(); - render(); + render(view({ onNewSpace })); await user.click(screen.getByRole("button", { name: /new space/i })); expect(onNewSpace).toHaveBeenCalledOnce(); }); }); + +describe("SpaceOverview — folding", () => { + const spaces = [ws("w1", "anchorgenius", 2, 3), ws("w2", "tgl", 1, 1)]; + + it("hides the list when folded, keeping the count on the header", () => { + render(view({ workspaces: spaces, open: false })); + expect(screen.queryByText("anchorgenius")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /spaces/i })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.getByText("(2)")).toBeInTheDocument(); + }); + + it("keeps the new-space button reachable while folded", async () => { + const user = userEvent.setup(); + const onNewSpace = vi.fn(); + render(view({ workspaces: spaces, open: false, onNewSpace })); + await user.click(screen.getByRole("button", { name: /new space/i })); + expect(onNewSpace).toHaveBeenCalledOnce(); + }); + + it("reports the fold to its owner rather than keeping the state itself", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(view({ workspaces: spaces, open: true, onOpenChange })); + await user.click(screen.getByRole("button", { name: /spaces/i })); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("shows a blocked count on the header, so you know why to expand", () => { + render( + view({ + workspaces: spaces, + open: false, + agents: [pane({ paneId: "w1:p1", workspaceId: "w1", status: "blocked" })], + }), + ); + expect(screen.getByLabelText("1 space needs you")).toBeInTheDocument(); + }); +}); + +describe("SpaceOverview — filtering", () => { + const spaces = [ws("w1", "moonward_os", 1, 1), ws("w2", "trader", 1, 1), ws("w3", "moon_probe", 1, 1)]; + + it("narrows the list as you type, case-insensitively", async () => { + const user = userEvent.setup(); + render(view({ workspaces: spaces })); + await user.type(screen.getByLabelText(/filter spaces/i), "MOON"); + expect(screen.getByText("moonward_os")).toBeInTheDocument(); + expect(screen.getByText("moon_probe")).toBeInTheDocument(); + expect(screen.queryByText("trader")).not.toBeInTheDocument(); + }); + + it("says so when nothing matches, instead of showing a bare empty area", async () => { + const user = userEvent.setup(); + render(view({ workspaces: spaces })); + await user.type(screen.getByLabelText(/filter spaces/i), "zzz"); + expect(screen.getByText(/no space matches/i)).toBeInTheDocument(); + }); + + it("offers no filter box for a single space — there is nothing to filter", () => { + render(view({ workspaces: [ws("w1", "solo", 1, 1)] })); + expect(screen.queryByLabelText(/filter spaces/i)).not.toBeInTheDocument(); + }); +}); + +describe("SpaceOverview — recency", () => { + it("puts the space you used most recently first, whatever Herdr's order", () => { + const spaces = [ws("w1", "alpha", 1, 1), ws("w2", "beta", 1, 1)]; + render( + view({ + workspaces: spaces, + agents: [ + pane({ paneId: "w1:p1", workspaceId: "w1", lastSeenAt: 100 }), + pane({ paneId: "w2:p1", workspaceId: "w2", lastSeenAt: 900 }), + ], + }), + ); + const labels = screen.getAllByRole("button", { name: /alpha|beta/ }).map((b) => b.textContent); + expect(labels[0]).toContain("beta"); + expect(labels[1]).toContain("alpha"); + }); + + it("counts a bare shell as having used the space", () => { + const spaces = [ws("w1", "alpha", 1, 1), ws("w2", "beta", 1, 1)]; + render( + view({ + workspaces: spaces, + agents: [pane({ paneId: "w1:p1", workspaceId: "w1", lastSeenAt: 100 })], + shellPanes: [pane({ paneId: "w2:p1", workspaceId: "w2", kind: "shell", lastSeenAt: 900 })], + }), + ); + const labels = screen.getAllByRole("button", { name: /alpha|beta/ }).map((b) => b.textContent); + expect(labels[0]).toContain("beta"); + }); + + it("shows no timestamp for a space on a bridge that reports none", () => { + render(view({ workspaces: [ws("w1", "alpha", 1, 1)] })); + expect(screen.queryByText(/ago|just now/i)).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/space-overview.tsx b/web/src/components/space-overview.tsx index 99953b0..cedff95 100644 --- a/web/src/components/space-overview.tsx +++ b/web/src/components/space-overview.tsx @@ -1,93 +1,159 @@ -import { ChevronRight, FolderPlus, Layers, LayoutGrid } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; +import { useState } from "react"; +import { ChevronRight, FolderPlus, LayoutGrid, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { Card } from "@/components/ui/card"; +import { SectionHeader } from "@/components/section-header"; import { StatusDot } from "@/components/status-badge"; -import { blockedCount, worstSpaceStatus } from "@/lib/spaces"; +import { + blockedCount, + filterSpaces, + sortSpacesByRecency, + spaceLastSeen, + worstSpaceStatus, +} from "@/lib/spaces"; +import { timeAgo } from "@/lib/format"; import { STATUS_LABEL } from "@/lib/types"; import type { AgentView, WorkspaceView } from "@/lib/types"; interface SpaceOverviewProps { workspaces: WorkspaceView[]; agents: AgentView[]; + /** Bare shells too — a space you only ever opened a shell in still counts as used. */ + shellPanes?: AgentView[]; onOpen: (workspaceId: string) => void; onNewSpace: () => void; + /** Fold state, owned by the dashboard so it can be persisted. */ + open: boolean; + onOpenChange: (open: boolean) => void; } -// The dashboard's top section: every space as a card with a status dot (its most-urgent agent), a -// blocked tint, and compact tab/pane counts. Tapping a space drills into its tab/pane view. -export function SpaceOverview({ workspaces, agents, onOpen, onNewSpace }: SpaceOverviewProps) { +// The dashboard's navigator, and the LAST section on the page: everything you might act on comes +// first. It folds to a single line — with 45 spaces that's the difference between a dashboard and a +// scroll — and expands to a recency-ordered, filterable list. +export function SpaceOverview({ + workspaces, + agents, + shellPanes = [], + onOpen, + onNewSpace, + open, + onOpenChange, +}: SpaceOverviewProps) { + // Ephemeral view state, like SpaceRoute's tab selection — a filter you typed yesterday should not + // greet you today with most of your spaces missing. + const [query, setQuery] = useState(""); + + const panes = [...agents, ...shellPanes]; + const blockedSpaces = workspaces.filter((w) => blockedCount(w.workspaceId, agents) > 0).length; + const visible = filterSpaces(sortSpacesByRecency(workspaces, panes), query); + return (
-
-

- Spaces ({workspaces.length}) -

- -
- - {workspaces.length === 0 ? ( -

No spaces yet.

- ) : ( -
- {workspaces.map((w) => { - const status = worstSpaceStatus(w.workspaceId, agents); - const blocked = blockedCount(w.workspaceId, agents) > 0; - return ( - + + } + /> + + {open && ( +
+ {/* Deliberately NOT autofocused: on a phone that would throw the keyboard over the list + you just asked to see. */} + {workspaces.length > 1 && ( + + )} + + {workspaces.length === 0 ? ( +

No spaces yet.

+ ) : visible.length === 0 ? ( +

+ No space matches “{query}”. +

+ ) : ( + visible.map((w) => { + const status = worstSpaceStatus(w.workspaceId, agents); + const blocked = blockedCount(w.workspaceId, agents) > 0; + const seen = spaceLastSeen(w.workspaceId, panes); + return ( + - ); - })} + + {status ? ( + <> + + {/* The dot alone is colour-only; give SR users the status word. */} + {STATUS_LABEL[status]} + + ) : ( + + )} + {w.label} + {/* One count plus a relative time is what a 390px row has room for — the tab + count went, the pane count is the useful one. */} + + + {w.paneCount} + + {seen > 0 && ( + + {timeAgo(seen)} + + )} + + + + ); + }) + )}
)}
); } - -// A compact count pill — icon + number, with a spelled-out aria-label (e.g. "3 panes") for a11y. -function Count({ icon: Icon, n, unit }: { icon: LucideIcon; n: number; unit: string }) { - return ( - - - {n} - - ); -} diff --git a/web/src/components/space-view.tsx b/web/src/components/space-view.tsx index 39af4e1..6a96733 100644 --- a/web/src/components/space-view.tsx +++ b/web/src/components/space-view.tsx @@ -41,8 +41,15 @@ export function SpaceView({ workspace, tabs, agents, shellPanes, selectedTab, on

(empty tab)

) : (
+ {/* scope="tab": this list already sits under its space heading and per-tab section, + so the cards lead with each pane's own name rather than repeating both. */} {g.panes.map((p) => ( - onOpen(p.paneId)} /> + onOpen(p.paneId)} + scope="tab" + /> ))}
)} diff --git a/web/src/hooks/use-dash-prefs.test.ts b/web/src/hooks/use-dash-prefs.test.ts new file mode 100644 index 0000000..be22d73 --- /dev/null +++ b/web/src/hooks/use-dash-prefs.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +import { + coerceDashPrefs, + SPACES_COLLAPSE_THRESHOLD, + spacesOpenFor, + useDashPrefs, +} from "./use-dash-prefs"; + +describe("spacesOpenFor", () => { + it("starts expanded on a small install", () => { + expect(spacesOpenFor(null, 2)).toBe(true); + expect(spacesOpenFor(null, SPACES_COLLAPSE_THRESHOLD)).toBe(true); + }); + + it("starts collapsed once the list is a wall", () => { + expect(spacesOpenFor(null, SPACES_COLLAPSE_THRESHOLD + 1)).toBe(false); + expect(spacesOpenFor(null, 45)).toBe(false); + }); + + it("an explicit choice always beats the threshold, in both directions", () => { + expect(spacesOpenFor(true, 45)).toBe(true); + expect(spacesOpenFor(false, 1)).toBe(false); + }); +}); + +describe("coerceDashPrefs", () => { + it("defaults an empty object", () => { + expect(coerceDashPrefs({})).toEqual({ spacesOpen: null, recentOpen: true, recentDir: "newest" }); + }); + + it("keeps valid values", () => { + expect(coerceDashPrefs({ spacesOpen: false, recentOpen: false, recentDir: "oldest" })).toEqual({ + spacesOpen: false, + recentOpen: false, + recentDir: "oldest", + }); + }); + + it("rejects a bogus direction rather than trusting it", () => { + expect(coerceDashPrefs({ recentDir: "sideways" }).recentDir).toBe("newest"); + }); + + it("survives garbage", () => { + expect(coerceDashPrefs(null).recentDir).toBe("newest"); + expect(coerceDashPrefs("nope").recentOpen).toBe(true); + expect(coerceDashPrefs({ spacesOpen: "yes" }).spacesOpen).toBeNull(); + }); +}); + +describe("useDashPrefs", () => { + beforeEach(() => localStorage.clear()); + + it("starts at the defaults", () => { + const { result } = renderHook(() => useDashPrefs()); + expect(result.current.prefs).toEqual({ + spacesOpen: null, + recentOpen: true, + recentDir: "newest", + }); + }); + + it("persists each setting across a remount", () => { + const first = renderHook(() => useDashPrefs()); + act(() => first.result.current.setSpacesOpen(true)); + act(() => first.result.current.setRecentOpen(false)); + act(() => first.result.current.setRecentDir("oldest")); + + const second = renderHook(() => useDashPrefs()); + expect(second.result.current.prefs).toEqual({ + spacesOpen: true, + recentOpen: false, + recentDir: "oldest", + }); + }); + + it("reads back a corrupt stored value as the defaults instead of throwing", () => { + localStorage.setItem("collie:dash-prefs:v1", "{not json"); + const { result } = renderHook(() => useDashPrefs()); + expect(result.current.prefs.recentDir).toBe("newest"); + }); +}); diff --git a/web/src/hooks/use-dash-prefs.ts b/web/src/hooks/use-dash-prefs.ts new file mode 100644 index 0000000..f5315c7 --- /dev/null +++ b/web/src/hooks/use-dash-prefs.ts @@ -0,0 +1,92 @@ +import { useCallback, useState } from "react"; + +import type { RecentDir } from "@/lib/triage"; + +// Dashboard layout preferences, persisted in localStorage. Deliberately separate from +// use-display-prefs (which is about the terminal mirror) — these are about the herd list. +// Safe in SSR contexts: every localStorage touch is guarded. + +export interface DashPrefs { + /** + * Whether the Spaces section is expanded. `null` means "never chosen" — the count threshold + * decides (see {@link spacesOpenFor}), so a two-space install isn't handed a mystery collapsed + * header while a forty-space one isn't handed a wall. An explicit choice always wins. + */ + spacesOpen: boolean | null; + /** Whether the Recent section is expanded. Defaults open — it's the recency list itself. */ + recentOpen: boolean; + /** Which way Recent runs. Attention sections are never affected. */ + recentDir: RecentDir; +} + +const STORAGE_KEY = "collie:dash-prefs:v1"; + +/** Above this many spaces, an un-chosen Spaces section starts collapsed. */ +export const SPACES_COLLAPSE_THRESHOLD = 8; + +const DEFAULTS: DashPrefs = { spacesOpen: null, recentOpen: true, recentDir: "newest" }; + +/** Resolve the effective Spaces open state: an explicit choice, else the count threshold. */ +export function spacesOpenFor(pref: boolean | null, spaceCount: number): boolean { + if (pref !== null) return pref; + return spaceCount <= SPACES_COLLAPSE_THRESHOLD; +} + +/** + * Coerce an untrusted parsed value into {@link DashPrefs}, filling anything missing or wrong-typed + * from the defaults. Pure + exported so the file-shape handling is unit-tested. + */ +export function coerceDashPrefs(raw: unknown): DashPrefs { + if (typeof raw !== "object" || raw === null) return { ...DEFAULTS }; + const p = raw as Record; + return { + spacesOpen: typeof p.spacesOpen === "boolean" ? p.spacesOpen : DEFAULTS.spacesOpen, + recentOpen: typeof p.recentOpen === "boolean" ? p.recentOpen : DEFAULTS.recentOpen, + recentDir: p.recentDir === "oldest" || p.recentDir === "newest" ? p.recentDir : DEFAULTS.recentDir, + }; +} + +function loadPrefs(): DashPrefs { + try { + const raw = typeof localStorage !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null; + if (!raw) return { ...DEFAULTS }; + return coerceDashPrefs(JSON.parse(raw)); + } catch { + return { ...DEFAULTS }; + } +} + +function savePrefs(prefs: DashPrefs): void { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } + } catch { + // Ignore quota / SSR write errors — a lost layout preference is not worth a broken render. + } +} + +export interface UseDashPrefsReturn { + prefs: DashPrefs; + setSpacesOpen: (open: boolean) => void; + setRecentOpen: (open: boolean) => void; + setRecentDir: (dir: RecentDir) => void; +} + +export function useDashPrefs(): UseDashPrefsReturn { + const [prefs, setPrefs] = useState(loadPrefs); + + const update = useCallback((patch: Partial) => { + setPrefs((p) => { + const next: DashPrefs = { ...p, ...patch }; + savePrefs(next); + return next; + }); + }, []); + + const setSpacesOpen = useCallback((spacesOpen: boolean) => update({ spacesOpen }), [update]); + const setRecentOpen = useCallback((recentOpen: boolean) => update({ recentOpen }), [update]); + const setRecentDir = useCallback((recentDir: RecentDir) => update({ recentDir }), [update]); + + return { prefs, setSpacesOpen, setRecentOpen, setRecentDir }; +} diff --git a/web/src/lib/agent-groups.test.ts b/web/src/lib/agent-groups.test.ts deleted file mode 100644 index ebb6e63..0000000 --- a/web/src/lib/agent-groups.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { AGENT_GROUPS } from "./agent-groups"; -import type { AgentStatus } from "./types"; - -const ALL_STATUSES: AgentStatus[] = ["idle", "working", "blocked", "done", "unknown"]; - -const groupFor = (s: AgentStatus) => AGENT_GROUPS.filter((g) => g.match(s)); - -describe("AGENT_GROUPS", () => { - it("has the three triage groups in needs → working → other order", () => { - expect(AGENT_GROUPS.map((g) => g.key)).toEqual(["needs", "working", "other"]); - }); - - it("only the 'needs you' group is accented", () => { - expect(AGENT_GROUPS.find((g) => g.key === "needs")!.accent).toBe(true); - expect(AGENT_GROUPS.find((g) => g.key === "working")!.accent).toBeFalsy(); - expect(AGENT_GROUPS.find((g) => g.key === "other")!.accent).toBeFalsy(); - }); - - it("assigns every status to exactly one group", () => { - for (const s of ALL_STATUSES) { - expect(groupFor(s)).toHaveLength(1); - } - }); - - it("routes 'blocked' to 'needs you'", () => { - expect(groupFor("blocked")[0]!.key).toBe("needs"); - }); - - it("routes 'working' to 'working'", () => { - expect(groupFor("working")[0]!.key).toBe("working"); - }); - - it("routes idle / done / unknown to 'other'", () => { - expect(groupFor("idle")[0]!.key).toBe("other"); - expect(groupFor("done")[0]!.key).toBe("other"); - expect(groupFor("unknown")[0]!.key).toBe("other"); - }); -}); diff --git a/web/src/lib/agent-groups.ts b/web/src/lib/agent-groups.ts deleted file mode 100644 index 035371a..0000000 --- a/web/src/lib/agent-groups.ts +++ /dev/null @@ -1,19 +0,0 @@ -// The triage grouping used by both the home list and the thread sidebar. Kept in one place so the -// two views can't drift apart. "Needs you" first (accented), then active work, then everything else. -import type { AgentStatus } from "./types"; - -export interface AgentGroup { - key: string; - label: string; - match: (s: AgentStatus) => boolean; - accent?: boolean; - /** Section bullet class — the same status palette the badges use, so a group's color can't drift - * from the status it collects. */ - dot: string; -} - -export const AGENT_GROUPS: readonly AgentGroup[] = [ - { key: "needs", label: "Needs you", match: (s) => s === "blocked", accent: true, dot: "bg-status-blocked" }, - { key: "working", label: "Working", match: (s) => s === "working", dot: "bg-status-working" }, - { key: "other", label: "Idle · done", match: (s) => s !== "blocked" && s !== "working", dot: "bg-status-idle" }, -]; diff --git a/web/src/lib/pane-name.test.ts b/web/src/lib/pane-name.test.ts new file mode 100644 index 0000000..430e0d7 --- /dev/null +++ b/web/src/lib/pane-name.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { paneSearchText, paneTitle } from "./pane-name"; +import type { AgentView } from "./types"; + +function pane(over: Partial = {}): AgentView { + return { + paneId: "w0:p1", + workspaceId: "w0", + workspaceLabel: "moonward_os", + workspaceNumber: 1, + tabId: "w0:t1", + agent: "claude", + status: "idle", + cwd: "/home/kon/dev/moonward", + focused: false, + ...over, + }; +} + +describe("paneTitle — primary line", () => { + it("is project · tab when the tab has a label", () => { + expect(paneTitle(pane({ tabLabel: "fix-auth" })).primary).toBe("moonward_os · fix-auth"); + }); + + it("falls back to the project alone when the bridge dropped the tab label", () => { + // An unlabelled tab in a single-tab space arrives with tabLabel absent (meaningfulTabLabel), + // so the row must not render a dangling separator. + expect(paneTitle(pane()).primary).toBe("moonward_os"); + }); + + it("never says 'claude'", () => { + expect(paneTitle(pane({ tabLabel: "fix-auth" })).primary).not.toContain("claude"); + expect(paneTitle(pane()).primary).not.toContain("claude"); + }); + + it("falls back to the workspace id if a space somehow has no label", () => { + expect(paneTitle(pane({ workspaceLabel: "" })).primary).toBe("w0"); + }); +}); + +describe("paneTitle — secondary line", () => { + it("prefers a user-set pane label", () => { + const t = paneTitle(pane({ paneLabel: "hand-named", sessionName: "auto-named" })); + expect(t.secondary).toBe("hand-named"); + }); + + it("falls back to Claude's own /rename session name", () => { + expect(paneTitle(pane({ sessionName: "oauth-refactor" })).secondary).toBe("oauth-refactor"); + }); + + it("falls back to a shortened cwd", () => { + expect(paneTitle(pane()).secondary).toBe("~/dev/moonward"); + }); + + it("is null when there is nothing to say", () => { + expect(paneTitle(pane({ cwd: "" })).secondary).toBeNull(); + }); + + it("keeps the pane's own name even when the tab is labelled — nothing is lost", () => { + const t = paneTitle(pane({ tabLabel: "fix-auth", sessionName: "oauth-refactor" })); + expect(t.primary).toBe("moonward_os · fix-auth"); + expect(t.secondary).toBe("oauth-refactor"); + }); +}); + +describe("paneTitle — shell panes", () => { + it("names a shell by its place, not by the word 'shell'", () => { + const t = paneTitle(pane({ kind: "shell", agent: "shell", tabLabel: "scratch" })); + expect(t.primary).toBe("moonward_os · scratch"); + }); +}); + +describe("paneSearchText", () => { + it("lets you find a pane by project, tab, session name or agent", () => { + const text = paneSearchText(pane({ tabLabel: "fix-auth", sessionName: "oauth-refactor" })); + expect(text).toContain("moonward_os"); + expect(text).toContain("fix-auth"); + expect(text).toContain("oauth-refactor"); + expect(text).toContain("claude"); + }); + + it("skips the missing parts without leaving double spaces", () => { + expect(paneSearchText(pane({ cwd: "" }))).toBe("moonward_os claude"); + }); +}); diff --git a/web/src/lib/pane-name.ts b/web/src/lib/pane-name.ts new file mode 100644 index 0000000..406e742 --- /dev/null +++ b/web/src/lib/pane-name.ts @@ -0,0 +1,62 @@ +// What a pane row is CALLED. Every agent used to render as "claude", because the title fell back to +// the agent name and the only distinguishing text was a small trailing workspace label. The agent's +// identity was never really in the text anyway — it's the avatar (AgentIcon) — which frees the title +// line to carry the two things that actually locate a piece of work: the project, and the tab. +// +// Nothing is lost: the pane's own name (a herdr `pane.rename` label, or Claude's own `/rename` +// session name) moves down one line, where it displaces the cwd. +import { shortCwd } from "./format"; +import type { AgentView } from "./types"; + +export interface PaneTitle { + /** "moonward_os · fix-auth", or just "moonward_os" when the tab label says nothing. */ + primary: string; + /** The pane's own name if it has one, else a shortened cwd. Null when there's neither. */ + secondary: string | null; +} + +/** The separator between project and tab. Exported so tests and search-text builders agree. */ +export const TITLE_SEP = " · "; + +/** + * Title and subtitle for a pane row. + * + * `tabLabel` is already filtered bridge-side (`meaningfulTabLabel`) — an unlabelled tab in a + * single-tab space arrives absent rather than as Herdr's positional "1" — so the rule here is + * simply "use it if it's there". + * + * Both fields are rendered as React text nodes by every caller, never markup: the same XSS boundary + * the pane mirror keeps. + */ +export function paneTitle(pane: AgentView): PaneTitle { + const project = pane.workspaceLabel || pane.workspaceId; + const primary = pane.tabLabel ? `${project}${TITLE_SEP}${pane.tabLabel}` : project; + const own = pane.paneLabel || pane.sessionName; + const secondary = own || (pane.cwd ? shortCwd(pane.cwd) : null); + return { primary, secondary }; +} + +/** + * The same row, rendered where the space and tab are ALREADY established by the surrounding UI — + * the space detail view, which groups panes under a per-tab heading. Repeating `project · tab` on + * every card there would say nothing, and worse: two panes in one tab would become indistinguishable, + * since the only thing telling them apart is the pane's own name. + * + * So in that scope the pane's own name leads, exactly as it always has, and the cwd sits beneath. + */ +export function paneTitleInTab(pane: AgentView): PaneTitle { + const own = pane.paneLabel || pane.sessionName; + const primary = own || (pane.kind === "shell" ? "shell" : pane.agent); + const secondary = pane.cwd ? shortCwd(pane.cwd) : null; + return { primary, secondary }; +} + +/** + * One flat string for the places that need a single searchable/announceable name — the command + * palette's match text, action-sheet headings, aria labels. Carries the same parts the row shows + * plus the pane's own name, so typing a project, a tab, or a session name all find the pane. + */ +export function paneSearchText(pane: AgentView): string { + const { primary, secondary } = paneTitle(pane); + return [primary, secondary, pane.agent].filter(Boolean).join(" "); +} diff --git a/web/src/lib/spaces.test.ts b/web/src/lib/spaces.test.ts index de95549..9430f49 100644 --- a/web/src/lib/spaces.test.ts +++ b/web/src/lib/spaces.test.ts @@ -1,5 +1,12 @@ -import { blockedCount, groupPanesByTab, worstSpaceStatus } from "./spaces"; -import type { AgentStatus, AgentView, TabView } from "./types"; +import { + blockedCount, + filterSpaces, + groupPanesByTab, + sortSpacesByRecency, + spaceLastSeen, + worstSpaceStatus, +} from "./spaces"; +import type { AgentStatus, AgentView, TabView, WorkspaceView } from "./types"; function agent( partial: Partial & { paneId: string; workspaceId: string; tabId: string }, @@ -101,3 +108,99 @@ describe("worstSpaceStatus", () => { expect(worstSpaceStatus("w1", [mk("working"), mk("unknown")])).toBe("working"); }); }); + +const ws = (workspaceId: string, label: string, number: number): WorkspaceView => ({ + workspaceId, + number, + label, + focused: false, + activeTabId: `${workspaceId}:t1`, + tabCount: 1, + paneCount: 1, +}); + +describe("spaceLastSeen", () => { + it("takes the most recent look across the space's panes", () => { + const panes = [ + agent({ paneId: "w1:p1", workspaceId: "w1", tabId: "w1:t1", lastSeenAt: 100 }), + agent({ paneId: "w1:p2", workspaceId: "w1", tabId: "w1:t1", lastSeenAt: 900 }), + ]; + expect(spaceLastSeen("w1", panes)).toBe(900); + }); + + it("ignores panes in other spaces", () => { + const panes = [ + agent({ paneId: "w1:p1", workspaceId: "w1", tabId: "w1:t1", lastSeenAt: 100 }), + agent({ paneId: "w2:p1", workspaceId: "w2", tabId: "w2:t1", lastSeenAt: 900 }), + ]; + expect(spaceLastSeen("w1", panes)).toBe(100); + }); + + it("counts bare shells, not just agents", () => { + const panes = [ + agent({ paneId: "w1:p1", workspaceId: "w1", tabId: "w1:t1", kind: "shell", lastSeenAt: 700 }), + ]; + expect(spaceLastSeen("w1", panes)).toBe(700); + }); + + it("is 0 for a space you've never opened, and on a bridge with no timestamps", () => { + expect(spaceLastSeen("w1", [])).toBe(0); + expect( + spaceLastSeen("w1", [agent({ paneId: "w1:p1", workspaceId: "w1", tabId: "w1:t1" })]), + ).toBe(0); + }); +}); + +describe("sortSpacesByRecency", () => { + const spaces = [ws("w1", "alpha", 1), ws("w2", "beta", 2), ws("w3", "gamma", 3)]; + + it("floats the space you used most recently to the top", () => { + const panes = [ + agent({ paneId: "w1:p1", workspaceId: "w1", tabId: "w1:t1", lastSeenAt: 100 }), + agent({ paneId: "w3:p1", workspaceId: "w3", tabId: "w3:t1", lastSeenAt: 900 }), + ]; + expect(sortSpacesByRecency(spaces, panes).map((w) => w.workspaceId)).toEqual([ + "w3", + "w1", + "w2", + ]); + }); + + it("leaves never-used spaces in Herdr's own order behind the used ones", () => { + const panes = [agent({ paneId: "w2:p1", workspaceId: "w2", tabId: "w2:t1", lastSeenAt: 5 })]; + expect(sortSpacesByRecency(spaces, panes).map((w) => w.workspaceId)).toEqual([ + "w2", + "w1", + "w3", + ]); + }); + + it("changes nothing at all on a bridge that reports no timestamps", () => { + const panes = [agent({ paneId: "w1:p1", workspaceId: "w1", tabId: "w1:t1" })]; + expect(sortSpacesByRecency(spaces, panes)).toEqual(spaces); + }); + + it("does not mutate its input", () => { + const panes = [agent({ paneId: "w3:p1", workspaceId: "w3", tabId: "w3:t1", lastSeenAt: 9 })]; + sortSpacesByRecency(spaces, panes); + expect(spaces.map((w) => w.workspaceId)).toEqual(["w1", "w2", "w3"]); + }); +}); + +describe("filterSpaces", () => { + const spaces = [ws("w1", "moonward_os", 1), ws("w2", "trader", 2), ws("w3", "MOON_probe", 3)]; + + it("matches case-insensitively, anywhere in the label", () => { + expect(filterSpaces(spaces, "moon").map((w) => w.workspaceId)).toEqual(["w1", "w3"]); + expect(filterSpaces(spaces, "RAD").map((w) => w.workspaceId)).toEqual(["w2"]); + }); + + it("returns everything for an empty or whitespace query", () => { + expect(filterSpaces(spaces, "")).toHaveLength(3); + expect(filterSpaces(spaces, " ")).toHaveLength(3); + }); + + it("returns nothing when nothing matches", () => { + expect(filterSpaces(spaces, "zzz")).toEqual([]); + }); +}); diff --git a/web/src/lib/spaces.ts b/web/src/lib/spaces.ts index 4474c69..2889f29 100644 --- a/web/src/lib/spaces.ts +++ b/web/src/lib/spaces.ts @@ -1,6 +1,12 @@ // Helpers for the space/tab navigator: shape the flat snapshot (agents + shell panes + tabs) into // the per-space, per-tab tree the home space view renders. -import { STATUS_RANK, type AgentStatus, type AgentView, type TabView } from "./types"; +import { + STATUS_RANK, + type AgentStatus, + type AgentView, + type TabView, + type WorkspaceView, +} from "./types"; export interface TabGroup { tabId: string; @@ -51,3 +57,45 @@ export function worstSpaceStatus(workspaceId: string, agents: AgentView[]): Agen inWs[0]!.status, ); } + +/** + * When you last used a space = the most recent `lastSeenAt` across its panes (agents AND shells). + * 0 for a space you've never opened, or on a bridge that doesn't report the timestamps. + */ +export function spaceLastSeen(workspaceId: string, panes: readonly AgentView[]): number { + let latest = 0; + for (const p of panes) { + if (p.workspaceId !== workspaceId) continue; + if ((p.lastSeenAt ?? 0) > latest) latest = p.lastSeenAt ?? 0; + } + return latest; +} + +/** + * Most-recently-used spaces first. Never-used spaces (and every space on an older bridge) tie at 0 + * and therefore keep Herdr's own workspace order behind the ones you actually touch — `sort` is + * stable, so no timestamps means no reordering at all. + */ +export function sortSpacesByRecency( + workspaces: readonly WorkspaceView[], + panes: readonly AgentView[], +): WorkspaceView[] { + const seen = new Map(); + for (const w of workspaces) seen.set(w.workspaceId, spaceLastSeen(w.workspaceId, panes)); + return [...workspaces].sort( + (a, b) => (seen.get(b.workspaceId) ?? 0) - (seen.get(a.workspaceId) ?? 0), + ); +} + +/** + * Case-insensitive substring match on the space label. An empty/whitespace query returns the input + * untouched, so the filter box costs nothing until you type in it. + */ +export function filterSpaces( + workspaces: readonly WorkspaceView[], + query: string, +): WorkspaceView[] { + const q = query.trim().toLowerCase(); + if (!q) return [...workspaces]; + return workspaces.filter((w) => w.label.toLowerCase().includes(q)); +} diff --git a/web/src/lib/triage.test.ts b/web/src/lib/triage.test.ts new file mode 100644 index 0000000..9d759ab --- /dev/null +++ b/web/src/lib/triage.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; + +import { flipDir, isUnseen, triage, type TriageKey } from "./triage"; +import type { AgentStatus, AgentView } from "./types"; + +function agent( + paneId: string, + status: AgentStatus, + ts: { active?: number; seen?: number } = {}, +): AgentView { + return { + paneId, + workspaceId: "w0", + workspaceLabel: "proj", + workspaceNumber: 1, + tabId: "w0:t1", + agent: "claude", + status, + cwd: "/home/k/proj", + focused: false, + ...(ts.active !== undefined ? { lastActiveAt: ts.active } : {}), + ...(ts.seen !== undefined ? { lastSeenAt: ts.seen } : {}), + }; +} + +/** The section an agent landed in, by pane id. */ +function sectionOf(sections: ReturnType, paneId: string): TriageKey | undefined { + return sections.find((s) => s.agents.some((a) => a.paneId === paneId))?.key; +} + +const ids = (sections: ReturnType, key: TriageKey) => + sections.find((s) => s.key === key)!.agents.map((a) => a.paneId); + +describe("isUnseen", () => { + it("is true for a done agent that finished after you last looked", () => { + expect(isUnseen(agent("p", "done", { active: 200, seen: 100 }))).toBe(true); + }); + + it("is false once you've looked since it finished", () => { + expect(isUnseen(agent("p", "done", { active: 100, seen: 200 }))).toBe(false); + }); + + it("is false at the exact tie — a look at the same ms counts as seen", () => { + expect(isUnseen(agent("p", "done", { active: 100, seen: 100 }))).toBe(false); + }); + + it("only ever applies to done agents", () => { + for (const s of ["working", "idle", "blocked", "unknown"] as AgentStatus[]) { + expect(isUnseen(agent("p", s, { active: 200, seen: 100 }))).toBe(false); + } + }); + + it("is false when the bridge reports no timestamps", () => { + expect(isUnseen(agent("p", "done"))).toBe(false); + }); +}); + +describe("triage — bucketing", () => { + it("puts each agent in exactly one section, in the documented priority", () => { + const s = triage([ + agent("blocked", "blocked", { active: 500, seen: 100 }), + agent("unseen", "done", { active: 400, seen: 100 }), + agent("working", "working", { active: 300, seen: 100 }), + agent("seen-done", "done", { active: 100, seen: 400 }), + agent("idle", "idle", { active: 100, seen: 200 }), + agent("unknown", "unknown", { active: 100, seen: 200 }), + ]); + + expect(sectionOf(s, "blocked")).toBe("needs"); + expect(sectionOf(s, "unseen")).toBe("ready"); + expect(sectionOf(s, "working")).toBe("working"); + expect(sectionOf(s, "seen-done")).toBe("recent"); + expect(sectionOf(s, "idle")).toBe("recent"); + expect(sectionOf(s, "unknown")).toBe("recent"); + }); + + it("blocked outranks unseen — an agent that blocked after finishing still needs you", () => { + const s = triage([agent("p", "blocked", { active: 900, seen: 1 })]); + expect(sectionOf(s, "p")).toBe("needs"); + }); + + it("returns all four sections in fixed order, empty ones included", () => { + const s = triage([]); + expect(s.map((x) => x.key)).toEqual(["needs", "ready", "working", "recent"]); + expect(s.every((x) => x.agents.length === 0)).toBe(true); + }); + + it("marks only Recent collapsible — an alert you can fold away is not an alert", () => { + const s = triage([]); + expect(s.filter((x) => x.collapsible).map((x) => x.key)).toEqual(["recent"]); + }); +}); + +describe("triage — ordering", () => { + it("orders attention sections by most recent activity", () => { + const s = triage([ + agent("old", "blocked", { active: 100, seen: 0 }), + agent("new", "blocked", { active: 900, seen: 0 }), + agent("mid", "blocked", { active: 500, seen: 0 }), + ]); + expect(ids(s, "needs")).toEqual(["new", "mid", "old"]); + }); + + it("orders Ready by most recently finished", () => { + const s = triage([ + agent("a", "done", { active: 100, seen: 1 }), + agent("b", "done", { active: 900, seen: 1 }), + ]); + expect(ids(s, "ready")).toEqual(["b", "a"]); + }); + + it("orders Recent by when you last used it, newest first by default", () => { + const s = triage([ + agent("stale", "idle", { active: 1, seen: 100 }), + agent("fresh", "idle", { active: 1, seen: 900 }), + agent("mid", "idle", { active: 1, seen: 500 }), + ]); + expect(ids(s, "recent")).toEqual(["fresh", "mid", "stale"]); + }); + + it("the direction toggle inverts Recent", () => { + const herd = [ + agent("stale", "idle", { active: 1, seen: 100 }), + agent("fresh", "idle", { active: 1, seen: 900 }), + agent("mid", "idle", { active: 1, seen: 500 }), + ]; + expect(ids(triage(herd, "oldest"), "recent")).toEqual(["stale", "mid", "fresh"]); + }); + + it("the direction toggle does NOT reach the pinned sections", () => { + const herd = [ + agent("old", "blocked", { active: 100, seen: 0 }), + agent("new", "blocked", { active: 900, seen: 0 }), + agent("w-old", "working", { active: 100, seen: 0 }), + agent("w-new", "working", { active: 900, seen: 0 }), + agent("r-old", "done", { active: 900, seen: 1 }), + agent("r-new", "done", { active: 950, seen: 1 }), + ]; + for (const dir of ["newest", "oldest"] as const) { + const s = triage(herd, dir); + expect(ids(s, "needs")).toEqual(["new", "old"]); + expect(ids(s, "working")).toEqual(["w-new", "w-old"]); + expect(ids(s, "ready")).toEqual(["r-new", "r-old"]); + } + }); +}); + +describe("triage — an older bridge that reports no timestamps", () => { + const herd = [ + agent("b1", "blocked"), + agent("b2", "blocked"), + agent("w1", "working"), + agent("i1", "idle"), + agent("d1", "done"), + ]; + + it("leaves Ready·unseen empty rather than guessing", () => { + expect(ids(triage(herd), "ready")).toEqual([]); + }); + + it("preserves the order the bridge sent, because the sort is stable", () => { + const s = triage(herd); + expect(ids(s, "needs")).toEqual(["b1", "b2"]); + expect(ids(s, "working")).toEqual(["w1"]); + expect(ids(s, "recent")).toEqual(["i1", "d1"]); + }); + + it("still buckets by status, so the dashboard is coherent", () => { + const s = triage(herd); + expect(sectionOf(s, "b1")).toBe("needs"); + expect(sectionOf(s, "w1")).toBe("working"); + expect(sectionOf(s, "d1")).toBe("recent"); + }); + + it("does not mutate the input array", () => { + const input = [agent("z", "idle", { seen: 1 }), agent("a", "idle", { seen: 9 })]; + const before = input.map((a) => a.paneId); + triage(input); + expect(input.map((a) => a.paneId)).toEqual(before); + }); +}); + +describe("flipDir", () => { + it("round-trips", () => { + expect(flipDir("newest")).toBe("oldest"); + expect(flipDir("oldest")).toBe("newest"); + }); +}); diff --git a/web/src/lib/triage.ts b/web/src/lib/triage.ts new file mode 100644 index 0000000..0c83342 --- /dev/null +++ b/web/src/lib/triage.ts @@ -0,0 +1,101 @@ +// The one ordering the whole app agrees on: what needs you, then what's newly ready, then what's +// running, then everything else by when you last touched it. Used by the dashboard, the in-pane +// sidebar and the command palette — kept in one place so those three can't drift apart (which is +// the job the module this replaces, agent-groups.ts, was written to do). +// +// It runs on the two timestamps the bridge keeps per pane (bridge/activity.ts): +// lastActiveAt — when the agent last changed status +// lastSeenAt — when you last opened or drove it through Collie +import type { AgentStatus, AgentView } from "./types"; + +/** Which way the Recent section runs. Attention sections never invert. */ +export type RecentDir = "newest" | "oldest"; + +export type TriageKey = "needs" | "ready" | "working" | "recent"; + +export interface TriageSection { + key: TriageKey; + label: string; + /** Render the heading in the alert colour (the "needs you" group). */ + accent?: boolean; + /** Section bullet class — the same status palette the badges use, so a section's colour can't + * drift from the status it collects. */ + dot: string; + /** Whether the user may fold this section away. Attention sections may not: collapsing an alert + * defeats the alert. */ + collapsible?: boolean; + agents: AgentView[]; +} + +/** + * An agent that finished while you weren't looking. NOT a stored flag — it's this comparison, which + * is why opening the pane clears it with no bookkeeping: the read bumps `lastSeenAt` past + * `lastActiveAt` and the agent falls into Recent on the next poll. + * + * Both timestamps absent (an older bridge) yields `false`, so the section is simply empty there. + */ +export function isUnseen(a: AgentView): boolean { + return a.status === "done" && (a.lastActiveAt ?? 0) > (a.lastSeenAt ?? 0); +} + +/** Descending comparator over an optional timestamp; absent sorts last but ties, never throws. */ +function byDesc(key: (a: AgentView) => number | undefined) { + return (x: AgentView, y: AgentView) => (key(y) ?? 0) - (key(x) ?? 0); +} + +const SECTION_META: Record> = { + needs: { key: "needs", label: "Needs you", accent: true, dot: "bg-status-blocked" }, + ready: { key: "ready", label: "Ready · unseen", dot: "bg-status-done" }, + working: { key: "working", label: "Working", dot: "bg-status-working" }, + recent: { key: "recent", label: "Recent", dot: "bg-status-idle", collapsible: true }, +}; + +/** + * Bucket and order a herd. Returns every section (including empty ones) in fixed display order — + * callers drop the empties, which keeps "which sections exist" a property of this module rather + * than something each view re-derives. + * + * The first three sections are pinned: they never move and never invert. `dir` reaches Recent only. + * + * **The old-bridge path is free.** With no timestamps every comparator returns 0, and + * `Array.prototype.sort` is stable, so each section preserves the order the bridge already sent + * (`STATUS_RANK → workspaceNumber → paneId`). Ready·unseen is empty because `isUnseen` is false. + * No feature detection, no branch. + */ +export function triage(agents: readonly AgentView[], dir: RecentDir = "newest"): TriageSection[] { + const needs: AgentView[] = []; + const ready: AgentView[] = []; + const working: AgentView[] = []; + const recent: AgentView[] = []; + + for (const a of agents) { + if (a.status === "blocked") needs.push(a); + else if (isUnseen(a)) ready.push(a); + else if (a.status === "working") working.push(a); + else recent.push(a); + } + + needs.sort(byDesc((a) => a.lastActiveAt)); + ready.sort(byDesc((a) => a.lastActiveAt)); + working.sort(byDesc((a) => a.lastActiveAt)); + recent.sort(byDesc((a) => a.lastSeenAt)); + if (dir === "oldest") recent.reverse(); + + return [ + { ...SECTION_META.needs, agents: needs }, + { ...SECTION_META.ready, agents: ready }, + { ...SECTION_META.working, agents: working }, + { ...SECTION_META.recent, agents: recent }, + ]; +} + +/** The other direction — for the toggle. */ +export function flipDir(dir: RecentDir): RecentDir { + return dir === "newest" ? "oldest" : "newest"; +} + +/** Statuses that put an agent in an attention section (so a caller can tint a row without + * re-deriving the rule). */ +export function isAttention(status: AgentStatus): boolean { + return status === "blocked"; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 95a6959..82f9aa1 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -37,6 +37,25 @@ export interface AgentView { * bridges/Herdr, which reads as "unknown" (the button then falls back to hidden). */ readableLines?: number; + /** + * The pane's tab label, denormalised bridge-side alongside `workspaceLabel`. Absent when it says + * nothing: Herdr names an unlabelled tab positionally ("1"), which in a single-tab space would + * render as `project · 1` (see `meaningfulTabLabel` in bridge/activity.ts). Render as text only, + * never markup — same XSS boundary as `paneLabel`. + */ + tabLabel?: string; + /** + * Epoch ms of this agent's last status transition, as the bridge observed it. Absent on an older + * bridge — which is exactly why triage degrades cleanly; see `triage()`. + */ + lastActiveAt?: number; + /** + * Epoch ms you last opened or drove this pane through Collie. Absent as above. + * + * There is no "seen" flag anywhere: a `done` agent is unseen precisely when + * `lastActiveAt > lastSeenAt`, so opening the pane clears it by construction. + */ + lastSeenAt?: number; } /** diff --git a/web/src/routes/home.tsx b/web/src/routes/home.tsx index e0cb943..e817144 100644 --- a/web/src/routes/home.tsx +++ b/web/src/routes/home.tsx @@ -11,21 +11,16 @@ import { NewSpaceSheet } from "@/components/new-space-sheet"; import { StatusArea } from "@/components/status-area"; import { BuildStamp } from "@/components/build-stamp"; import { UpdateBanner } from "@/components/update-banner"; +import { useDashPrefs, spacesOpenFor } from "@/hooks/use-dash-prefs"; import { useLoadingStalled } from "@/hooks/use-loading-stalled"; import { useSpaceActions } from "@/hooks/use-spaces"; -import { AGENT_GROUPS } from "@/lib/agent-groups"; import { ROOT_ROUTE_ID, type HomeData } from "@/lib/loaders"; import { panePath, spacePath } from "@/lib/nav"; -// "Needs you" is the urgent triage (accented group); hoist it above everything else. The rest of the -// triage (working / idle · done) renders below the spaces overview. -const ATTENTION_GROUPS = AGENT_GROUPS.filter((g) => g.accent); -const REST_GROUPS = AGENT_GROUPS.filter((g) => !g.accent); - -// Dashboard home screen. Reads the herd from the root loader. "Needs you" sits at the very top (the -// most important thing to act on), then the Spaces overview (each space with its tab/pane counts and -// worst-agent status), then the rest of the agent triage: tapping an agent opens its pane, tapping a -// space drills into its detail route (/space/:id). +// Dashboard home screen. Everything you might ACT on comes first — Needs you → Ready · unseen → +// Working → Recent (see lib/triage.ts) — and the Spaces navigator sits last, under the thing it +// navigates to. Recent and Spaces fold; fold both and the page is the triaged herd and nothing else. +// Tapping an agent opens its pane; tapping a space drills into /space/:id. export function HomeRoute() { const data = useRouteLoaderData(ROOT_ROUTE_ID) as HomeData; // A stalled load (a black-holed poll, or a pane-open tap whose navigation hangs) gallops the @@ -35,6 +30,10 @@ export function HomeRoute() { const navigate = useNavigate(); const { newSpace } = useSpaceActions(); const [newSpaceOpen, setNewSpaceOpen] = useState(false); + const { prefs, setSpacesOpen, setRecentOpen, setRecentDir } = useDashPrefs(); + // No stored choice yet? The space count decides — a two-space install shouldn't be handed a + // mystery collapsed header, and a forty-space one shouldn't be handed a wall. + const spacesOpen = spacesOpenFor(prefs.spacesOpen, data.workspaces.length); const open = (id: string) => navigate(panePath(id, data.session)); const drillInto = (id: string) => navigate(spacePath(id, data.session)); @@ -62,22 +61,27 @@ export function HomeRoute() {
- {/* Needs-you first — the most urgent triage, hoisted above the spaces overview. Renders - nothing when no agent is blocked (emptyState off, so the placeholder shows only once - below). */} + {/* One list, every section, in triage order. It used to be split in two so "Needs you" + could be hoisted above the spaces overview; with Spaces last there is nothing to + straddle. */} setNewSpaceOpen(true)} + open={spacesOpen} + onOpenChange={setSpacesOpen} /> -
{/* An available update / needed restart, then the build stamp (which bundle you're From 6786ca186849aaafa7c676dd3636e4de778f6831 Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 21:11:53 +0400 Subject: [PATCH 03/19] =?UTF-8?q?docs:=20ADR=200003=20=E2=80=94=20one=20sh?= =?UTF-8?q?ared=20"seen",=20and=20only=20Collie's=20own=20reads=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the two options that will otherwise be re-proposed forever: making "seen" per-device (rejected — an alert cleared on the phone must not still shout on the laptop), and counting a Herdr focus at the desk (rejected — a pane clicked past would silently clear an alert you never read; a false positive costs one tap, a false negative costs a missed agent). Co-Authored-By: Claude Opus 5 (1M context) --- .adr/0003-one-shared-seen.md | 71 ++++++++++++++++++++++++++++++++++++ .adr/README.md | 1 + 2 files changed, 72 insertions(+) create mode 100644 .adr/0003-one-shared-seen.md diff --git a/.adr/0003-one-shared-seen.md b/.adr/0003-one-shared-seen.md new file mode 100644 index 0000000..f166cfc --- /dev/null +++ b/.adr/0003-one-shared-seen.md @@ -0,0 +1,71 @@ +# 0003 — "Seen" is one shared fact, and Collie only trusts what happened in Collie + +Status: **Accepted** (2026-07-28) + +## Context + +The dashboard sorts the herd by attention and then by recency, and it surfaces a **Ready · unseen** +section: agents that finished while you weren't looking. Both need to know two things per pane — +when the agent last moved, and when *you* last looked at it. + +Herdr supplies neither. Its pane, tab, and workspace records carry **no timestamps of any kind** +(see [`HERDR_API.md`](../HERDR_API.md)), so every notion of "when" in this feature is one Collie +derives and owns. That forced two questions that would otherwise never have been asked out loud. + +**Where does "seen" live?** Collie is a phone UI for a herd you also drive from a desk, and the same +person uses both. The bridge already persists exactly this kind of state — `snooze.json`, +`notify-prefs.json` — bridge-wide rather than per-device, on the same reasoning: a notification +fans out to every device, so muting it on one must mute it on all. + +**What counts as looking?** Herdr reports a `focused` flag per pane, so the bridge *could* see you +working in a pane at the desk and count that as having seen it. That was considered and rejected +during design. + +## Decision + +**One shared "seen", recorded bridge-side and persisted to the state dir.** `activity.json` holds +`{activeAt, seenAt}` per pane, keyed by session name (pane ids are session-scoped and collide across +sessions). Not per-device, not in `localStorage`. + +**Only what happens in Collie counts as seeing.** `seenAt` is stamped when a request reaches +`/api/pane/:id` — opening the pane, replying, sending keys, reading its history. A Herdr focus at +the desk does not stamp it, and neither does anything else the bridge merely observes. + +**"Seen" is a comparison, not a stored flag.** An agent is unread exactly when +`status === "done" && activeAt > seenAt`. There is no read-receipt table and nothing to keep in +sync: opening the pane bumps `seenAt` past `activeAt`, and the row leaves the section by itself. + +**A first sighting is seeded as already-seen** (`activeAt = seenAt = now`), so only transitions +observed *after* Collie first saw a pane can mark it unread. This is the same rule the state engine +already applies to notifications — a first sighting never fires a transition, so a fresh start +doesn't notify for agents that were already blocked. + +## Consequences + +- **A second device agrees with the first.** An alert cleared on the phone is cleared on the laptop. + This is the whole point, and it's why per-device storage was rejected: the failure mode there is + an alert you already dealt with still shouting at you somewhere else. +- **Two people sharing one bridge share one "seen".** Accepted. Collie's threat model is a personal + tailnet with one operator; a bridge is remote shell access, not a multi-tenant service. +- **Working in a pane at the desk does not clear its Collie alert.** This is the deliberate cost. + Counting a Herdr focus would let a pane you merely clicked past silently clear an alert you never + read — a false negative on the one thing the dashboard exists to surface. A false *positive* (an + item still listed as unseen after you dealt with it at the desk) costs one tap; a false negative + costs a missed agent. +- **The ledger writes on a debounce.** An open pane polls about once a second and each poll stamps + `seenAt`; in memory that's free, on disk it would be a write per second forever. Flushes are + capped at one per 10s plus one on shutdown, so an unclean kill can lose up to ten seconds of + precision — imperceptible in a feature whose finest unit is "just now". +- **The state can be thrown away.** Delete `activity.json` and the next poll re-seeds every pane as + seen. Nothing else depends on it. + +### What would justify revisiting + +- Herdr starts reporting real per-pane activity timestamps — then `activeAt` should come from the + source rather than from Collie's own observation, and a bridge restart would stop being a + re-seed. +- Collie grows genuine multi-user support (distinct identities, not just distinct devices). Then + "seen" becomes per-identity, and this ADR is superseded rather than amended. +- Evidence that clearing-at-the-desk actually matters in practice — i.e. the false positives are + frequent and annoying enough to outweigh the missed-agent risk. That's a usage question, not a + design one, and it should be answered with usage. diff --git a/.adr/README.md b/.adr/README.md index befebdc..f20a1ad 100644 --- a/.adr/README.md +++ b/.adr/README.md @@ -44,3 +44,4 @@ A superseded ADR is never deleted or edited into agreement with the present. Mar | --- | --- | --- | | [0001](./0001-one-managed-front-door.md) | Collie manages exactly one front door | Accepted | | [0002](./0002-invert-the-light-terminal-mirror.md) | The light terminal mirror is inverted, not re-themed | Accepted | +| [0003](./0003-one-shared-seen.md) | "Seen" is one shared fact, and only Collie's own reads count | Accepted | From 29ede4debf1055f054fb85e0ed7b5399772a05ab Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 21:12:17 +0400 Subject: [PATCH 04/19] release: 0.20.0 --- CHANGELOG.md | 15 +++++++++++++++ herdr-plugin.toml | 2 +- package.json | 2 +- web/package.json | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 587b171..987aee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ All notable changes to Collie are recorded here. The format follows `version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by `scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy. +## [0.20.0] - 2026-07-29 + +### Added +- **The dashboard is triaged, not listed.** Needs you → Ready · unseen → Working → Recent; the first three are pinned, Recent sorts by when you last used each pane (3850d96) +- **Ready · unseen** — agents that finished while you weren't looking. Opening one clears it, on every device (839d0f3) +- Recent and Spaces fold and remember it; fold both and the page is the triaged herd and nothing else (3850d96) +- Spaces are ordered by last used and filterable — 45 of them are now three keystrokes, not a scroll (3850d96) +- The bridge keeps two timestamps per pane (`activeAt`, `seenAt`) in `activity.json`, because Herdr reports none (839d0f3) + +### Changed +- **Agent rows are titled `project · tab`, not "claude".** The pane's own name moves to the second line; the agent stays in the avatar (3850d96) +- Spaces moved BELOW every agent section — it's a navigator, not a work queue (3850d96) +- Only Collie's own reads count as seeing a pane; a Herdr focus at the desk does not — [ADR 0003](.adr/0003-one-shared-seen.md) (16201fe) +- MINOR, not MAJOR: additive, no config or API break. An older bridge reports no timestamps and simply renders the previous dashboard, minus the one section that would be empty + ## [0.19.0] - 2026-07-29 ### Added diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 0b322d5..200739f 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "herdr.collie" name = "Collie" -version = "0.19.0" +version = "0.20.0" min_herdr_version = "0.7.0" description = "Mobile web UI to monitor and reply to your agent herd, served over Tailscale" platforms = ["linux", "macos"] diff --git a/package.json b/package.json index 70e37b2..192c75b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "collie", - "version": "0.19.0", + "version": "0.20.0", "private": true, "license": "MIT", "description": "Collie — a mobile web UI to monitor and reply to your Herdr agent herd over Tailscale", diff --git a/web/package.json b/web/package.json index 85af38f..2313446 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "collie-web", - "version": "0.19.0", + "version": "0.20.0", "private": true, "license": "MIT", "type": "module", From f9000cb15cc249ea2c47dd4707eacf55389c1d07 Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 21:24:01 +0400 Subject: [PATCH 05/19] fix(bridge): don't let a cross-site GET clear your unseen agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review of this branch caught a regression it introduced. Marking a pane seen made a READ-level GET mutate server state, which no read had done before — and checkAccess deliberately does not require an Origin on reads, because browsers omit it on same-origin GETs and demanding one would reject the real client. So a page the operator visits while on the tailnet could fire at guessable pane ids (herdr's are w:p) and silently clear the whole "Ready · unseen" section. The response is opaque to the attacker and nothing can be typed into a terminal — writes still require an Origin and the device gate — but the write lands, and the operator simply stops being told their agents finished. That is precisely the signal this release exists to deliver. marksPaneSeen now gates it on a custom request header the web app sets on its own pane and history reads. A no-cors cross-site request cannot set one: doing so promotes it to a preflighted CORS request, and the bridge answers no preflight. Write actions need no header — they already cleared the Origin-requiring write gate. Pure and exported, so it is unit-tested in bun's runner like checkAccess beside it. Also folds spaceLastSeen into a single-pass spaceLastSeenMap. The dashboard re-renders every poll and was deriving it per space and again per row — spaces x panes, three times over (45 x 59 on a real herd). Co-Authored-By: Claude Opus 5 (1M context) --- bridge/server.test.ts | 34 +++++++++++++++++++++++++ bridge/server.ts | 36 +++++++++++++++++++++++++-- web/src/components/space-overview.tsx | 8 +++--- web/src/lib/api.ts | 12 +++++++-- web/src/lib/spaces.test.ts | 25 +++++++++++++++++++ web/src/lib/spaces.ts | 19 ++++++++++++-- 6 files changed, 125 insertions(+), 9 deletions(-) diff --git a/bridge/server.test.ts b/bridge/server.test.ts index 824a7ce..73a387a 100644 --- a/bridge/server.test.ts +++ b/bridge/server.test.ts @@ -4,6 +4,8 @@ import { BUILD_HEADER, cacheControlFor, checkAccess, + marksPaneSeen, + SEEN_HEADER, deviceAuth, guard, historyParams, @@ -864,3 +866,35 @@ describe("isReservedAuthPath — the namespace a fronting proxy owns", () => { } }); }); + +// marksPaneSeen guards the one place a READ mutates server state. checkAccess lets a read through +// without an Origin (browsers omit it on same-origin GETs), so without this a cross-site at a +// guessed pane id could silently clear the "Ready · unseen" section. +describe("marksPaneSeen — CSRF guard on marking a pane seen", () => { + const withHeader = (h: Record = {}) => new Request("http://x/api/pane/w1:p1", { headers: h }); + + test("a read carrying the client header counts — only our own page can set it", () => { + expect(marksPaneSeen(withHeader({ [SEEN_HEADER]: "1" }), undefined)).toBe(true); + }); + + test("a bare cross-site GET does NOT count", () => { + // What an produces: no Origin, no custom header. + expect(marksPaneSeen(withHeader(), undefined)).toBe(false); + }); + + test("history is a read — it needs the header too", () => { + expect(marksPaneSeen(withHeader(), "history")).toBe(false); + expect(marksPaneSeen(withHeader({ [SEEN_HEADER]: "1" }), "history")).toBe(true); + }); + + test("write actions count without it — they already cleared the Origin-requiring write gate", () => { + for (const action of ["reply", "keys", "upload", "close", "rename"]) { + expect(marksPaneSeen(withHeader(), action)).toBe(true); + } + }); + + test("any header value counts — presence is the proof, not the contents", () => { + expect(marksPaneSeen(withHeader({ [SEEN_HEADER]: "" }), undefined)).toBe(true); + expect(marksPaneSeen(withHeader({ [SEEN_HEADER]: "anything" }), undefined)).toBe(true); + }); +}); diff --git a/bridge/server.ts b/bridge/server.ts index 16d1830..15a2454 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -98,6 +98,37 @@ const MAX_HISTORY_LIMIT = 5000; // (create) is an exact match on `/api/tab`, so it never collides with this `/api/tab//`. const TAB_ACTION_ROUTE = /^\/api\/tab\/([^/]+)\/(rename|close)$/; +/** + * Header the web app sets on its own pane reads, and the ONLY thing that lets a read mark a pane + * seen. See {@link marksPaneSeen} for why a header, of all things, is the check. + */ +export const SEEN_HEADER = "x-collie-seen"; + +/** + * Whether this request proves it came from Collie's own page, and may therefore stamp the pane as + * seen (bridge/activity.ts). + * + * This exists because marking-seen made a **read-level GET mutate server state**, which it never did + * before. `checkAccess` deliberately does not demand an `Origin` on reads — browsers omit it on + * same-origin GETs, so demanding one would reject the real client — and that exemption was safe only + * while reads had no side effects. Without this check, a page the operator visits while on the + * tailnet could fire `` at guessable pane ids and silently + * clear the "Ready · unseen" section: the response is opaque to the attacker, but the write lands, + * and the operator simply stops being told their agents finished. + * + * A custom request header is the check because a no-cors cross-site request **cannot set one** — + * doing so promotes it to a preflighted CORS request, and the bridge answers no preflight. Our own + * same-origin `fetch` sets it freely. + * + * Write actions (reply/keys/upload/close/rename) need no header: they already cleared + * `guard(…, "write")`, which requires an `Origin`. `history` is a read despite being an action + * segment, so it needs the header like any other read. + */ +export function marksPaneSeen(req: Request, action: string | undefined): boolean { + if (req.headers.get(SEEN_HEADER) !== null) return true; + return action !== undefined && action !== "history"; +} + export function startServer(opts: { cfg: Config; registry: SessionRegistry; @@ -218,8 +249,9 @@ export function startServer(opts: { // You are in this pane: reading it, replying, sending keys, browsing its history. That is // the whole definition of "seen" (.adr/0003), and this is the one place every such request // passes through. It cannot false-positive from background polling — the dashboard loader - // only ever fetches /api/snapshot; paneLoader is the sole reader of pane text. - activity.noteSeen(session, paneId); + // only ever fetches /api/snapshot; paneLoader is the sole reader of pane text — nor from a + // cross-site request forged at a guessed pane id (see marksPaneSeen). + if (marksPaneSeen(req, action)) activity.noteSeen(session, paneId); // Every action is a write; attribute it to the authorised device for the audit trail. // `history` is a read, so it gets no device attribution (nothing is written to attribute). const device = action && action !== "history" ? deviceAuth(req, cfg).device : null; diff --git a/web/src/components/space-overview.tsx b/web/src/components/space-overview.tsx index cedff95..4e7c38f 100644 --- a/web/src/components/space-overview.tsx +++ b/web/src/components/space-overview.tsx @@ -9,7 +9,7 @@ import { blockedCount, filterSpaces, sortSpacesByRecency, - spaceLastSeen, + spaceLastSeenMap, worstSpaceStatus, } from "@/lib/spaces"; import { timeAgo } from "@/lib/format"; @@ -45,8 +45,10 @@ export function SpaceOverview({ const [query, setQuery] = useState(""); const panes = [...agents, ...shellPanes]; + // One pass over the panes, then map lookups — this component re-renders on every poll. + const lastSeen = spaceLastSeenMap(panes); const blockedSpaces = workspaces.filter((w) => blockedCount(w.workspaceId, agents) > 0).length; - const visible = filterSpaces(sortSpacesByRecency(workspaces, panes), query); + const visible = filterSpaces(sortSpacesByRecency(workspaces, panes, lastSeen), query); return (
@@ -108,7 +110,7 @@ export function SpaceOverview({ visible.map((w) => { const status = worstSpaceStatus(w.workspaceId, agents); const blocked = blockedCount(w.workspaceId, agents) > 0; - const seen = spaceLastSeen(w.workspaceId, panes); + const seen = lastSeen.get(w.workspaceId) ?? 0; return (

+ {trailing && {trailing}}
); diff --git a/web/src/hooks/use-dash-prefs.test.ts b/web/src/hooks/use-dash-prefs.test.ts index be22d73..67b8264 100644 --- a/web/src/hooks/use-dash-prefs.test.ts +++ b/web/src/hooks/use-dash-prefs.test.ts @@ -3,39 +3,47 @@ import { act, renderHook } from "@testing-library/react"; import { coerceDashPrefs, - SPACES_COLLAPSE_THRESHOLD, - spacesOpenFor, + COLLAPSE_THRESHOLD, + openForCount, useDashPrefs, } from "./use-dash-prefs"; -describe("spacesOpenFor", () => { +describe("openForCount", () => { it("starts expanded on a small install", () => { - expect(spacesOpenFor(null, 2)).toBe(true); - expect(spacesOpenFor(null, SPACES_COLLAPSE_THRESHOLD)).toBe(true); + expect(openForCount(null, 2)).toBe(true); + expect(openForCount(null, COLLAPSE_THRESHOLD)).toBe(true); }); it("starts collapsed once the list is a wall", () => { - expect(spacesOpenFor(null, SPACES_COLLAPSE_THRESHOLD + 1)).toBe(false); - expect(spacesOpenFor(null, 45)).toBe(false); + expect(openForCount(null, COLLAPSE_THRESHOLD + 1)).toBe(false); + expect(openForCount(null, 45)).toBe(false); }); it("an explicit choice always beats the threshold, in both directions", () => { - expect(spacesOpenFor(true, 45)).toBe(true); - expect(spacesOpenFor(false, 1)).toBe(false); + expect(openForCount(true, 45)).toBe(true); + expect(openForCount(false, 1)).toBe(false); }); }); describe("coerceDashPrefs", () => { it("defaults an empty object", () => { - expect(coerceDashPrefs({})).toEqual({ spacesOpen: null, recentOpen: true, recentDir: "newest" }); + expect(coerceDashPrefs({})).toEqual({ + spacesOpen: null, + shellsOpen: null, + recentOpen: true, + recentDir: "newest", + }); }); it("keeps valid values", () => { - expect(coerceDashPrefs({ spacesOpen: false, recentOpen: false, recentDir: "oldest" })).toEqual({ - spacesOpen: false, - recentOpen: false, - recentDir: "oldest", - }); + expect( + coerceDashPrefs({ + spacesOpen: false, + shellsOpen: true, + recentOpen: false, + recentDir: "oldest", + }), + ).toEqual({ spacesOpen: false, shellsOpen: true, recentOpen: false, recentDir: "oldest" }); }); it("rejects a bogus direction rather than trusting it", () => { @@ -56,6 +64,7 @@ describe("useDashPrefs", () => { const { result } = renderHook(() => useDashPrefs()); expect(result.current.prefs).toEqual({ spacesOpen: null, + shellsOpen: null, recentOpen: true, recentDir: "newest", }); @@ -64,12 +73,14 @@ describe("useDashPrefs", () => { it("persists each setting across a remount", () => { const first = renderHook(() => useDashPrefs()); act(() => first.result.current.setSpacesOpen(true)); + act(() => first.result.current.setShellsOpen(true)); act(() => first.result.current.setRecentOpen(false)); act(() => first.result.current.setRecentDir("oldest")); const second = renderHook(() => useDashPrefs()); expect(second.result.current.prefs).toEqual({ spacesOpen: true, + shellsOpen: true, recentOpen: false, recentDir: "oldest", }); diff --git a/web/src/hooks/use-dash-prefs.ts b/web/src/hooks/use-dash-prefs.ts index f5315c7..ec5e408 100644 --- a/web/src/hooks/use-dash-prefs.ts +++ b/web/src/hooks/use-dash-prefs.ts @@ -13,6 +13,11 @@ export interface DashPrefs { * header while a forty-space one isn't handed a wall. An explicit choice always wins. */ spacesOpen: boolean | null; + /** + * Whether the Shells section of the pane switcher is expanded. `null` = never chosen, so the + * count decides — a herd with 37 bare shells shouldn't bury the agents you actually switch to. + */ + shellsOpen: boolean | null; /** Whether the Recent section is expanded. Defaults open — it's the recency list itself. */ recentOpen: boolean; /** Which way Recent runs. Attention sections are never affected. */ @@ -21,15 +26,25 @@ export interface DashPrefs { const STORAGE_KEY = "collie:dash-prefs:v1"; -/** Above this many spaces, an un-chosen Spaces section starts collapsed. */ -export const SPACES_COLLAPSE_THRESHOLD = 8; +/** Above this many rows, an un-chosen foldable section starts collapsed. */ +export const COLLAPSE_THRESHOLD = 8; -const DEFAULTS: DashPrefs = { spacesOpen: null, recentOpen: true, recentDir: "newest" }; +const DEFAULTS: DashPrefs = { + spacesOpen: null, + shellsOpen: null, + recentOpen: true, + recentDir: "newest", +}; -/** Resolve the effective Spaces open state: an explicit choice, else the count threshold. */ -export function spacesOpenFor(pref: boolean | null, spaceCount: number): boolean { +/** + * The effective open state of a count-sensitive section: an explicit choice always wins, otherwise + * it opens only while it's short enough to be worth showing. Used by Spaces on the dashboard and by + * Shells in the pane switcher — a two-item list shouldn't greet you as a mystery collapsed header, + * and a forty-item one shouldn't greet you as a wall. + */ +export function openForCount(pref: boolean | null, count: number): boolean { if (pref !== null) return pref; - return spaceCount <= SPACES_COLLAPSE_THRESHOLD; + return count <= COLLAPSE_THRESHOLD; } /** @@ -41,6 +56,7 @@ export function coerceDashPrefs(raw: unknown): DashPrefs { const p = raw as Record; return { spacesOpen: typeof p.spacesOpen === "boolean" ? p.spacesOpen : DEFAULTS.spacesOpen, + shellsOpen: typeof p.shellsOpen === "boolean" ? p.shellsOpen : DEFAULTS.shellsOpen, recentOpen: typeof p.recentOpen === "boolean" ? p.recentOpen : DEFAULTS.recentOpen, recentDir: p.recentDir === "oldest" || p.recentDir === "newest" ? p.recentDir : DEFAULTS.recentDir, }; @@ -69,6 +85,7 @@ function savePrefs(prefs: DashPrefs): void { export interface UseDashPrefsReturn { prefs: DashPrefs; setSpacesOpen: (open: boolean) => void; + setShellsOpen: (open: boolean) => void; setRecentOpen: (open: boolean) => void; setRecentDir: (dir: RecentDir) => void; } @@ -85,8 +102,9 @@ export function useDashPrefs(): UseDashPrefsReturn { }, []); const setSpacesOpen = useCallback((spacesOpen: boolean) => update({ spacesOpen }), [update]); + const setShellsOpen = useCallback((shellsOpen: boolean) => update({ shellsOpen }), [update]); const setRecentOpen = useCallback((recentOpen: boolean) => update({ recentOpen }), [update]); const setRecentDir = useCallback((recentDir: RecentDir) => update({ recentDir }), [update]); - return { prefs, setSpacesOpen, setRecentOpen, setRecentDir }; + return { prefs, setSpacesOpen, setShellsOpen, setRecentOpen, setRecentDir }; } diff --git a/web/src/routes/home.tsx b/web/src/routes/home.tsx index e817144..205ae10 100644 --- a/web/src/routes/home.tsx +++ b/web/src/routes/home.tsx @@ -11,7 +11,7 @@ import { NewSpaceSheet } from "@/components/new-space-sheet"; import { StatusArea } from "@/components/status-area"; import { BuildStamp } from "@/components/build-stamp"; import { UpdateBanner } from "@/components/update-banner"; -import { useDashPrefs, spacesOpenFor } from "@/hooks/use-dash-prefs"; +import { useDashPrefs, openForCount } from "@/hooks/use-dash-prefs"; import { useLoadingStalled } from "@/hooks/use-loading-stalled"; import { useSpaceActions } from "@/hooks/use-spaces"; import { ROOT_ROUTE_ID, type HomeData } from "@/lib/loaders"; @@ -33,7 +33,7 @@ export function HomeRoute() { const { prefs, setSpacesOpen, setRecentOpen, setRecentDir } = useDashPrefs(); // No stored choice yet? The space count decides — a two-space install shouldn't be handed a // mystery collapsed header, and a forty-space one shouldn't be handed a wall. - const spacesOpen = spacesOpenFor(prefs.spacesOpen, data.workspaces.length); + const spacesOpen = openForCount(prefs.spacesOpen, data.workspaces.length); const open = (id: string) => navigate(panePath(id, data.session)); const drillInto = (id: string) => navigate(spacePath(id, data.session)); From 6754742d1d0376bc1299560a0afb22b342d054fa Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 21:42:55 +0400 Subject: [PATCH 08/19] docs: note the Switch pane folds in 0.20.0 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 521b948..beffbc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to Collie are recorded here. The format follows - **The dashboard is triaged, not listed.** Needs you → Ready · unseen → Working → Recent; the first three are pinned, Recent sorts by when you last used each pane (3850d96) - **Ready · unseen** — agents that finished while you weren't looking. Opening one clears it, on every device (839d0f3) - Recent and Spaces fold and remember it; fold both and the page is the triaged herd and nothing else (3850d96) +- The swipe-up **Switch pane** sheet folds its long tails too — Recent, and the bare **Shells** group that buried the agents underneath it (ed0f0fa) - Spaces are ordered by last used and filterable — 45 of them are now three keystrokes, not a scroll (3850d96) - The bridge keeps two timestamps per pane (`activeAt`, `seenAt`) in `activity.json`, because Herdr reports none (839d0f3) From 8a8a4c9f9d64358421325e93d41cb4ae8161ce91 Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 22:02:30 +0400 Subject: [PATCH 09/19] fix(web): let the tab survive truncation, and stop every row restating its own section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX round 1/5, against the real herd at 390px. The rename shipped its premise and then broke it with a different word. Eight panes in one project all begin "moonward_os · ", so tail-truncating the joined title ate the tab and left rows reading "moonward_os · t…" — two different panes rendered the identical string. The 11 characters that survived were the ones every row shares. Project and tab now render as separate spans (paneParts): the project is capped at 45% and yields width first, the tab takes what's left. paneTitle stays as the joined form for search text and aria labels. The width came from three places that were spending it on nothing: - every Recent row wore an "idle" pill under a heading that already said Recent, and every Working row a "working" pill. Inside a triage section the status is now a dot (the word stays for screen readers); the full badge remains where status ISN'T implied, i.e. the space view. - the ChevronRight on a full-width button that already has press feedback. - timeAgo moved to line 2, beside the secondary text rather than competing with the title. This restores the layout design.md specified all along. Also: section headers were two different sizes and cases, because a ); diff --git a/web/src/components/agent-list.test.tsx b/web/src/components/agent-list.test.tsx index a3c6070..f6ca80d 100644 --- a/web/src/components/agent-list.test.tsx +++ b/web/src/components/agent-list.test.tsx @@ -53,14 +53,46 @@ describe("AgentList — sections", () => { onOpen={vi.fn()} />, ); - expect(screen.getByText("moonward_os · fix-auth")).toBeInTheDocument(); + // Rendered as separate spans so the tab survives truncation — assert both parts, and that the + // row is still announced as one name. + expect(screen.getByText("moonward_os")).toBeInTheDocument(); + expect(screen.getByText("fix-auth")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /moonward_os.*fix-auth/ })).toBeInTheDocument(); expect(screen.queryByText("claude")).not.toBeInTheDocument(); }); + it("gives the tab the width and lets the project truncate — the tab is the discriminator", () => { + render( + , + ); + // The project yields width first (capped + shrinkable); the tab takes what's left. + expect(screen.getByText("moonward_os").className).toMatch(/max-w-\[45%\]/); + expect(screen.getByText("fix-auth").className).toMatch(/flex-1/); + }); + it("omits a section with no members rather than showing an empty heading", () => { render(); - expect(screen.queryByText(/needs you/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/ready · unseen/i)).not.toBeInTheDocument(); + expect(headings()).toEqual([expect.stringContaining("working")]); + }); + + it("says so when nothing needs you, rather than leaving an absence to interpret", () => { + render(); + expect(screen.getByText(/nothing needs you/i)).toBeInTheDocument(); + }); + + it("stays quiet about it when something DOES need you", () => { + render(); + expect(screen.queryByText(/nothing needs you/i)).not.toBeInTheDocument(); + }); + + it("drops the status pill inside triage sections — the heading already says it", () => { + render(); + // The word survives for screen readers, but not as a pill on every row. + const row = screen.getByRole("button", { name: /w/ }); + expect(row.querySelector(".sr-only")?.textContent).toBe("working"); }); it("opens the pane behind a tapped row", async () => { diff --git a/web/src/components/agent-list.tsx b/web/src/components/agent-list.tsx index ba56fb5..4449bf9 100644 --- a/web/src/components/agent-list.tsx +++ b/web/src/components/agent-list.tsx @@ -1,7 +1,7 @@ -import { ArrowDown, ArrowUp, Inbox } from "lucide-react"; +import { ArrowDown, ArrowUp, Check, Inbox } from "lucide-react"; import { SectionHeader } from "@/components/section-header"; -import { flipDir, triage, type RecentDir, type TriageKey } from "@/lib/triage"; +import { flipDir, sectionHeaderProps, triage, type RecentDir, type TriageKey } from "@/lib/triage"; import type { AgentView, BridgeStatus } from "@/lib/types"; import { AgentCard } from "./agent-card"; @@ -51,11 +51,21 @@ export function AgentList({ ); } - const sections = triage(agents, recentDir).filter((s) => s.agents.length > 0); + const all = triage(agents, recentDir); + const sections = all.filter((s) => s.agents.length > 0); if (sections.length === 0) return null; + // "What needs me right now?" deserves an answer even when the answer is "nothing". Without this + // the section simply doesn't render, and an absence reads the same as a stale load. + const allClear = all.find((s) => s.key === "needs")!.agents.length === 0; return (
+ {allClear && ( +

+ + Nothing needs you +

+ )} {sections.map((s) => { // Recent is the only foldable section, and only where the parent wired the state. const foldable = !!s.collapsible && onRecentOpenChange !== undefined; @@ -66,9 +76,7 @@ export function AgentList({ return (
{open && (
+ {/* statusStyle="dot": the section heading already says the status, so a pill on + every row restates it and costs the width the title needs. */} {s.agents.map((a) => ( onOpen(a.paneId)} + statusStyle="dot" {...(age ? { age } : {})} /> ))} @@ -112,10 +123,12 @@ function SortToggle({ dir, onChange }: { dir: RecentDir; onChange: (dir: RecentD ? "Sorted by most recently used first — switch to oldest first" : "Sorted by oldest first — switch to most recently used first" } - className="flex min-h-9 items-center gap-1 rounded-md px-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:scale-95" + // A bordered chip, not bare text: unstyled it read as an annotation ("sorted newest") rather + // than something you can press. Fixed width so flipping it doesn't shift the header. + className="flex min-h-9 items-center justify-center gap-1 rounded-full border bg-muted/50 px-2.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:scale-95" > - {newest ? "Newest" : "Oldest"} + {newest ? "Newest" : "Oldest"} ); } diff --git a/web/src/components/agent-sidebar.tsx b/web/src/components/agent-sidebar.tsx index d4c2441..36d81f8 100644 --- a/web/src/components/agent-sidebar.tsx +++ b/web/src/components/agent-sidebar.tsx @@ -3,8 +3,8 @@ import { TerminalSquare } from "lucide-react"; import { cn } from "@/lib/utils"; import { AgentIcon } from "@/components/agent-icon"; import { SectionHeader } from "@/components/section-header"; -import { paneTitle } from "@/lib/pane-name"; -import { triage } from "@/lib/triage"; +import { paneParts } from "@/lib/pane-name"; +import { sectionHeaderProps, triage } from "@/lib/triage"; import type { AgentView } from "@/lib/types"; interface ThreadSidebarProps { @@ -61,10 +61,7 @@ export function ThreadSidebar({
{members.map((a) => ( @@ -152,9 +149,9 @@ function PaneRow({ onSelect: (paneId: string) => void; }) { const isShell = pane.kind === "shell"; - // project · tab, with the pane's own name (or cwd) beneath — see paneTitle. The agent's identity - // stays in the icon, which is why the title line is free to say where the work is. - const { primary, secondary } = paneTitle(pane); + // project · tab as separate spans so the TAB survives truncation — see paneParts. The agent's + // identity stays in the icon, which is why the title line is free to say where the work is. + const { project, tab, secondary } = paneParts(pane); return ( ) : ( - {inner} + {inner} )} {trailing && {trailing}} diff --git a/web/src/components/space-overview.tsx b/web/src/components/space-overview.tsx index 4e7c38f..7388ae9 100644 --- a/web/src/components/space-overview.tsx +++ b/web/src/components/space-overview.tsx @@ -95,7 +95,8 @@ export function SpaceOverview({ onChange={(e) => setQuery(e.target.value)} placeholder="Filter spaces…" aria-label="Filter spaces" - className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + // min-h-9 so the control itself clears the 36px touch floor, not just its padded label. + className="min-h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" /> )} diff --git a/web/src/lib/pane-name.ts b/web/src/lib/pane-name.ts index 406e742..9cd3a8b 100644 --- a/web/src/lib/pane-name.ts +++ b/web/src/lib/pane-name.ts @@ -15,9 +15,36 @@ export interface PaneTitle { secondary: string | null; } +/** + * The title's parts, unjoined — because at 390px they must not truncate as one string. + * + * Eight panes in the same project all begin `moonward_os · `, so tail-truncating the joined title + * eats the tab name and leaves every row reading `moonward_os · t…`: the 11 characters that survive + * are the ones every row shares. Rendering the parts separately lets the PROJECT give up width + * first and the tab — the only discriminator — survive. + */ +export interface PaneParts { + project: string; + /** The tab label, or null when it says nothing (see meaningfulTabLabel, bridge-side). */ + tab: string | null; + /** The pane's own name if it has one, else a shortened cwd. Null when there's neither. */ + secondary: string | null; +} + /** The separator between project and tab. Exported so tests and search-text builders agree. */ export const TITLE_SEP = " · "; +/** {@link PaneParts} for a pane — the render-time form of {@link paneTitle}. */ +export function paneParts(pane: AgentView): PaneParts { + const project = pane.workspaceLabel || pane.workspaceId; + const own = pane.paneLabel || pane.sessionName; + return { + project, + tab: pane.tabLabel ?? null, + secondary: own || (pane.cwd ? shortCwd(pane.cwd) : null), + }; +} + /** * Title and subtitle for a pane row. * @@ -29,11 +56,8 @@ export const TITLE_SEP = " · "; * the pane mirror keeps. */ export function paneTitle(pane: AgentView): PaneTitle { - const project = pane.workspaceLabel || pane.workspaceId; - const primary = pane.tabLabel ? `${project}${TITLE_SEP}${pane.tabLabel}` : project; - const own = pane.paneLabel || pane.sessionName; - const secondary = own || (pane.cwd ? shortCwd(pane.cwd) : null); - return { primary, secondary }; + const { project, tab, secondary } = paneParts(pane); + return { primary: tab ? `${project}${TITLE_SEP}${tab}` : project, secondary }; } /** diff --git a/web/src/lib/triage.ts b/web/src/lib/triage.ts index 0c83342..63c3cdd 100644 --- a/web/src/lib/triage.ts +++ b/web/src/lib/triage.ts @@ -89,6 +89,20 @@ export function triage(agents: readonly AgentView[], dir: RecentDir = "newest"): ]; } +/** + * The presentation fields a section header needs, in one place. Both the dashboard and the pane + * switcher spread this rather than picking fields by hand — that's how the dashboard silently ended + * up without the status-colour bullet the switcher had, and a new field would have done it again. + */ +export function sectionHeaderProps(s: TriageSection) { + return { + label: s.label, + count: s.agents.length, + dot: s.dot, + ...(s.accent ? { accent: s.accent } : {}), + }; +} + /** The other direction — for the toggle. */ export function flipDir(dir: RecentDir): RecentDir { return dir === "newest" ? "oldest" : "newest"; From 1a1100d098eed25a935a6eade4c2ead0c3415d09 Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 22:12:11 +0400 Subject: [PATCH 10/19] fix(web): make the card shape mean something, and stop line 2 repeating line 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX round 2/5. The density win came from the second line, not the borders. Eight of eighteen Recent rows read "moonward_os" on line 1 and "…ropbox/dev/moonward/moonward_os" on line 2 — the same word twice, in a mono face the eye slows down for, and clipped at BOTH ends (shortCwd head-elided it, then CSS tail-truncated what was left to seat the timestamp). A string cut at both ends is worth nothing. The cwd is now dropped when the directory's own name is the project label, which is almost always — a space is named after its directory. It survives for the case that carries information: a pane sitting somewhere other than the space root, a worktree or a subdir. Most rows collapse to one line. shortCwd also drops whole path segments now instead of characters, so an abbreviation stops looking like a rendering fault. Card chrome on 100% of rows is wallpaper, not emphasis: a Working row and a Recent row rendered pixel-identically, throwing away the four-level priority triage() had just computed. Cards are now reserved for the sections that mean "a human is required here" (Needs you, Ready · unseen); everything else is a flat row on a hairline divider. See a card, something wants you. The blocked tint survives on both paths untouched — it's the one cue that reads at a glance. Status dots were drawn as solid discs in a palette tuned for TEXT contrast, so eighteen idle dots carried the same weight as the one thing that needs you. Resting states (idle/unknown) are hollow rings now; states that mean something is happening stay solid. The dot also moved from the far right — where the eye crossed 200px of empty card to reach a 10px mark — onto the avatar's corner. Also: "Nothing needs you" is the product of the whole glance and was rendered in the page's faintest type; it now has presence. The sort chip lost its fill (it outweighed the heading beside it). The Spaces count reports what you can SEE while filtering, the filter sticks to the top of a five-screen list, and its rows lost a chevron the agent rows had already dropped. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/agent-card.tsx | 63 +++++++++++++++++++-------- web/src/components/agent-list.tsx | 28 +++++++++--- web/src/components/space-overview.tsx | 31 ++++++++----- web/src/components/status-badge.tsx | 25 ++++++++++- web/src/lib/format.test.ts | 19 +++++--- web/src/lib/format.ts | 38 +++++++++++++--- web/src/lib/pane-name.test.ts | 24 ++++++++++ web/src/lib/pane-name.ts | 18 +++++++- 8 files changed, 200 insertions(+), 46 deletions(-) diff --git a/web/src/components/agent-card.tsx b/web/src/components/agent-card.tsx index 1528cd7..8489c67 100644 --- a/web/src/components/agent-card.tsx +++ b/web/src/components/agent-card.tsx @@ -30,6 +30,16 @@ interface AgentCardProps { * nothing and costs a third of the row's width, which is exactly the width the title needs. */ statusStyle?: "badge" | "dot"; + /** + * "card" (default) is the bordered, shadowed treatment. "row" is flat — no border, no shadow, + * separated by a hairline instead. + * + * Card chrome on 100% of rows is wallpaper, not emphasis: a Working row and a Recent row rendered + * pixel-identically, throwing away the four-level priority `triage()` had just computed. Reserving + * the card for the sections that mean "a human is required here" makes the shape itself carry the + * signal — see a card, something wants you; all flat, nothing does. + */ + density?: "card" | "row"; } // A pane row, used by the triage home and the space view. Usually an agent; for a bare shell pane @@ -45,34 +55,56 @@ export function AgentCard({ age, scope = "herd", statusStyle = "badge", + density = "card", }: AgentCardProps) { const isShell = agent.kind === "shell"; const blocked = agent.status === "blocked"; const inTab = scope === "tab"; + const flat = density === "row"; const parts = paneParts(agent); const tabTitle = paneTitleInTab(agent); const stamp = age === "seen" ? agent.lastSeenAt : age === "active" ? agent.lastActiveAt : undefined; const secondary = inTab ? tabTitle.secondary : parts.secondary; + // The dot rides the avatar's corner rather than the far right: at the right edge the eye read a + // title, then crossed 200px of empty card to a 10px mark describing it. + const cornerDot = statusStyle === "dot" && !isShell; + + const Shell = flat ? "div" : Card; return ( ); } diff --git a/web/src/components/agent-list.tsx b/web/src/components/agent-list.tsx index 4449bf9..fd7f9ac 100644 --- a/web/src/components/agent-list.tsx +++ b/web/src/components/agent-list.tsx @@ -1,5 +1,6 @@ import { ArrowDown, ArrowUp, Check, Inbox } from "lucide-react"; +import { cn } from "@/lib/utils"; import { SectionHeader } from "@/components/section-header"; import { flipDir, sectionHeaderProps, triage, type RecentDir, type TriageKey } from "@/lib/triage"; import type { AgentView, BridgeStatus } from "@/lib/types"; @@ -26,6 +27,9 @@ const AGE_BY_SECTION: Partial> = { recent: "seen", }; +/** The sections that mean "a human is required here" — the only ones that get card chrome. */ +const ATTENTION: ReadonlySet = new Set(["needs", "ready"]); + // The herd in the one order the app agrees on: Needs you → Ready · unseen → Working → Recent // (lib/triage.ts). Only Recent folds, and only Recent takes the direction toggle; the three // attention sections are pinned open and never invert. @@ -60,9 +64,11 @@ export function AgentList({ return (
+ {/* The product of the twenty-times-a-day glance. Rendered with presence, not as a caption: + you should be able to resolve it one-handed at arm's length without focusing. */} {allClear && ( -

- +

+ Nothing needs you

)} @@ -88,7 +94,16 @@ export function AgentList({ } /> {open && ( -
+
{/* statusStyle="dot": the section heading already says the status, so a pill on every row restates it and costs the width the title needs. */} {s.agents.map((a) => ( @@ -97,6 +112,7 @@ export function AgentList({ agent={a} onClick={() => onOpen(a.paneId)} statusStyle="dot" + density={ATTENTION.has(s.key) ? "card" : "row"} {...(age ? { age } : {})} /> ))} @@ -124,8 +140,10 @@ function SortToggle({ dir, onChange }: { dir: RecentDir; onChange: (dir: RecentD : "Sorted by oldest first — switch to most recently used first" } // A bordered chip, not bare text: unstyled it read as an annotation ("sorted newest") rather - // than something you can press. Fixed width so flipping it doesn't shift the header. - className="flex min-h-9 items-center justify-center gap-1 rounded-full border bg-muted/50 px-2.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:scale-95" + // than something you can press. Fixed width so flipping it doesn't shift the header. No fill — + // filled, it outweighed the heading it sits beside, which is backwards for a control that + // reorders the section you care least about. + className="flex min-h-9 items-center justify-center gap-1 rounded-full border px-2.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:scale-95" > {newest ? "Newest" : "Oldest"} diff --git a/web/src/components/space-overview.tsx b/web/src/components/space-overview.tsx index 7388ae9..0a3b744 100644 --- a/web/src/components/space-overview.tsx +++ b/web/src/components/space-overview.tsx @@ -1,8 +1,7 @@ import { useState } from "react"; -import { ChevronRight, FolderPlus, LayoutGrid, Search } from "lucide-react"; +import { FolderPlus, LayoutGrid, Search } from "lucide-react"; import { cn } from "@/lib/utils"; -import { Card } from "@/components/ui/card"; import { SectionHeader } from "@/components/section-header"; import { StatusDot } from "@/components/status-badge"; import { @@ -54,7 +53,9 @@ export function SpaceOverview({
{open && ( -
+
{/* Deliberately NOT autofocused: on a phone that would throw the keyboard over the list you just asked to see. */} + {/* Sticky: at 45 spaces the list is five screens, and a filter that scrolls away turns + "wrong part of the list" into scroll-up, type, scroll-down. */} {workspaces.length > 1 && ( -
); }) diff --git a/web/src/components/status-badge.tsx b/web/src/components/status-badge.tsx index 2554b7c..e407ebe 100644 --- a/web/src/components/status-badge.tsx +++ b/web/src/components/status-badge.tsx @@ -18,7 +18,25 @@ const CHIP: Record = { unknown: "border-status-unknown/30 bg-status-unknown/10 text-status-unknown", }; +/** + * As a FILL, the status palette needs a different ramp than it does as text. Every --status-* value + * is tuned near the same lightness for text contrast, so drawn as solid discs the resting states + * (idle / unknown) carry exactly as much weight as blocked — eighteen idle dots would out-shout the + * one thing that needs you. The resting states are therefore hollow rings; the states that mean + * something is happening stay solid. + */ +const RESTING: ReadonlySet = new Set(["idle", "unknown"]); + +const RING: Record = { + blocked: "border-status-blocked", + working: "border-status-working", + done: "border-status-done", + idle: "border-status-idle/60", + unknown: "border-status-unknown/60", +}; + export function StatusDot({ status, className }: { status: AgentStatus; className?: string }) { + const hollow = RESTING.has(status); return ( {status === "working" && ( @@ -29,7 +47,12 @@ export function StatusDot({ status, className }: { status: AgentStatus; classNam )} /> )} - + ); } diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts index cf6eb64..f353cac 100644 --- a/web/src/lib/format.test.ts +++ b/web/src/lib/format.test.ts @@ -27,12 +27,21 @@ describe("shortCwd", () => { expect(out).not.toContain("…"); }); - it("truncates a long tail with a leading ellipsis, respecting max", () => { + it("drops whole segments, never cutting one in half", () => { + // Cutting on a character count produced "…ropbox/dev/…", which reads as a rendering fault + // rather than an abbreviation. + const out = shortCwd("/home/you/Dropbox/dev/ai/moonward/moonward_os"); + expect(out).toBe("…/dev/ai/moonward/moonward_os"); + expect(out).not.toContain("…rop"); + for (const seg of out.replace("…/", "").split("/")) { + expect("/home/you/Dropbox/dev/ai/moonward/moonward_os".split("/")).toContain(seg); + } + }); + + it("keeps the last segment even when it alone busts the budget", () => { + // It's the part that identifies the directory — a half of it identifies nothing. const out = shortCwd("/var/home/you/" + "x".repeat(40)); // default max = 32 - expect(out.startsWith("…")).toBe(true); - expect(out).toHaveLength(32); - expect(out.endsWith("x")).toBe(true); - // The home "~" got truncated away — we keep the most-specific tail. + expect(out).toBe("…/" + "x".repeat(40)); expect(out).not.toContain("~"); }); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 5bd0dc1..0504387 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -1,11 +1,39 @@ // Small presentational helpers. -/** Collapse $HOME and keep the tail of a long path so it fits a phone row. */ +/** Collapse $HOME to `~` — handles /home/, /Users/ (macOS), /var/home/ (Fedora). */ +export function tildeHome(cwd: string): string { + return cwd.replace(/^\/(?:var\/)?home\/[^/]+/, "~").replace(/^\/Users\/[^/]+/, "~"); +} + +/** + * Collapse $HOME and keep the tail of a long path so it fits a phone row. + * + * Drops WHOLE SEGMENTS, not characters: cutting mid-segment produced `…ropbox/dev/…`, which reads + * as a rendering fault rather than an abbreviation. The last segment is always kept even when it + * alone exceeds the budget — it's the part that identifies the directory. + */ export function shortCwd(cwd: string, max = 32): string { - // Handles /home/, /Users/ (macOS), and /var/home/ (Fedora Atomic / Silverblue). - let p = cwd.replace(/^\/(?:var\/)?home\/[^/]+/, "~").replace(/^\/Users\/[^/]+/, "~"); - if (p.length > max) p = "…" + p.slice(p.length - max + 1); - return p; + const p = tildeHome(cwd); + if (p.length <= max) return p; + + const segments = p.split("/").filter(Boolean); + const last = segments.pop(); + if (last === undefined) return p; + + const kept = [last]; + let len = last.length + 1; // + the leading "…/" + for (const seg of segments.reverse()) { + if (len + seg.length + 1 > max) break; + kept.unshift(seg); + len += seg.length + 1; + } + return `…/${kept.join("/")}`; +} + +/** The last path segment (the directory's own name), with any trailing slash ignored. */ +export function baseName(path: string): string { + const parts = path.split("/").filter(Boolean); + return parts[parts.length - 1] ?? ""; } /** Two-letter avatar fallback from an agent name (e.g. "claude" → "CL"). */ diff --git a/web/src/lib/pane-name.test.ts b/web/src/lib/pane-name.test.ts index 430e0d7..7a7ab7c 100644 --- a/web/src/lib/pane-name.test.ts +++ b/web/src/lib/pane-name.test.ts @@ -84,3 +84,27 @@ describe("paneSearchText", () => { expect(paneSearchText(pane({ cwd: "" }))).toBe("moonward_os claude"); }); }); + +describe("paneTitle — the cwd fallback only when it says something", () => { + it("drops the cwd when the directory is just the project again", () => { + // The space is named after its directory on almost every row, so the fallback was printing + // line 1 twice. + expect(paneTitle(pane({ workspaceLabel: "collie", cwd: "/home/kon/dev/ai/collie" })).secondary) + .toBeNull(); + }); + + it("is case-insensitive about that match", () => { + expect(paneTitle(pane({ workspaceLabel: "Collie", cwd: "/home/kon/dev/ai/collie" })).secondary) + .toBeNull(); + }); + + it("KEEPS the cwd when the pane sits somewhere else — a worktree or a subdir", () => { + expect(paneTitle(pane({ workspaceLabel: "collie", cwd: "/home/kon/dev/ai/collie/web" })).secondary) + .toBe("~/dev/ai/collie/web"); + }); + + it("still prefers the pane's own name over either", () => { + const t = paneTitle(pane({ workspaceLabel: "collie", cwd: "/home/kon/dev/ai/collie", sessionName: "oauth" })); + expect(t.secondary).toBe("oauth"); + }); +}); diff --git a/web/src/lib/pane-name.ts b/web/src/lib/pane-name.ts index 9cd3a8b..58b04f3 100644 --- a/web/src/lib/pane-name.ts +++ b/web/src/lib/pane-name.ts @@ -5,7 +5,7 @@ // // Nothing is lost: the pane's own name (a herdr `pane.rename` label, or Claude's own `/rename` // session name) moves down one line, where it displaces the cwd. -import { shortCwd } from "./format"; +import { baseName, shortCwd } from "./format"; import type { AgentView } from "./types"; export interface PaneTitle { @@ -34,6 +34,20 @@ export interface PaneParts { /** The separator between project and tab. Exported so tests and search-text builders agree. */ export const TITLE_SEP = " · "; +/** + * The cwd, but only when it says something the title doesn't. + * + * A space is almost always named after its directory, so the fallback line spent itself repeating + * line 1: `moonward_os` above `…/dev/moonward/moonward_os`, on row after row. Dropping it when the + * directory's own name matches the project keeps the path for exactly the case that carries + * information — a pane sitting somewhere OTHER than the space root, in a worktree or a subdirectory. + */ +function informativeCwd(cwd: string, project: string): string | null { + if (!cwd) return null; + if (baseName(cwd).toLowerCase() === project.trim().toLowerCase()) return null; + return shortCwd(cwd); +} + /** {@link PaneParts} for a pane — the render-time form of {@link paneTitle}. */ export function paneParts(pane: AgentView): PaneParts { const project = pane.workspaceLabel || pane.workspaceId; @@ -41,7 +55,7 @@ export function paneParts(pane: AgentView): PaneParts { return { project, tab: pane.tabLabel ?? null, - secondary: own || (pane.cwd ? shortCwd(pane.cwd) : null), + secondary: own || informativeCwd(pane.cwd, project), }; } From 5c044539e898ea30c059dc86bcf5e1227d09e39f Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 22:23:23 +0400 Subject: [PATCH 11/19] fix(web): stop the hollow status ring reading as a notch cut out of the avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX round 3/5. The hollow ring shipped with bg-transparent, so on the avatar's corner its interior showed orange logo through one half and page grey through the other, wrapped in a ring — indistinguishable from a rendering fault. It was worst exactly where it mattered: Recent is the only section that ISN'T homogeneous (idle, unknown and already-seen done all land there), so its per-row dot is the only status signal on the page, and it rendered broken on 17 of 21 rows. A hollow ring is now filled with the surface it sits on, and the card/flat paths ring in their own surface rather than both assuming the page. Line 2 had become a lone mono timestamp — a footnote given the same vertical presence as the title, in the font the path used to occupy, left-aligned because nothing pushed it right. The age moved up to the title row's trailing slot and the second line renders only when there's something to say, so most rows are one line. That freed enough room to date the Working rows too: "working for 3h" and "working for 40s" are very different facts. Hoisting the age then cost the title ~55px and truncation crept back ("interview-con…"), so the column drops the "ago" every entry was repeating — the column's meaning is already established. Card and flat rows also disagreed about their avatar origin by 5px, so the column your eye rides down the page stepped sideways at each section boundary. And the switcher — the surface you use to jump TO the thing that needs you — rendered every pane identically, with no way to show a blocked one as blocked. It now applies the tint via isAttention(), which exists for exactly this and had no callers. Denser than the dashboard is fine; blind is not. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/agent-card.tsx | 43 ++++++++++++++++++-------- web/src/components/agent-list.test.tsx | 8 ++--- web/src/components/agent-list.tsx | 3 ++ web/src/components/agent-sidebar.tsx | 7 ++++- web/src/components/status-badge.tsx | 18 +++++++++-- web/src/lib/format.test.ts | 15 ++++++++- web/src/lib/format.ts | 10 ++++++ 7 files changed, 83 insertions(+), 21 deletions(-) diff --git a/web/src/components/agent-card.tsx b/web/src/components/agent-card.tsx index 8489c67..2c10ae2 100644 --- a/web/src/components/agent-card.tsx +++ b/web/src/components/agent-card.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/utils"; import { Card } from "@/components/ui/card"; import { ShellBadge, StatusBadge, StatusDot } from "@/components/status-badge"; import { AgentIcon } from "@/components/agent-icon"; -import { timeAgo } from "@/lib/format"; +import { timeAgoShort } from "@/lib/format"; import { paneParts, paneTitleInTab } from "@/lib/pane-name"; import { STATUS_LABEL } from "@/lib/types"; import type { AgentView } from "@/lib/types"; @@ -42,6 +42,12 @@ interface AgentCardProps { density?: "card" | "row"; } +/** The row's age, in the trailing slot of whichever line it sits on. Not mono — it's a footnote, + * not data; mono made it read like the path it replaced. */ +function Age({ at }: { at: number }) { + return {timeAgoShort(at)}; +} + // A pane row, used by the triage home and the space view. Usually an agent; for a bare shell pane // (kind:"shell") it shows a terminal glyph and a muted "shell" tag instead of a status badge. // @@ -82,8 +88,10 @@ export function AgentCard({ > )}
{inTab ? ( -
{tabTitle.primary}
+
+ {tabTitle.primary} +
) : (
{/* The project yields first: capped and truncatable. */} @@ -124,17 +139,19 @@ export function AgentCard({ {parts.tab} )} + {/* The age rides the title row: alone on a line of its own it claimed the same + vertical presence as the title, for a footnote. */} + {stamp !== undefined && }
)} -
- {secondary && {secondary}} - {stamp !== undefined && ( - - {timeAgo(stamp)} - - )} -
+ {/* Only rendered when there's something to say — most rows are one line now. */} + {secondary && ( +
+ {secondary} + {inTab && stamp !== undefined && } +
+ )}
{isShell ? ( diff --git a/web/src/components/agent-list.test.tsx b/web/src/components/agent-list.test.tsx index f6ca80d..3bc037c 100644 --- a/web/src/components/agent-list.test.tsx +++ b/web/src/components/agent-list.test.tsx @@ -245,7 +245,7 @@ describe("AgentList — timestamps on rows", () => { it("dates a Recent row by when you last used it", () => { const seen = Date.now() - 5 * 60 * 1000; render(); - expect(screen.getByText("5m ago")).toBeInTheDocument(); + expect(screen.getByText("5m")).toBeInTheDocument(); }); it("dates a Ready · unseen row by when it FINISHED, not when you last looked", () => { @@ -257,7 +257,7 @@ describe("AgentList — timestamps on rows", () => { />, ); const section = screen.getByText(/ready · unseen/i).closest("section")!; - expect(within(section).getByText("2m ago")).toBeInTheDocument(); + expect(within(section).getByText("2m")).toBeInTheDocument(); }); it("puts no age on a blocked row — it's noise beside 'needs you'", () => { @@ -267,7 +267,7 @@ describe("AgentList — timestamps on rows", () => { onOpen={vi.fn()} />, ); - expect(screen.queryByText(/ago/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^\d+[mhd]$|^now$/)).not.toBeInTheDocument(); }); }); @@ -285,6 +285,6 @@ describe("AgentList — an older bridge with no timestamps", () => { expect.stringContaining("recent"), ]); expect(screen.queryByText(/ready · unseen/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/ago/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^\d+[mhd]$|^now$/)).not.toBeInTheDocument(); }); }); diff --git a/web/src/components/agent-list.tsx b/web/src/components/agent-list.tsx index fd7f9ac..0a9908b 100644 --- a/web/src/components/agent-list.tsx +++ b/web/src/components/agent-list.tsx @@ -24,6 +24,9 @@ interface AgentListProps { * agent's age is noise beside the fact that it's blocked. */ const AGE_BY_SECTION: Partial> = { ready: "active", + // "working for 3h" and "working for 40s" are very different facts, and now that the age rides + // the title row it costs no vertical space to say which. + working: "active", recent: "seen", }; diff --git a/web/src/components/agent-sidebar.tsx b/web/src/components/agent-sidebar.tsx index 36d81f8..2447a74 100644 --- a/web/src/components/agent-sidebar.tsx +++ b/web/src/components/agent-sidebar.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/utils"; import { AgentIcon } from "@/components/agent-icon"; import { SectionHeader } from "@/components/section-header"; import { paneParts } from "@/lib/pane-name"; -import { sectionHeaderProps, triage } from "@/lib/triage"; +import { isAttention, sectionHeaderProps, triage } from "@/lib/triage"; import type { AgentView } from "@/lib/types"; interface ThreadSidebarProps { @@ -160,6 +160,11 @@ function PaneRow({ className={cn( "flex w-full min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors", active ? "bg-accent text-accent-foreground" : "hover:bg-muted/60 active:bg-muted", + // The switcher is exactly where you jump TO the thing that needs you, so it must be able to + // SHOW that — it renders every pane identically otherwise. Staying denser than the dashboard + // is fine; being unable to mark a blocked pane is not. (isAttention, so the rule isn't + // re-derived here.) + !active && isAttention(pane.status) && "border border-status-blocked/40 bg-status-blocked/5", )} > {isShell ? ( diff --git a/web/src/components/status-badge.tsx b/web/src/components/status-badge.tsx index e407ebe..e2d711d 100644 --- a/web/src/components/status-badge.tsx +++ b/web/src/components/status-badge.tsx @@ -35,7 +35,21 @@ const RING: Record = { unknown: "border-status-unknown/60", }; -export function StatusDot({ status, className }: { status: AgentStatus; className?: string }) { +export function StatusDot({ + status, + surface = "bg-background", + className, +}: { + status: AgentStatus; + /** + * The colour the dot sits ON. A hollow ring must be FILLED with its surface, not left + * transparent: over the avatar's corner a transparent interior showed orange logo through one + * half and page grey through the other, reading as a notch cut out of the icon rather than a + * badge. Pass the card's surface when the dot sits on a card. + */ + surface?: string; + className?: string; +}) { const hollow = RESTING.has(status); return ( @@ -50,7 +64,7 @@ export function StatusDot({ status, className }: { status: AgentStatus; classNam diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts index f353cac..9ef5fc8 100644 --- a/web/src/lib/format.test.ts +++ b/web/src/lib/format.test.ts @@ -1,4 +1,4 @@ -import { initials, shortCwd, timeAgo } from "./format"; +import { initials, shortCwd, timeAgo, timeAgoShort } from "./format"; describe("shortCwd", () => { it("collapses /home/ to ~", () => { @@ -93,3 +93,16 @@ describe("timeAgo", () => { expect(timeAgo(now - 90 * 60_000, now)).toBe("1h ago"); // 90m → 1h }); }); + +describe("timeAgoShort", () => { + const now = 1_000_000_000; + it("drops the redundant 'ago' for a column of ages", () => { + expect(timeAgoShort(now - 5 * 60_000, now)).toBe("5m"); + expect(timeAgoShort(now - 3 * 3_600_000, now)).toBe("3h"); + expect(timeAgoShort(now - 2 * 86_400_000, now)).toBe("2d"); + }); + + it("says 'now' rather than 'just now'", () => { + expect(timeAgoShort(now - 1000, now)).toBe("now"); + }); +}); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 0504387..e6f615e 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -46,6 +46,16 @@ export function initials(name: string): string { * Compact "time ago" for a past epoch-ms timestamp — "just now" under a minute, then "5m"/"2h"/"3d" * ago. A future or now timestamp reads "just now". Deliberately coarse: it's a footnote, not a clock. */ +/** + * The same age with the word "ago" dropped — for a right-aligned column of them, where every entry + * would repeat it and the column's meaning is already established. Worth ~25px a row, which is the + * difference between `interview-con…` and `interview-consistency`. + */ +export function timeAgoShort(ts: number, now: number = Date.now()): string { + const full = timeAgo(ts, now); + return full === "just now" ? "now" : full.replace(/ ago$/, ""); +} + export function timeAgo(ts: number, now: number = Date.now()): string { const secs = Math.max(0, Math.round((now - ts) / 1000)); if (secs < 60) return "just now"; From dab7e05666a5ed5840dbefaee9894850e4a88736 Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 22:32:50 +0400 Subject: [PATCH 12/19] fix(web): light --accent was byte-identical to --background, so "you are here" showed nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX round 4/5. Two objective findings, both measured. Light --accent was still stock shadcn's oklch(0.97) — correct only while --background is pure white. 0.18.0 deliberately moved background to 0.97 (the hairline-contrast fix), which silently made accent === background, so every "this is the current one" fill rendered as literally nothing in light mode: the open pane in the switcher, the current session in the session switcher, and every hover:bg-accent. It now sits at 0.92, between --background and --muted. That is also why round 3's switcher fix only half-landed. A blocked pane you were CURRENTLY IN had its attention tint suppressed by the !active guard and its active fill was invisible, so it rendered with no marking at all — the one pane you're looking at, in the state the app exists to surface. The border now applies regardless of active, so both cues compose: accent fill plus alarm edge. Only the background is withheld, because two backgrounds can't both win. Second: the flex filler that pushes the age to the right edge lived on the TAB span, and an unlabelled single-tab space has no tab — so those rows had no filler and the age butted straight against the project name, reading as part of it ("comm_cli 37m") while every other row's age lined up at x=360. The project takes the width itself when it is the whole name. Deliberately NOT taken, on the evaluator's own reasoning: an age on blocked rows (it would re-truncate the titles round 3 fought to free, and "blocked" already outranks "how long"), and a tooltip disambiguating last-opened from time-in-state (a title attribute does nothing on a phone). Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/agent-card.tsx | 14 ++++++++++--- web/src/components/agent-list.test.tsx | 27 ++++++++++++++++++++++++++ web/src/components/agent-sidebar.tsx | 7 ++++++- web/src/index.css | 7 ++++++- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/web/src/components/agent-card.tsx b/web/src/components/agent-card.tsx index 2c10ae2..557d91b 100644 --- a/web/src/components/agent-card.tsx +++ b/web/src/components/agent-card.tsx @@ -126,8 +126,17 @@ export function AgentCard({
) : (
- {/* The project yields first: capped and truncatable. */} - + {/* With a tab present the project yields width first (capped, truncatable) and the + tab — the discriminator — takes the rest. With NO tab the project IS the name, so + it takes the width itself; leaving the fill on the tab span meant an unlabelled + row had no filler at all and its age butted against the name, reading as part of + it ("comm_cli 37m"). */} + {parts.project} {parts.tab && ( @@ -135,7 +144,6 @@ export function AgentCard({ · - {/* The tab is the discriminator — it gets the remaining width. */} {parts.tab} )} diff --git a/web/src/components/agent-list.test.tsx b/web/src/components/agent-list.test.tsx index 3bc037c..7fb2832 100644 --- a/web/src/components/agent-list.test.tsx +++ b/web/src/components/agent-list.test.tsx @@ -288,3 +288,30 @@ describe("AgentList — an older bridge with no timestamps", () => { expect(screen.queryByText(/^\d+[mhd]$|^now$/)).not.toBeInTheDocument(); }); }); + +describe("AgentList — the age column", () => { + it("keeps the age off the end of the name when a row has no tab label", () => { + // An unlabelled single-tab space returns tab: null. Without a flex filler the age butted + // against the project and read as part of it ("comm_cli 37m"). + render( + , + ); + expect(screen.getByText("comm_cli").className).toMatch(/flex-1/); + }); + + it("hands the width to the tab instead when there IS one", () => { + render( + , + ); + expect(screen.getByText("comm_cli").className).toMatch(/max-w-\[45%\]/); + expect(screen.getByText("main").className).toMatch(/flex-1/); + }); +}); diff --git a/web/src/components/agent-sidebar.tsx b/web/src/components/agent-sidebar.tsx index 2447a74..cb78c55 100644 --- a/web/src/components/agent-sidebar.tsx +++ b/web/src/components/agent-sidebar.tsx @@ -164,7 +164,12 @@ function PaneRow({ // SHOW that — it renders every pane identically otherwise. Staying denser than the dashboard // is fine; being unable to mark a blocked pane is not. (isAttention, so the rule isn't // re-derived here.) - !active && isAttention(pane.status) && "border border-status-blocked/40 bg-status-blocked/5", + // + // The border is applied even to the ACTIVE row, so the two cues compose: the pane you're in + // AND blocked keeps both its accent fill and its alarm edge. Only the fill is withheld, + // because two backgrounds can't both win. + isAttention(pane.status) && "border border-status-blocked/40", + !active && isAttention(pane.status) && "bg-status-blocked/5", )} > {isShell ? ( diff --git a/web/src/index.css b/web/src/index.css index b71c311..855db6d 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -57,7 +57,12 @@ headroom back; the alpha modifiers on small text were dropped as well, since no token value rescues a /70. */ --muted-foreground: light-dark(oklch(0.48 0 0), oklch(0.708 0 0)); - --accent: light-dark(oklch(0.97 0 0), oklch(0.269 0 0)); + /* Light --accent must be a real step off --background, not stock shadcn's 0.97. Stock is right + only while background is pure white; 0.18.0 deliberately moved background to 0.97 (see above), + which silently made accent === background — so every "this is the current one" highlight + (`bg-accent` in the pane switcher and session switcher) and every `hover:bg-accent` rendered + as nothing at all in light mode. Sits between --background (0.97) and --muted (0.94). */ + --accent: light-dark(oklch(0.92 0 0), oklch(0.269 0 0)); --accent-foreground: light-dark(oklch(0.205 0 0), oklch(0.985 0 0)); --destructive: light-dark(oklch(0.577 0.245 27.325), oklch(0.704 0.191 22.216)); --destructive-foreground: light-dark(oklch(0.985 0 0), oklch(0.985 0 0)); From 50a068ff3fa3b5c0026cef40a2190f32563d1f92 Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 22:38:59 +0400 Subject: [PATCH 13/19] docs: correct the --accent comment, and record the UX sweep's fixes in 0.20.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed 0.92 "sits between --background (0.97) and --muted (0.94)" — it's below muted, not between. The value is right (the switcher needs three separable steps: resting 0.97, hover 0.94, active 0.92, and 0.94 would have collided the last two); the sentence describing it was wrong, and would have misled whoever reasoned from it next. Round 5/5 verdict: SHIP. Confirmed all three round-4 fixes landed by measurement, swept r0→r4 for regressions across seven properties (full tab names, uniform headings, cards only on attention sections, single-line rows, the age column, avatar alignment, hollow-vs-solid dots) — all pass, no horizontal overflow and no sub-36px targets at 390/768/1280. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++++ web/src/index.css | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beffbc8..c319431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ All notable changes to Collie are recorded here. The format follows ### Fixed - Marking a pane seen had made a read-level GET mutate state, so a cross-site `` at a guessed pane id could silently clear your unseen agents. Only a request carrying the app's own header counts now — caught in this release's security review, never shipped (c84c8bd) +- **Light `--accent` was byte-identical to `--background`**, so "this is the current one" showed nothing in light mode — the open pane in the switcher, the current session, every `hover:bg-accent`. Predates this release; found by the UX sweep (a18e8a9) +- Titles truncated away the tab — the only part that identifies a row — leaving several panes rendering the same `moonward_os · t…` (6dc6ded) +- Section headings rendered at two different sizes and cases, because a ` ); } diff --git a/web/src/lib/triage.test.ts b/web/src/lib/triage.test.ts index 9d759ab..15fb7f1 100644 --- a/web/src/lib/triage.test.ts +++ b/web/src/lib/triage.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { flipDir, isUnseen, triage, type TriageKey } from "./triage"; +import { + flipDir, + isUnseen, + triage, + TRIAGE_STATUS, + worstTriage, + type TriageKey, +} from "./triage"; import type { AgentStatus, AgentView } from "./types"; function agent( @@ -186,3 +193,50 @@ describe("flipDir", () => { expect(flipDir("oldest")).toBe("newest"); }); }); + +describe("worstTriage — what a tab or space chip advertises", () => { + it("reports the most urgent thing inside, not the first", () => { + expect(worstTriage([agent("a", "idle", { seen: 5 }), agent("b", "blocked")])).toBe("needs"); + expect(worstTriage([agent("a", "idle", { seen: 5 }), agent("b", "working")])).toBe("working"); + }); + + it("ranks blocked over ready over working over the rest", () => { + const blocked = agent("x", "blocked"); + const ready = agent("y", "done", { active: 9, seen: 1 }); + const working = agent("z", "working"); + const idle = agent("i", "idle", { seen: 5 }); + expect(worstTriage([idle, working, ready, blocked])).toBe("needs"); + expect(worstTriage([idle, working, ready])).toBe("ready"); + expect(worstTriage([idle, working])).toBe("working"); + expect(worstTriage([idle])).toBe("recent"); + }); + + it("is null for a container with no agents — that is NOT the same as idle", () => { + // An empty tab has nothing to report; a resting dot would claim otherwise. + expect(worstTriage([])).toBeNull(); + }); + + it("agrees with triage() about which bucket an agent is in", () => { + const herd = [ + agent("b", "blocked"), + agent("r", "done", { active: 9, seen: 1 }), + agent("w", "working"), + agent("i", "idle", { seen: 5 }), + ]; + for (const a of herd) { + const section = triage([a]).find((s) => s.agents.length > 0)!; + expect(worstTriage([a])).toBe(section.key); + } + }); +}); + +describe("TRIAGE_STATUS", () => { + it("maps every bucket to the status whose colour it should borrow", () => { + expect(TRIAGE_STATUS).toEqual({ + needs: "blocked", + ready: "done", + working: "working", + recent: "idle", + }); + }); +}); diff --git a/web/src/lib/triage.ts b/web/src/lib/triage.ts index 63c3cdd..4823678 100644 --- a/web/src/lib/triage.ts +++ b/web/src/lib/triage.ts @@ -38,6 +38,43 @@ export function isUnseen(a: AgentView): boolean { return a.status === "done" && (a.lastActiveAt ?? 0) > (a.lastSeenAt ?? 0); } +/** Which section an agent belongs to. The single classifier — {@link triage} and + * {@link worstTriage} both route through it, so a list and a chip can't disagree. */ +export function bucketOf(a: AgentView): TriageKey { + if (a.status === "blocked") return "needs"; + if (isUnseen(a)) return "ready"; + if (a.status === "working") return "working"; + return "recent"; +} + +/** Display order, most urgent first. */ +export const TRIAGE_ORDER: readonly TriageKey[] = ["needs", "ready", "working", "recent"]; + +/** + * The status one representative {@link StatusDot} should show for a bucket, so a tab chip, a space + * chip and a list row all draw the same colour for the same meaning. + */ +export const TRIAGE_STATUS: Record = { + needs: "blocked", + ready: "done", + working: "working", + recent: "idle", +}; + +/** + * The most urgent bucket among a set of panes — what a tab or space chip should advertise. Null when + * the set holds no agent at all, which is deliberately NOT the same as "idle": an empty tab has + * nothing to report, and showing it a resting dot would claim otherwise. + */ +export function worstTriage(agents: readonly AgentView[]): TriageKey | null { + let best: number | null = null; + for (const a of agents) { + const rank = TRIAGE_ORDER.indexOf(bucketOf(a)); + if (best === null || rank < best) best = rank; + } + return best === null ? null : TRIAGE_ORDER[best]!; +} + /** Descending comparator over an optional timestamp; absent sorts last but ties, never throws. */ function byDesc(key: (a: AgentView) => number | undefined) { return (x: AgentView, y: AgentView) => (key(y) ?? 0) - (key(x) ?? 0); @@ -68,12 +105,8 @@ export function triage(agents: readonly AgentView[], dir: RecentDir = "newest"): const working: AgentView[] = []; const recent: AgentView[] = []; - for (const a of agents) { - if (a.status === "blocked") needs.push(a); - else if (isUnseen(a)) ready.push(a); - else if (a.status === "working") working.push(a); - else recent.push(a); - } + const into = { needs, ready, working, recent }; + for (const a of agents) into[bucketOf(a)].push(a); needs.sort(byDesc((a) => a.lastActiveAt)); ready.sort(byDesc((a) => a.lastActiveAt)); From 472277fafec0e2b206f51919b623146e86f59406 Mon Sep 17 00:00:00 2001 From: konpyl Date: Tue, 28 Jul 2026 22:45:01 +0400 Subject: [PATCH 15/19] docs: note the tab/space status dots in 0.20.0 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c319431..1de2565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to Collie are recorded here. The format follows - The swipe-up **Switch pane** sheet folds its long tails too — Recent, and the bare **Shells** group that buried the agents underneath it (ed0f0fa) - Spaces are ordered by last used and filterable — 45 of them are now three keystrokes, not a scroll (3850d96) - The bridge keeps two timestamps per pane (`activeAt`, `seenAt`) in `activity.json`, because Herdr reports none (839d0f3) +- **Tab and space chips carry a status dot** — blocked / ready / working / idle, in the herd list's own palette. They only ever showed a dot for blocked before, so every other state read the same as every other (ba49eaf) ### Changed - **Agent rows are titled `project · tab`, not "claude".** The pane's own name moves to the second line; the agent stays in the avatar (3850d96) From e024f489ceffc6cefb778281ddf20c09ddc4c4fa Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Wed, 29 Jul 2026 11:58:45 +0200 Subject: [PATCH 16/19] fix(web): one classifier for rows and chips, and clear out the parts with no caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass over the triage work. Four things, plus folding the release into 0.20.0. **A space row and its chip disagreed about colour.** The PR's stated invariant is that a chip and a row can't mean different things by the same colour, and the space rows didn't hold it: SpaceOverview still ranked by STATUS_RANK (via worstSpaceStatus/blockedCount) while SpaceStrip had moved to the triage classifier. A space holding one `working` agent and one unseen-`done` agent showed "working" on the dashboard and "ready" in the strip, because triage ranks ready above working and STATUS_RANK ranks done last. Both now route through bucketOf, via a single-pass spaceTriageMap that also replaces the per-space filtering the sibling lastSeen map had already been optimised away from — that was spaces x agents, three times per render, on the component the optimisation commit touched. **Dead API.** paneTitle, paneSearchText and spaceLastSeen had no production caller; paneSearchText's doc claimed the command palette used it, but the palette is a slash-command palette and was never touched. Their tests were the only thing keeping them alive, which noUnusedLocals cannot see. The behaviours those tests covered still ship — they live in paneParts — so the tests move rather than go, and paneTitleInTab picks up coverage it never had. **paneTitleInTab reimplemented paneDisplayName** line for line (label -> session name -> agent), two copies of the precedence rule that pane-name.ts exists to keep in one place. Delegates now. **aria-controls pointed at nothing while collapsed.** The body is unmounted, not hidden, so the attribute named a missing id in precisely the state a screen-reader user is in when deciding whether to expand. Emitted only while the target exists. **A status dot ignored a smaller size**, because the wrapper took className while the inner span hard-coded size-2.5 — the chips ask for size-2. It uses size-full now, like the ping span beside it always did. Version folds into 0.20.0 rather than minting 0.21.0: 0.20.0 is not tagged or released, so this ships as one release with the theme work. Changelog entries merged, and its commit citations repointed at commits that exist (they named a pre-rebase branch). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 44 +++++------ herdr-plugin.toml | 2 +- package.json | 2 +- web/package.json | 2 +- web/src/components/section-header.tsx | 9 ++- web/src/components/space-overview.tsx | 19 +++-- web/src/components/status-badge.tsx | 5 +- web/src/lib/pane-name.test.ts | 79 ++++++++++--------- web/src/lib/pane-name.ts | 40 ++-------- web/src/lib/spaces.test.ts | 106 +++++++++----------------- web/src/lib/spaces.ts | 57 ++++++-------- 11 files changed, 153 insertions(+), 212 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5fbf3..0e300c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,40 +6,26 @@ All notable changes to Collie are recorded here. The format follows `version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by `scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy. -## [0.21.0] - 2026-07-29 - -### Added -- **The dashboard is triaged, not listed.** Needs you → Ready · unseen → Working → Recent; the first three are pinned, Recent sorts by when you last used each pane (3850d96) -- **Ready · unseen** — agents that finished while you weren't looking. Opening one clears it, on every device (839d0f3) -- Recent and Spaces fold and remember it; fold both and the page is the triaged herd and nothing else (3850d96) -- The swipe-up **Switch pane** sheet folds its long tails too — Recent, and the bare **Shells** group that buried the agents underneath it (ed0f0fa) -- Spaces are ordered by last used and filterable — 45 of them are now three keystrokes, not a scroll (3850d96) -- The bridge keeps two timestamps per pane (`activeAt`, `seenAt`) in `activity.json`, because Herdr reports none (839d0f3) -- **Tab and space chips carry a status dot** — blocked / ready / working / idle, in the herd list's own palette. They only ever showed a dot for blocked before, so every other state read the same as every other (ba49eaf) - -### Changed -- **Agent rows are titled `project · tab`, not "claude".** The pane's own name moves to the second line; the agent stays in the avatar (3850d96) -- Spaces moved BELOW every agent section — it's a navigator, not a work queue (3850d96) -- Only Collie's own reads count as seeing a pane; a Herdr focus at the desk does not — [ADR 0003](.adr/0003-one-shared-seen.md) (16201fe) -- MINOR, not MAJOR: additive, no config or API break. An older bridge reports no timestamps and simply renders the previous dashboard, minus the one section that would be empty - -### Fixed -- Marking a pane seen had made a read-level GET mutate state, so a cross-site `` at a guessed pane id could silently clear your unseen agents. Only a request carrying the app's own header counts now — caught in this release's security review, never shipped (c84c8bd) -- **Light `--accent` was byte-identical to `--background`**, so "this is the current one" showed nothing in light mode — the open pane in the switcher, the current session, every `hover:bg-accent`. Predates this release; found by the UX sweep (a18e8a9) -- Titles truncated away the tab — the only part that identifies a row — leaving several panes rendering the same `moonward_os · t…` (6dc6ded) -- Section headings rendered at two different sizes and cases, because a `
- {prefs && - ROWS.map((row) => ( + {/* Rendered before `prefs` lands, not after. ROWS is static, so the card's SHAPE is known from + the first frame — only the switch values are pending. Gating the whole list on `prefs` grew + this card by ~180px a moment after paint and pushed the rest of the page down with it. The + switches stay disabled until the real values arrive, so nothing can be toggled from a + placeholder state. */} + {ROWS.map((row) => (
{row.hint}

void toggle(row.key, next)} aria-label={row.label} />
- ))} + ))} ); } diff --git a/web/src/components/space-overview.tsx b/web/src/components/space-overview.tsx index c31a464..388221d 100644 --- a/web/src/components/space-overview.tsx +++ b/web/src/components/space-overview.tsx @@ -120,7 +120,10 @@ export function SpaceOverview({ type="button" onClick={() => onOpen(w.workspaceId)} className={cn( - "w-full rounded-lg text-left transition-colors active:scale-[0.99]", + // Square, like the herd rows: this is a divide-y list, and a rounded fill under + // a straight hairline reads as a fault. The blocked row below has a real border, + // so it keeps its radius. + "w-full text-left transition-colors active:scale-[0.99]", !blocked && "hover:bg-muted/50", )} > diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index b9b1e9a..a5403fd 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -86,16 +86,20 @@ export function SettingsRoute() {

- {state ? ( - - ) : ( - - )} + {/* Fixed slot the size of the Switch (h-6 w-11): the spinner is smaller, so without it + the row — and the whole page under it — resized when state landed. */} +
+ {state ? ( + + ) : ( + + )} +
{state && blocked && ( @@ -110,7 +114,12 @@ export function SettingsRoute() { )} - {state && state.availability !== "server-off" && ( + {/* Mounted while push state is still UNKNOWN, and only removed once we positively learn the + bridge has no VAPID keys. Gating on `state` truthiness instead inserted ~400px into the + middle of the page one frame late, shoving everything below it down. These two are + bridge-wide settings — which transitions notify, and quiet hours — so they are meaningful + whatever this particular device's push status turns out to be. */} + {state?.availability !== "server-off" && ( <> From e208408f28285b2d1755af84af2e9f40a11b39d0 Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Wed, 29 Jul 2026 12:13:24 +0200 Subject: [PATCH 18/19] fix(web): give the mirror a top edge so the pane row doesn't bleed into output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every band in the pane stack — tab strip, pane strip — draws its own TOP border, so the last one in the stack had nothing under it and terminal output began immediately below the pane chips. The chrome and the mirror read as a single surface. Drawn on the mirror wrapper rather than as a border-b on PaneStrip because PaneStrip only renders when a tab holds more than one pane; on the common single-pane tab the gap would have come back. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +++ web/src/components/agent-chat.tsx | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e300c9..21b6f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ All notable changes to Collie are recorded here. The format follows - A space row and its chip could disagree about what a colour meant — the row still ranked by `STATUS_RANK` while the chip used the triage classifier, so a space holding one working agent and one unseen-done agent showed "working" on the dashboard and "ready" in the strip. Both route through `bucketOf` now, in one pass rather than spaces x agents per render - `aria-controls` on a collapsed section pointed at an element that isn't rendered — exactly when a screen-reader user is deciding whether to expand it - A status dot passed a smaller size only resized its wrapper, so chip dots rendered at the wrong size +- The Settings page rearranged itself a frame after opening — Notify-when and Snooze mounted only once push state resolved, inserting ~400px into the middle of the page, and Notify-when then grew another ~180px waiting on its own prefs. Both render from the first frame now, switches disabled until their values land +- The pane row ran straight into terminal output with no edge between them, so the chrome and the mirror read as one surface +- Herd and space rows had a border radius with no border to own it, so a rounded hover fill sat under a straight `divide-y` hairline. Rows without a border are square; the ones with a real border keep their radius ## [0.19.0] - 2026-07-29 diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx index 94fe22c..f233d75 100644 --- a/web/src/components/agent-chat.tsx +++ b/web/src/components/agent-chat.tsx @@ -643,7 +643,12 @@ export function AgentChat({ (unless you're selecting text to copy, which the tap must not collapse). */} {/* min-w-0 only — do NOT set overflow-x-hidden here: that forces overflow-y to `auto` (CSS quirk) and makes this wrapper a second vertical scroller competing with ChatMessageList. */} -
+ {/* border-t like the strips above it: every band in this stack draws its own TOP edge, so + whichever one ends up last still has a boundary under it. Without this the pane row ran + straight into terminal output — the chrome and the mirror read as one surface. Drawing it + here rather than as a border-b on PaneStrip covers the case where that strip is absent + (a tab holding a single pane), which is the common one. */} +
Date: Wed, 29 Jul 2026 12:32:53 +0200 Subject: [PATCH 19/19] fix(bridge): only a request that will be served marks a pane seen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stamp sat before the method dispatch, so a request that falls through to 405 — a GET at /reply, a POST at /history — still marked the pane seen. No security consequence (it is behind the access guard, and anyone past that can read the pane, which marks it seen legitimately), but a malformed request should not clear an alert. PANE_ROUTE already constrains `action` to the known set, so a method mismatch is the ONLY way to reach the stamp unrouted; the predicate is exact rather than a second copy of the dispatch table. Hoists `isRead` while there — the same read/write ternary was written out three times. Also drops a docstring reference to paneTitle, removed earlier in this branch. Co-Authored-By: Claude Opus 5 (1M context) --- bridge/server.ts | 12 +++++++++--- web/src/lib/pane-name.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bridge/server.ts b/bridge/server.ts index 520eaee..29a5e6c 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -252,7 +252,8 @@ export function startServer(opts: { // Reading a pane is allowed for any access-gated client; every action (reply/keys/upload/ // close) types into or restructures a terminal, so it additionally needs an authorised device. // `history` is a READ despite being an action segment — it only ever reads a log off disk. - const denied = guard(req, cfg, action && action !== "history" ? "write" : "read"); + const isRead = !action || action === "history"; + const denied = guard(req, cfg, isRead ? "read" : "write"); if (denied) return denied; const rt = registry.get(sessionName); if (!rt) return unknownSession(); @@ -262,10 +263,15 @@ export function startServer(opts: { // passes through. It cannot false-positive from background polling — the dashboard loader // only ever fetches /api/snapshot; paneLoader is the sole reader of pane text — nor from a // cross-site request forged at a guessed pane id (see marksPaneSeen). - if (marksPaneSeen(req, action)) activity.noteSeen(session, paneId); + // + // Gated on the request actually being ROUTED below. PANE_ROUTE constrains `action` to the + // known set, so the only way to reach here unrouted is a method mismatch (a GET at /reply, a + // POST at /history) — which 405s. Without this a malformed request still marked the pane seen. + const routed = isRead ? req.method === "GET" : req.method === "POST"; + if (routed && marksPaneSeen(req, action)) activity.noteSeen(session, paneId); // Every action is a write; attribute it to the authorised device for the audit trail. // `history` is a read, so it gets no device attribution (nothing is written to attribute). - const device = action && action !== "history" ? deviceAuth(req, cfg).device : null; + const device = isRead ? null : deviceAuth(req, cfg).device; if (!action && req.method === "GET") return readPane(herdr, cfg, paneId, url, req); if (action === "history" && req.method === "GET") diff --git a/web/src/lib/pane-name.ts b/web/src/lib/pane-name.ts index a05ff8d..aa57a3c 100644 --- a/web/src/lib/pane-name.ts +++ b/web/src/lib/pane-name.ts @@ -50,7 +50,7 @@ function informativeCwd(cwd: string, project: string): string | null { return shortCwd(cwd); } -/** {@link PaneParts} for a pane — the render-time form of {@link paneTitle}. */ +/** The parts of a herd-list row title, unjoined — see {@link PaneParts}. */ export function paneParts(pane: AgentView): PaneParts { const project = pane.workspaceLabel || pane.workspaceId; const own = pane.paneLabel || pane.sessionName;