feat: sync thread read state across clients - #9124

Open
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state
Open

feat: sync thread read state across clients#9124
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state

Conversation

@t3dotgg

@t3dotggt3dotgg commented Sep 1, 2026

Copy link
Copy Markdown
Member

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 viewedAt timestamp per thread, the same way it owns settlement since #8600. Clients send thread.view when a focused, visible thread's latest completion is read and thread.mark-unread from 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:

  • The decider caps the boundary at server time and never moves it backward, so a stale view cannot undo a newer completion or a mark-unread.
  • Web only acknowledges a view when the document is visible and focused. Mobile only acknowledges when the thread route is focused, the app is active, and the environment is connected.
  • Viewing does not bump updatedAt, so reading a thread never reorders the list. View-only events are excluded from agent awareness.
  • Servers without the threadViewState capability 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
Touches orchestration commands, SQLite migration/backfill, and cross-client read semantics; mis-timed client acks or backfill could briefly show wrong Done state until shell sync.

Overview
Adds server-owned thread read state (viewedAt) so clearing the Done indicator on one device updates every connected client. Clients send thread.view (with the completion timestamp the user actually saw) and thread.mark-unread; the UI follows the shell’s viewedAt with no optimistic override, same pattern as settlement.

On the server, new commands persist through existing thread.meta-updated events (view-only updates do not bump updatedAt or reorder the list). The decider caps client boundaries at server time and never moves viewedAt backward. Migration 044 adds viewed_at and backfills existing threads as read. Environments advertise threadViewState; AgentAwarenessRelay ignores view-only meta updates.

Web routes mark-read/unread through useThreadViewState, with capability detection and local-storage fallback on older servers. ChatView only acknowledges a view when the tab is visible and focused, and waits for config/connection when using server commands.

Mobile sends thread.view from the focused thread route when the app is active, connected, detail is loaded, and shouldAcknowledgeThreadView says the latest completion is still unread.

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

Note

Add server-persisted thread viewedAt and thread.view/thread.mark-unread commands

  • Introduces server-backed thread view state so read/unread status syncs across clients. The server decider validates and caps client-supplied boundaries, prevents viewedAt from moving backward, and emits thread.meta-updated events carrying the new boundary. thread.mark-unread sets the boundary to just before the latest completed turn.
  • Adds migration 44 (044_ProjectionThreadsViewedAt.ts), which adds a nullable viewed_at column to projection_threads and backfills existing rows from updated_at (falling back to created_at).
  • Web and mobile clients gain a capability-aware useThreadViewState hook and mobile shouldAcknowledgeThreadView predicate that dispatch server commands on supported environments and fall back to local UI-store on older servers. Sidebar rows, action menus, and archive/settle actions all route through this path.
  • The AgentAwarenessRelay now suppresses thread.meta-updated events whose only changed field is viewedAt, so view-state sync does not trigger agent notifications.
  • Risk: migration 44 backfills viewed_at from updated_at for all existing projection threads — verify the backfill result matches expected read state on upgrade. Client view-state writes are suppressed while the environment capability is unresolved, so threads may appear unread until configuration loads.

Macroscope summarized 9175d4f.

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/hooks/useThreadViewState.ts Outdated
Comment threadpackages/contracts/src/orchestration.ts
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.3 KiB+30 B (+0.2%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+5 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.4 KiB+25 B (+0.4%)7.8 KiB
CodexLive turn WebSocket decoded55.6 KiB55.6 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages10100 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB+16 B (+0.1%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−3 B (−0.0%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB+19 B (+0.3%)7.8 KiB
ClaudeLive turn WebSocket decoded56.4 KiB56.4 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages10100 (0.0%)21

Baseline: 80c708a · PR result: 9175d4f · 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: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

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

@github-actionsgithub-actionsBot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/components/ChatView.tsx
@macroscopeapp

macroscopeappBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces durable server-owned read state, new orchestration commands, a database migration/backfill, and coordinated behavior changes across web, mobile, and shared runtime paths. Its cross-client UX impact and changed read-state defaults make the change broader than a small additive update.

No code changes detected at 9175d4f. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@cursorcursorBot 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 1 potential issue.

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 d6aad18. Configure here.

Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/ChatView.tsx Outdated
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.
@t3dotgg
t3dotggforce-pushed the t3code/server-owned-thread-read-state branch from 3626c5c to 9175d4fCompareSeptember 2, 2026 01:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:trustedPR 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

@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: sync thread read state across clients - #9124

Open
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state
Open

feat: sync thread read state across clients#9124
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state

Conversation

@t3dotgg

@t3dotggt3dotgg commented Sep 1, 2026

Copy link
Copy Markdown
Member

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 viewedAt timestamp per thread, the same way it owns settlement since #8600. Clients send thread.view when a focused, visible thread's latest completion is read and thread.mark-unread from 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:

  • The decider caps the boundary at server time and never moves it backward, so a stale view cannot undo a newer completion or a mark-unread.
  • Web only acknowledges a view when the document is visible and focused. Mobile only acknowledges when the thread route is focused, the app is active, and the environment is connected.
  • Viewing does not bump updatedAt, so reading a thread never reorders the list. View-only events are excluded from agent awareness.
  • Servers without the threadViewState capability 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
Touches orchestration commands, SQLite migration/backfill, and cross-client read semantics; mis-timed client acks or backfill could briefly show wrong Done state until shell sync.

Overview
Adds server-owned thread read state (viewedAt) so clearing the Done indicator on one device updates every connected client. Clients send thread.view (with the completion timestamp the user actually saw) and thread.mark-unread; the UI follows the shell’s viewedAt with no optimistic override, same pattern as settlement.

On the server, new commands persist through existing thread.meta-updated events (view-only updates do not bump updatedAt or reorder the list). The decider caps client boundaries at server time and never moves viewedAt backward. Migration 044 adds viewed_at and backfills existing threads as read. Environments advertise threadViewState; AgentAwarenessRelay ignores view-only meta updates.

Web routes mark-read/unread through useThreadViewState, with capability detection and local-storage fallback on older servers. ChatView only acknowledges a view when the tab is visible and focused, and waits for config/connection when using server commands.

Mobile sends thread.view from the focused thread route when the app is active, connected, detail is loaded, and shouldAcknowledgeThreadView says the latest completion is still unread.

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

Note

Add server-persisted thread viewedAt and thread.view/thread.mark-unread commands

  • Introduces server-backed thread view state so read/unread status syncs across clients. The server decider validates and caps client-supplied boundaries, prevents viewedAt from moving backward, and emits thread.meta-updated events carrying the new boundary. thread.mark-unread sets the boundary to just before the latest completed turn.
  • Adds migration 44 (044_ProjectionThreadsViewedAt.ts), which adds a nullable viewed_at column to projection_threads and backfills existing rows from updated_at (falling back to created_at).
  • Web and mobile clients gain a capability-aware useThreadViewState hook and mobile shouldAcknowledgeThreadView predicate that dispatch server commands on supported environments and fall back to local UI-store on older servers. Sidebar rows, action menus, and archive/settle actions all route through this path.
  • The AgentAwarenessRelay now suppresses thread.meta-updated events whose only changed field is viewedAt, so view-state sync does not trigger agent notifications.
  • Risk: migration 44 backfills viewed_at from updated_at for all existing projection threads — verify the backfill result matches expected read state on upgrade. Client view-state writes are suppressed while the environment capability is unresolved, so threads may appear unread until configuration loads.

Macroscope summarized 9175d4f.

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/hooks/useThreadViewState.ts Outdated
Comment threadpackages/contracts/src/orchestration.ts
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.3 KiB+30 B (+0.2%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+5 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.4 KiB+25 B (+0.4%)7.8 KiB
CodexLive turn WebSocket decoded55.6 KiB55.6 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages10100 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB+16 B (+0.1%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−3 B (−0.0%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB+19 B (+0.3%)7.8 KiB
ClaudeLive turn WebSocket decoded56.4 KiB56.4 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages10100 (0.0%)21

Baseline: 80c708a · PR result: 9175d4f · 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: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

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

@github-actionsgithub-actionsBot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/components/ChatView.tsx
@macroscopeapp

macroscopeappBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces durable server-owned read state, new orchestration commands, a database migration/backfill, and coordinated behavior changes across web, mobile, and shared runtime paths. Its cross-client UX impact and changed read-state defaults make the change broader than a small additive update.

No code changes detected at 9175d4f. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@cursorcursorBot 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 1 potential issue.

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 d6aad18. Configure here.

Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/ChatView.tsx Outdated
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.
@t3dotgg
t3dotggforce-pushed the t3code/server-owned-thread-read-state branch from 3626c5c to 9175d4fCompareSeptember 2, 2026 01:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:trustedPR 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

@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: sync thread read state across clients - #9124

Open
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state
Open

feat: sync thread read state across clients#9124
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state

Conversation

@t3dotgg

@t3dotggt3dotgg commented Sep 1, 2026

Copy link
Copy Markdown
Member

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 viewedAt timestamp per thread, the same way it owns settlement since #8600. Clients send thread.view when a focused, visible thread's latest completion is read and thread.mark-unread from 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:

  • The decider caps the boundary at server time and never moves it backward, so a stale view cannot undo a newer completion or a mark-unread.
  • Web only acknowledges a view when the document is visible and focused. Mobile only acknowledges when the thread route is focused, the app is active, and the environment is connected.
  • Viewing does not bump updatedAt, so reading a thread never reorders the list. View-only events are excluded from agent awareness.
  • Servers without the threadViewState capability 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
Touches orchestration commands, SQLite migration/backfill, and cross-client read semantics; mis-timed client acks or backfill could briefly show wrong Done state until shell sync.

Overview
Adds server-owned thread read state (viewedAt) so clearing the Done indicator on one device updates every connected client. Clients send thread.view (with the completion timestamp the user actually saw) and thread.mark-unread; the UI follows the shell’s viewedAt with no optimistic override, same pattern as settlement.

On the server, new commands persist through existing thread.meta-updated events (view-only updates do not bump updatedAt or reorder the list). The decider caps client boundaries at server time and never moves viewedAt backward. Migration 044 adds viewed_at and backfills existing threads as read. Environments advertise threadViewState; AgentAwarenessRelay ignores view-only meta updates.

Web routes mark-read/unread through useThreadViewState, with capability detection and local-storage fallback on older servers. ChatView only acknowledges a view when the tab is visible and focused, and waits for config/connection when using server commands.

Mobile sends thread.view from the focused thread route when the app is active, connected, detail is loaded, and shouldAcknowledgeThreadView says the latest completion is still unread.

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

Note

Add server-persisted thread viewedAt and thread.view/thread.mark-unread commands

  • Introduces server-backed thread view state so read/unread status syncs across clients. The server decider validates and caps client-supplied boundaries, prevents viewedAt from moving backward, and emits thread.meta-updated events carrying the new boundary. thread.mark-unread sets the boundary to just before the latest completed turn.
  • Adds migration 44 (044_ProjectionThreadsViewedAt.ts), which adds a nullable viewed_at column to projection_threads and backfills existing rows from updated_at (falling back to created_at).
  • Web and mobile clients gain a capability-aware useThreadViewState hook and mobile shouldAcknowledgeThreadView predicate that dispatch server commands on supported environments and fall back to local UI-store on older servers. Sidebar rows, action menus, and archive/settle actions all route through this path.
  • The AgentAwarenessRelay now suppresses thread.meta-updated events whose only changed field is viewedAt, so view-state sync does not trigger agent notifications.
  • Risk: migration 44 backfills viewed_at from updated_at for all existing projection threads — verify the backfill result matches expected read state on upgrade. Client view-state writes are suppressed while the environment capability is unresolved, so threads may appear unread until configuration loads.

Macroscope summarized 9175d4f.

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/hooks/useThreadViewState.ts Outdated
Comment threadpackages/contracts/src/orchestration.ts
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.3 KiB+30 B (+0.2%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+5 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.4 KiB+25 B (+0.4%)7.8 KiB
CodexLive turn WebSocket decoded55.6 KiB55.6 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages10100 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB+16 B (+0.1%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−3 B (−0.0%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB+19 B (+0.3%)7.8 KiB
ClaudeLive turn WebSocket decoded56.4 KiB56.4 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages10100 (0.0%)21

Baseline: 80c708a · PR result: 9175d4f · 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: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

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

@github-actionsgithub-actionsBot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/components/ChatView.tsx
@macroscopeapp

macroscopeappBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces durable server-owned read state, new orchestration commands, a database migration/backfill, and coordinated behavior changes across web, mobile, and shared runtime paths. Its cross-client UX impact and changed read-state defaults make the change broader than a small additive update.

No code changes detected at 9175d4f. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@cursorcursorBot 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 1 potential issue.

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 d6aad18. Configure here.

Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/ChatView.tsx Outdated
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.
@t3dotgg
t3dotggforce-pushed the t3code/server-owned-thread-read-state branch from 3626c5c to 9175d4fCompareSeptember 2, 2026 01:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:trustedPR 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

@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: sync thread read state across clients - #9124

Open
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state
Open

feat: sync thread read state across clients#9124
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state

Conversation

@t3dotgg

@t3dotggt3dotgg commented Sep 1, 2026

Copy link
Copy Markdown
Member

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 viewedAt timestamp per thread, the same way it owns settlement since #8600. Clients send thread.view when a focused, visible thread's latest completion is read and thread.mark-unread from 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:

  • The decider caps the boundary at server time and never moves it backward, so a stale view cannot undo a newer completion or a mark-unread.
  • Web only acknowledges a view when the document is visible and focused. Mobile only acknowledges when the thread route is focused, the app is active, and the environment is connected.
  • Viewing does not bump updatedAt, so reading a thread never reorders the list. View-only events are excluded from agent awareness.
  • Servers without the threadViewState capability 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
Touches orchestration commands, SQLite migration/backfill, and cross-client read semantics; mis-timed client acks or backfill could briefly show wrong Done state until shell sync.

Overview
Adds server-owned thread read state (viewedAt) so clearing the Done indicator on one device updates every connected client. Clients send thread.view (with the completion timestamp the user actually saw) and thread.mark-unread; the UI follows the shell’s viewedAt with no optimistic override, same pattern as settlement.

On the server, new commands persist through existing thread.meta-updated events (view-only updates do not bump updatedAt or reorder the list). The decider caps client boundaries at server time and never moves viewedAt backward. Migration 044 adds viewed_at and backfills existing threads as read. Environments advertise threadViewState; AgentAwarenessRelay ignores view-only meta updates.

Web routes mark-read/unread through useThreadViewState, with capability detection and local-storage fallback on older servers. ChatView only acknowledges a view when the tab is visible and focused, and waits for config/connection when using server commands.

Mobile sends thread.view from the focused thread route when the app is active, connected, detail is loaded, and shouldAcknowledgeThreadView says the latest completion is still unread.

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

Note

Add server-persisted thread viewedAt and thread.view/thread.mark-unread commands

  • Introduces server-backed thread view state so read/unread status syncs across clients. The server decider validates and caps client-supplied boundaries, prevents viewedAt from moving backward, and emits thread.meta-updated events carrying the new boundary. thread.mark-unread sets the boundary to just before the latest completed turn.
  • Adds migration 44 (044_ProjectionThreadsViewedAt.ts), which adds a nullable viewed_at column to projection_threads and backfills existing rows from updated_at (falling back to created_at).
  • Web and mobile clients gain a capability-aware useThreadViewState hook and mobile shouldAcknowledgeThreadView predicate that dispatch server commands on supported environments and fall back to local UI-store on older servers. Sidebar rows, action menus, and archive/settle actions all route through this path.
  • The AgentAwarenessRelay now suppresses thread.meta-updated events whose only changed field is viewedAt, so view-state sync does not trigger agent notifications.
  • Risk: migration 44 backfills viewed_at from updated_at for all existing projection threads — verify the backfill result matches expected read state on upgrade. Client view-state writes are suppressed while the environment capability is unresolved, so threads may appear unread until configuration loads.

Macroscope summarized 9175d4f.

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/hooks/useThreadViewState.ts Outdated
Comment threadpackages/contracts/src/orchestration.ts
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.3 KiB+30 B (+0.2%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+5 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.4 KiB+25 B (+0.4%)7.8 KiB
CodexLive turn WebSocket decoded55.6 KiB55.6 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages10100 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB+16 B (+0.1%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−3 B (−0.0%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB+19 B (+0.3%)7.8 KiB
ClaudeLive turn WebSocket decoded56.4 KiB56.4 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages10100 (0.0%)21

Baseline: 80c708a · PR result: 9175d4f · 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: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

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

@github-actionsgithub-actionsBot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/components/ChatView.tsx
@macroscopeapp

macroscopeappBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces durable server-owned read state, new orchestration commands, a database migration/backfill, and coordinated behavior changes across web, mobile, and shared runtime paths. Its cross-client UX impact and changed read-state defaults make the change broader than a small additive update.

No code changes detected at 9175d4f. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@cursorcursorBot 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 1 potential issue.

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 d6aad18. Configure here.

Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/ChatView.tsx Outdated
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.
@t3dotgg
t3dotggforce-pushed the t3code/server-owned-thread-read-state branch from 3626c5c to 9175d4fCompareSeptember 2, 2026 01:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:trustedPR 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

@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: sync thread read state across clients - #9124

Open
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state
Open

feat: sync thread read state across clients#9124
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state

Conversation

@t3dotgg

@t3dotggt3dotgg commented Sep 1, 2026

Copy link
Copy Markdown
Member

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 viewedAt timestamp per thread, the same way it owns settlement since #8600. Clients send thread.view when a focused, visible thread's latest completion is read and thread.mark-unread from 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:

  • The decider caps the boundary at server time and never moves it backward, so a stale view cannot undo a newer completion or a mark-unread.
  • Web only acknowledges a view when the document is visible and focused. Mobile only acknowledges when the thread route is focused, the app is active, and the environment is connected.
  • Viewing does not bump updatedAt, so reading a thread never reorders the list. View-only events are excluded from agent awareness.
  • Servers without the threadViewState capability 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
Touches orchestration commands, SQLite migration/backfill, and cross-client read semantics; mis-timed client acks or backfill could briefly show wrong Done state until shell sync.

Overview
Adds server-owned thread read state (viewedAt) so clearing the Done indicator on one device updates every connected client. Clients send thread.view (with the completion timestamp the user actually saw) and thread.mark-unread; the UI follows the shell’s viewedAt with no optimistic override, same pattern as settlement.

On the server, new commands persist through existing thread.meta-updated events (view-only updates do not bump updatedAt or reorder the list). The decider caps client boundaries at server time and never moves viewedAt backward. Migration 044 adds viewed_at and backfills existing threads as read. Environments advertise threadViewState; AgentAwarenessRelay ignores view-only meta updates.

Web routes mark-read/unread through useThreadViewState, with capability detection and local-storage fallback on older servers. ChatView only acknowledges a view when the tab is visible and focused, and waits for config/connection when using server commands.

Mobile sends thread.view from the focused thread route when the app is active, connected, detail is loaded, and shouldAcknowledgeThreadView says the latest completion is still unread.

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

Note

Add server-persisted thread viewedAt and thread.view/thread.mark-unread commands

  • Introduces server-backed thread view state so read/unread status syncs across clients. The server decider validates and caps client-supplied boundaries, prevents viewedAt from moving backward, and emits thread.meta-updated events carrying the new boundary. thread.mark-unread sets the boundary to just before the latest completed turn.
  • Adds migration 44 (044_ProjectionThreadsViewedAt.ts), which adds a nullable viewed_at column to projection_threads and backfills existing rows from updated_at (falling back to created_at).
  • Web and mobile clients gain a capability-aware useThreadViewState hook and mobile shouldAcknowledgeThreadView predicate that dispatch server commands on supported environments and fall back to local UI-store on older servers. Sidebar rows, action menus, and archive/settle actions all route through this path.
  • The AgentAwarenessRelay now suppresses thread.meta-updated events whose only changed field is viewedAt, so view-state sync does not trigger agent notifications.
  • Risk: migration 44 backfills viewed_at from updated_at for all existing projection threads — verify the backfill result matches expected read state on upgrade. Client view-state writes are suppressed while the environment capability is unresolved, so threads may appear unread until configuration loads.

Macroscope summarized 9175d4f.

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/hooks/useThreadViewState.ts Outdated
Comment threadpackages/contracts/src/orchestration.ts
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.3 KiB+30 B (+0.2%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+5 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.4 KiB+25 B (+0.4%)7.8 KiB
CodexLive turn WebSocket decoded55.6 KiB55.6 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages10100 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB+16 B (+0.1%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−3 B (−0.0%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB+19 B (+0.3%)7.8 KiB
ClaudeLive turn WebSocket decoded56.4 KiB56.4 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages10100 (0.0%)21

Baseline: 80c708a · PR result: 9175d4f · 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: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

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

@github-actionsgithub-actionsBot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/components/ChatView.tsx
@macroscopeapp

macroscopeappBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces durable server-owned read state, new orchestration commands, a database migration/backfill, and coordinated behavior changes across web, mobile, and shared runtime paths. Its cross-client UX impact and changed read-state defaults make the change broader than a small additive update.

No code changes detected at 9175d4f. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@cursorcursorBot 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 1 potential issue.

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 d6aad18. Configure here.

Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/ChatView.tsx Outdated
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.
@t3dotgg
t3dotggforce-pushed the t3code/server-owned-thread-read-state branch from 3626c5c to 9175d4fCompareSeptember 2, 2026 01:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:trustedPR 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

@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: sync thread read state across clients - #9124

Open
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state
Open

feat: sync thread read state across clients#9124
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state

Conversation

@t3dotgg

@t3dotggt3dotgg commented Sep 1, 2026

Copy link
Copy Markdown
Member

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 viewedAt timestamp per thread, the same way it owns settlement since #8600. Clients send thread.view when a focused, visible thread's latest completion is read and thread.mark-unread from 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:

  • The decider caps the boundary at server time and never moves it backward, so a stale view cannot undo a newer completion or a mark-unread.
  • Web only acknowledges a view when the document is visible and focused. Mobile only acknowledges when the thread route is focused, the app is active, and the environment is connected.
  • Viewing does not bump updatedAt, so reading a thread never reorders the list. View-only events are excluded from agent awareness.
  • Servers without the threadViewState capability 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
Touches orchestration commands, SQLite migration/backfill, and cross-client read semantics; mis-timed client acks or backfill could briefly show wrong Done state until shell sync.

Overview
Adds server-owned thread read state (viewedAt) so clearing the Done indicator on one device updates every connected client. Clients send thread.view (with the completion timestamp the user actually saw) and thread.mark-unread; the UI follows the shell’s viewedAt with no optimistic override, same pattern as settlement.

On the server, new commands persist through existing thread.meta-updated events (view-only updates do not bump updatedAt or reorder the list). The decider caps client boundaries at server time and never moves viewedAt backward. Migration 044 adds viewed_at and backfills existing threads as read. Environments advertise threadViewState; AgentAwarenessRelay ignores view-only meta updates.

Web routes mark-read/unread through useThreadViewState, with capability detection and local-storage fallback on older servers. ChatView only acknowledges a view when the tab is visible and focused, and waits for config/connection when using server commands.

Mobile sends thread.view from the focused thread route when the app is active, connected, detail is loaded, and shouldAcknowledgeThreadView says the latest completion is still unread.

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

Note

Add server-persisted thread viewedAt and thread.view/thread.mark-unread commands

  • Introduces server-backed thread view state so read/unread status syncs across clients. The server decider validates and caps client-supplied boundaries, prevents viewedAt from moving backward, and emits thread.meta-updated events carrying the new boundary. thread.mark-unread sets the boundary to just before the latest completed turn.
  • Adds migration 44 (044_ProjectionThreadsViewedAt.ts), which adds a nullable viewed_at column to projection_threads and backfills existing rows from updated_at (falling back to created_at).
  • Web and mobile clients gain a capability-aware useThreadViewState hook and mobile shouldAcknowledgeThreadView predicate that dispatch server commands on supported environments and fall back to local UI-store on older servers. Sidebar rows, action menus, and archive/settle actions all route through this path.
  • The AgentAwarenessRelay now suppresses thread.meta-updated events whose only changed field is viewedAt, so view-state sync does not trigger agent notifications.
  • Risk: migration 44 backfills viewed_at from updated_at for all existing projection threads — verify the backfill result matches expected read state on upgrade. Client view-state writes are suppressed while the environment capability is unresolved, so threads may appear unread until configuration loads.

Macroscope summarized 9175d4f.

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/hooks/useThreadViewState.ts Outdated
Comment threadpackages/contracts/src/orchestration.ts
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.3 KiB+30 B (+0.2%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+5 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.4 KiB+25 B (+0.4%)7.8 KiB
CodexLive turn WebSocket decoded55.6 KiB55.6 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages10100 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB+16 B (+0.1%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−3 B (−0.0%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB+19 B (+0.3%)7.8 KiB
ClaudeLive turn WebSocket decoded56.4 KiB56.4 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages10100 (0.0%)21

Baseline: 80c708a · PR result: 9175d4f · 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: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

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

@github-actionsgithub-actionsBot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/components/ChatView.tsx
@macroscopeapp

macroscopeappBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces durable server-owned read state, new orchestration commands, a database migration/backfill, and coordinated behavior changes across web, mobile, and shared runtime paths. Its cross-client UX impact and changed read-state defaults make the change broader than a small additive update.

No code changes detected at 9175d4f. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@cursorcursorBot 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 1 potential issue.

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 d6aad18. Configure here.

Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/ChatView.tsx Outdated
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.
@t3dotgg
t3dotggforce-pushed the t3code/server-owned-thread-read-state branch from 3626c5c to 9175d4fCompareSeptember 2, 2026 01:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:trustedPR 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

@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: sync thread read state across clients - #9124

Open
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state
Open

feat: sync thread read state across clients#9124
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state

Conversation

@t3dotgg

@t3dotggt3dotgg commented Sep 1, 2026

Copy link
Copy Markdown
Member

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 viewedAt timestamp per thread, the same way it owns settlement since #8600. Clients send thread.view when a focused, visible thread's latest completion is read and thread.mark-unread from 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:

  • The decider caps the boundary at server time and never moves it backward, so a stale view cannot undo a newer completion or a mark-unread.
  • Web only acknowledges a view when the document is visible and focused. Mobile only acknowledges when the thread route is focused, the app is active, and the environment is connected.
  • Viewing does not bump updatedAt, so reading a thread never reorders the list. View-only events are excluded from agent awareness.
  • Servers without the threadViewState capability 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
Touches orchestration commands, SQLite migration/backfill, and cross-client read semantics; mis-timed client acks or backfill could briefly show wrong Done state until shell sync.

Overview
Adds server-owned thread read state (viewedAt) so clearing the Done indicator on one device updates every connected client. Clients send thread.view (with the completion timestamp the user actually saw) and thread.mark-unread; the UI follows the shell’s viewedAt with no optimistic override, same pattern as settlement.

On the server, new commands persist through existing thread.meta-updated events (view-only updates do not bump updatedAt or reorder the list). The decider caps client boundaries at server time and never moves viewedAt backward. Migration 044 adds viewed_at and backfills existing threads as read. Environments advertise threadViewState; AgentAwarenessRelay ignores view-only meta updates.

Web routes mark-read/unread through useThreadViewState, with capability detection and local-storage fallback on older servers. ChatView only acknowledges a view when the tab is visible and focused, and waits for config/connection when using server commands.

Mobile sends thread.view from the focused thread route when the app is active, connected, detail is loaded, and shouldAcknowledgeThreadView says the latest completion is still unread.

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

Note

Add server-persisted thread viewedAt and thread.view/thread.mark-unread commands

  • Introduces server-backed thread view state so read/unread status syncs across clients. The server decider validates and caps client-supplied boundaries, prevents viewedAt from moving backward, and emits thread.meta-updated events carrying the new boundary. thread.mark-unread sets the boundary to just before the latest completed turn.
  • Adds migration 44 (044_ProjectionThreadsViewedAt.ts), which adds a nullable viewed_at column to projection_threads and backfills existing rows from updated_at (falling back to created_at).
  • Web and mobile clients gain a capability-aware useThreadViewState hook and mobile shouldAcknowledgeThreadView predicate that dispatch server commands on supported environments and fall back to local UI-store on older servers. Sidebar rows, action menus, and archive/settle actions all route through this path.
  • The AgentAwarenessRelay now suppresses thread.meta-updated events whose only changed field is viewedAt, so view-state sync does not trigger agent notifications.
  • Risk: migration 44 backfills viewed_at from updated_at for all existing projection threads — verify the backfill result matches expected read state on upgrade. Client view-state writes are suppressed while the environment capability is unresolved, so threads may appear unread until configuration loads.

Macroscope summarized 9175d4f.

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/hooks/useThreadViewState.ts Outdated
Comment threadpackages/contracts/src/orchestration.ts
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.3 KiB+30 B (+0.2%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+5 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.4 KiB+25 B (+0.4%)7.8 KiB
CodexLive turn WebSocket decoded55.6 KiB55.6 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages10100 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB+16 B (+0.1%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−3 B (−0.0%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB+19 B (+0.3%)7.8 KiB
ClaudeLive turn WebSocket decoded56.4 KiB56.4 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages10100 (0.0%)21

Baseline: 80c708a · PR result: 9175d4f · 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: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

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

@github-actionsgithub-actionsBot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/components/ChatView.tsx
@macroscopeapp

macroscopeappBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces durable server-owned read state, new orchestration commands, a database migration/backfill, and coordinated behavior changes across web, mobile, and shared runtime paths. Its cross-client UX impact and changed read-state defaults make the change broader than a small additive update.

No code changes detected at 9175d4f. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@cursorcursorBot 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 1 potential issue.

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 d6aad18. Configure here.

Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/ChatView.tsx Outdated
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.
@t3dotgg
t3dotggforce-pushed the t3code/server-owned-thread-read-state branch from 3626c5c to 9175d4fCompareSeptember 2, 2026 01:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:trustedPR 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

@t3dotgg
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: sync thread read state across clients - #9124

Open
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state
Open

feat: sync thread read state across clients#9124
t3dotgg wants to merge 6 commits into
mainfrom
t3code/server-owned-thread-read-state

Conversation

@t3dotgg

@t3dotggt3dotgg commented Sep 1, 2026

Copy link
Copy Markdown
Member

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 viewedAt timestamp per thread, the same way it owns settlement since #8600. Clients send thread.view when a focused, visible thread's latest completion is read and thread.mark-unread from 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:

  • The decider caps the boundary at server time and never moves it backward, so a stale view cannot undo a newer completion or a mark-unread.
  • Web only acknowledges a view when the document is visible and focused. Mobile only acknowledges when the thread route is focused, the app is active, and the environment is connected.
  • Viewing does not bump updatedAt, so reading a thread never reorders the list. View-only events are excluded from agent awareness.
  • Servers without the threadViewState capability 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
Touches orchestration commands, SQLite migration/backfill, and cross-client read semantics; mis-timed client acks or backfill could briefly show wrong Done state until shell sync.

Overview
Adds server-owned thread read state (viewedAt) so clearing the Done indicator on one device updates every connected client. Clients send thread.view (with the completion timestamp the user actually saw) and thread.mark-unread; the UI follows the shell’s viewedAt with no optimistic override, same pattern as settlement.

On the server, new commands persist through existing thread.meta-updated events (view-only updates do not bump updatedAt or reorder the list). The decider caps client boundaries at server time and never moves viewedAt backward. Migration 044 adds viewed_at and backfills existing threads as read. Environments advertise threadViewState; AgentAwarenessRelay ignores view-only meta updates.

Web routes mark-read/unread through useThreadViewState, with capability detection and local-storage fallback on older servers. ChatView only acknowledges a view when the tab is visible and focused, and waits for config/connection when using server commands.

Mobile sends thread.view from the focused thread route when the app is active, connected, detail is loaded, and shouldAcknowledgeThreadView says the latest completion is still unread.

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

Note

Add server-persisted thread viewedAt and thread.view/thread.mark-unread commands

  • Introduces server-backed thread view state so read/unread status syncs across clients. The server decider validates and caps client-supplied boundaries, prevents viewedAt from moving backward, and emits thread.meta-updated events carrying the new boundary. thread.mark-unread sets the boundary to just before the latest completed turn.
  • Adds migration 44 (044_ProjectionThreadsViewedAt.ts), which adds a nullable viewed_at column to projection_threads and backfills existing rows from updated_at (falling back to created_at).
  • Web and mobile clients gain a capability-aware useThreadViewState hook and mobile shouldAcknowledgeThreadView predicate that dispatch server commands on supported environments and fall back to local UI-store on older servers. Sidebar rows, action menus, and archive/settle actions all route through this path.
  • The AgentAwarenessRelay now suppresses thread.meta-updated events whose only changed field is viewedAt, so view-state sync does not trigger agent notifications.
  • Risk: migration 44 backfills viewed_at from updated_at for all existing projection threads — verify the backfill result matches expected read state on upgrade. Client view-state writes are suppressed while the environment capability is unresolved, so threads may appear unread until configuration loads.

Macroscope summarized 9175d4f.

@github-actionsgithub-actionsBot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/hooks/useThreadViewState.ts Outdated
Comment threadpackages/contracts/src/orchestration.ts
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire13.3 KiB13.3 KiB+30 B (+0.2%)15.1 KiB
CodexThread snapshot wire6.9 KiB6.9 KiB+5 B (+0.1%)7.3 KiB
CodexLive turn WebSocket wire6.4 KiB6.4 KiB+25 B (+0.4%)7.8 KiB
CodexLive turn WebSocket decoded55.6 KiB55.6 KiB0 B (0.0%)66.4 KiB
CodexLive turn messages10100 (0.0%)21
ClaudeTotal thread wire13.3 KiB13.3 KiB+16 B (+0.1%)15.1 KiB
ClaudeThread snapshot wire6.9 KiB6.9 KiB−3 B (−0.0%)7.3 KiB
ClaudeLive turn WebSocket wire6.4 KiB6.4 KiB+19 B (+0.3%)7.8 KiB
ClaudeLive turn WebSocket decoded56.4 KiB56.4 KiB0 B (0.0%)66.4 KiB
ClaudeLive turn messages10100 (0.0%)21

Baseline: 80c708a · PR result: 9175d4f · 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: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

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

@github-actionsgithub-actionsBot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment threadapps/web/src/components/ChatView.tsx
@macroscopeapp

macroscopeappBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces durable server-owned read state, new orchestration commands, a database migration/backfill, and coordinated behavior changes across web, mobile, and shared runtime paths. Its cross-client UX impact and changed read-state defaults make the change broader than a small additive update.

No code changes detected at 9175d4f. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@cursorcursorBot 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 1 potential issue.

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 d6aad18. Configure here.

Comment threadapps/web/src/components/ChatView.tsx Outdated
Comment threadapps/web/src/components/ChatView.tsx Outdated
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.
@t3dotgg
t3dotggforce-pushed the t3code/server-owned-thread-read-state branch from 3626c5c to 9175d4fCompareSeptember 2, 2026 01:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL500-999 changed lines (additions + deletions).vouch:trustedPR 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

@t3dotgg