Running → Unread → Remaining, reusing the existing cards and tiles. New human-facing assistant output makes a session unread; tool traffic and status changes do not. A reply counts as read only once it has actually been put in front of the operator.


list : [running now] 1 · deploy tiles : [running now] 1 · deploy
[unread] 2 · billing, triage [unread] 2 · billing, triage
[by the last reply] 2 · infra, docs [by the last reply] 2 · infra, docs
total cards on screen: 5 total cards on screen: 5


Driven by keyboard, since that is the harder path:
accessible name: "Mark the 2 unread replies in this section as read.
Running agents and anything the current filters hide are left alone."
before : running now=1 unread=2 by the last reply=2
after Enter : running now=1 by the last reply=4 ← empty section omitted
server state: deploy:read triage:read billing:read docs:read infra:read
after a new reply arrives:
running now=1 unread=1 by the last reply=3 ← and it is unread again
A comparison, not a flag: the newest reply the transcript has produced against the newest the operator has been shown. Both are {src, seq, hash} — the transcript generation, how many distinct replies it has produced, and a hash of the full text of the newest one.
| Case | New unread? | Why |
|---|---|---|
| A new assistant reply | yes | the sequence advances |
| A streaming answer that grew | yes | same position, different hash — including past the 280-char card clip, where the visible summary is unchanged |
| Two replies with identical text | yes | a prompt between them starts a new reply whatever it says |
| Two replies at the same timestamp | yes | the sequence separates them; no clock is consulted |
| The same record mirrored | no | codex writes agent_message and task_complete with the same words |
| Tool calls, token counts, status | no | the cursor moves on assistant text only — deliberately not the file's revision, which moves for all of these |
| The operator sends a prompt | no | and, critically, it does not clear an existing unread state — see below |
| The transcript is replaced (new run) | yes | the generation changes, so the old mark cannot cover the new file's replies |
The bug this had to avoid: the existing digest clears its assistant fields on every new prompt. Had the cursor lived in those fields, typing at an agent would have silently marked its unseen answer as read. The cursor deliberately survives digestPrompt, and there is a test that fails if it stops doing so.
The bar is higher than "the component rendered". Mounting a conversation, selecting a route, a fetch resolving and the reader's At latest flag all describe the program, not what the operator can see. The hook asks the browser instead: is the element carrying the latest reply intersecting the viewport, in a foreground tab, on the active surface?
| Situation | Acknowledged? |
|---|---|
| Latest reply visible in an expanded Overview conversation, foreground | yes |
| Latest reply visible in the Reader, in the active pane, foreground | yes |
| Rendered but below the fold | no |
| On screen, but the tab is in the background | no |
| A card or tile preview | no |
| A pane sitting behind another in a deck | no |
| Paged back to an earlier turn | no |
| A reply too long for the card's copy of it | no — finished in the Reader, which serves the whole thing |
| A search showing older matches | no |
Proven against a real IntersectionObserver in Chromium rather than by calling the acknowledgement path directly — the test also asserts the observer would have fired in the hidden-tab case, so it is the guard that stops it and not an accident of layout.
An acknowledgement names the exact version that was displayed, never "whatever is newest". The server accepts it only if that is still the newest, so a reply arriving between rendering and acknowledging stays unread instead of being swept up by a mark it was never part of.
| Request | Answer |
|---|---|
| Exactly what is on screen | ok |
| The same one again | ok — replays are harmless |
| An older reply, arriving late | ok, but recorded progress does not move back |
| A different transcript generation | stale |
| Further than any reply that exists | future |
| Right position, wrong content (a streaming answer moved on) | mismatch |
| A session that does not exist | unknown |
A rejection is an ordinary answer, not a failure to retry: it means the reply it described is no longer the newest, and the card correctly stays unread. Nothing is claimed read unless the server said so — a failed bulk action leaves the section exactly as it was, with one compact retry that reuses the original capture.
Existing replies start read, once ever, from the versions actually observed in the first pass that sees any output — not a wall-clock cutoff. A reply landing while the baseline is being written is newer than anything captured, so it stays eligible for Unread.
A session with nothing readable yet is skipped rather than marked read at position zero, so its first reply is unread rather than presumed seen. Persistence and the once-only rule are tested across a restart.

The three sections live in the ranked feed, which is the layout that already renders every card under one parent so a card changing section is a reorder rather than a remount. Manual mode draws each group as its own capsule; hoisting a session out of its capsule into an Unread block would change its parent, and React remounts on a changed parent however stable the key — destroying the reply draft, unfolded work and scroll position of a card that became unread from a background poll. That is exactly the failure the ranked feed's stable-parent comment exists to prevent.
Giving manual mode the section therefore requires the capsule layout to adopt a single parent first, which is a separate change. Also left out, as the issue directs: toolbar relocation, full-history search, general polling changes and the mode-switch position fix.