Skip to content

feat: sync thread unread state across clients - #6662

Closed
t3dotgg wants to merge 18 commits into
mainfrom
t3code/sync-thread-viewed-state
Closed

t3dotgg wants to merge 18 commits into
mainfrom
t3code/sync-thread-viewed-state

fix(mobile): preserve remote unread state during retries

c55dd68
Select commit
Loading
Failed to load commit list.
MacroscopeApp / Macroscope - Correctness Check succeeded Sep 1, 2026 in 3m 24s

No issues identified (59 code objects reviewed).

• Merge Base: 0bfb6df
• Head: c55dd68

Details

✅ File Path U3 Bytes Comments Posted Reason
✅ apps/web/src/hooks/useThreadViewState.ts 8989 0
✅ docs/user/thread-sidebar.md 917 0
➖ apps/web/src/hooks/useThreadViewState.test.ts 17307 Excluded by default ignore patterns
✅ apps/mobile/src/features/threads/threadViewState.ts 3053 0
✅ apps/web/src/state/entities.ts 644 0
➖ apps/server/src/orchestration/decider.viewState.test.ts 14775 Excluded by default ignore patterns
➖ apps/mobile/src/features/threads/threadViewState.test.ts 5124 Excluded by default ignore patterns
✅ packages/contracts/src/environment.ts 581 0
✅ apps/server/src/persistence/Migrations/044_ProjectionThreadsViewedAt.ts 1023 0
✅ apps/web/src/components/ChatView.logic.ts 1753 0
➖ packages/contracts/src/environment.test.ts 560 Excluded by default ignore patterns
➖ apps/server/src/persistence/Migrations/044_ProjectionThreadsViewedAt.test.ts 4073 Excluded by default ignore patterns
✅ apps/server/src/relay/AgentAwarenessRelay.ts 464 0
✅ apps/mobile/src/state/use-thread-selection.ts 268 0
✅ apps/server/src/orchestration/decider.ts 5151 0
✅ apps/server/src/persistence/Migrations.ts 714 0
➖ apps/server/src/orchestration/projector.test.ts 191 Excluded by default ignore patterns
✅ apps/server/src/environment/ServerEnvironment.ts 265 0
✅ apps/server/src/orchestration/projector.ts 538 0
✅ packages/client-runtime/src/state/threadDetail.ts 247 0
➖ apps/server/src/relay/AgentAwarenessRelay.test.ts 867 Excluded by default ignore patterns
➖ packages/client-runtime/src/state/runtime.test.ts 1925 Excluded by default ignore patterns
✅ packages/client-runtime/src/state/threadReducer.ts 318 0
➖ apps/server/src/environment/ServerEnvironment.test.ts 501 Excluded by default ignore patterns
➖ apps/server/src/orchestration/projector.settled.test.ts 979 Excluded by default ignore patterns
✅ packages/client-runtime/src/operations/commands.ts 1241 0
➖ packages/client-runtime/src/state/threadReducer.test.ts 909 Excluded by default ignore patterns
➖ apps/web/src/components/ChatView.logic.test.ts 3969 Excluded by default ignore patterns
✅ apps/server/src/persistence/Services/ProjectionThreads.ts 347 0
➖ apps/web/src/uiStateStore.test.ts 5183 Excluded by default ignore patterns
➖ packages/client-runtime/src/operations/commands.test.ts 1741 Excluded by default ignore patterns
➖ packages/client-runtime/src/state/entities.test.ts 664 Excluded by default ignore patterns
➖ apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts 2171 Excluded by default ignore patterns
➖ apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts 2467 Excluded by default ignore patterns
✅ apps/server/src/orchestration/Layers/ProjectionPipeline.ts 578 0
➖ packages/contracts/src/orchestration.test.ts 3953 Excluded by default ignore patterns
✅ packages/contracts/src/orchestration.ts 2340 0
✅ packages/client-runtime/src/state/threadCommands.ts 1652 0
✅ apps/web/src/components/ThreadStatusIndicators.tsx 1288 0
➖ apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts 498 Excluded by default ignore patterns
✅ apps/server/src/persistence/Layers/ProjectionThreads.ts 1300 0
✅ apps/mobile/src/features/threads/ThreadRouteScreen.tsx 4358 0
✅ apps/web/src/uiStateStore.ts 6254 0
✅ apps/web/src/hooks/useThreadActionMenu.ts 1724 0
✅ apps/web/src/hooks/useThreadActions.ts 2545 0
✅ apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts 3302 0
✅ apps/web/src/components/LegacySidebar.tsx 3599 0
✅ apps/web/src/components/Sidebar.tsx 3484 0
✅ apps/web/src/components/ChatView.tsx 6040 0

Billed Total: 52.23KB of diff | $2.61
Saved $0.64 vs. the upper-bound estimate of $3.25 — cached code objects from prior reviews on this PR were not re-billed.

Filtered Issues Details

apps/mobile/src/features/threads/ThreadRouteScreen.tsx
  • line 301: The tracking effect cannot schedule a retry after thread.view is capped by the server clock. resolveThreadViewTrackingState only produces retryBoundary when the returned viewedAt exactly matches a requested boundary, but the server explicitly caps a future viewedThrough to its current time. When a mobile client has received a completedAt later than that server clock, the resulting marker remains below completion and matches no requested boundary, leaving threadViewRetryBoundary unchanged; the focused callback is not recreated, so the thread stays unread until some unrelated focus/dependency change. [ Already posted ]
apps/mobile/src/features/threads/threadViewState.ts
  • line 46: The timestamp-only match at Date.parse(requestedBoundary) === viewedAtMs cannot distinguish this device's view response from a remote thread.mark-unread. If this device previously requested a view at completion T, and another client marks a subsequent completion at T + 1ms unread, the server stores T (completedAt - 1ms). This function treats that remote unread marker as its own applied request, changes retryBoundary, and the focus callback immediately sends a guarded view that matches and overwrites the other client's explicit unread action. [ Already posted ]
  • line 46: resolveAppliedThreadViewBoundary only recognizes a server-applied view when its timestamp exactly equals a requested boundary. The server can cap a future viewedThrough boundary to its current time, leaving viewedAt < completedAt but unequal to every requested boundary. In that case this returns undefined, so retryBoundary does not change and the focused screen does not issue the needed retry when that completion boundary becomes reachable; the completed thread remains unread until another dependency/focus transition happens. [ Already posted ]
apps/web/src/components/ChatView.logic.ts
  • line 102: shouldAcknowledgeVisibleThreadCompletion compares unvalidated strings with Date.parse. IsoDateTime is currently Schema.String, so a malformed completedAt (or persisted local view timestamp) is accepted; Date.parse yields NaN and the comparison is false. After the initial local-only acknowledgement, capability discovery therefore never synchronizes the view state to the server, leaving the thread unread on other clients. [ Already posted ]
apps/web/src/components/ChatView.tsx
  • line 1875: The completion acknowledgement at markViewed(threadRef, completedAt, supportsThreadViewState) treats its RPC as complete once its event sequence arrives, even if the server capped a future completedAt to its current time. The server explicitly performs that cap, leaving viewedAt < completedAt; however this effect does not depend on viewedAt or pending-state changes, so it will not retry while the tab remains focused. A server clock rollback or a future completion boundary therefore leaves other clients showing the thread unread until the user changes focus or reopens it. [ Already posted ]
apps/web/src/hooks/useThreadActionMenu.ts
  • line 235: markUnread falls back to markLocalUnread when the environment capability has not loaded and the shell lacks viewedAt, without recording pending state or retrying. Thus, selecting Mark unread from this header menu during startup creates only a local marker; when configuration later confirms view-state support, the action is never sent to the server and other clients remain read. [ Already posted ]
apps/web/src/hooks/useThreadViewState.ts
  • line 136: markViewed treats an unavailable server configuration and a shell without viewedAt as an unsupported server at lines 132-138, then only writes the local marker. That path creates no pending entry or retry, so a user action made before the configuration/shell finishes loading is never sent when threadViewState later proves supported; other clients continue to show the thread unread. [ Already posted ]
  • line 190: markUnread falls back to markLocalUnread when the environment config has not loaded and the shell does not yet expose viewedAt (lines 190-198). Unlike a rejected server operation, this fallback records no pending intent to resume, so marking a thread unread during startup remains local after the server capability arrives and does not synchronize to other clients. [ Already posted ]