feat(ui,host,desktop): mark Inbox items read and unread

Merged
DA
2d ago
+1327-73

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 and Mail "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 U beside it, above Copy link.
  • Plain U toggles the chosen item. It's a new APP_COMMAND.TOGGLE_INBOX_ITEM_READ with { key: U, plain: true }, so it's skipped while typing, like J/K. ⇧⌘U stays 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 }, calls markInboxItemRead or markInboxItemUnread.
  • codingAgents.inboxReadAll, payload #3618's own inboxReadAllRequestSchema ({ workspaceId? }), calls markInboxRead.

@luke/ui ListRow

  • New actionsAt: LIST_ROW_ACTIONS.HOVER, which puts the row's actions over the end of its metadata, shown on :hover and :has(:focus-visible).
  • unread now means a semibold title and the description in the body's ink. Nothing else used ListRow's unread.

Screenshots

Rendered from the jsdom fixtures with the built apps/desktop/dist/renderer/styles.css, captured with Playwright.

DarkLight
Unread rowsdarklight
Hover: the mark in the time's slotdarklight
View options › Mark all as readdarklight

Evidence

  • pnpm check:affected: lint, knip, and discarded-effect pass.
    • Two failures came from sandbox memory, and both pass when run alone:
      • @luke/desktop typecheck was OOM-killed (exit 137); alone, it reports 0 errors.
      • issue-client.test.ts timed out under load; alone, it passes 4/4.
    • src/main/app-menu.test.ts can't run here because the sandbox has no Electron binary.
  • 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 under LUKE_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 U above Copy link, and plain U;
      • 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: withReadMarks holds 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): onReread reads 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): U is 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.sh green. (CI)
  • JSON Schema goldens unchanged (LUKE_UPDATE_FIXTURES not 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 as type assertion outside the allowlisted files. (CI via anti-slop)
  • No Effect.runPromise/runSync/runFork outside the runtime edges and the shims tools/anti-slop/effect-edges.json records. (CI via anti-slop/no-run-promise-outside-edges)
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch outside the files tools/anti-slop/effect-edges.json records. (CI via anti-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.md sentence naming a changed part is edited; root pair stays byte-identical. (CI for pair existence; manual for wording)
  • PRIVACY.md unchanged, or the change is a product decision called out in the PR body.
  • Package package.json deps match what the sources import by bare specifier; effect via catalog:. (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 renderer node: 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


Devin Review


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/ui ListRow gains LIST_ROW_ACTIONS.HOVER, placing row actions over the metadata's end, and a new unread look.