feat(service,features,database): keep each member's read state on Inbox items
Merged
DA
2d ago+14837+14837+01234567890123456789012345678901234567890123456789-55-55-01234567890123456789
Summary
This is the service and wire half of the Inbox list-row redesign (docs/proposals/inbox-threads.md §3–§4). Every Inbox item now carries the reader's unread flag. A member can mark one item read or unread, or mark every item read.
- Storage. New table
inbox_item_read: one marker per member per item, holdingread_through(the item'supdated_atthe member has read up to) andmarked_unread. There is a unique index on(item_id, user_id). - The rule (
unreadForinpackages/service/src/inbox/inbox-store.ts). An item is unread for a member when they marked it unread, or when itsupdated_atis later than the later of theirread_throughand the item's ownread_through.- Anything that moves
updated_atmakes the item unread again for everyone who had read it. That covers a post or repost under its key, a close, another member's answer or message, the agent'sreply_inbox, and an answer being withdrawn. - A member's own answer, thread message, or withdrawn answer writes their marker in the same transaction. The item stays read for them and turns unread for teammates.
- Marking an item unread sticks until that member reads it again.
- Anything that moves
- No dots at launch (cutoff, no backfill). The migration adds
inbox_item.read_through timestamptz DEFAULT now()and then drops the default in the same transaction.now()is stable, so Postgres works it out once and keeps it in the catalog as the value for every existing row. No row is rewritten, and this is not a backfill.- Every item that exists at deploy reads as read for every member until it changes again. Items posted afterwards get
NULLand start unread. - The SQL comments in the migration say the same. Because of this, the migration SQL was hand-edited after
db:generate. The snapshot is unchanged (nullable column, no default), anddrizzle-kit checkpasses.
- Wire.
inboxItemSchemagainsunread?: boolean. It is used by the plan inbox, the agent inbox, and the item returned by an answer or a message.accountInboxEntrySchemaandaccountTasklessEntrySchemagainunread?: boolean.- The service always sends the field. It is
optionalKeyso that a desktop talking to an older service reads a missing value as "read", the same convention asstartedBy.
- Routes (one function,
apps/web/server/routes/inbox/read.ts):PUT /api/inbox/:id/readmarks one item read. It answers{ id, unread: false }and is idempotent: when the marker already stands, it writes nothing (a conflictsetWhereskips the update). A read only ever moves a marker forward (greatestof the existing and newread_through), so a read that raced the member's own answer can't make that answer read as unread.DELETE /api/inbox/:id/readmarks one item unread. It answers{ id, unread: true }.PUT /api/inbox/readwith body{ workspaceId?: string }marks read every item the member's Inbox lists: items on tasks that aren't archived, and items of agents with no task. Settled items are included. It covers one workspace, or all of them when no workspace is given, and answers{ marked: number }, the count that was unread. It clears the dots only; open items still wait.- Any member of the item's workspace may mark an item, and only their own marker changes. An item outside the account's workspaces is
not-found. - The
/api/inbox/readrewrite comes before/api/inbox/([^/]+). To allow that,SegmentRewrite.queryis now optional.
- Client. New
HostedCodingAgentClientmethods:markInboxItemRead(itemId)andmarkInboxItemUnread(itemId)return aCodingAgentInboxReadStateView.markInboxRead({ workspaceId? })returns aCodingAgentInboxReadAllView.
- Live reads.
- The stamps a held read compares now carry the reader's
unread, andcursorOfdigests it when present. A member's own marker changing wakes their plan, agent, and account held reads, and leaves teammates' reads alone. - Rows without an unread state (chat, failed runs, plan comments) digest exactly as before, so their cursors don't change.
- The Neon Realtime inbox follows (#3217, #3232) are not merged yet. Whichever lands second needs to treat a change to
inbox_item_readfor the reader as moving their inbox.
- The stamps a held read compares now carry the reader's
Evidence
- Platform-independent checks:
pnpm check:affectedran. The repository contract checks,check:migrations(drizzle-kit check), oxlint and biome, and knip all passed, along with the ui and service vitest suites. Some parts failed for sandbox reasons only, so I re-ran them on their own and they passed:- The
@luke/servicetypecheck was OOM-killed (exit 137) anddiscarded-effectran out of heap. Run one at a time, the service, web, host, and desktop typechecks all passed, andpnpm discarded-effectreported no discarded Effect in 2279 files. desktopapp-menu.test.tsfails because there is no Electron binary in the sandbox. It is unrelated to this change.vitest --project web --project features --project database --project hostpassed: 174 files.
- The
- New tests, all on PGlite. Each fails without its behavior; I checked by mutating the store and the migration:
inbox-store.test.ts› "an inbox item's read state":- new items are unread, and so are items that changed after a read (reply, a teammate's answer, a repost, a close);
- your own answer or message leaves the item read for you and unread for a teammate;
- mark unread, then read again; a teammate's marker never moves yours; an outsider is refused;
- mark all read, scoped to a workspace and across all of them, with settled items included and only unread items counted;
- an item from before the release reads as read until it changes;
- held reads (plan and account) wake when your own marker moves, and a teammate's cursor stays where it was.
apps/web/tests/inbox-item-read-migration.test.ts: this runs the real migration over an item that already exists. That item reads as read for both members, an item posted afterwards reads unread, no markers are written, and running the migration again changes nothing.coding-agents-app.test.ts: covers the route (PUT/DELETE, mark all, 404 for an outsider, 405, 400).coding-agent-client.test.ts: covers the three client methods, their paths and bodies, and their refusals.
- Test count: +10 (7 store, 1 migration, 1 route, 1 client).
Invariants
-
./scripts/check.shgreen. (CI) - JSON Schema goldens changed, with
LUKE_UPDATE_FIXTURES=1. Each change:inbox-wire-inboxItemSchema, plus the schemas that embed it (inboxAnswerSchema,inboxAnsweredSchema,inboxMessagedSchema,codingAgentInboxAnswerViewSchema,codingAgentInboxAnsweredViewSchema,codingAgentInboxMessagedViewSchema): add the optional propertyunread: boolean.account-inbox-wire-accountInboxEntrySchemaandaccountTasklessEntrySchema, plus the schemas that embed them (accountInboxAnswerSchema,codingAgentAccountInboxAnswerViewSchema, 6 lines each): add the optional propertyunread: boolean.- New goldens:
inboxReadStateSchema({ id, unread }),inboxReadAllRequestSchema({ workspaceId? }),inboxReadAllSchema({ marked }),codingAgentInboxReadStateViewSchema, andcodingAgentInboxReadAllViewSchema(each answer, or a failure).
- Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
- Gateway envelope goldens unchanged. (CI)
- No new
astype assertion outside the allowlisted files. - No
Effect.runPromise/runSync/runForkoutside the runtime edges. - No new
setTimeout/setInterval/new Promise/AbortController/fs.watch. - No Effect, Stream, or Layer written as a statement and dropped.
- Test count delta listed above (+10).
- No hand-rolled file replaced.
- No
CLAUDE.md/AGENTS.mdsentence names a changed part. -
PRIVACY.mdunchanged. A read marker is the member's own state on their own workspace's items. - Package deps match imports. The new
@luke/database/schemas/inbox-item-readexport was added. - Barrel/door rule: no new Node-reaching module behind a renderer barrel.
Notes
- Rollout: a regular Drizzle migration. It creates one new empty table and adds one nullable column with a catalog-only default (no table rewrite). The foreign keys on the new table are checked against an empty table. I did not measure against the Dev database. The claim that this is catalog-only rests on Postgres's documented fast-default behavior for a non-volatile
DEFAULTonADD COLUMN. - Blockers or follow-up verification: the desktop rows ("Inbox list rows") read
unreadand call the three client methods. The host acts and IPC are left to that work.
Made with Luke
Summary by cubic
Keeps each member's own read state on Inbox items so the Inbox lists show what is unread for the reader. Every Inbox item now carries unread, and a member can mark one item read or unread, or mark every item their Inbox lists read.
Storage and rule
- New
inbox_item_readtable holds one marker per member per item; an item is unread when marked unread or changed after the later of the member's and the item's read-through points. - A read never moves a marker backward: both read upserts keep the later of the standing and new read-through points and write only where the marker moves, so a member's own answer landing while their read waits on the marker's lock is not read as unread, and mark-all counts only what it moved.
- A member's own answer, message, or withdrawn answer writes their marker in the same transaction, leaving the item read for them and unread for teammates.
- Items that existed before this ships read as read until they change: the migration adds
inbox_item.read_throughwith a catalog-onlynow()default and drops it in the same transaction, so no rows are rewritten and items posted afterwards start unread.
Wire and routes
inboxItemSchemaand the account inbox entry schemas gain optionalunread; a missing value reads as read, the same convention asstartedBy.- New routes
PUT/DELETE /api/inbox/:id/read(idempotent for an already-standing marker) andPUT /api/inbox/read(optionally scoped to one workspace) answer the item's read state and the count marked; the client gets three matching methods. - Held-read cursors digest the reader's unread state, so a marker change wakes only that member's held reads and leaves teammates' cursors alone.