Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2f4d691
feat(bridge): keep two timestamps per pane, so the dashboard can sort…
konpyl Jul 28, 2026
da4f44c
feat(web): triage the dashboard by attention then recency, and stop c…
konpyl Jul 28, 2026
6786ca1
docs: ADR 0003 — one shared "seen", and only Collie's own reads count
konpyl Jul 28, 2026
29ede4d
release: 0.20.0
konpyl Jul 28, 2026
f9000cb
fix(bridge): don't let a cross-site GET clear your unseen agents
konpyl Jul 28, 2026
f8064c1
docs: record the CSRF fix in the 0.20.0 entry
konpyl Jul 28, 2026
4cca8db
feat(web): fold the long tails in the "Switch pane" sheet, shells inc…
konpyl Jul 28, 2026
6754742
docs: note the Switch pane folds in 0.20.0
konpyl Jul 28, 2026
8a8a4c9
fix(web): let the tab survive truncation, and stop every row restatin…
konpyl Jul 28, 2026
1a1100d
fix(web): make the card shape mean something, and stop line 2 repeati…
konpyl Jul 28, 2026
5c04453
fix(web): stop the hollow status ring reading as a notch cut out of t…
konpyl Jul 28, 2026
dab7e05
fix(web): light --accent was byte-identical to --background, so "you …
konpyl Jul 28, 2026
50a068f
docs: correct the --accent comment, and record the UX sweep's fixes i…
konpyl Jul 28, 2026
22d4a5f
feat(web): say what's going on inside each tab and space, not just "b…
konpyl Jul 28, 2026
472277f
docs: note the tab/space status dots in 0.20.0
konpyl Jul 28, 2026
9d87bb0
Merge branch 'main' into feat/dashboard-triage-takeover
AltanS Jul 29, 2026
e024f48
fix(web): one classifier for rows and chips, and clear out the parts …
AltanS Jul 29, 2026
3d5b191
fix(web): stop the settings page rearranging itself, and square off t…
AltanS Jul 29, 2026
e208408
fix(web): give the mirror a top edge so the pane row doesn't bleed in…
AltanS Jul 29, 2026
f7e616b
fix(bridge): only a request that will be served marks a pane seen
AltanS Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .adr/0003-one-shared-seen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 0003 — "Seen" is one shared fact, and Collie only trusts what happened in Collie

Status: **Accepted** (2026-07-28)

## Context

The dashboard sorts the herd by attention and then by recency, and it surfaces a **Ready · unseen**
section: agents that finished while you weren't looking. Both need to know two things per pane —
when the agent last moved, and when *you* last looked at it.

Herdr supplies neither. Its pane, tab, and workspace records carry **no timestamps of any kind**
(see [`HERDR_API.md`](../HERDR_API.md)), so every notion of "when" in this feature is one Collie
derives and owns. That forced two questions that would otherwise never have been asked out loud.

**Where does "seen" live?** Collie is a phone UI for a herd you also drive from a desk, and the same
person uses both. The bridge already persists exactly this kind of state — `snooze.json`,
`notify-prefs.json` — bridge-wide rather than per-device, on the same reasoning: a notification
fans out to every device, so muting it on one must mute it on all.

**What counts as looking?** Herdr reports a `focused` flag per pane, so the bridge *could* see you
working in a pane at the desk and count that as having seen it. That was considered and rejected
during design.

## Decision

**One shared "seen", recorded bridge-side and persisted to the state dir.** `activity.json` holds
`{activeAt, seenAt}` per pane, keyed by session name (pane ids are session-scoped and collide across
sessions). Not per-device, not in `localStorage`.

**Only what happens in Collie counts as seeing.** `seenAt` is stamped when a request reaches
`/api/pane/:id` — opening the pane, replying, sending keys, reading its history. A Herdr focus at
the desk does not stamp it, and neither does anything else the bridge merely observes.

**"Seen" is a comparison, not a stored flag.** An agent is unread exactly when
`status === "done" && activeAt > seenAt`. There is no read-receipt table and nothing to keep in
sync: opening the pane bumps `seenAt` past `activeAt`, and the row leaves the section by itself.

**A first sighting is seeded as already-seen** (`activeAt = seenAt = now`), so only transitions
observed *after* Collie first saw a pane can mark it unread. This is the same rule the state engine
already applies to notifications — a first sighting never fires a transition, so a fresh start
doesn't notify for agents that were already blocked.

## Consequences

- **A second device agrees with the first.** An alert cleared on the phone is cleared on the laptop.
This is the whole point, and it's why per-device storage was rejected: the failure mode there is
an alert you already dealt with still shouting at you somewhere else.
- **Two people sharing one bridge share one "seen".** Accepted. Collie's threat model is a personal
tailnet with one operator; a bridge is remote shell access, not a multi-tenant service.
- **Working in a pane at the desk does not clear its Collie alert.** This is the deliberate cost.
Counting a Herdr focus would let a pane you merely clicked past silently clear an alert you never
read — a false negative on the one thing the dashboard exists to surface. A false *positive* (an
item still listed as unseen after you dealt with it at the desk) costs one tap; a false negative
costs a missed agent.
- **The ledger writes on a debounce.** An open pane polls about once a second and each poll stamps
`seenAt`; in memory that's free, on disk it would be a write per second forever. Flushes are
capped at one per 10s plus one on shutdown, so an unclean kill can lose up to ten seconds of
precision — imperceptible in a feature whose finest unit is "just now".
- **The state can be thrown away.** Delete `activity.json` and the next poll re-seeds every pane as
seen. Nothing else depends on it.

### What would justify revisiting

- Herdr starts reporting real per-pane activity timestamps — then `activeAt` should come from the
source rather than from Collie's own observation, and a bridge restart would stop being a
re-seed.
- Collie grows genuine multi-user support (distinct identities, not just distinct devices). Then
"seen" becomes per-identity, and this ADR is superseded rather than amended.
- Evidence that clearing-at-the-desk actually matters in practice — i.e. the false positives are
frequent and annoying enough to outweigh the missed-agent risk. That's a usage question, not a
design one, and it should be answered with usage.
1 change: 1 addition & 0 deletions .adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ A superseded ADR is never deleted or edited into agreement with the present. Mar
| --- | --- | --- |
| [0001](./0001-one-managed-front-door.md) | Collie manages exactly one front door | Accepted |
| [0002](./0002-invert-the-light-terminal-mirror.md) | The light terminal mirror is inverted, not re-themed | Accepted |
| [0003](./0003-one-shared-seen.md) | "Seen" is one shared fact, and only Collie's own reads count | Accepted |
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,21 @@ All notable changes to Collie are recorded here. The format follows
### Added
- **Light and system themes.** Collie follows your phone's appearance by default; pin Light or Dark from **Settings → Appearance**. Per device, and documented under [Dark mode / light mode](./README.md#dark-mode--light-mode) (59bcfe1, df47112)
- ANSI slots 0–15 are now CSS variables (`--ansi-*`), so indexed terminal colour is defined in one place and reaches the mirror through both `31m` and `38;5;1` spellings (59bcfe1)
- **The dashboard is triaged, not listed.** Needs you → Ready · unseen → Working → Recent; the first three are pinned, Recent sorts by when you last used each pane (da4f44c)
- **Ready · unseen** — agents that finished while you weren't looking. Opening one clears it, on every device (2f4d691)
- Recent and Spaces fold and remember it; fold both and the page is the triaged herd and nothing else (da4f44c)
- The swipe-up **Switch pane** sheet folds its long tails too — Recent, and the bare **Shells** group that buried the agents underneath it (4cca8db)
- Spaces are ordered by last used and filterable — 45 of them are now three keystrokes, not a scroll (da4f44c)
- The bridge keeps two timestamps per pane (`activeAt`, `seenAt`) in `activity.json`, because Herdr reports none (2f4d691)
- **Tab and space chips carry a status dot** — blocked / ready / working / idle, in the herd list's own palette. They only ever showed a dot for blocked before, so every other state read the same as every other (22d4a5f)

### Changed
- The pane mirror renders in dark space under every theme and light mode inverts it, because agents emit truecolor almost exclusively and no palette can re-theme an absolute colour — [ADR 0002](.adr/0002-invert-the-light-terminal-mirror.md) (78425bd)
- In light, the page is a step off white with cards staying white, so the dashboard's hierarchy no longer rests on a single hairline — and the mirror's edge stops showing a seam (59bcfe1)
- MINOR, not MAJOR: pre-1.0, purely additive, no config or API break. Defaulting to your phone's appearance is the feature working as designed, and Settings pins it either way
- **Agent rows are titled `project · tab`, not "claude".** The pane's own name moves to the second line; the agent stays in the avatar (da4f44c)
- Spaces moved BELOW every agent section — it's a navigator, not a work queue (da4f44c)
- Only Collie's own reads count as seeing a pane; a Herdr focus at the desk does not — [ADR 0003](.adr/0003-one-shared-seen.md) (6786ca1)
- MINOR, not MAJOR: pre-1.0, purely additive, no config or API break. Defaulting to your phone's appearance is the feature working as designed and Settings pins it either way; an older bridge reports no activity timestamps and simply renders the previous dashboard, minus the one section that would be empty

### Fixed
- **The space and tab chip rows overlapped each other on the space screen** — both strips were missing `shrink-0` inside the route's flex scroller, so they collapsed to 16px around 32px chips and the tab row painted over the space row. Pre-dates this release (636b7af)
Expand All @@ -26,6 +36,17 @@ All notable changes to Collie are recorded here. The format follows
- Header controls had 20px touch targets; the Settings gear and the Settings back button are both 44px now, with no change to how they look (59bcfe1)
- The boot splash stepped from white to the page colour when React took over, and its caption measured 3.45:1 — it used `#ffffff`/`#8a8a8a` under a comment claiming they matched `--background`/`--muted-foreground`, which rasterize to `#f5f5f5`/`#5d5d5d`. Same fix for the light `theme-color` meta, so Android's URL bar matches the page (7f0189d)
- Inverse-video segments in the mirror emitted theme tokens while the muted glyphs beside them used literals; the mirror keeps one spelling now (identical pixels — the literals are those tokens' dark halves) (7f0189d)
- Marking a pane seen had made a read-level GET mutate state, so a cross-site `<img>` at a guessed pane id could silently clear your unseen agents. Only a request carrying the app's own header counts now — caught in this release's security review, never shipped (f9000cb)
- **Light `--accent` was byte-identical to `--background`**, so "this is the current one" showed nothing in light mode — the open pane in the switcher, the current session, every `hover:bg-accent`. Predates this release; found by the UX sweep (dab7e05)
- Titles truncated away the tab — the only part that identifies a row — leaving several panes rendering the same `moonward_os · t…` (8a8a4c9)
- Section headings rendered at two different sizes and cases, because a `<button>` doesn't inherit `text-transform` from its `<h2>` (8a8a4c9)
- A hollow status ring on the avatar's corner read as a notch cut out of the logo (5c04453)
- A space row and its chip could disagree about what a colour meant — the row still ranked by `STATUS_RANK` while the chip used the triage classifier, so a space holding one working agent and one unseen-done agent showed "working" on the dashboard and "ready" in the strip. Both route through `bucketOf` now, in one pass rather than spaces x agents per render
- `aria-controls` on a collapsed section pointed at an element that isn't rendered — exactly when a screen-reader user is deciding whether to expand it
- A status dot passed a smaller size only resized its wrapper, so chip dots rendered at the wrong size
- The Settings page rearranged itself a frame after opening — Notify-when and Snooze mounted only once push state resolved, inserting ~400px into the middle of the page, and Notify-when then grew another ~180px waiting on its own prefs. Both render from the first frame now, switches disabled until their values land
- The pane row ran straight into terminal output with no edge between them, so the chrome and the mirror read as one surface
- Herd and space rows had a border radius with no border to own it, so a rounded hover fill sat under a straight `divide-y` hairline. Rows without a border are square; the ones with a real border keep their radius

## [0.19.0] - 2026-07-29

Expand Down
Loading
Loading