feat: triage the dashboard by attention and recency, and stop calling every agent "claude" - #42
Conversation
Both strips are direct children of the space route's `flex min-h-0 flex-1 flex-col overflow-y-auto` scroller with no `shrink-0`, so they were flex-shrunk vertically: 16px and 17px containers around 32px chips, whose natural need is 48px. The rows then overlapped by 15px — the space pills rendered sliced in half under the tab row, and a short label like `code` was reduced to its two rounded end-caps. Predates the theme work (reproduced against the shipped 0.17.0), and it shows in both themes. Split out from that branch so it can be read on its own.
Collie has been hardcoded to dark since 0.1.0 (`class="dark"` on <html>). Unpin it and let the cascade decide. Cascade. Every themed value is one `light-dark()` declaration keyed on `color-scheme`, so System needs no JavaScript at all and there is no second dark block to drift. Verified first that `color-mix()` resolves a `light-dark()` argument, which the app-wide `border-border/60` and `outline-ring/50` base rules depend on. First paint. public/theme-init.js applies an explicit pin before paint. A same-origin file, so `script-src 'self'` already allows it — no CSP change. It stores a BARE string; useTheme deliberately diverges from use-display-prefs' JSON encoding to match, because a strict compare against a JSON-quoted value would fail silently and take the anti-flash with it. useTheme owns what CSS cannot: the pin class (bidirectionally — a stale class is why Dark to System would otherwise do nothing until a reload), the theme-color metas, and the OS listener. Module-scoped so the three controls agree and the listener outlives the idle lock unmounting the router. The mirror renders in dark space and light inverts it (ADR 0002 follows). Agents emit truecolor almost exclusively — 446 sequences in a live pane, zero basic ANSI — and truecolor names an absolute colour no palette can re-theme. ansi.ts still emits var(--ansi-N) for indexed colour, via both spellings: color256() resolves 0-15 through the same table, so theming one and not the other renders the same logical colour two ways. Light surfaces are a ladder (page rgb 235 / card 255) rather than stock shadcn's all-white, which rests the whole dashboard hierarchy on one 1.26:1 hairline. It also lands exactly on the mirror's inverted background, so the seam at the pre's edge disappears. Contrast work, all measured in a browser rather than modelled: light --status-* retuned against the worst ground they land on (a translucent chip over the pane header, not white — that took three attempts); --muted- foreground darkened and every /70, /80 and opacity-60 modifier on small text dropped, since no token value rescues a /70; focus rings to full strength (they were 1.77:1); the off-switch given an outline (it was a white thumb on a 1.09:1 track); header controls to 44px boxes without negative margins, which overlap their neighbours and overflow the page. Version bump rides with the release commit, so every commit on this branch passes scripts/check-version.sh.
The decision closes off an option someone will absolutely re-propose ("why
not just give the mirror a light ANSI palette?"), and the answer is a
measurement rather than a preference: four live panes emit 446/150/461/109
truecolor sequences and zero basic ANSI codes.
Records the three rules that follow — dark-space authoring, literals not
tokens, filter scoped to the pre — and the sharp edge that cost a blocker
during review: cancelling the filter is only safe on an element that fully
specifies its own foreground, or inherited text double-inverts to invisible.
Carries the short normative rule in CLAUDE.md linking here. The ADR states
its own measurements and method rather than citing a design trail, so it
stands alone.
… by attention and recency
Herdr reports no timestamps at all — not on panes, tabs, or workspaces — so Collie derives and
owns them. An ActivityLedger persisted to the state dir (alongside snooze.json / notify-prefs.json)
records, per session and pane:
activeAt — the last agent status transition the state engine observed
seenAt — the last time you opened or drove the pane through Collie
That is enough for the whole feature. "Unseen" needs no stored flag: an agent is newly-finished-
and-unread exactly when `status === "done" && activeAt > seenAt`, so opening the pane clears it by
construction.
A first sighting seeds activeAt = seenAt, matching the rule the engine already applies to
notifications — a fresh start must not open on a screen of alerts you were never shown. Writes are
debounced to one per 10s (an open pane polls ~1/s and each poll marks it seen) plus one on
shutdown; entries are reconciled against the live pane set each successful poll, which reaps bare
shells too — the engine's removal event is agent-derived and never fires for them.
Also denormalises the pane's tab label onto AgentView, exactly as workspaceLabel already is, so no
client has to join tabs[]. meaningfulTabLabel drops Herdr's positional default ("1") in a
single-tab space, where it would render as "project · 1" and read as a bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alling every agent "claude" Three complaints, one cause: the dashboard was ordered by how Herdr stores things, not by what needs doing. 45 spaces rendered in creation order above the agents; agents sorted by status then creation order, with nothing distinguishing one that finished ten seconds ago from one dealt with yesterday; and every row titled "claude", because the title fell back to the agent name. Ordering (lib/triage.ts, replacing lib/agent-groups.ts): Needs you → Ready · unseen → Working → Recent The first three are pinned — they never move, never invert, and never fold. Recent runs by when you last used each pane and is the only section the direction toggle reaches. Recent and Spaces fold and remember it; fold both and the page is the triaged herd and nothing else. Naming (lib/pane-name.ts): a row is titled `project · tab`, with the pane's own name (a herdr label or Claude's /rename session name) on the second line where the cwd used to be. Nothing is lost — the agent's identity was always the avatar, never the text. In the space detail view, which already groups under a per-tab heading, the pane's own name leads instead (scope="tab"): repeating the heading would say nothing, and two panes in one tab would stop being distinguishable. Spaces moves BELOW every agent section — it's a navigator, not a work queue — and gains recency ordering plus a filter box. That retires the reason home.tsx split the agent list in two to hoist "Needs you" above it; AgentList now renders once. With no timestamps (an older bridge) this degrades to today's dashboard with no branch: the unseen test is false, every comparator returns 0, and Array.sort is stable, so each section keeps the bridge's own order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the two options that will otherwise be re-proposed forever: making "seen" per-device (rejected — an alert cleared on the phone must not still shout on the laptop), and counting a Herdr focus at the desk (rejected — a pane clicked past would silently clear an alert you never read; a false positive costs one tap, a false negative costs a missed agent). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Security review of this branch caught a regression it introduced. Marking a pane seen made a READ-level GET mutate server state, which no read had done before — and checkAccess deliberately does not require an Origin on reads, because browsers omit it on same-origin GETs and demanding one would reject the real client. So a page the operator visits while on the tailnet could fire <img src="https://collie…/api/pane/w1:p1"> at guessable pane ids (herdr's are w<n>:p<m>) and silently clear the whole "Ready · unseen" section. The response is opaque to the attacker and nothing can be typed into a terminal — writes still require an Origin and the device gate — but the write lands, and the operator simply stops being told their agents finished. That is precisely the signal this release exists to deliver. marksPaneSeen now gates it on a custom request header the web app sets on its own pane and history reads. A no-cors cross-site request cannot set one: doing so promotes it to a preflighted CORS request, and the bridge answers no preflight. Write actions need no header — they already cleared the Origin-requiring write gate. Pure and exported, so it is unit-tested in bun's runner like checkAccess beside it. Also folds spaceLastSeen into a single-pass spaceLastSeenMap. The dashboard re-renders every poll and was deriving it per space and again per row — spaces x panes, three times over (45 x 59 on a real herd). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folded into 0.20.0 rather than cut as 0.19.1 — the release was never pushed or tagged, so the vulnerable code never shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…luded The swipe-up switcher feeds ThreadSidebar the WHOLE herd, so it had the exact problem the dashboard was just fixed for: on a real herd that is 20 agents and 37 bare shells, and the handful you opened the sheet to reach are buried under both. Recent and Shells now fold, using the dashboard's own SectionHeader so the affordance is identical in both places rather than a second implementation that drifts. The attention sections stay pinned open here too — collapsing an alert defeats the alert. Shells fold on the same count rule Spaces uses, so the preference generalises: spacesOpenFor becomes openForCount, with one COLLAPSE_THRESHOLD. An explicit choice always wins; otherwise a section opens only while it is short enough to be worth showing. Recent's fold state is shared with the dashboard, so "hide the long tail" means one thing across the app. SectionHeader gains a heading level, defaulting to 2 — the switcher's sections are h3 because the sheet's own title is the h2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g its own section
UX round 1/5, against the real herd at 390px.
The rename shipped its premise and then broke it with a different word. Eight panes in one project
all begin "moonward_os · ", so tail-truncating the joined title ate the tab and left rows reading
"moonward_os · t…" — two different panes rendered the identical string. The 11 characters that
survived were the ones every row shares.
Project and tab now render as separate spans (paneParts): the project is capped at 45% and yields
width first, the tab takes what's left. paneTitle stays as the joined form for search text and
aria labels.
The width came from three places that were spending it on nothing:
- every Recent row wore an "idle" pill under a heading that already said Recent, and every
Working row a "working" pill. Inside a triage section the status is now a dot (the word stays
for screen readers); the full badge remains where status ISN'T implied, i.e. the space view.
- the ChevronRight on a full-width button that already has press feedback.
- timeAgo moved to line 2, beside the secondary text rather than competing with the title.
This restores the layout design.md specified all along.
Also: section headers were two different sizes and cases, because a <button> doesn't inherit
text-transform or font-size from its <h2> — pinned sections rendered small-caps "WORKING" while the
foldable tail rendered larger sentence-case "Recent", making the least important section the
loudest thing on the page. Both branches now set the type explicitly.
And the dashboard was silently missing the status-colour bullet the switcher had, because each
caller picked header fields by hand; both now spread sectionHeaderProps().
Smaller: the sort control reads as a pressable chip rather than an annotation, and is fixed-width so
flipping it doesn't shift the header; the filter input clears the 36px floor; and when nothing is
blocked the list says "Nothing needs you" instead of leaving an absence to interpret.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng line 1 UX round 2/5. The density win came from the second line, not the borders. Eight of eighteen Recent rows read "moonward_os" on line 1 and "…ropbox/dev/moonward/moonward_os" on line 2 — the same word twice, in a mono face the eye slows down for, and clipped at BOTH ends (shortCwd head-elided it, then CSS tail-truncated what was left to seat the timestamp). A string cut at both ends is worth nothing. The cwd is now dropped when the directory's own name is the project label, which is almost always — a space is named after its directory. It survives for the case that carries information: a pane sitting somewhere other than the space root, a worktree or a subdir. Most rows collapse to one line. shortCwd also drops whole path segments now instead of characters, so an abbreviation stops looking like a rendering fault. Card chrome on 100% of rows is wallpaper, not emphasis: a Working row and a Recent row rendered pixel-identically, throwing away the four-level priority triage() had just computed. Cards are now reserved for the sections that mean "a human is required here" (Needs you, Ready · unseen); everything else is a flat row on a hairline divider. See a card, something wants you. The blocked tint survives on both paths untouched — it's the one cue that reads at a glance. Status dots were drawn as solid discs in a palette tuned for TEXT contrast, so eighteen idle dots carried the same weight as the one thing that needs you. Resting states (idle/unknown) are hollow rings now; states that mean something is happening stay solid. The dot also moved from the far right — where the eye crossed 200px of empty card to reach a 10px mark — onto the avatar's corner. Also: "Nothing needs you" is the product of the whole glance and was rendered in the page's faintest type; it now has presence. The sort chip lost its fill (it outweighed the heading beside it). The Spaces count reports what you can SEE while filtering, the filter sticks to the top of a five-screen list, and its rows lost a chevron the agent rows had already dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he avatar
UX round 3/5.
The hollow ring shipped with bg-transparent, so on the avatar's corner its interior showed orange
logo through one half and page grey through the other, wrapped in a ring — indistinguishable from a
rendering fault. It was worst exactly where it mattered: Recent is the only section that ISN'T
homogeneous (idle, unknown and already-seen done all land there), so its per-row dot is the only
status signal on the page, and it rendered broken on 17 of 21 rows. A hollow ring is now filled with
the surface it sits on, and the card/flat paths ring in their own surface rather than both assuming
the page.
Line 2 had become a lone mono timestamp — a footnote given the same vertical presence as the title,
in the font the path used to occupy, left-aligned because nothing pushed it right. The age moved up
to the title row's trailing slot and the second line renders only when there's something to say, so
most rows are one line. That freed enough room to date the Working rows too: "working for 3h" and
"working for 40s" are very different facts.
Hoisting the age then cost the title ~55px and truncation crept back ("interview-con…"), so the
column drops the "ago" every entry was repeating — the column's meaning is already established.
Card and flat rows also disagreed about their avatar origin by 5px, so the column your eye rides
down the page stepped sideways at each section boundary.
And the switcher — the surface you use to jump TO the thing that needs you — rendered every pane
identically, with no way to show a blocked one as blocked. It now applies the tint via isAttention(),
which exists for exactly this and had no callers. Denser than the dashboard is fine; blind is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…are here" showed nothing
UX round 4/5. Two objective findings, both measured.
Light --accent was still stock shadcn's oklch(0.97) — correct only while --background is pure
white. 0.18.0 deliberately moved background to 0.97 (the hairline-contrast fix), which silently made
accent === background, so every "this is the current one" fill rendered as literally nothing in
light mode: the open pane in the switcher, the current session in the session switcher, and every
hover:bg-accent. It now sits at 0.92, between --background and --muted.
That is also why round 3's switcher fix only half-landed. A blocked pane you were CURRENTLY IN had
its attention tint suppressed by the !active guard and its active fill was invisible, so it rendered
with no marking at all — the one pane you're looking at, in the state the app exists to surface. The
border now applies regardless of active, so both cues compose: accent fill plus alarm edge. Only the
background is withheld, because two backgrounds can't both win.
Second: the flex filler that pushes the age to the right edge lived on the TAB span, and an
unlabelled single-tab space has no tab — so those rows had no filler and the age butted straight
against the project name, reading as part of it ("comm_cli 37m") while every other row's age lined
up at x=360. The project takes the width itself when it is the whole name.
Deliberately NOT taken, on the evaluator's own reasoning: an age on blocked rows (it would re-truncate
the titles round 3 fought to free, and "blocked" already outranks "how long"), and a tooltip
disambiguating last-opened from time-in-state (a title attribute does nothing on a phone).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n 0.20.0 The comment claimed 0.92 "sits between --background (0.97) and --muted (0.94)" — it's below muted, not between. The value is right (the switcher needs three separable steps: resting 0.97, hover 0.94, active 0.92, and 0.94 would have collided the last two); the sentence describing it was wrong, and would have misled whoever reasoned from it next. Round 5/5 verdict: SHIP. Confirmed all three round-4 fixes landed by measurement, swept r0→r4 for regressions across seven properties (full tab names, uniform headings, cards only on attention sections, single-line rows, the age column, avatar alignment, hollow-vs-solid dots) — all pass, no horizontal overflow and no sub-36px targets at 390/768/1280. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…locked" The tab strip rendered labels and nothing else, so the only way to learn a tab's state was to open it. It did carry a dot — but only ever for blocked, which meant every other state (working, finished and unread, idle) was indistinguishable from every other, and an absent dot was ambiguous between "quiet" and "nothing here". Each chip now leads with a status dot for the most urgent thing inside it, in the same palette and with the same solid-vs-hollow rule the herd list uses: red solid for blocked, green for finished and unread, amber pulsing for working, a hollow ring for resting. The colour is spelled out for screen readers too. The classifier is shared rather than re-derived: bucketOf() is now the single place an agent is assigned a section, used by both triage() (the list) and worstTriage() (the chips), so a chip and a row can never disagree about what a colour means. A container with NO agent reports nothing at all, deliberately — an empty tab is not an idle tab, and a resting dot would claim otherwise. Applied to the space strip as well, since it sits directly above the tab strip and the two reading differently would be worse than either. The dot leads the label instead of riding the corner as a badge: a corner badge needs a ring in the chip's own fill, and a chip has two fills. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0b02d4e to
472277f
Compare
Dashboard triage by attention and recency (supersedes #42)
|
Superseded by #47, now merged — thank you, this was excellent work. Your 15 commits are carried verbatim and are in Fixed on top:
It ships in the untagged 0.20.0 alongside the theming work rather than taking its own version. |
The problem
Open Collie on a phone with a real herd — 20 agents, 37 bare shells, 45 spaces — and the dashboard
answers a question nobody asked. It lists 45 spaces in workspace-creation order above everything
else; it sorts agents by
STATUS_RANK → workspaceNumber → paneId, so nothing distinguishes an agentthat finished ten seconds ago from one you dealt with yesterday; and every row is titled
claude,because the title falls back to the agent name and the tab label — the one thing that says what the
work is — was never on screen at all.
The idea
Herdr reports no timestamps of any kind — not on panes, tabs, or workspaces. So Collie derives
and owns them, and the whole feature falls out of two numbers per pane:
"Unseen" is then a comparison, not a stored flag:
status === "done" && activeAt > seenAt. Thereis no read-receipt table and nothing to keep in sync — opening the pane bumps
seenAtpastactiveAtand the row leaves the section by itself.What changes
Ordering.
lib/triage.ts(replacinglib/agent-groups.ts) buckets the herd intoNeeds you → Ready · unseen → Working → Recent. The first three are pinned: they never move,
never invert, and never fold — collapsing an alert defeats the alert. Recent runs by when you last
used each pane and is the only section the direction toggle reaches. Recent and Spaces fold and
remember it; fold both and the page is the triaged herd and nothing else.
Spaces moved below every agent section. It's a navigator, not a work queue, so it sits under the
thing it navigates to — recency-ordered, with a filter box, folded by default above 8 spaces.
Naming. A row is titled
project · tab(moonward_os · fix-auth), rendered as two spans so thetab survives truncation — eight panes in one project all begin
moonward_os ·, and truncating thejoined string left several rows rendering the identical
moonward_os · t…. The pane's own name (aherdr label, or Claude's
/renamesession name) moves to line 2. The agent's identity was always theavatar, never the text.
Tab and space chips carry a status dot — blocked / ready / working / idle, in the herd list's own
palette. They previously showed a dot only for blocked, so every other state read identically.
Reach. The dashboard, the in-pane switcher and the space view share one classifier (
bucketOf),so a chip and a row can't disagree about what a colour means.
Backward compatibility
An older bridge reports no timestamps, and the client degrades to today's dashboard with no branch
and no feature detection: the unseen test is
false, every comparator returns0, andArray.prototype.sortis stable — so each section keeps the bridge's existing order andReady · unseen is simply empty.
Security
Marking a pane seen made a read-level GET mutate server state, which no read had done before —
and
checkAccessdeliberately doesn't require anOriginon reads (browsers omit it on same-originGETs). A page visited while on the tailnet could therefore have fired
<img src="…/api/pane/w1:p1">at guessable pane ids and silently cleared the whole Ready · unseen section. Caught in this branch's
own security review and fixed before it shipped:
marksPaneSeen()gates on a custom request header,which a no-cors cross-site request cannot set without a preflight the bridge never answers. Write
actions need no header — they already clear the
Origin-requiring write gate.Decision record
ADR 0003 — "seen" is one shared, Collie-only fact. It records the
two options that will otherwise be re-proposed forever: making it per-device (rejected — an alert
cleared on the phone must not still shout on the laptop), and counting a Herdr focus at the desk
(rejected — a pane clicked past would silently clear an alert you never read; a false positive costs
one tap, a false negative costs a missed agent).
How it was checked
Built against a real 45-space herd, then refined over five evaluate → fix → re-capture rounds with
screenshots at 390 / 768 / 1280 and an objective audit (horizontal overflow, sub-36px tap targets,
heading tree, page height). That loop caught, among others: the title truncation above; section
headings rendering at two different sizes because a
<button>doesn't inherittext-transformfromits
<h2>; a hollow status ring that read as a notch cut out of the avatar; and — not from thiswork — light
--accentbeing byte-identical to--background, so every "this is the currentone" fill and every
hover:bg-accentrendered as nothing in light mode.scripts/check-version.sh ✓must not open on 45 unread alerts); reading a pane marks only that pane; the polling dashboard
cannot false-positive, because
rootLoaderonly ever fetches/api/snapshotVersion
0.19.0 — MINOR: additive, no config or API break.
🤖 Generated with Claude Code