No spaces yet.
- ) : (
-
- {workspaces.map((w) => {
- const status = worstSpaceStatus(w.workspaceId, agents);
- const blocked = blockedCount(w.workspaceId, agents) > 0;
- return (
-
onOpen(w.workspaceId)}
- className="w-full text-left transition-transform active:scale-[0.99]"
+
+ {/* Why you'd bother expanding — stays visible while folded. */}
+ {blockedSpaces > 0 && (
+
-
+ {blockedSpaces}
+
+ )}
+
+
+
+ >
+ }
+ />
+
+ {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 && (
+
+
+ setQuery(e.target.value)}
+ placeholder="Filter spaces…"
+ aria-label="Filter spaces"
+ // min-h-9 so the control itself clears the 36px touch floor, not just its padded label.
+ className="min-h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
+ />
+
+ )}
+
+ {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 (
+
onOpen(w.workspaceId)}
className={cn(
- "flex-row items-center gap-3 rounded-xl px-3.5 py-3 shadow-sm",
- blocked && "border-status-blocked/40 bg-status-blocked/5",
+ // Square, like the herd rows: this is a divide-y list, and a rounded fill under
+ // a straight hairline reads as a fault. The blocked row below has a real border,
+ // so it keeps its radius.
+ "w-full text-left transition-colors active:scale-[0.99]",
+ !blocked && "hover:bg-muted/50",
)}
>
- {status ? (
- <>
-
- {/* The dot alone is color-only; give SR users the status word (as StatusBadge). */}
- {STATUS_LABEL[status]}
- >
- ) : (
- // Solid, not /40: at 1px on a 10px circle the alpha ring measured 1.86:1 and
- // read as nothing at all. Same antialiasing problem as the switch's outline.
-
- )}
- {w.label}
-
-
-
-
-
- );
- })}
+ {/* 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() {