feat(ui,host,desktop): mark Inbox items read and unread
Summary
PR 2 of 3 for the Inbox list rows in docs/proposals/inbox-threads.md §3–§4, approved by Dean. It builds on #3612 (the two-line rows) and #3618 (the service's per-member read state), both merged. PR 3, the two-stage swipe, stacks on this one.
Unread (tasks/inbox-list.tsx). An item is unread when it changed since the reader last read it, or when they marked it unread. An unread item shows:
- the blue dot in a left gutter before its tile;
- its title in semibold;
- its preview in the body's ink instead of muted;
- "Unread" spoken to a screen reader.
The gutter stands on every row, so the tiles line up whether or not a row has a dot. Needs you stays the orange attention mark, independent of unread.
Ways to mark. Each one sends one act and flips the dot at once (tasks/use-inbox-read-marks.ts).
- Opening an item marks it read, once each time it's shown. That includes the item that stands chosen when the Inbox opens. If you mark the shown item unread, it stays unread until you open it again, as Mail does.
- Hover button in the time's slot. It reads Lucide
MailOpen"Mark as read" on an unread item andMail"Mark as unread" on a read one. While the row is hovered or holds the keyboard, the time and the attention mark step aside, as Pin and Archive do on a sidebar row. It reuses the plans'markReadItem. - Right-click menu. "Mark as read" or "Mark as unread" with
Ubeside it, above Copy link. - Plain
Utoggles the chosen item. It's a newAPP_COMMAND.TOGGLE_INBOX_ITEM_READwith{ key: U, plain: true }, so it's skipped while typing, like J/K.⇧⌘Ustays Automations'. - View options › Mark all as read, in its own group under the groupings. It marks the listed workspace's items and clears dots only; attention marks stay. It's disabled when nothing is unread.
How the marks hold (withReadMarks in inbox-model.ts, use-inbox-read-marks.ts).
- A mark lies over the reads until they hold the item the way the mark left it. Then the mark is dropped, so a later read, including another window's mark, is what the row draws.
- Marks go to the service one at a time, in click order. If an item is marked again before its last mark goes, only the latest is sent. Mark all as read takes its turn in the same queue.
- If the service refuses a mark, the mark is dropped.
- Once the service takes Mark all as read, every listed plan and every agent with no plan is read again, because that write changes nothing that main hears about.
U, and marking an item read when it opens, both stand down while the side panel holds the keys or fills the window.
A task's own Inbox tab draws the same rows with the same marks. It has no view menu, so it doesn't offer Mark all as read.
The Factory (desktop-factory.tsx). Its rows and summary now show the attention mark for "waits on you" instead of the blue .inbox-row-dot, so blue means unread everywhere. The orchestrator agreed to this. .inbox-row-dot and its CSS are gone.
Host acts (packages/host, packages/gateway). Each has one ACT_KIND entry, a payload schema, a result guard, and one router row, over #3618's client methods:
codingAgents.inboxReadState, payload{ itemId, unread }, callsmarkInboxItemReadormarkInboxItemUnread.codingAgents.inboxReadAll, payload #3618's owninboxReadAllRequestSchema({ workspaceId? }), callsmarkInboxRead.
@luke/ui ListRow
- New
actionsAt: LIST_ROW_ACTIONS.HOVER, which puts the row's actions over the end of its metadata, shown on:hoverand:has(:focus-visible). unreadnow means a semibold title and the description in the body's ink. Nothing else used ListRow'sunread.
Screenshots
Rendered from the jsdom fixtures with the built apps/desktop/dist/renderer/styles.css, captured with Playwright.
| Dark | Light | |
|---|---|---|
| Unread rows | ![]() | ![]() |
| Hover: the mark in the time's slot | ![]() | ![]() |
| View options › Mark all as read | ![]() | ![]() |
Evidence
pnpm check:affected: lint, knip, anddiscarded-effectpass.- Two failures came from sandbox memory, and both pass when run alone:
@luke/desktoptypecheck was OOM-killed (exit 137); alone, it reports 0 errors.issue-client.test.tstimed out under load; alone, it passes 4/4.
src/main/app-menu.test.tscan't run here because the sandbox has no Electron binary.
- Two failures came from sandbox memory, and both pass when run alone:
- After rebasing onto main past #3618: typecheck reports 0 errors in features, host, and desktop.
- Nine suites, 569 tests, all pass: hosted-wire-schemas, coding-agent-acts, compose-coding-agents, desktop-inbox, desktop-tasks, desktop-factory, desktop-home, inbox-model, and list-row.
- JSON Schema goldens: one new file,
coding-agent-view-codingAgentInboxReadStateParamsSchema.json({ itemId: non-empty string, unread: boolean }, both required, no extra keys), written underLUKE_UPDATE_FIXTURES=1. No existing golden changed. - Test count: +15 (six from the review fixes are at the end of this list).
desktop-inbox.test.ts, +5:- the unread look, and opening an item marks it read;
- the hover button, the menu item with
Uabove Copy link, and plainU; - marking the shown item unread keeps it unread until it's reopened;
- a refused mark is dropped;
- Mark all as read: its placement, workspace, dots only, and disabled when nothing is unread.
desktop-tasks.test.ts, +1: a task's own Inbox marks what it opens read and offers the row mark.inbox-model.test.ts, +1:withReadMarksholds until the item changes.coding-agent-acts.test.ts, +1: both acts route, and the voice window is refused.hosted-wire-schemas, +1: the recorded params schema.compose-coding-agents.test.ts: the existing round-trip test now covers both gateway methods.- From Devin's review (+6):
use-inbox-read-marks.test.ts(+3): a mark stands until the reads catch up, after which another window's mark is drawn; marks go one at a time in order, with an item's latest mark winning and Mark all as read taking its turn; a refused mark, or a refused Mark all as read, is dropped and asks nothing again.use-inbox-items.test.ts(+1):onRereadreads every listed plan again.desktop-inbox.test.ts(+1): Mark all as read re-reads every plan and every agent with no plan.inbox-side-panel.test.ts(+1):Uis not the Inbox's while the panel holds the keys.
desktop-factory.test.ts:rows()now requires the attention mark with "Needs you" and no blue dot on every waiting row, and the summary counts beside the mark.
Invariants
-
./scripts/check.shgreen. (CI) - JSON Schema goldens unchanged (
LUKE_UPDATE_FIXTURESnot run, or diff explained line by line). (CI) One new golden, explained above; no existing one changed. - Every behavior change has, in this PR, the test that fails without it; no change-detector test added. (manual; root
AGENTS.md"Testing") - Gateway envelope goldens unchanged. (CI)
- No new
astype assertion outside the allowlisted files. (CI via anti-slop) - No
Effect.runPromise/runSync/runForkoutside the runtime edges and the shimstools/anti-slop/effect-edges.jsonrecords. (CI viaanti-slop/no-run-promise-outside-edges) - No new
setTimeout/setInterval/new Promise/AbortController/fs.watchoutside the filestools/anti-slop/effect-edges.jsonrecords. (CI viaanti-slop/no-raw-async-primitives) - No Effect, Stream, or Layer written as a statement and dropped; a description a caller walks past runs nothing. (CI via
pnpm discarded-effect) - Test count equals the pre-PR count or the delta is listed. (manual:
vitest run --reporter=json) - Each hand-rolled file the PR replaces is deleted, or the part that remains is a permanent adaptor named in root
AGENTS.md's "The permanent adaptors" with the reason it stays. - Every
CLAUDE.md/AGENTS.mdsentence naming a changed part is edited; root pair stays byte-identical. (CI for pair existence; manual for wording) -
PRIVACY.mdunchanged, or the change is a product decision called out in the PR body. - Package
package.jsondeps match what the sources import by bare specifier;effectviacatalog:. (CI) - Barrel/door rule: no Node-reaching Effect module (
@effect/platform-node,effect/sql) behind a barrel the renderer or a web function opens. (CI: existing renderernode:grep plus a new grep for those specifiers in renderer bundles)
Notes
- Read titles keep the body's primary ink. The design show draws them a step quieter, but the dot, the semibold weight, and the brighter preview already set unread rows apart clearly.
- Home's Inbox and a person's page draw the unread dot from the item, but don't offer the hover button or the menu mark.
- Blockers: none. PR 3, the two-stage swipe, follows on this branch.
Made with Luke
Summary by cubic
Adds read and unread states to Inbox item rows: an item changed since the reader last read it, or marked unread, wears a blue dot, a semibold title, and a body-ink preview until opened. Opening an item marks it read; a hover button, right-click menu item, plain U, and View options › Mark all as read mark items read or unread.
New Features
- A mark flips the dot at once, is dropped if the service refuses it, and gives way once a read holds the item as the mark left it, so another window's later mark is drawn. Marks reach the service one at a time in the order made; Mark all as read re-reads every listed plan and agent once taken, since the write moves nothing main hears of.
- A task's own Inbox tab draws the same marks but omits Mark all as read; Home shows the dot without the mark controls.
- Factory rows and summary use the attention mark for "needs you", so blue means unread everywhere.
@luke/uiListRowgainsLIST_ROW_ACTIONS.HOVER, placing row actions over the metadata's end, and a new unread look.





