Skip to content

feat: sync thread read state across devices - #6078

Open
t3dotgg wants to merge 1 commit into
mainfrom
t3code/server-viewed-state-tracking
Open

feat: sync thread read state across devices#6078
t3dotgg wants to merge 1 commit into
mainfrom
t3code/server-viewed-state-tracking

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Aug 11, 2026

Copy link
Copy Markdown
Member

Opening a thread on web did not clear the unread cue anywhere else. The visit stamp lived in web localStorage only, so a fresh mobile install treated every finished thread as needing attention even after I already read them all on desktop.

Now the server owns the acknowledgement. A new thread.visit command stamps lastVisitedAt on the thread projection, and it flows to every client through the existing shell stream.

  • The command carries the acknowledged turn's completedAt (not wall-clock), matching web's existing local semantics: a completion that lands while the thread is open still reads as unseen, and clock skew can't leak in.
  • The decider is monotonic (a raced older visit re-emits the current value) and never touches updatedAt, so reading a thread cannot churn ordering.
  • Web and mobile both dispatch the visit on thread open, gated on a new threadVisits capability so old servers never see the command. Clients only dispatch when the synced value is behind, so the event log gets at most one visit per completion.
  • Web unread indicators now fall back to the shell's synced value when localStorage has no stamp. Local stamps still win, so mark-unread behaves exactly as before on this device.

Mobile does not render anything from lastVisitedAt yet; UI changes come separately. This PR only makes the state real and synced.

Testing: new decider tests for thread.visit (first visit, monotonic advance, stale re-emit, archived thread, unknown thread); projection fixtures updated; full typecheck plus contracts / client-runtime / web / mobile / server suites pass (the only server failures are the pre-existing service-launcher version mismatches in this worktree, verified present without this change).

Built by Claude Fable 5 running in Claude Code.

🤖 Generated with Claude Code


Note

Medium Risk
Touches orchestration commands, projections, and cross-client unread semantics; behavior is guarded by capability and monotonic visits, but version skew and race handling matter for correctness.

Overview
Adds server-synced thread read acknowledgement so opening a finished thread clears the unread cue on every client, not only in web localStorage.

A new thread.visit command and thread.visited event persist lastVisitedAt on the thread projection (DB migration, decider, projector, shell queries). Visits use the acknowledged turn’s completedAt, advance monotonically, work on archived threads, and do not bump updatedAt. Servers advertise threadVisits capability; clients gate dispatch on capability and only send when synced lastVisitedAt is behind the latest completion.

Web and mobile dispatch visits when a thread is open (mirroring existing local markThreadVisited on web). Web sidebar/status indicators prefer local visit stamps but fall back to thread.lastVisitedAt from the shell so another device’s read clears the cue here.

Reviewed by Cursor Bugbot for commit f52f172. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Sync thread read state across devices via server-persisted lastVisitedAt

  • Adds a thread.visit command and thread.visited event to the contracts, server decider, and projections so that opening a completed thread stamps lastVisitedAt on the server.
  • Web (ChatView.tsx) and mobile (ThreadRouteScreen.tsx) dispatch thread.visit when a thread is viewed and lastVisitedAt is missing or stale relative to latestTurn.completedAt.
  • Sidebar unread indicators on web fall back to the server-synced lastVisitedAt from the thread shell when no local stamp exists.
  • A new capabilities.threadVisits flag on the server descriptor gates the feature so older clients remain unaffected.
  • Adds migration 041_ProjectionThreadsLastVisitedAt.ts to add last_visited_at to projection_threads.
📊 Macroscope summarized f52f172. 21 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

Opening a finished thread now stamps a server-synced lastVisitedAt via a
new thread.visit command, so reading a thread on web clears the unread
cue on mobile and vice versa. Local storage stays as the offline and
old-server fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: db8d4e58-401d-4fd6-a5c2-1cf74f49bf9b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 11, 2026
// Version skew: never send thread.visit to a server that predates it.
useEffect(() => {
if (selectedThread === null) return;
const supportsThreadVisits =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/ThreadRouteScreen.tsx:228

The thread.visit effect reads environmentServerConfigsAtom imperatively via appAtomRegistry.get(...), so it never re-runs when the server config arrives later. When a cached thread is already present but the connection's server config is still null at mount, the effect returns early at the capability check; once the config loads with threadVisits: true, neither selectedThread nor visitThreadMutation has changed, so the effect does not re-run and the finished thread is never acknowledged as seen. The server config should be read through a reactive hook and the derived supportsThreadVisits flag included in the effect's dependency array.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadRouteScreen.tsx around line 228:

The `thread.visit` effect reads `environmentServerConfigsAtom` imperatively via `appAtomRegistry.get(...)`, so it never re-runs when the server config arrives later. When a cached thread is already present but the connection's server config is still `null` at mount, the effect returns early at the capability check; once the config loads with `threadVisits: true`, neither `selectedThread` nor `visitThreadMutation` has changed, so the effect does not re-run and the finished thread is never acknowledged as seen. The server config should be read through a reactive hook and the derived `supportsThreadVisits` flag included in the effect's dependency array.

// Local visit stamps win; the shell's server-synced lastVisitedAt fills the
// gap so a visit on another device still clears the unread cue here.
const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
const lastVisitedAt = localLastVisitedAt ?? thread.lastVisitedAt ?? undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/LegacySidebar.tsx:378

localLastVisitedAt ?? thread.lastVisitedAt always prefers the local stamp even when it is older than the newly synced thread.lastVisitedAt. After this device visited an older completion, another device visits a newer completion, and the shell delivers that newer stamp — but this row keeps comparing against the stale local timestamp and never clears the unread cue. Cross-device clearing only works on devices with no local history. Consider taking the later of the two timestamps so a newer server-synced visit clears the unread state on already-used devices.

Also found in 1 other location(s)

apps/web/src/components/Sidebar.tsx:732

localLastVisitedAt ?? thread.lastVisitedAt always prefers any existing local stamp, even when it is older than the newly synced server stamp. For example, after this device locally visited completion A, another device visits newer completion B; the shell delivers B's newer thread.lastVisitedAt, but this row continues comparing against A and remains unread. Thus cross-device reads only clear the cue on devices with no local history, defeating the sync behavior for previously used devices. Choose the later valid timestamp, while preserving the explicit older local timestamp used by mark-unread (which likely requires separately tracking that override intent).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/LegacySidebar.tsx around line 378:

`localLastVisitedAt ?? thread.lastVisitedAt` always prefers the local stamp even when it is older than the newly synced `thread.lastVisitedAt`. After this device visited an older completion, another device visits a newer completion, and the shell delivers that newer stamp — but this row keeps comparing against the stale local timestamp and never clears the unread cue. Cross-device clearing only works on devices with no local history. Consider taking the later of the two timestamps so a newer server-synced visit clears the unread state on already-used devices.

Also found in 1 other location(s):
- apps/web/src/components/Sidebar.tsx:732 -- `localLastVisitedAt ?? thread.lastVisitedAt` always prefers any existing local stamp, even when it is older than the newly synced server stamp. For example, after this device locally visited completion A, another device visits newer completion B; the shell delivers B's newer `thread.lastVisitedAt`, but this row continues comparing against A and remains unread. Thus cross-device reads only clear the cue on devices with no local history, defeating the sync behavior for previously used devices. Choose the later valid timestamp, while preserving the explicit older local timestamp used by mark-unread (which likely requires separately tracking that override intent).

// shell's synced value so the echo of our own visit does not re-dispatch;
// the decider is monotonic, so a raced older visit is a server-side no-op.
// Version skew: never send thread.visit to a server that predates it.
useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/ThreadRouteScreen.tsx:226

The useEffect at line 226 stamps a thread visit whenever selectedThread changes, but it does not check navigation focus. React Navigation keeps this screen mounted after it pushes routes like ThreadFile, ThreadTerminal, or GitOverview, so if a turn completes while the user is viewing one of those pushed screens, the still-mounted thread screen receives the updated selectedThread and dispatches visitThreadMutation with that turn's completedAt. This marks the thread as visited across all devices even though the user never saw the completed turn, incorrectly clearing the unread cue. Consider gating the side effect on focus via useIsFocused or useFocusEffect so visits are only stamped when the thread screen is actually visible.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadRouteScreen.tsx around line 226:

The `useEffect` at line 226 stamps a thread visit whenever `selectedThread` changes, but it does not check navigation focus. React Navigation keeps this screen mounted after it pushes routes like `ThreadFile`, `ThreadTerminal`, or `GitOverview`, so if a turn completes while the user is viewing one of those pushed screens, the still-mounted thread screen receives the updated `selectedThread` and dispatches `visitThreadMutation` with that turn's `completedAt`. This marks the thread as visited across all devices even though the user never saw the completed turn, incorrectly clearing the unread cue. Consider gating the side effect on focus via `useIsFocused` or `useFocusEffect` so visits are only stamped when the thread screen is actually visible.

if (!completedAt) return;
const syncedVisitedAt = activeThreadShell.lastVisitedAt;
if (syncedVisitedAt != null && Date.parse(syncedVisitedAt) >= Date.parse(completedAt)) return;
void visitThreadMutation({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/ChatView.tsx:4094

A failed visitThreadMutation is silently discarded with void, so a transient transport/server failure while opening a finished thread leaves the thread's unread state unsynced on other devices — lastVisitedAt never advances past completedAt. No retry is scheduled, and every effect dependency stays unchanged, so the visit is never re-dispatched until an unrelated shell update or thread re-entry. Consider handling the failure (e.g., re-attempting after backoff or re-triggering the effect) instead of fire-and-forget.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 4094:

A failed `visitThreadMutation` is silently discarded with `void`, so a transient transport/server failure while opening a finished thread leaves the thread's unread state unsynced on other devices — `lastVisitedAt` never advances past `completedAt`. No retry is scheduled, and every effect dependency stays unchanged, so the visit is never re-dispatched until an unrelated shell update or thread re-entry. Consider handling the failure (e.g., re-attempting after backoff or re-triggering the effect) instead of fire-and-forget.

@github-actions

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 11.3 KiB 11.3 KiB +13 B (+0.1%) 15.1 KiB
Codex Thread snapshot wire 5.5 KiB 5.5 KiB +3 B (+0.1%) 7.3 KiB
Codex Live turn WebSocket wire 5.9 KiB 5.9 KiB +10 B (+0.2%) 7.8 KiB
Codex Live turn WebSocket decoded 49.7 KiB 49.7 KiB 0 B (0.0%) 66.4 KiB
Codex Live turn messages 16 16 0 (0.0%) 21
Claude Total thread wire 11.3 KiB 11.3 KiB +7 B (+0.1%) 15.1 KiB
Claude Thread snapshot wire 5.5 KiB 5.5 KiB +9 B (+0.2%) 7.3 KiB
Claude Live turn WebSocket wire 5.9 KiB 5.9 KiB −2 B (−0.0%) 7.8 KiB
Claude Live turn WebSocket decoded 50.6 KiB 50.6 KiB 0 B (0.0%) 66.4 KiB
Claude Live turn messages 16 16 0 (0.0%) 21

Baseline: 9afef94 · PR result: f52f172 · Source CI: success

Scenario and decoded snapshot size

10 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.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f52f172. Configure here.

environmentId: selectedThread.environmentId,
input: { threadId: selectedThread.id, visitedAt: completedAt },
});
}, [selectedThread, visitThreadMutation]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mobile visit guard never sees sync

High Severity

The visit effect gates on selectedThread.lastVisitedAt, but when the shell is missing (for example archived threads, which are excluded from the active shell) selectedThread comes from threadDetailToShell, which never copies lastVisitedAt. The behind-check always fails open, so each thread.visited echo rebuilds selectedThread and dispatches another visit.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f52f172. Configure here.

environmentId: selectedThread.environmentId,
input: { threadId: selectedThread.id, visitedAt: completedAt },
});
}, [selectedThread, visitThreadMutation]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visit skipped until thread changes

Medium Severity

The mobile visit effect reads threadVisits via a one-shot appAtomRegistry.get and only depends on selectedThread and visitThreadMutation. If server config is still unset on first run, the effect returns early and does not re-run when the capability later becomes true. Web includes reactive supportsThreadVisits in its effect deps for this reason.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f52f172. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

4 blocking correctness issues found. This PR introduces a new cross-device feature for syncing thread read state, spanning mobile, web, server, contracts, and adding a database migration. Multiple unresolved findings identify potential issues including an infinite dispatch loop for archived threads, non-reactive config reads, and timestamp merge logic bugs.

You can customize Macroscope's approvability policy. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant