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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index a5b52cc..21b6f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,21 @@ All notable changes to Collie are recorded here. The format follows ### Added - **Light and system themes.** Collie follows your phone's appearance by default; pin Light or Dark from **Settings → Appearance**. Per device, and documented under [Dark mode / light mode](./README.md#dark-mode--light-mode) (59bcfe1, df47112) - ANSI slots 0–15 are now CSS variables (`--ansi-*`), so indexed terminal colour is defined in one place and reaches the mirror through both `31m` and `38;5;1` spellings (59bcfe1) +- **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 (da4f44c) +- **Ready · unseen** — agents that finished while you weren't looking. Opening one clears it, on every device (2f4d691) +- Recent and Spaces fold and remember it; fold both and the page is the triaged herd and nothing else (da4f44c) +- The swipe-up **Switch pane** sheet folds its long tails too — Recent, and the bare **Shells** group that buried the agents underneath it (4cca8db) +- Spaces are ordered by last used and filterable — 45 of them are now three keystrokes, not a scroll (da4f44c) +- The bridge keeps two timestamps per pane (`activeAt`, `seenAt`) in `activity.json`, because Herdr reports none (2f4d691) +- **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 (22d4a5f) ### Changed - The pane mirror renders in dark space under every theme and light mode inverts it, because agents emit truecolor almost exclusively and no palette can re-theme an absolute colour — [ADR 0002](.adr/0002-invert-the-light-terminal-mirror.md) (78425bd) - In light, the page is a step off white with cards staying white, so the dashboard's hierarchy no longer rests on a single hairline — and the mirror's edge stops showing a seam (59bcfe1) -- MINOR, not MAJOR: pre-1.0, purely additive, no config or API break. Defaulting to your phone's appearance is the feature working as designed, and Settings pins it either way +- **Agent rows are titled `project · tab`, not "claude".** The pane's own name moves to the second line; the agent stays in the avatar (da4f44c) +- Spaces moved BELOW every agent section — it's a navigator, not a work queue (da4f44c) +- 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) (6786ca1) +- MINOR, not MAJOR: pre-1.0, purely additive, no config or API break. Defaulting to your phone's appearance is the feature working as designed and Settings pins it either way; an older bridge reports no activity timestamps and simply renders the previous dashboard, minus the one section that would be empty ### Fixed - **The space and tab chip rows overlapped each other on the space screen** — both strips were missing `shrink-0` inside the route's flex scroller, so they collapsed to 16px around 32px chips and the tab row painted over the space row. Pre-dates this release (636b7af) @@ -26,6 +36,17 @@ All notable changes to Collie are recorded here. The format follows - Header controls had 20px touch targets; the Settings gear and the Settings back button are both 44px now, with no change to how they look (59bcfe1) - The boot splash stepped from white to the page colour when React took over, and its caption measured 3.45:1 — it used `#ffffff`/`#8a8a8a` under a comment claiming they matched `--background`/`--muted-foreground`, which rasterize to `#f5f5f5`/`#5d5d5d`. Same fix for the light `theme-color` meta, so Android's URL bar matches the page (7f0189d) - Inverse-video segments in the mirror emitted theme tokens while the muted glyphs beside them used literals; the mirror keeps one spelling now (identical pixels — the literals are those tokens' dark halves) (7f0189d) +- 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 (f9000cb) +- **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 (dab7e05) +- Titles truncated away the tab — the only part that identifies a row — leaving several panes rendering the same `moonward_os · t…` (8a8a4c9) +- Section headings rendered at two different sizes and cases, because a ` ); } diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx index f79f4ef..f233d75 100644 --- a/web/src/components/agent-chat.tsx +++ b/web/src/components/agent-chat.tsx @@ -4,6 +4,7 @@ import { useNavigate, useRevalidator } from "react-router"; import { ArrowUpToLine, Loader2, ScrollText, TerminalSquare } from "lucide-react"; import { useSwipeUp } from "@/hooks/use-swipe"; import { useSpaceActions } from "@/hooks/use-spaces"; +import { useDashPrefs, openForCount } from "@/hooks/use-dash-prefs"; import { useDisplayPrefs } from "@/hooks/use-display-prefs"; import { useStableTerminalDraft } from "@/hooks/use-terminal-draft"; import { isConnecting } from "@/lib/connection"; @@ -131,6 +132,9 @@ export function AgentChat({ // threshold + a taller hit area (below) make the gesture easy to land with a thumb; tapping is the // reliable fallback. "Up" naturally reveals a bottom sheet without fighting the mirror's scroll. const swipe = useSwipeUp(() => setDrawer("switcher"), 24); + // Fold state for the "Switch pane" sheet's two long tails, shared with the dashboard so one + // "hide the long tail" preference means the same thing in both places. + const dash = useDashPrefs(); // Mirror freeze: at the bottom we follow live output; the moment you scroll up to read backscroll // we hold the text steady (no reflow / no re-pin) until you jump back to latest — so a long @@ -639,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. */} +
diff --git a/web/src/components/agent-list.test.tsx b/web/src/components/agent-list.test.tsx new file mode 100644 index 0000000..7fb2832 --- /dev/null +++ b/web/src/components/agent-list.test.tsx @@ -0,0 +1,317 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { AgentList } from "./agent-list"; +import type { AgentStatus, AgentView } from "@/lib/types"; + +function agent( + paneId: string, + status: AgentStatus, + over: Partial = {}, +): AgentView { + return { + paneId, + workspaceId: "w0", + workspaceLabel: paneId, + workspaceNumber: 1, + tabId: "w0:t1", + agent: "claude", + status, + cwd: "/home/k/proj", + focused: false, + ...over, + }; +} + +/** Section headings, in the order they render. Queried by role, because "needs you" is also the + * blocked STATUS_LABEL on every row's badge — matching on text alone catches both. */ +const headings = () => + screen.getAllByRole("heading").map((el) => el.textContent?.toLowerCase() ?? ""); + +describe("AgentList — sections", () => { + const herd = [ + agent("blocked", "blocked", { lastActiveAt: 500, lastSeenAt: 1 }), + agent("unseen", "done", { lastActiveAt: 400, lastSeenAt: 1 }), + agent("busy", "working", { lastActiveAt: 300, lastSeenAt: 1 }), + agent("old", "idle", { lastActiveAt: 1, lastSeenAt: 200 }), + ]; + + it("renders the four sections in triage order, agents first", () => { + render(); + expect(headings()).toEqual([ + expect.stringContaining("needs you"), + expect.stringContaining("ready · unseen"), + expect.stringContaining("working"), + expect.stringContaining("recent"), + ]); + }); + + it("titles rows by project · tab, never by the agent name", () => { + render( + , + ); + // 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(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 () => { + const user = userEvent.setup(); + const onOpen = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: /p1/ })); + expect(onOpen).toHaveBeenCalledExactlyOnceWith("p1"); + }); + + it("shows the herd-empty placeholder, and suppresses it when asked", () => { + const { rerender } = render(); + expect(screen.getByText(/no agents running/i)).toBeInTheDocument(); + rerender(); + expect(screen.queryByText(/no agents running/i)).not.toBeInTheDocument(); + }); + + it("says it's waiting when the bridge is down, rather than 'no agents'", () => { + render(); + expect(screen.getByText(/waiting for herdr/i)).toBeInTheDocument(); + }); +}); + +describe("AgentList — the attention sections are pinned", () => { + it("gives them no fold control at all", () => { + render( + , + ); + // Only Recent may fold — and it isn't rendered here, so nothing is expandable. + expect(screen.queryByRole("button", { expanded: true })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { expanded: false })).not.toBeInTheDocument(); + }); +}); + +describe("AgentList — Recent folds", () => { + const herd = [ + agent("b", "blocked"), + agent("r1", "idle", { lastSeenAt: 900 }), + agent("r2", "idle", { lastSeenAt: 100 }), + ]; + + it("hides its rows when folded but keeps Needs you visible", () => { + render( + , + ); + expect(screen.queryByText(/^r1$/)).not.toBeInTheDocument(); + expect(headings()).toEqual([ + expect.stringContaining("needs you"), + expect.stringContaining("recent"), + ]); + expect(screen.getByText("b")).toBeInTheDocument(); + }); + + it("reports the fold to its owner", async () => { + const user = userEvent.setup(); + const onRecentOpenChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /recent/i })); + expect(onRecentOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("withdraws the sort control while folded — sorting invisible rows does nothing", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("button", { name: /switch to oldest first/i })).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByRole("button", { name: /switch to oldest first/i })).not.toBeInTheDocument(); + }); +}); + +describe("AgentList — the direction toggle", () => { + const herd = [ + agent("fresh", "idle", { lastSeenAt: 900 }), + agent("stale", "idle", { lastSeenAt: 100 }), + ]; + + it("orders Recent newest-first and flips on tap", async () => { + const user = userEvent.setup(); + const onRecentDirChange = vi.fn(); + render( + , + ); + const rows = screen.getAllByRole("button", { name: /fresh|stale/ }); + expect(rows[0]!.textContent).toContain("fresh"); + + await user.click(screen.getByRole("button", { name: /switch to oldest first/i })); + expect(onRecentDirChange).toHaveBeenCalledExactlyOnceWith("oldest"); + }); + + it("renders oldest-first when told to", () => { + render( + , + ); + const rows = screen.getAllByRole("button", { name: /fresh|stale/ }); + expect(rows[0]!.textContent).toContain("stale"); + }); + + it("never reorders the pinned sections", () => { + const attention = [ + agent("new", "blocked", { lastActiveAt: 900 }), + agent("old", "blocked", { lastActiveAt: 100 }), + ]; + for (const dir of ["newest", "oldest"] as const) { + const { unmount } = render( + , + ); + const rows = screen.getAllByRole("button", { name: /new|old/ }); + expect(rows[0]!.textContent).toContain("new"); + unmount(); + } + }); + + it("shows no toggle when the parent doesn't wire one", () => { + render(); + expect(screen.queryByRole("button", { name: /switch to/i })).not.toBeInTheDocument(); + }); +}); + +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")).toBeInTheDocument(); + }); + + it("dates a Ready · unseen row by when it FINISHED, not when you last looked", () => { + const finished = Date.now() - 2 * 60 * 1000; + render( + , + ); + const section = screen.getByText(/ready · unseen/i).closest("section")!; + expect(within(section).getByText("2m")).toBeInTheDocument(); + }); + + it("puts no age on a blocked row — it's noise beside 'needs you'", () => { + render( + , + ); + expect(screen.queryByText(/^\d+[mhd]$|^now$/)).not.toBeInTheDocument(); + }); +}); + +describe("AgentList — an older bridge with no timestamps", () => { + it("still renders a coherent dashboard, with Ready·unseen simply absent", () => { + render( + , + ); + expect(headings()).toEqual([ + expect.stringContaining("needs you"), + expect.stringContaining("working"), + expect.stringContaining("recent"), + ]); + expect(screen.queryByText(/ready · unseen/i)).not.toBeInTheDocument(); + 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-list.tsx b/web/src/components/agent-list.tsx index 5b3d793..0a9908b 100644 --- a/web/src/components/agent-list.tsx +++ b/web/src/components/agent-list.tsx @@ -1,7 +1,8 @@ -import { Inbox } from "lucide-react"; +import { ArrowDown, ArrowUp, Check, Inbox } from "lucide-react"; import { cn } from "@/lib/utils"; -import { AGENT_GROUPS, type AgentGroup } from "@/lib/agent-groups"; +import { SectionHeader } from "@/components/section-header"; +import { flipDir, sectionHeaderProps, triage, type RecentDir, type TriageKey } from "@/lib/triage"; import type { AgentView, BridgeStatus } from "@/lib/types"; import { AgentCard } from "./agent-card"; @@ -9,19 +10,40 @@ interface AgentListProps { agents: AgentView[]; bridge?: BridgeStatus | undefined; onOpen: (paneId: string) => void; - /** Which triage groups to render (default: all). Lets the dashboard hoist "Needs you" above the - * spaces overview while the rest render below it. */ - groups?: readonly AgentGroup[]; - /** Show the "no agents" placeholder when the herd is empty (default true). Turn off for a partial - * slice (e.g. the hoisted "Needs you" list) so the placeholder only appears once, on the main list. */ + /** Which way Recent runs, and how to flip it. Omit to render Recent newest-first with no toggle. */ + recentDir?: RecentDir; + onRecentDirChange?: (dir: RecentDir) => void; + /** Whether Recent is expanded, and how to fold it. Omit to leave it always open (the sidebar). */ + recentOpen?: boolean; + onRecentOpenChange?: (open: boolean) => void; + /** Show the "no agents" placeholder when the herd is empty (default true). */ emptyState?: boolean; } +/** Which timestamp a section's rows date themselves by. Attention rows show none — a blocked + * 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", +}; + +/** 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. export function AgentList({ agents, bridge, onOpen, - groups = AGENT_GROUPS, + recentDir = "newest", + onRecentDirChange, + recentOpen = true, + onRecentOpenChange, emptyState = true, }: AgentListProps) { if (agents.length === 0) { @@ -36,32 +58,98 @@ export function AgentList({ ); } - // Only render groups that actually have members — and nothing at all if this slice is empty (e.g. - // the hoisted "Needs you" list when no agent is blocked), so it adds no stray padding. - const sections = groups - .map((g) => ({ g, members: agents.filter((a) => g.match(a.status)) })) - .filter((s) => s.members.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 (
- {sections.map(({ g, members }) => ( -
-

+ + 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; + const open = foldable ? recentOpen : true; + const bodyId = `agent-section-${s.key}`; + const age = AGE_BY_SECTION[s.key]; + + return ( +
+

-
- {members.map((a) => ( - onOpen(a.paneId)} /> - ))} -
-
- ))} + + ); + })}
); } + +// One tap flips the Recent order. Deliberately not a menu — the design offers a direction, not a +// choice of sort keys. min-h-9 keeps it on the 36px touch floor. +function SortToggle({ dir, onChange }: { dir: RecentDir; onChange: (dir: RecentDir) => void }) { + const newest = dir === "newest"; + const Icon = newest ? ArrowDown : ArrowUp; + return ( + + ); +} diff --git a/web/src/components/agent-sidebar.test.tsx b/web/src/components/agent-sidebar.test.tsx index 244330d..5a29e6a 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"); }); @@ -117,3 +120,121 @@ describe("ThreadSidebar", () => { } }); }); + +// The "Switch pane" sheet sees the WHOLE herd, so it has the dashboard's original problem: the two +// long tails (Recent, and the bare shells) bury the handful of agents you opened it to reach. +describe("ThreadSidebar — folding the long tails", () => { + const manyShells: AgentView[] = Array.from({ length: 12 }, (_, i) => ({ + paneId: `w3:s${i}`, + workspaceId: "w3", + workspaceLabel: `scratch${i}`, + workspaceNumber: 3, + tabId: "w3:t2", + agent: "shell", + status: "unknown", + cwd: "/home/you/sandbox", + focused: false, + kind: "shell", + })); + + it("folds Shells away, keeping the count and the agents visible", () => { + render( + , + ); + expect(screen.queryByText("scratch0")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /shells/i })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.getByText("(12)")).toBeInTheDocument(); + // The agents you came for are still there. + expect(screen.getByText("webapp")).toBeInTheDocument(); + }); + + it("shows the shells again when expanded", () => { + render( + , + ); + expect(screen.getByText("scratch0")).toBeInTheDocument(); + }); + + it("reports the Shells fold to its owner rather than keeping the state itself", async () => { + const user = userEvent.setup(); + const onShellsOpenChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /shells/i })); + expect(onShellsOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("folds Recent too, the same way the dashboard does", async () => { + const user = userEvent.setup(); + const onRecentOpenChange = vi.fn(); + render( + , + ); + expect(screen.getByText("sandbox")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /recent/i })); + expect(onRecentOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("never offers a fold on the attention sections", () => { + render( + , + ); + // fixtureAgents are blocked + working; only Shells should be expandable here. + const expandable = screen.getAllByRole("button", { expanded: true }).map((b) => b.textContent); + expect(expandable).toHaveLength(1); + expect(expandable[0]).toMatch(/shells/i); + }); + + it("stays un-foldable when the parent wires nothing, as before", () => { + render( + , + ); + expect(screen.getByText("scratch0")).toBeInTheDocument(); + expect(screen.queryByRole("button", { expanded: true })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/agent-sidebar.tsx b/web/src/components/agent-sidebar.tsx index 79ebfcc..cb78c55 100644 --- a/web/src/components/agent-sidebar.tsx +++ b/web/src/components/agent-sidebar.tsx @@ -2,9 +2,9 @@ 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 { SectionHeader } from "@/components/section-header"; +import { paneParts } from "@/lib/pane-name"; +import { isAttention, sectionHeaderProps, triage } from "@/lib/triage"; import type { AgentView } from "@/lib/types"; interface ThreadSidebarProps { @@ -13,20 +13,34 @@ interface ThreadSidebarProps { shellPanes?: AgentView[]; currentPaneId: string; onSelect: (paneId: string) => void; + /** Whether the Recent section is expanded, and how to fold it. Omit to leave it always open. */ + recentOpen?: boolean; + onRecentOpenChange?: (open: boolean) => void; + /** Whether the Shells section is expanded, and how to fold it. Omit to leave it always open. */ + shellsOpen?: boolean; + onShellsOpenChange?: (open: boolean) => void; /** Override the list container padding (e.g. flush inside a bottom sheet). */ className?: string; } -// The pane switcher reused by both the side drawer's PANES section and the swipe-up bottom sheet: -// every agent pane grouped/sorted like the home triage, then any bare shell panes under a "Shells" -// group, scrollable, with the open one highlighted. Mirrors the Herdr TUI's pane list. Switching is +// The pane switcher behind the swipe-up "Switch pane" sheet: every agent pane grouped and sorted +// exactly like the dashboard (lib/triage.ts — the two must not disagree about what needs you), then +// any bare shell panes under a trailing "Shells" group, with the open one highlighted. Switching is // the ONLY action here — closing a pane lives in the pane pill's long-press sheet (with its own // confirm), so a fat-thumbed switch can never destroy a pane. +// +// This sheet sees the WHOLE herd, so it has the same problem the dashboard had: the two long tails +// (Recent, and 30-odd bare shells) bury the handful of agents you actually came to switch to. Both +// fold, and both remember it, using the dashboard's own header primitive. export function ThreadSidebar({ agents, shellPanes = [], currentPaneId, onSelect, + recentOpen = true, + onRecentOpenChange, + shellsOpen = true, + onShellsOpenChange, className, }: ThreadSidebarProps) { if (agents.length === 0 && shellPanes.length === 0) { @@ -37,11 +51,19 @@ export function ThreadSidebar({ return (
- {AGENT_GROUPS.map((g) => { - const members = agents.filter((a) => g.match(a.status)); + {triage(agents).map((g) => { + const members = g.agents; if (members.length === 0) return null; + // Recent is the only foldable triage section, and only where the parent wired the state. + const foldable = !!g.collapsible && onRecentOpenChange !== undefined; + const open = foldable ? recentOpen : true; return ( -
+
{members.map((a) => ( 0 && ( -
+
{shellPanes.map((p) => ( void; children: React.ReactNode; }) { + const foldable = open !== undefined && onToggle !== undefined; return (
-

-

- {children} + + {(!foldable || open) &&
{children}
}
); } @@ -111,9 +149,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 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 ( ); diff --git a/web/src/components/connection-info.tsx b/web/src/components/connection-info.tsx index 68e4b03..0e1f082 100644 --- a/web/src/components/connection-info.tsx +++ b/web/src/components/connection-info.tsx @@ -42,7 +42,9 @@ export function ConnectionInfo({ {d.text} - {build && {build}} + {/* Always present, even before the value lands: appearing late grew this card and moved + everything under it. An em dash is a truthful "not known yet" and the same height. */} + {build ?? "—"} ); diff --git a/web/src/components/notify-prefs-control.tsx b/web/src/components/notify-prefs-control.tsx index de93c30..7e194c8 100644 --- a/web/src/components/notify-prefs-control.tsx +++ b/web/src/components/notify-prefs-control.tsx @@ -32,8 +32,12 @@ export function NotifyPrefsControl() { {!prefs && }
- {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/section-header.tsx b/web/src/components/section-header.tsx new file mode 100644 index 0000000..cc5943d --- /dev/null +++ b/web/src/components/section-header.tsx @@ -0,0 +1,111 @@ +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`, but ONLY while that element is + * actually rendered. Callers unmount the body when collapsed (rather than hiding it), so emitting + * the attribute in that state would point assistive tech at an id that does not exist — exactly + * when a screen-reader user is deciding whether to expand. + */ + 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; + /** Heading level. 2 on a page; 3 inside the pane-switcher sheet, whose own title is the h2. */ + level?: 2 | 3; + 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, + level = 2, + className, +}: SectionHeaderProps) { + const foldable = open !== undefined && onToggle !== undefined; + const Heading = level === 3 ? "h3" : "h2"; + + 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"); + // Set explicitly on BOTH branches, never inherited from the heading: a + ) : ( + {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..388221d 100644 --- a/web/src/components/space-overview.tsx +++ b/web/src/components/space-overview.tsx @@ -1,93 +1,173 @@ -import { ChevronRight, FolderPlus, Layers, LayoutGrid } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; +import { useState } from "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 { blockedCount, worstSpaceStatus } from "@/lib/spaces"; +import { filterSpaces, sortSpacesByRecency, spaceLastSeenMap, spaceTriageMap } from "@/lib/spaces"; +import { TRIAGE_STATUS } from "@/lib/triage"; +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]; + // One pass over the panes, then map lookups — this component re-renders on every poll. + const lastSeen = spaceLastSeenMap(panes); + // One pass for "what's the most urgent thing in each space", shared with the chips so a row and a + // chip can never mean different things by the same colour (lib/spaces.ts). + const worstBySpace = spaceTriageMap(agents); + const blockedSpaces = [...worstBySpace.values()].filter((b) => b === "needs").length; + const visible = filterSpaces(sortSpacesByRecency(workspaces, panes, lastSeen), 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. */} + {/* 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 && ( + + )} + + {workspaces.length === 0 ? ( +

No spaces yet.

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

+ No space matches “{query}”. +

+ ) : ( + visible.map((w) => { + const bucket = worstBySpace.get(w.workspaceId); + const status = bucket ? TRIAGE_STATUS[bucket] : null; + const blocked = bucket === "needs"; + const seen = lastSeen.get(w.workspaceId) ?? 0; + return ( + - ); - })} + {/* Flat rows, not cards: these are single-line entries, so a card is 100% chrome + around one string, forty-five times. Card treatment is reserved for the agent + sections that mean "a human is required here". A blocked space still gets the + tint — that's the one cue worth the weight. */} +
+ {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-strip.tsx b/web/src/components/space-strip.tsx index 006e4e8..1ce612e 100644 --- a/web/src/components/space-strip.tsx +++ b/web/src/components/space-strip.tsx @@ -2,7 +2,7 @@ import { ChevronLeft, Plus } from "lucide-react"; import { Chip } from "@/components/ui/chip"; import { SectionLabel } from "@/components/ui/section-label"; -import { blockedCount } from "@/lib/spaces"; +import { worstTriage } from "@/lib/triage"; import type { AgentView, WorkspaceView } from "@/lib/types"; interface SpaceStripProps { @@ -55,7 +55,8 @@ export function SpaceStrip({ label={w.label} active={selected === w.workspaceId} ring={w.focused} - alert={blockedCount(w.workspaceId, agents) > 0} + // Same dot language as the tab strip directly below it, and as the herd list. + status={worstTriage(agents.filter((a) => a.workspaceId === w.workspaceId))} onClick={() => onSelect(w.workspaceId)} /> ))} 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/components/status-badge.tsx b/web/src/components/status-badge.tsx index 2554b7c..ed863ca 100644 --- a/web/src/components/status-badge.tsx +++ b/web/src/components/status-badge.tsx @@ -18,7 +18,39 @@ const CHIP: Record = { unknown: "border-status-unknown/30 bg-status-unknown/10 text-status-unknown", }; -export function StatusDot({ status, className }: { status: AgentStatus; className?: string }) { +/** + * 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, + 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 ( {status === "working" && ( @@ -29,7 +61,15 @@ export function StatusDot({ status, className }: { status: AgentStatus; classNam )} /> )} - + {/* size-full, not a second size-2.5: the wrapper owns the size so `className` can change it + (the chips ask for size-2), and a hard-coded inner would overflow or get squashed by the + flex parent instead. The ping span above already works this way. */} + ); } diff --git a/web/src/components/tab-strip.test.tsx b/web/src/components/tab-strip.test.tsx index 2c028bc..a429591 100644 --- a/web/src/components/tab-strip.test.tsx +++ b/web/src/components/tab-strip.test.tsx @@ -4,7 +4,7 @@ import { http, HttpResponse } from "msw"; import { server } from "@/test/setup"; import { TabStrip } from "./tab-strip"; -import type { TabView } from "@/lib/types"; +import type { AgentStatus, AgentView, TabView } from "@/lib/types"; const tabs: TabView[] = [ { tabId: "w1:t1", workspaceId: "w1", number: 1, label: "1", focused: true, paneCount: 2 }, @@ -218,3 +218,65 @@ describe("TabStrip — long-press actions", () => { expect(document.querySelector("img")).toBeNull(); }); }); + +describe("TabStrip — status on the chips", () => { + const tabs: TabView[] = [ + { tabId: "w1:t1", workspaceId: "w1", number: 1, label: "code", focused: false, paneCount: 1 }, + { tabId: "w1:t2", workspaceId: "w1", number: 2, label: "empty", focused: false, paneCount: 0 }, + ]; + const pane = (tabId: string, status: AgentStatus, extra: Partial = {}): AgentView => ({ + paneId: `${tabId}:p1`, + workspaceId: "w1", + workspaceLabel: "ws", + workspaceNumber: 1, + tabId, + agent: "claude", + status, + cwd: "/home/you/ws", + focused: false, + ...extra, + }); + + const strip = (agents: AgentView[]) => + render( + , + ); + + it("says what's going on in a tab, in words as well as colour", () => { + strip([pane("w1:t1", "blocked")]); + expect(screen.getByRole("button", { name: /code/ })).toHaveTextContent("needs you"); + }); + + it("reports the most urgent pane when a tab holds several", () => { + strip([pane("w1:t1", "idle"), { ...pane("w1:t1", "blocked"), paneId: "w1:t1:p2" }]); + expect(screen.getByRole("button", { name: /code/ })).toHaveTextContent("needs you"); + }); + + it("distinguishes a finished-but-unseen tab from a working one", () => { + const { unmount } = strip([pane("w1:t1", "done", { lastActiveAt: 9, lastSeenAt: 1 })]); + expect(screen.getByRole("button", { name: /code/ })).toHaveTextContent("done"); + unmount(); + strip([pane("w1:t1", "working")]); + expect(screen.getByRole("button", { name: /code/ })).toHaveTextContent("working"); + }); + + it("says idle rather than staying silent about a quiet tab", () => { + strip([pane("w1:t1", "idle")]); + expect(screen.getByRole("button", { name: /code/ })).toHaveTextContent("idle"); + }); + + it("reports nothing for a tab with no agents — empty is not idle", () => { + strip([pane("w1:t1", "idle")]); + const empty = screen.getByRole("button", { name: /empty/ }); + for (const word of ["idle", "working", "done", "needs you"]) { + expect(empty).not.toHaveTextContent(word); + } + }); +}); diff --git a/web/src/components/tab-strip.tsx b/web/src/components/tab-strip.tsx index 7e6d06a..6404126 100644 --- a/web/src/components/tab-strip.tsx +++ b/web/src/components/tab-strip.tsx @@ -4,6 +4,7 @@ import { Plus } from "lucide-react"; import { Chip } from "@/components/ui/chip"; import { SectionLabel } from "@/components/ui/section-label"; import { TabActionsSheet } from "@/components/tab-actions-sheet"; +import { worstTriage } from "@/lib/triage"; import type { AgentView, TabView } from "@/lib/types"; interface TabStripProps { @@ -28,8 +29,9 @@ interface TabStripProps { // The selected space's tabs as a horizontal strip — the second header row under SpaceStrip, mirroring // it one level down. "All" shows every tab's panes; tapping a tab filters the space to it; the -// trailing + creates a new tab (and opens its fresh shell). The desktop-focused tab gets a ring; a -// tab holding a blocked agent gets an alert dot. A long-press on a tab chip opens its actions sheet +// trailing + creates a new tab (and opens its fresh shell). The desktop-focused tab gets a ring; +// each tab carries a status dot for the most urgent thing inside it. A long-press on a chip opens +// its actions sheet // (rename / close) when the parent wires both onRenamed and onClosed (the "All" chip and the + never // take long-press). export function TabStrip({ @@ -65,7 +67,9 @@ export function TabStrip({ label={t.label} active={selected === t.tabId} ring={t.focused} - alert={agents.some((a) => a.tabId === t.tabId && a.status === "blocked")} + // What's actually going on in there — blocked / ready / working / idle — instead of a + // dot that only ever appeared for blocked and left every other state unreadable. + status={worstTriage(agents.filter((a) => a.tabId === t.tabId))} onClick={() => onSelect(t.tabId)} // Long-press (and a tap on the already-active tab) opens the actions sheet — only when the // parent wired the actions; otherwise the chips stay plain tap-to-switch. diff --git a/web/src/components/ui/chip.tsx b/web/src/components/ui/chip.tsx index cf53e9d..e633add 100644 --- a/web/src/components/ui/chip.tsx +++ b/web/src/components/ui/chip.tsx @@ -1,13 +1,21 @@ import { cn } from "@/lib/utils"; import { useLongPress } from "@/hooks/use-long-press"; +import { StatusDot } from "@/components/status-badge"; +import { TRIAGE_STATUS, type TriageKey } from "@/lib/triage"; +import { STATUS_LABEL } from "@/lib/types"; interface ChipProps { label: string; active: boolean; /** Subtle ring marking the item focused in the desktop TUI. */ ring?: boolean; - /** Alert dot for a blocked agent within this space/tab. */ - alert?: boolean; + /** + * The most urgent thing happening inside this space/tab ({@link worstTriage}) — drawn as a leading + * dot in the same palette the herd list uses, so a chip and a row can't mean different things by + * the same colour. Omit (or pass null) when the container holds no agent at all: that's not the + * same as idle, and a resting dot would claim otherwise. + */ + status?: TriageKey | null; onClick: () => void; /** * Long-press (or right-click / Android contextmenu) opens actions for this chip — e.g. the tab @@ -23,9 +31,13 @@ interface ChipProps { } // Pill button shared by the space and tab strips: active fill, an optional desktop-focus ring, and -// an optional alert dot for a contained blocked agent. Tab chips additionally wire a long-press to +// a leading status dot saying what's going on inside. Tab chips additionally wire a long-press to // open their rename sheet (space chips leave it unset — the handlers stay inert). -export function Chip({ label, active, ring, alert, onClick, onLongPress, onTapActive }: ChipProps) { +// +// The dot leads the label rather than riding the corner as a badge: a corner badge needs a ring in +// the chip's own fill, and the chip has two fills (active/inactive). Inline, it just works, and it +// matches how the space rows and section headings already read. +export function Chip({ label, active, ring, status, onClick, onLongPress, onTapActive }: ChipProps) { const longPress = useLongPress(onLongPress); // A long-press already suppresses the ensuing click (via longPress.onClickCapture), so this only @@ -48,17 +60,26 @@ export function Chip({ label, active, ring, alert, onClick, onLongPress, onTapAc className={cn( // select-none + -webkit-touch-callout:none stop iOS Safari's selection loupe / touch callout, // whose native long-press gesture otherwise fires pointercancel and kills the hold timer. - "relative shrink-0 select-none [-webkit-touch-callout:none] whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium transition-colors active:scale-95", + "relative flex shrink-0 select-none items-center gap-1.5 [-webkit-touch-callout:none] whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium transition-colors active:scale-95", active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-muted/70", ring && !active && "ring-1 ring-inset ring-primary/40", )} > - {label} - {alert && ( - + {status && ( + <> + {/* A hollow resting dot is filled with the chip's own fill, which differs when active. */} + + {/* The dot is colour-only; say it in words for screen readers. */} + {STATUS_LABEL[TRIAGE_STATUS[status]]} + )} + {label} ); } 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..67b8264 --- /dev/null +++ b/web/src/hooks/use-dash-prefs.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +import { + coerceDashPrefs, + COLLAPSE_THRESHOLD, + openForCount, + useDashPrefs, +} from "./use-dash-prefs"; + +describe("openForCount", () => { + it("starts expanded on a small install", () => { + expect(openForCount(null, 2)).toBe(true); + expect(openForCount(null, COLLAPSE_THRESHOLD)).toBe(true); + }); + + it("starts collapsed once the list is a wall", () => { + 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(openForCount(true, 45)).toBe(true); + expect(openForCount(false, 1)).toBe(false); + }); +}); + +describe("coerceDashPrefs", () => { + it("defaults an empty object", () => { + expect(coerceDashPrefs({})).toEqual({ + spacesOpen: null, + shellsOpen: null, + recentOpen: true, + recentDir: "newest", + }); + }); + + it("keeps valid values", () => { + 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", () => { + 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, + shellsOpen: 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.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", + }); + }); + + 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..ec5e408 --- /dev/null +++ b/web/src/hooks/use-dash-prefs.ts @@ -0,0 +1,110 @@ +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 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. */ + recentDir: RecentDir; +} + +const STORAGE_KEY = "collie:dash-prefs:v1"; + +/** Above this many rows, an un-chosen foldable section starts collapsed. */ +export const COLLAPSE_THRESHOLD = 8; + +const DEFAULTS: DashPrefs = { + spacesOpen: null, + shellsOpen: null, + recentOpen: true, + recentDir: "newest", +}; + +/** + * 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 count <= 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, + 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, + }; +} + +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; + setShellsOpen: (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 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, setShellsOpen, setRecentOpen, setRecentDir }; +} diff --git a/web/src/index.css b/web/src/index.css index b71c311..5c8d449 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -57,7 +57,14 @@ 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. It sits a step BELOW --muted (0.94), not between it and + --background: the switcher needs three separable values — resting 0.97, hover (bg-muted/60) + ~0.94, active 0.92 — and 0.94 would have collided the last two. */ + --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)); 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/api.ts b/web/src/lib/api.ts index 2084435..8ae0821 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -203,7 +203,10 @@ export async function fetchPane( const cacheKey = `${session ?? ""}\u0000${paneId}`; const cached = paneCache.get(cacheKey); - const headers: Record = {}; + // SEEN_HEADER is what tells the bridge this read came from our own page and may mark the pane + // seen. A cross-site no-cors GET can't set a custom header, so it can't clear your alerts by + // guessing pane ids (bridge/server.ts → marksPaneSeen). + const headers: Record = { "x-collie-seen": "1" }; if (cached) headers["if-none-match"] = cached.etag; const res = await fetch(url, { signal: withTimeout(signal, GET_TIMEOUT_MS), headers }); @@ -256,7 +259,12 @@ export function fetchHistory( if (opts.before) q.set("before", opts.before); const qs = q.toString(); const path = `/api/pane/${encodeURIComponent(paneId)}/history${qs ? `?${qs}` : ""}`; - return req(withSession(path, session), { signal }); + // Reading the transcript is looking at the pane — and history is a READ, so like fetchPane it + // carries the header that lets the bridge count it (bridge/server.ts → marksPaneSeen). + return req(withSession(path, session), { + signal, + headers: { "x-collie-seen": "1" }, + }); } export function sendReply( diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts index cf6eb64..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 ~", () => { @@ -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("~"); }); @@ -84,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 5bd0dc1..e6f615e 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"). */ @@ -18,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"; diff --git a/web/src/lib/pane-name.test.ts b/web/src/lib/pane-name.test.ts new file mode 100644 index 0000000..eecaaa2 --- /dev/null +++ b/web/src/lib/pane-name.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { paneParts, paneTitleInTab, TITLE_SEP } 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, + }; +} + +/** What the row shows: the two spans are rendered side by side, so the assertions read them joined. + * They stay SEPARATE in the DOM on purpose — the project must be able to truncate before the tab + * does, which a single joined string cannot express. */ +const join = (p: { project: string; tab: string | null }) => + p.tab ? `${p.project}${TITLE_SEP}${p.tab}` : p.project; +const joined = (pane: AgentView) => join(paneParts(pane)); + +describe("paneParts — the title line", () => { + it("is project · tab when the tab has a label", () => { + expect(joined(pane({ tabLabel: "fix-auth" }))).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(joined(pane())).toBe("moonward_os"); + }); + + it("never says 'claude'", () => { + expect(joined(pane({ tabLabel: "fix-auth" }))).not.toContain("claude"); + expect(joined(pane())).not.toContain("claude"); + }); + + it("falls back to the workspace id if a space somehow has no label", () => { + expect(joined(pane({ workspaceLabel: "" }))).toBe("w0"); + }); +}); + +describe("paneParts — the second line", () => { + it("prefers a user-set pane label", () => { + const t = paneParts(pane({ paneLabel: "hand-named", sessionName: "auto-named" })); + expect(t.secondary).toBe("hand-named"); + }); + + it("falls back to Claude's own /rename session name", () => { + expect(paneParts(pane({ sessionName: "oauth-refactor" })).secondary).toBe("oauth-refactor"); + }); + + it("falls back to a shortened cwd", () => { + expect(paneParts(pane()).secondary).toBe("~/dev/moonward"); + }); + + it("is null when there is nothing to say", () => { + expect(paneParts(pane({ cwd: "" })).secondary).toBeNull(); + }); + + it("keeps the pane's own name even when the tab is labelled — nothing is lost", () => { + const t = paneParts(pane({ tabLabel: "fix-auth", sessionName: "oauth-refactor" })); + expect(join(t)).toBe("moonward_os · fix-auth"); + expect(t.secondary).toBe("oauth-refactor"); + }); +}); + +describe("paneParts — shell panes", () => { + it("names a shell by its place, not by the word 'shell'", () => { + const t = paneParts(pane({ kind: "shell", agent: "shell", tabLabel: "scratch" })); + expect(join(t)).toBe("moonward_os · scratch"); + }); +}); + +describe("paneParts — 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(paneParts(pane({ workspaceLabel: "collie", cwd: "/home/kon/dev/ai/collie" })).secondary) + .toBeNull(); + }); + + it("is case-insensitive about that match", () => { + expect(paneParts(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(paneParts(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 = paneParts(pane({ workspaceLabel: "collie", cwd: "/home/kon/dev/ai/collie", sessionName: "oauth" })); + expect(t.secondary).toBe("oauth"); + }); +}); + +describe("paneTitleInTab — inside a space view, where project and tab are already established", () => { + it("leads with the pane's own name, since repeating project · tab would say nothing", () => { + const t = paneTitleInTab(pane({ tabLabel: "fix-auth", sessionName: "oauth-refactor" })); + expect(t.primary).toBe("oauth-refactor"); + expect(t.secondary).toBe("~/dev/moonward"); + }); + + it("falls back through paneDisplayName's precedence: label, session name, then agent", () => { + expect(paneTitleInTab(pane({ paneLabel: "hand-named", sessionName: "auto" })).primary).toBe("hand-named"); + expect(paneTitleInTab(pane()).primary).toBe("claude"); + expect(paneTitleInTab(pane({ kind: "shell", agent: "shell" })).primary).toBe("shell"); + }); +}); diff --git a/web/src/lib/pane-name.ts b/web/src/lib/pane-name.ts new file mode 100644 index 0000000..aa57a3c --- /dev/null +++ b/web/src/lib/pane-name.ts @@ -0,0 +1,76 @@ +// 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 { baseName, shortCwd } from "./format"; +import { paneDisplayName, type AgentView } from "./types"; + +/** A two-line row label. Only {@link paneTitleInTab} returns one — the herd list renders + * {@link PaneParts} instead, so the project can give up width before the tab does. */ +export interface PaneTitle { + primary: string; + /** The pane's own name if it has one, else a shortened cwd. Null when there's neither. */ + 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, rendered between the two spans. Exported so the tests + * assert against one definition rather than a repeated literal. */ +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); +} + +/** 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; + return { + project, + tab: pane.tabLabel ?? null, + secondary: own || informativeCwd(pane.cwd, project), + }; +} + +/** + * 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 { + // paneDisplayName IS this precedence (label -> session name -> agent/"shell"); it was reproduced + // here line for line, which is two copies of the rule pane-name.ts exists to keep in one place. + return { primary: paneDisplayName(pane), secondary: pane.cwd ? shortCwd(pane.cwd) : null }; +} diff --git a/web/src/lib/spaces.test.ts b/web/src/lib/spaces.test.ts index de95549..68eddf2 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 { + filterSpaces, + groupPanesByTab, + sortSpacesByRecency, + spaceLastSeenMap, + spaceTriageMap, +} from "./spaces"; +import { worstTriage } from "./triage"; +import type { AgentStatus, AgentView, TabView, WorkspaceView } from "./types"; function agent( partial: Partial & { paneId: string; workspaceId: string; tabId: string }, @@ -59,45 +66,132 @@ describe("groupPanesByTab", () => { }); }); -describe("blockedCount", () => { - it("counts only blocked agents within the given workspace", () => { +describe("spaceTriageMap — one classifier for rows and chips", () => { + const mk = (id: string, ws: string, status: AgentStatus, extra: Partial = {}) => + agent({ paneId: id, workspaceId: ws, tabId: `${ws}:t1`, status, ...extra }); + + it("keeps the most urgent bucket per workspace and omits spaces with no agent", () => { + const m = spaceTriageMap([ + mk("w1:p1", "w1", "idle"), + mk("w1:p2", "w1", "blocked"), + mk("w2:p1", "w2", "working"), + ]); + expect(m.get("w1")).toBe("needs"); + expect(m.get("w2")).toBe("working"); + // Not the same as idle: an empty space has nothing to report. + expect(m.get("w3")).toBeUndefined(); + }); + + it("ranks an unseen-done agent ABOVE a working one — the disagreement this replaced", () => { + // STATUS_RANK put working (1) ahead of done (4), so the old space row said "working" while the + // space chip, which already routed through bucketOf, said "ready". Same input, one answer now. + const m = spaceTriageMap([ + mk("w1:p1", "w1", "working"), + mk("w1:p2", "w1", "done", { lastActiveAt: 2000, lastSeenAt: 1000 }), + ]); + expect(m.get("w1")).toBe("ready"); + }); + + it("agrees with worstTriage, which is the point of sharing bucketOf", () => { const agents = [ - agent({ paneId: "w1:p1", workspaceId: "w1", tabId: "w1:t1", status: "blocked" }), - agent({ paneId: "w1:p2", workspaceId: "w1", tabId: "w1:t1", status: "working" }), - agent({ paneId: "w2:p1", workspaceId: "w2", tabId: "w2:t1", status: "blocked" }), + mk("w1:p1", "w1", "done", { lastActiveAt: 2000, lastSeenAt: 1000 }), + mk("w1:p2", "w1", "working"), + mk("w1:p3", "w1", "idle"), ]; - expect(blockedCount("w1", agents)).toBe(1); - expect(blockedCount("w2", agents)).toBe(1); - expect(blockedCount("w3", agents)).toBe(0); + expect(spaceTriageMap(agents).get("w1")).toBe(worstTriage(agents)); }); - it("counts every blocked agent, not just presence", () => { - const agents = [ - agent({ paneId: "w1:p1", workspaceId: "w1", tabId: "w1:t1", status: "blocked" }), - agent({ paneId: "w1:p2", workspaceId: "w1", tabId: "w1:t1", status: "blocked" }), - agent({ paneId: "w1:p3", workspaceId: "w1", tabId: "w1:t1", status: "working" }), + it("a done agent you have already seen is not 'ready'", () => { + const m = spaceTriageMap([mk("w1:p1", "w1", "done", { lastActiveAt: 1000, lastSeenAt: 2000 })]); + expect(m.get("w1")).toBe("recent"); + }); +}); + +const ws = (workspaceId: string, label: string, number: number): WorkspaceView => ({ + workspaceId, + number, + label, + focused: false, + activeTabId: `${workspaceId}:t1`, + tabCount: 1, + paneCount: 1, +}); + +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(blockedCount("w1", agents)).toBe(2); + 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("worstSpaceStatus", () => { - const mk = (status: AgentStatus) => - agent({ paneId: `w1:${status}`, workspaceId: "w1", tabId: "w1:t1", status }); +describe("filterSpaces", () => { + const spaces = [ws("w1", "moonward_os", 1), ws("w2", "trader", 2), ws("w3", "MOON_probe", 3)]; - it("returns null when the workspace has no agents", () => { - expect(worstSpaceStatus("w1", [])).toBeNull(); - expect(worstSpaceStatus("w1", [agent({ paneId: "w2:p1", workspaceId: "w2", tabId: "w2:t1" })])).toBeNull(); + 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([]); + }); +}); + +describe("spaceLastSeenMap", () => { + it("agrees with spaceLastSeen for every space, in one pass", () => { + const panes = [ + agent({ paneId: "w1:p1", workspaceId: "w1", tabId: "w1:t1", lastSeenAt: 100 }), + agent({ paneId: "w1:p2", workspaceId: "w1", tabId: "w1:t1", lastSeenAt: 900 }), + agent({ paneId: "w2:p1", workspaceId: "w2", tabId: "w2:t1", lastSeenAt: 400 }), + ]; + const map = spaceLastSeenMap(panes); + expect(map.get("w1")).toBe(900); + expect(map.get("w2")).toBe(400); }); - it("returns the most-urgent status (blocked beats working beats idle/done)", () => { - expect(worstSpaceStatus("w1", [mk("idle"), mk("working"), mk("blocked")])).toBe("blocked"); - expect(worstSpaceStatus("w1", [mk("done"), mk("working")])).toBe("working"); - expect(worstSpaceStatus("w1", [mk("idle"), mk("done")])).toBe("idle"); + it("omits spaces with no panes, which callers read as 0", () => { + expect(spaceLastSeenMap([]).get("w1")).toBeUndefined(); }); - it("ranks unknown between working and idle", () => { - expect(worstSpaceStatus("w1", [mk("idle"), mk("unknown")])).toBe("unknown"); - expect(worstSpaceStatus("w1", [mk("working"), mk("unknown")])).toBe("working"); + it("gives the same ordering whether or not the map is passed in", () => { + const spaces = [ws("w1", "alpha", 1), ws("w2", "beta", 2)]; + const panes = [agent({ paneId: "w2:p1", workspaceId: "w2", tabId: "w2:t1", lastSeenAt: 900 })]; + expect(sortSpacesByRecency(spaces, panes, spaceLastSeenMap(panes))).toEqual( + sortSpacesByRecency(spaces, panes), + ); }); }); diff --git a/web/src/lib/spaces.ts b/web/src/lib/spaces.ts index 4474c69..20820e3 100644 --- a/web/src/lib/spaces.ts +++ b/web/src/lib/spaces.ts @@ -1,6 +1,7 @@ // 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 { bucketOf, TRIAGE_ORDER, type TriageKey } from "./triage"; +import type { AgentView, TabView, WorkspaceView } from "./types"; export interface TabGroup { tabId: string; @@ -34,20 +35,73 @@ export function groupPanesByTab( return groups; } -/** Agents needing attention (blocked) in a workspace — drives the space chip's alert dot. */ -export function blockedCount(workspaceId: string, agents: AgentView[]): number { - return agents.filter((a) => a.workspaceId === workspaceId && a.status === "blocked").length; +/** + * The most urgent bucket in each workspace, in ONE pass over the agents. + * + * Routes through {@link bucketOf}, the same classifier the herd list and the tab/space chips use — + * this replaces a pair of helpers that ranked by STATUS_RANK instead, so a space row and its chip + * could disagree about what a colour meant (a space holding one `working` agent and one unseen + * `done` one showed "working" on the dashboard and "ready" on the chip). One classifier, one answer. + * + * A missing entry means the space holds no agent at all, which is deliberately NOT the same as + * idle: an empty space has nothing to report, and a resting dot would claim otherwise. + * + * One pass rather than per-space filtering because the dashboard re-renders on every poll and used + * to derive this per space AND again per row — spaces x agents, three times over (45 x 59 on a real + * herd). + */ +export function spaceTriageMap(agents: readonly AgentView[]): Map { + const worst = new Map(); + for (const a of agents) { + const bucket = bucketOf(a); + const held = worst.get(a.workspaceId); + if (held === undefined || TRIAGE_ORDER.indexOf(bucket) < TRIAGE_ORDER.indexOf(held)) { + worst.set(a.workspaceId, bucket); + } + } + return worst; +} + +/** + * Last-used time for EVERY space in one pass over the panes. The dashboard needs this per space and + * again per rendered row, and it re-renders on every poll; deriving it per space would be + * spaces × panes each time (45 × 59 on a real herd, three times over). One pass, then map lookups. + */ +export function spaceLastSeenMap(panes: readonly AgentView[]): Map { + const seen = new Map(); + for (const p of panes) { + const at = p.lastSeenAt ?? 0; + if (at > (seen.get(p.workspaceId) ?? 0)) seen.set(p.workspaceId, at); + } + return seen; } /** - * The most-urgent agent status in a workspace (blocked > working > … > done), or null if the space - * has no agents at all (only shells, or empty). Drives the status dot beside each space row. + * 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. + * + * Pass a prebuilt {@link spaceLastSeenMap} when the caller already has one. */ -export function worstSpaceStatus(workspaceId: string, agents: AgentView[]): AgentStatus | null { - const inWs = agents.filter((a) => a.workspaceId === workspaceId); - if (inWs.length === 0) return null; - return inWs.reduce( - (worst, a) => (STATUS_RANK[a.status] < STATUS_RANK[worst] ? a.status : worst), - inWs[0]!.status, +export function sortSpacesByRecency( + workspaces: readonly WorkspaceView[], + panes: readonly AgentView[], + seen: Map = spaceLastSeenMap(panes), +): WorkspaceView[] { + 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..15fb7f1 --- /dev/null +++ b/web/src/lib/triage.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; + +import { + flipDir, + isUnseen, + triage, + TRIAGE_STATUS, + worstTriage, + 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"); + }); +}); + +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 new file mode 100644 index 0000000..4823678 --- /dev/null +++ b/web/src/lib/triage.ts @@ -0,0 +1,148 @@ +// 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); +} + +/** 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); +} + +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[] = []; + + 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)); + 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 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"; +} + +/** 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 1dfe3ad..5f985fc 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -42,6 +42,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 0256cfc..c0f35eb 100644 --- a/web/src/routes/home.tsx +++ b/web/src/routes/home.tsx @@ -10,21 +10,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, openForCount } 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 @@ -34,6 +29,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 = openForCount(prefs.spacesOpen, data.workspaces.length); const open = (id: string) => navigate(panePath(id, data.session)); const drillInto = (id: string) => navigate(spacePath(id, data.session)); @@ -56,22 +55,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 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" && ( <>