feat: sync thread read state across clients - #9124
Conversation
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR adds server-owned thread read state across web and mobile, including new orchestration commands, persistence, migration backfill, and focus/connection-dependent client behavior. The cross-client workflow and changed default treatment of existing threads create a broad runtime surface that merits human review. You can add or adjust custom eligibility rules. Learn more. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d6aad18047e49be65cc9dd672893f8f24e3de3ca. Configure here.
3626c5c to
9175d4f
Compare
Thread read and unread state lived in each web client's local storage, so opening a thread on one device did not clear its Done indicator anywhere else. The server now owns a viewedAt timestamp per thread. Clients send thread.view when a focused, visible thread's latest completion is read and thread.mark-unread from the existing menus. The decider caps the boundary at server time and never moves it backward. Clients render the server value directly, with no optimistic override, the same shape as server-side settlement. Servers without the threadViewState capability keep the previous local fallback. Migration 044 backfills existing threads as read. Made with Claude Fable 5.1 through Claude Code.
An acknowledgement sent before the capability loaded fell through to local storage and never reached other clients.
readEnvironmentSupportsViewState now returns undefined until the config arrives, and the hook drops the write instead of falling back to local storage on every entry point.
…nd load Web kept its focus listeners only until the first acknowledgement, so a thread marked unread elsewhere was not re-acked on refocus. It also sent the acknowledgement while disconnected, where it failed silently with no retry. Mobile acknowledged from the thread shell before messages loaded. Web now keeps the listeners for the life of the effect and waits for a connected environment, re-running on reconnect. Mobile acknowledges only once the loaded detail includes the completion.
9175d4f to
aa961ac
Compare

Opening a thread on one device did not clear its Done indicator anywhere else. Read state lived in each web client's local storage, and mobile had no read state at all.
The server now owns a
viewedAttimestamp per thread, the same way it owns settlement since #8600. Clients sendthread.viewwhen a focused, visible thread's latest completion is read andthread.mark-unreadfrom the existing menus, then render the server value directly. There is no optimistic local override, so the indicator flips when the shell update arrives, one round trip later.Details:
updatedAt, so reading a thread never reorders the list. View-only events are excluded from agent awareness.threadViewStatecapability keep the previous device-local fallback. Migration 044 backfills existing threads as read.Replaces #6662, which carried the same server model plus about 550 lines of client-side pending, retry, and reconciliation state. This PR keeps the server half and drops the rest.
Tests: focused decider, migration, projection, relay, contracts, client-runtime, web, and mobile tests, plus typecheck for contracts, client-runtime, server, web, and mobile.
Made with Claude Fable 5.1 through Claude Code.
Note
Medium Risk
Changes orchestration commands, projection schema/migration backfill, and client read/unread timing; incorrect ack rules or backfill could show wrong Done state across devices.
Overview
Adds server-persisted thread read state so clearing a thread’s Done indicator on one device updates every connected client. Each thread gets a
viewedAtboundary (DB migration 048, projection/query/reducer paths), and the server advertises thethreadViewStatecapability.thread.viewrecords the completion the user actually saw (viewedThrough, capped at server time, never moved backward; does not bumpupdatedAt).thread.mark-unreadsetsviewedAtjust before the latest completed turn. Both emitthread.meta-updatedwithviewedAtfor backward-compatible event shape.Web routes mark-read/unread through
useThreadViewState: server commands when capable, otherwise the existing local visit store. Read acks wait for visible/focused document and a live connection when using the server path. Sidebars and indicators preferthread.viewedAtover local stamps.Mobile sends
thread.viewon focused thread routes when the app is active, connected, detail is loaded, andshouldAcknowledgeThreadViewsays the completion is still unread;viewedAtis included in thread selection shells.Agent awareness ignores view-only
thread.meta-updatedevents so read sync does not ping agents. User docs describe cross-device read/unread and the upgrade backfill (existing threads treated as read).Reviewed by Cursor Bugbot for commit aa961ac. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add server-backed thread read state syncing across clients
thread.viewandthread.mark-unreadcommands to persist thread read state on the server.useThreadViewStateto sync read state when the server advertises thethreadViewStatecapability.048_ProjectionThreadsViewedAtmigration with a nullableviewed_atcolumn onprojection_threads.048_ProjectionThreadsViewedAtbackfills existingprojection_threadsrows by settingviewed_attoupdated_at(orcreated_atif null), marking all old threads as read.Macroscope summarized aa961ac.