feat(web,desktop): Inbox notifications, a Dock badge, and an all-plans Inbox in the sidebar
Merged
DA
7d ago+2240+2240+0123456789012345678901234567890123456789-178-178-012345678901234567890123456789
Summary
Until now the Inbox was per plan, read only while that plan was open, and nothing told the developer an item had landed while they were elsewhere. This PR adds three things.
GET /api/inbox?after=lists the account's open items across every plan it hasn't archived.- Entries are slim: item id, plan, agent id, the agent's current title and model, item title, revision, and instants, plus the service's
readAt. Every open item is returned with no cap, like the plan read; the per-agent bound of 20 keeps the total finite.inbox-wire.tsitem fields are untouched; the shape lives in a new@sidecar/hosted/account-inbox-wire. - It's held open while nothing changed, the same way
GET /api/plans/{id}/inboxis (coder-host/inbox-page.ts, which now shares one hold helper between the two reads). Each look inside the hold reads stamps only (id, revision, status, updated_at), every 2s instead of the plan read's 500ms, because a desktop holds this read for as long as it's signed in. - New index
inbox_item(user_id, status, updated_at), migration 0093 (main holds through 0092). inbox_item was indexed only on(agent_id, key)and(plan_id, updated_at). Without the new index, the account-wide filter on user and open status would scan the table on every look of every signed-in Mac. The archived-plan filter joinsplanby primary key from the rows this index finds, so it never scans either.EXPLAIN ANALYZEof the exact stamps query, against the real migrations on Postgres 16 with 600k items across 2,000 accounts: a Bitmap Index Scan oninbox_item_user_status_updated(user_id, status), then a nested loop intoplan_pkeywitharchived_at IS NULLas a filter. 0.29 ms, 165 buffers. - It goes through the same chain as the existing inbox calls: client ā gateway (
codingAgents.accountInbox) ā host operator.
- Entries are slim: item id, plan, agent id, the agent's current title and model, item title, revision, and instants, plus the service's
- Notifications and a Dock badge, held in main's
agent-notices.tsnext to the agent-end notices.- Main holds the account read while an account is signed in, writes each plan's open items to a new
inboxapp-state slice, and sets the Dock badge to the total.
- Main holds the account read while an account is signed in, writes each plan's open items to a new
- An Inbox section in the sidebar, above Pinned, only while anything waits. It has one plan row per waiting plan, showing the plan's name, its open count in the time slot, and its repository underneath. The header is drawn like Pinned's: a tray glyph that turns into the fold chevron, the fold remembered across launches, and the folded header showing the total item count. Pressing a row opens that plan on its Inbox tab, and the row is marked current while that tab is on screen.
- Cross-Mac gap closed. If the account read says a plan has open items, that plan gets its Inbox tab and its plan inbox read even when this window's agent list doesn't include the agent that posted (for example, one started on another Mac).
Notification behaviour
- The first answer after launch or sign-in sets the baseline and notifies nothing.
- After that, an item notifies when it becomes open: newly posted, or posted again under the key of an item the developer answered or the agent closed. It notifies only at a revision no earlier answer for the account held, and only if it changed after the previous answer's
readAt, minus 1 minute to allow for clock drift between service instances. Unarchiving a plan therefore brings its old items back without a burst of notifications, even within that minute. - An agent editing an item that's already open notifies nothing.
- Gating matches agent ends:
- Window focused on that plan's Inbox tab: nothing.
- Window focused anywhere else: the window's own toast, in a new "ask" tone with the Inbox tray glyph, whose View opens the plan's Inbox. The toast goes away when the item stops waiting (judged only from a version of main's document newer than the one held when the toast arrived) or its Inbox comes on screen.
- Window behind: a Mac notification titled with the plan's name, with the body ": ". Clicking it opens the plan's Inbox. It's skipped when the agent-notifications setting is off.
- The badge doesn't follow Luke's setting: macOS gates badges itself in System Settings āŗ Notifications. The setting's label and description now cover Inbox items: "Notify me when a coding agent finishes or needs me".
- Signing out or switching accounts interrupts the held read and clears the slice, the counts and the badge before anything is read for the next account. Nothing is posted for the old account.
- Cost: this is one held read per signed-in Mac for as long as Luke runs, as agreed with the orchestrator.
Evidence
- Platform-independent checks:
./scripts/check.shpasses (knip, lint, discarded-effect, typecheck, every test shard, build with bundle budget). - macOS Electron verification (
./scripts/verify.sh): not run. This was built in a Linux cloud workspace. CI can't verify this UI. A Mac break would be caught at the release rehearsal on a tag, not at review. - New tests at each layer:
- Route (
tests/coding-agents-app.test.ts, +2): open items across plans with their agent; settled items, archived plans' items and another account's items left out; 401 without a bearer; the read holds, then answers once an item opens or a waiting one is answered. - Client chain (+2 tests, 2 extended): client address and hold past the ordinary deadline; composer method and params refusal; host-operator round trip and unreadable answer.
- Main notices (
agent-notices.test.ts, +7): baseline announces nothing; newly opened and reopened items announce once, edits of open items don't, and click opens the Inbox; focus and setting gating; account switch and sign-out clear everything and announce nothing for the new account's baseline; a failed read retries on the watch clock; unarchived items are counted but not announced, whether old or within the skew minute. The shown-tab act test is extended to carry the Inbox plan. - Renderer (+10): sidebar Inbox rows, order and counts; press from Settings and current row; fold kept across launches; header and count styles compared against Pinned's header and a row's time. Also the inbox toast and its withdrawal, the Inbox tab standing for a waiting plan, the plan's inbox read for a stale agent list, and main being told which Inbox is shown.
- Route (
Visual evidence
These aren't verify.sh captures. They show the real DesktopSidebar, ToastHost and useInboxToasts with the built stylesheet, drawn in headless Chrome over fixture plans.
Sidebar Inbox. "Teammate invitations" is open on its Inbox tab:

An item that lands while the window is focused on another plan:

Invariants
-
./scripts/check.shgreen, including the build's bundle budget against the renderer baseline #2228 recorded../scripts/verify.shnot run (no Mac; see above). - JSON Schema goldens: no existing golden changed. New ones recorded for
account-inbox-wire(entry, answer) and the twocoding-agent-viewaccount-inbox schemas. - Every behavior change has, in this PR, a test that fails without it; no change-detector tests.
- Gateway envelope goldens unchanged.
- No new
asassertion outside the allowlisted files, noEffect.run*outside the runtime edges, no new raw async primitives outside the recorded files, and no Effect, Stream or Layer dropped as a statement. - Test count: +21 tests, none removed (several existing tests extended).
- No hand-rolled file replaced.
- No
CLAUDE.md/AGENTS.mdsentence names a changed part. -
PRIVACY.mdchanged (product decision): the inbox paragraph now says Luke on the Mac reads the items waiting across plans to count them in the sidebar and on the Dock, and that an item landing while the window is behind is named by its plan, agent and title in a Mac notification, which macOS shows and keeps. - Package deps match imports; barrel/door rule kept (the renderer reaches only Schema-only doors).
- Nothing agents are told changes: no instructions or tool descriptions are touched.
Notes
- Migration order: Effect's migrator skips any id at or below the latest one applied, so this PR's 0090 must stay above main's highest migration when it merges (currently 0092).
š¤ Generated with Claude Code