Skip to content

feat(service,features,database): keep each member's read state on Inbox items - #3618

Merged
luke-agent[bot] merged 3 commits into
mainfrom
feat/inbox-item-read-state
Oct 18, 2026
Merged

luke-agent[bot] merged 3 commits into
mainfrom
feat/inbox-item-read-state

Conversation

@dastratakos

@dastratakos dastratakos commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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, holding read_through (the item's updated_at the member has read up to) and marked_unread. There is a unique index on (item_id, user_id).
  • The rule (unreadFor in packages/service/src/inbox/inbox-store.ts). An item is unread for a member when they marked it unread, or when its updated_at is later than the later of their read_through and the item's own read_through.
    • Anything that moves updated_at makes 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's reply_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.
  • 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 NULL and 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), and drizzle-kit check passes.
  • Wire.
    • inboxItemSchema gains unread?: boolean. It is used by the plan inbox, the agent inbox, and the item returned by an answer or a message.
    • accountInboxEntrySchema and accountTasklessEntrySchema gain unread?: boolean.
    • The service always sends the field. It is optionalKey so that a desktop talking to an older service reads a missing value as "read", the same convention as startedBy.
  • Routes (one function, apps/web/server/routes/inbox/read.ts):
    • PUT /api/inbox/:id/read marks one item read. It answers { id, unread: false } and is idempotent: when the marker already stands, it writes nothing (a conflict setWhere skips the update). A read only ever moves a marker forward (greatest of the existing and new read_through), so a read that raced the member's own answer can't make that answer read as unread.
    • DELETE /api/inbox/:id/read marks one item unread. It answers { id, unread: true }.
    • PUT /api/inbox/read with 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/read rewrite comes before /api/inbox/([^/]+). To allow that, SegmentRewrite.query is now optional.
  • Client. New HostedCodingAgentClient methods:
    • markInboxItemRead(itemId) and markInboxItemUnread(itemId) return a CodingAgentInboxReadStateView.
    • markInboxRead({ workspaceId? }) returns a CodingAgentInboxReadAllView.
  • Live reads.

Evidence

  • Platform-independent checks: pnpm check:affected ran. 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/service typecheck was OOM-killed (exit 137) and discarded-effect ran out of heap. Run one at a time, the service, web, host, and desktop typechecks all passed, and pnpm discarded-effect reported no discarded Effect in 2279 files.
    • desktop app-menu.test.ts fails because there is no Electron binary in the sandbox. It is unrelated to this change.
    • vitest --project web --project features --project database --project host passed: 174 files.
  • 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.sh green. (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 property unread: boolean.
    • account-inbox-wire-accountInboxEntrySchema and accountTasklessEntrySchema, plus the schemas that embed them (accountInboxAnswerSchema, codingAgentAccountInboxAnswerViewSchema, 6 lines each): add the optional property unread: boolean.
    • New goldens: inboxReadStateSchema ({ id, unread }), inboxReadAllRequestSchema ({ workspaceId? }), inboxReadAllSchema ({ marked }), codingAgentInboxReadStateViewSchema, and codingAgentInboxReadAllViewSchema (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 as type assertion outside the allowlisted files.
  • No Effect.runPromise/runSync/runFork outside 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.md sentence names a changed part.
  • PRIVACY.md unchanged. 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-read export 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 DEFAULT on ADD COLUMN.
  • Blockers or follow-up verification: the desktop rows ("Inbox list rows") read unread and call the three client methods. The host acts and IPC are left to that work.

Made with Luke


Devin Review


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_read table 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_through with a catalog-only now() default and drops it in the same transaction, so no rows are rewritten and items posted afterwards start unread.

Wire and routes

  • inboxItemSchema and the account inbox entry schemas gain optional unread; a missing value reads as read, the same convention as startedBy.
  • New routes PUT/DELETE /api/inbox/:id/read (idempotent for an already-standing marker) and PUT /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.

Written for commit 986b702. Summary will update on new commits.

View guided diff Turn on auto-fix

dastratakos and others added 2 commits October 18, 2026 16:10
…ox items

An inbox item now carries `unread` for the member reading it, kept per member
and item in `inbox_item_read`. An item is unread while it changed after the
last change the member read through, or while they marked it unread. A
member's own answer or message marks it read for them in the same write.
`PUT`/`DELETE /api/inbox/:id/read` mark one item read or unread, and
`PUT /api/inbox/read` marks every item the member's Inbox lists read,
optionally in one workspace. The held reads' cursors digest the reader's
unread state, so a marker moving wakes that member's reads.

Items that stood before this ships read as read: the migration adds
`inbox_item.read_through` with a `now()` default kept in the catalog, which
stamps every standing row without rewriting it, and drops the default, so
items posted after it start unread. No backfill.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
@vercel

vercel Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
luke-web Ready Ready Preview Oct 18, 2026 4:21pm UTC

Request Review

devin-ai-integration[bot]

This comment was marked as resolved.

A read selects the item's last change before it takes the marker's lock, so a
member's own answer recorded meanwhile could be overwritten by the older
change, reading their answer as unread. Both read upserts now keep the later
of the two and write only where the marker moves, so mark all counts only
what it moved.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
@luke-agent
luke-agent Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 3e5ba11 Oct 18, 2026
11 checks passed
@luke-agent
luke-agent Bot deleted the feat/inbox-item-read-state branch October 18, 2026 16:33

This branch was successfully deployed

1 active deployment
Preview — 986b702d Deployed Oct 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant