Skip to content

feat: triage the dashboard by attention and recency, and stop calling every agent "claude" - #42

Merged
AltanS merged 19 commits into
AltanS:mainfrom
konpyl:feat/dashboard-triage
Jul 29, 2026
Merged

feat: triage the dashboard by attention and recency, and stop calling every agent "claude"#42
AltanS merged 19 commits into
AltanS:mainfrom
konpyl:feat/dashboard-triage

Conversation

@konpyl

@konpyl konpyl commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Stacked on #41. This branch is cut from feat/light-and-system-theme, so until #41 merges the
diff below also contains its four commits (0.18.0). Once #41 lands on main this PR's diff reduces
to its own 15 commits on its own — no rebase needed, since the repo merges rather than squashes.
Please merge #41 first. Reviewable now; the commits to read start at feat(bridge): keep two timestamps per pane.

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 agent
that 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:

activeAt — when the agent last changed status   (the bridge observes it)
seenAt   — when you last opened or drove it     (through Collie, and only through Collie)

"Unseen" is then a comparison, not a stored flag: status === "done" && activeAt > seenAt. There
is no read-receipt table and nothing to keep in sync — opening the pane bumps seenAt past
activeAt and the row leaves the section by itself.

What changes

Ordering. lib/triage.ts (replacing lib/agent-groups.ts) buckets the herd into
Needs 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 the
tab survives truncation — eight panes in one project all begin moonward_os · , and truncating the
joined string left several rows rendering the identical moonward_os · t…. The pane's own name (a
herdr label, or Claude's /rename session name) moves to line 2. The agent's identity was always the
avatar, 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 returns 0, and
Array.prototype.sort is stable — so each section keeps the bridge's existing order and
Ready · unseen is simply empty.

Security

Marking a pane seen made a read-level GET mutate server state, which no read had done before —
and checkAccess deliberately doesn't require an Origin on reads (browsers omit it on same-origin
GETs). 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 inherit text-transform from
its <h2>; a hollow status ring that read as a notch cut out of the avatar; and — not from this
work — light --accent being byte-identical to --background, so every "this is the current
one" fill and every hover:bg-accent rendered as nothing in light mode.

  • 351 bridge tests, 1170 web tests, both typecheck clean, scripts/check-version.sh ✓
  • Verified live against the real herd: cold start seeds every pane as already-seen (a fresh install
    must not open on 45 unread alerts); reading a pane marks only that pane; the polling dashboard
    cannot false-positive, because rootLoader only ever fetches /api/snapshot

Version

0.19.0 — MINOR: additive, no config or API break.

🤖 Generated with Claude Code

konpyl and others added 19 commits July 29, 2026 07:25
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>
@konpyl
konpyl force-pushed the feat/dashboard-triage branch from 0b02d4e to 472277f Compare July 29, 2026 03:30
AltanS added a commit that referenced this pull request Jul 29, 2026
Dashboard triage by attention and recency (supersedes #42)
@AltanS
AltanS merged commit 9d87bb0 into AltanS:main Jul 29, 2026
@AltanS

AltanS commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Superseded by #47, now merged — thank you, this was excellent work.

Your 15 commits are carried verbatim and are in main with your authorship intact. The design is the part worth calling out: deriving the two timestamps locally because Herdr has none, and then making "unseen" a comparison rather than a stored flag, means there is no read-receipt state to keep in sync and no sync bug to write later. The backward-compatibility story falls out of stable sort and zero-returning comparators with no feature detection at all — that is the kind of thing most implementations get wrong by adding a hasTimestamps branch. And catching the read-level-GET CSRF hole in your own security review, before it shipped, is exactly the standard this repo wants.

Fixed on top:

  • A space row and its chip disagreed about colour — the invariant the PR set out to enforce. SpaceOverview still ranked by STATUS_RANK while SpaceStrip had moved to bucketOf, so a space holding one working agent and one unseen-done agent read "working" on the dashboard and "ready" in the strip. Both share the classifier now, through a single-pass map that also removes an O(spaces × agents)-per-render cost.
  • The seen-stamp ran before the method dispatch, so a request that 405s still cleared an alert.
  • paneTitle, paneSearchText and spaceLastSeen had no production caller — their tests were the only thing keeping them alive. The behaviours still ship, so the tests moved to paneParts rather than being dropped.
  • paneTitleInTab reimplemented paneDisplayName line for line; aria-controls named an unmounted id while collapsed; a StatusDot size override only resized its wrapper.
  • Some UI polish found on-device: the Settings page shifted ~470px a frame after opening, divided rows had a radius with no border to own it, and the mirror had no top edge so the pane row bled into terminal output.

It ships in the untagged 0.20.0 alongside the theming work rather than taking its own version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants