Skip to content

[codex] Rewrite client connection architecture#2978

Open
juliusmarminge wants to merge 1 commit into
mainfrom
codex/connection-state-audit
Open

[codex] Rewrite client connection architecture#2978
juliusmarminge wants to merge 1 commit into
mainfrom
codex/connection-state-audit

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jun 6, 2026

Copy link
Copy Markdown
Member

Summary

Replace the independent web and mobile connection implementations with a shared Effect-based client runtime.

  • introduce environment descriptors, connection drivers, resolution, supervision, authorization, relay discovery, RPC sessions, and lifecycle ownership in @t3tools/client-runtime
  • move runtime state to explicit environment-scoped modules for auth, connections, projects, threads, shell, terminal, filesystem, source control, review, and presentation
  • migrate web, desktop, and mobile integrations to explicit client-runtime subpath exports
  • add persisted connection catalogs for web, mobile, and desktop, including encrypted desktop storage and mobile legacy migration
  • centralize retry/backoff, connectivity wakeups, credential refresh, account-transition cleanup, session ownership, subscription recovery, and mobile outbox draining
  • preserve scoped relay tracing across the rewritten runtime and release configuration
  • harden local relay startup and agent-activity credential reconciliation

Scope

The native mobile composer, markdown renderer, Clerk sheet routing, turn-fold presentation, and Pierre icons were reviewed separately in #3101 and are now part of main. This PR contains the client connection/runtime rearchitecture on top of that merged base.

Validation

  • node scripts/release-smoke.ts
  • vp check
  • vp run typecheck
  • vp run lint:mobile
  • 96 changed test files passed, covering 741 tests including every changed backend test

Note

High Risk
Large cross-platform auth, credential, and persistence changes (encrypted catalogs, migration, account transitions, connection phase renames) with high blast radius if imports or session field migrations are missed.

Overview
This PR replaces per-app connection wiring with a shared @t3tools/client-runtime connection layer (atoms, platform storage, supervision, relay/cloud hooks) and moves imports off the package root onto explicit subpaths (/connection, /relay, /rpc, /authorization, etc.).

Desktop gains an encrypted connection catalog (DesktopConnectionCatalogStore) with IPC read/write/clear, one-time migration from legacy saved environments/secrets, and stricter persistence behavior (malformed registry/catalog surfaces read errors instead of silently emptying). Electron safe-storage types move to ElectronSafeStorageService for reuse in tests and the new store.

Mobile adds a full connection platform: secure-store catalog with legacy t3code.connections migration, file-backed shell/thread snapshot caches, expo-network / app-state wakeups, and SSH explicitly blocked. Screens pivot to useWorkspaceState, entity hooks, and useThreadOutboxDrain; cloud environments use useConnectionController, account-transition cleanup in CloudAuthProvider, coalesced APNs device registration, and richer status/trace UI. HTTP assets (e.g. favicons) use shared remote auth headers including DPoP.

Saved environments on desktop still exist for migration but main composition prefers the catalog store layer; cloud auth fetch fixes header passing for Effect HTTP client.

CI drops two web browser test files from the bundled components job.

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

Note

Replace WebSocket/store connection layer with environment-scoped atom registry

  • Introduces a new connection/ subsystem in packages/client-runtime with EnvironmentRegistry, ConnectionSupervisor, ConnectionDriver, ConnectionResolver, and ConnectionOnboarding services that manage environment lifecycle, backoff/retry, and credential resolution.
  • Adds environment-scoped reactive atom families (state/shell, state/threads, state/terminal, state/vcs, state/preview, state/server, etc.) backed by a new connectionAtomRuntime that replaces the prior webRuntime/mobileRuntime exports.
  • Migrates all UI components (web, mobile, desktop) from legacy Zustand/react-query stores and direct RPC calls to useEnvironmentQuery / useAtomSet / atom-based actions; readLocalApi and direct EnvironmentApi usage are removed from the browser layer.
  • Renames EnvironmentScopedProjectShellEnvironmentProject, cwdworkspaceRoot, session orchestrationStatusstatus, and closedstopped throughout.
  • Adds an encrypted, filesystem-backed DesktopConnectionCatalogStore and exposes get/set/clear via IPC; mobile adds a SecureCatalogStorage-backed CatalogStore with legacy migration.
  • Introduces a persistent thread outbox (state/thread-outbox) on mobile with deduplication, capped retry/backoff, and a useThreadOutboxDrain hook that delivers queued messages once the environment is connected.
  • Risk: @t3tools/client-runtime no longer has a root export; all consumers must import from explicit subpaths (e.g. @t3tools/client-runtime/state/shell). Session status values and several field names have changed, which may affect any code not updated in this PR.

Macroscope summarized 2eaface.

@coderabbitai

coderabbitai Bot commented Jun 6, 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

Run ID: 21106745-3eb6-491e-98dd-b41f011813d6

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/connection-state-audit

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

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jun 6, 2026
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
  🤖 Android 🍎 iOS
Fingerprint 83171f85d345ada59856c90536c38cf297b0a53f ee8a4bee0cbd7d33f547b38ef5cd97d14825973e
Build Details Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 83171f85d345ada59856c90536c38cf297b0a53f
App version: 0.1.0
Git commit: f46d2e08227486b9efb00126aa42b9e380c44a7a
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: ee8a4bee0cbd7d33f547b38ef5cd97d14825973e
App version: 0.1.0
Git commit: ec5abf6c95e63723d4eb09d3c2cf35f9d1a96bbb
Update Details Update Permalink
DetailsBranch: pr-2978
Runtime version: 83171f85d345ada59856c90536c38cf297b0a53f
Git commit: f46d2e08227486b9efb00126aa42b9e380c44a7a
Update Permalink
DetailsBranch: pr-2978
Runtime version: ee8a4bee0cbd7d33f547b38ef5cd97d14825973e
Git commit: f46d2e08227486b9efb00126aa42b9e380c44a7a
Update QR

Comment thread apps/web/src/environments/runtime/service.ts Outdated
Comment thread apps/web/src/connection/appQueries.ts Outdated
Comment thread apps/web/src/connection/storage.ts
Comment thread apps/mobile/src/app/settings/environments.tsx
Comment thread apps/web/src/state/terminalSessions.ts
Comment thread packages/client-runtime/src/connection/resolver.ts Outdated
Comment thread packages/client-runtime/src/state/threads.ts
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch from 16c9aba to 2a29e34 Compare June 7, 2026 19:53
@juliusmarminge juliusmarminge changed the title Harden remote connection state handling across mobile, web, and relay [codex] Rewrite client connection architecture Jun 7, 2026
@juliusmarminge juliusmarminge changed the base branch from main to codex/connection-infra-otel-base June 7, 2026 19:53
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch from 2a29e34 to b976d5f Compare June 7, 2026 20:09
Comment on lines +87 to +113
const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* () {
return yield* Effect.callback<IDBDatabase, ConnectionTransientError>((resume) => {
if (typeof indexedDB === "undefined") {
resume(
Effect.fail(catalogError("open", "IndexedDB is unavailable in this browser context.")),
);
return;
}
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.addEventListener("upgradeneeded", () => {
if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) {
request.result.createObjectStore(CATALOG_STORE_NAME);
}
if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) {
request.result.createObjectStore(SHELL_STORE_NAME);
}
if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) {
request.result.createObjectStore(THREAD_STORE_NAME);
}
});
request.addEventListener("error", () => {
resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error")));
});
request.addEventListener("success", () => {
resume(Effect.succeed(request.result));
});
});

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 connection/webConnectionStorage.ts:87

When indexedDB.open() needs to upgrade the schema but another tab holds an older version, the blocked event fires and the Effect never completes — neither success nor error handlers execute until the blocking tab closes. If that tab never closes, openDatabase hangs indefinitely. Consider handling the blocked event by resuming with a descriptive error or triggering a retry with timeout.

+    request.addEventListener("blocked", () => {
+      resume(Effect.fail(catalogError("open", "Database upgrade blocked by another tab")));
+    });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/connection/webConnectionStorage.ts around lines 87-113:

When `indexedDB.open()` needs to upgrade the schema but another tab holds an older version, the `blocked` event fires and the Effect never completes — neither `success` nor `error` handlers execute until the blocking tab closes. If that tab never closes, `openDatabase` hangs indefinitely. Consider handling the `blocked` event by resuming with a descriptive error or triggering a retry with timeout.

Evidence trail:
apps/web/src/connection/webConnectionStorage.ts lines 87-114 at REVIEWED_COMMIT: `openDatabase` registers handlers for `upgradeneeded` (line 96), `error` (line 107), and `success` (line 110), but not for `blocked`. IndexedDB spec: https://w3c.github.io/IndexedDB/#request-api — the `blocked` event fires on IDBOpenDBRequest when the open operation is blocked by existing connections, and `success`/`error` don't fire until the block is resolved.

@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch 2 times, most recently from fb36d5e to 8de82fb Compare June 7, 2026 20:23
Comment thread apps/web/src/connection/webConnectionPlatform.ts Outdated
Comment thread apps/mobile/src/features/cloud/CloudAuthProvider.tsx Outdated
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch 4 times, most recently from 9ba3552 to a01b7f9 Compare June 7, 2026 20:45
@juliusmarminge juliusmarminge force-pushed the codex/connection-infra-otel-base branch from ced8bdf to 22b4b24 Compare June 7, 2026 20:50
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch 4 times, most recently from 409fd6c to ecd84a7 Compare June 7, 2026 21:06
Base automatically changed from codex/connection-infra-otel-base to main June 8, 2026 03:21
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch from ecd84a7 to f5c0afe Compare June 8, 2026 04:09
@juliusmarminge juliusmarminge changed the base branch from main to codex/hosted-web-tracing-poc June 8, 2026 04:09
Comment thread packages/client-runtime/src/connection/registry.ts Outdated
Comment on lines -59 to +77
);
if (!isSignedIn || !userId) {
setManagedRelaySession(appAtomRegistry, null);
if (previousAccount !== null) {

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 cloud/managedAuth.tsx:59

On first render when signed out, previousAccount is undefined, so undefined !== null evaluates to true and queueAccountCleanup() runs unnecessarily. This triggers removeRelayEnvironments() and relay client token cache reset even though no account was ever established. Change the condition to if (previousAccount) so cleanup only happens when transitioning from an actual signed-in state.

-      if (previousAccount !== null) {
+      if (previousAccount) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/cloud/managedAuth.tsx around line 59:

On first render when signed out, `previousAccount` is `undefined`, so `undefined !== null` evaluates to `true` and `queueAccountCleanup()` runs unnecessarily. This triggers `removeRelayEnvironments()` and relay client token cache reset even though no account was ever established. Change the condition to `if (previousAccount)` so cleanup only happens when transitioning from an actual signed-in state.

Evidence trail:
apps/web/src/cloud/managedAuth.tsx lines 26, 35, 37, 57-61, 63 at REVIEWED_COMMIT. Line 26: ref initialized to `undefined`. Line 35: `previousAccount = observedAccountRef.current` (undefined on first render). Line 59: condition `previousAccount !== null` doesn't account for `undefined`. Line 63: correctly checks all three states (`!== undefined && !== null && !== userId`).

? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected")
: "connected";
const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
const activeEnvironment =

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:1119

The refactored code removed the guard that excluded the primary environment from unavailability checks. Now activeEnvironmentUnavailable is computed for all environments including the primary, so if the primary's connection.phase is temporarily "connecting" or "available" during startup, the UI shows an unavailable banner and blocks message dispatch — behavior that never occurred before.

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

The refactored code removed the guard that excluded the primary environment from unavailability checks. Now `activeEnvironmentUnavailable` is computed for all environments including the primary, so if the primary's `connection.phase` is temporarily `"connecting"` or `"available"` during startup, the UI shows an unavailable banner and blocks message dispatch — behavior that never occurred before.

Evidence trail:
Old guard at MERGE_BASE in ChatView.tsx: `activeThread.environmentId !== primaryEnvironmentId` check in `activeSavedEnvironmentRecord` assignment; new code at REVIEWED_COMMIT ChatView.tsx lines 1119-1123 with no primary guard; `AVAILABLE_CONNECTION_STATE` defined at packages/client-runtime/src/connection/model.ts:148-154 with `phase: "available"`; `activeEnvironmentUnavailable` used to block sends at ChatView.tsx:2806, ChatView.tsx:3427; used to block revert at ChatView.tsx:2746; used for banner at ChatView.tsx:1353; `isConnecting` always false at ChatView.tsx:904 (`_setIsConnecting` unused).

Comment thread apps/web/src/hooks/useSettings.ts
Comment thread apps/web/src/state/query.ts
Comment thread apps/web/src/components/ProviderUpdateLaunchNotification.tsx
Comment thread apps/mobile/src/features/threads/use-project-actions.ts
Comment thread apps/mobile/src/connection/storage.ts
Comment thread packages/client-runtime/src/connection/supervisor.ts
Comment thread apps/mobile/src/features/threads/new-task-flow-provider.tsx
Comment on lines +1808 to +2190
(activeThread.session !== null && activeThread.session.status !== "stopped")),

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:1808

The envLocked check at line 1808 only excludes status !== "stopped", but the new session model has two additional terminal states "interrupted" and "error". Threads with these statuses will incorrectly have envLocked = true, blocking users from changing branch/env-mode settings on effectively terminated threads. Consider updating the condition to also exclude "interrupted" and "error".

-      (activeThread.session !== null && activeThread.session.status !== "stopped")),
+      (activeThread.session !== null &&
+        activeThread.session.status !== "stopped" &&
+        activeThread.session.status !== "interrupted" &&
+        activeThread.session.status !== "error")),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 1808:

The `envLocked` check at line 1808 only excludes `status !== "stopped"`, but the new session model has two additional terminal states `"interrupted"` and `"error"`. Threads with these statuses will incorrectly have `envLocked = true`, blocking users from changing branch/env-mode settings on effectively terminated threads. Consider updating the condition to also exclude `"interrupted"` and `"error"`.

Evidence trail:
packages/contracts/src/orchestration.ts:249-257 (OrchestrationSessionStatus defines: idle, starting, running, ready, interrupted, stopped, error); apps/web/src/session-logic.ts:1252-1260 (derivePhase treats stopped/interrupted/error all as 'disconnected' terminal states); apps/web/src/components/ChatView.tsx:1805-1809 (envLocked check only excludes 'stopped'); apps/web/src/components/ChatView.tsx:1816 (envLocked blocks onEnvironmentChange); apps/web/src/components/ChatView.tsx:2497 (envLocked blocks canOverrideServerThreadEnvMode); apps/web/src/components/ChatView.tsx:3820 (envLocked passed to child component)

@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch from a71ceef to 25717b6 Compare June 11, 2026 08:38

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

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Stale tokens break selection mapping
    • Extracted a currentValidTokens() helper that filters tokens against the current value string, and used it in displayOffset(forSourceOffset:) so stale tokens no longer participate in offset calculations.

Create PR

Or push these changes by commenting:

@cursor push b5abc57d3c
Preview (b5abc57d3c)
diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
--- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
+++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
@@ -464,12 +464,7 @@
     let result = NSMutableAttributedString()
     let source = value as NSString
     var cursor = 0
-    let validTokens = tokens.filter {
-      $0.start >= cursor &&
-        $0.end > $0.start &&
-        $0.end <= source.length &&
-        source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source
-    }
+    let validTokens = currentValidTokens()
 
     for token in validTokens {
       if token.start < cursor {
@@ -678,11 +673,12 @@
 
   private func displayOffset(forSourceOffset sourceOffset: Int) -> Int {
     let boundedOffset = max(0, min((value as NSString).length, sourceOffset))
+    let active = currentValidTokens()
     var collapsedLength = 0
-    for token in tokens where token.end <= boundedOffset {
+    for token in active where token.end <= boundedOffset {
       collapsedLength += max(0, token.end - token.start - 1)
     }
-    if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) {
+    if let token = active.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) {
       return token.start - collapsedLength + 1
     }
     return boundedOffset - collapsedLength
@@ -723,9 +719,9 @@
     return try? JSONDecoder().decode(type, from: data)
   }
 
-  private func tokensMatchCurrentValue() -> Bool {
+  private func currentValidTokens() -> [ComposerTokenPayload] {
     let source = value as NSString
-    return tokens.allSatisfy {
+    return tokens.filter {
       $0.start >= 0 &&
         $0.end > $0.start &&
         $0.end <= source.length &&
@@ -733,19 +729,12 @@
     }
   }
 
+  private func tokensMatchCurrentValue() -> Bool {
+    currentValidTokens().count == tokens.count
+  }
+
   private func documentMatchesExpectedTokens() -> Bool {
-    let source = value as NSString
-    let expectedSources = tokens.compactMap { token -> String? in
-      guard token.start >= 0,
-            token.end > token.start,
-            token.end <= source.length,
-            source.substring(
-              with: NSRange(location: token.start, length: token.end - token.start)
-            ) == token.source else {
-        return nil
-      }
-      return token.source
-    }
+    let expectedSources = currentValidTokens().map(\.source)
     var renderedSources: [String] = []
     textView.attributedText.enumerateAttribute(
       .attachment,

You can send follow-ups to the cloud agent here.

Comment thread apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts

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

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Recording lock survives tab close
    • Added a check in closeTab to clear recordingTabIdRef when the closed tab matches the currently recording tab, preventing the stale lock from blocking future recordings.

Create PR

Or push these changes by commenting:

@cursor push 49b0b52408
Preview (49b0b52408)
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -1130,6 +1130,10 @@
     const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
     if (!tab) return;
     yield* cancelPickElement(tabId);
+    const recordingTabId = yield* Ref.get(recordingTabIdRef);
+    if (Option.isSome(recordingTabId) && recordingTabId.value === tabId) {
+      yield* Ref.set(recordingTabIdRef, Option.none());
+    }
     if (tab.webContentsId != null) {
       yield* Effect.all(
         [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)],

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/preview/Manager.ts
Comment on lines +323 to +326
const editor = resolveAndPersistPreferredEditor(serverConfig.availableEditors);
if (!editor) {
return;
}

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.

🟢 Low routes/__root.tsx:323

When resolveAndPersistPreferredEditor returns null, the onClick handler returns silently at line 325 without showing any feedback to the user. Previously this threw new Error("No available editors found.") which was caught and displayed in an error toast. Now the button appears to do nothing when no editor is available.

            if (!editor) {
+              toastManager.add(
+                stackedThreadToast({
+                  type: "error",
+                  title: "Unable to open keybindings file",
+                  description: "No available editors found.",
+                }),
+              );
              return;
            }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/routes/__root.tsx around lines 323-326:

When `resolveAndPersistPreferredEditor` returns `null`, the `onClick` handler returns silently at line 325 without showing any feedback to the user. Previously this threw `new Error("No available editors found.")` which was caught and displayed in an error toast. Now the button appears to do nothing when no editor is available.

Evidence trail:
apps/web/src/routes/__root.tsx lines 319-342 at REVIEWED_COMMIT (new code with silent return). git_diff base=MERGE_BASE head=REVIEWED_COMMIT path=apps/web/src/routes/__root.tsx shows old code had `throw new Error("No available editors found.")` inside a `.then()` caught by `.catch()` that displayed an error toast. Commit a01d712751f8 introduced `resolveAndPersistPreferredEditor` with the same throw pattern in the previous iteration.

@@ -423,14 +438,25 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
const userImages = row.message.attachments ?? [];
const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);

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.

🟠 High chat/MessagesTimeline.tsx:439

Line 449 calls extractTrailingElementContexts(visibleText) on text that has already had element contexts stripped by deriveDisplayedUserMessageState on line 439, so elementContextState.contexts is always empty. The element contexts are actually stored in displayedUserMessage.elementContexts, but the code ignores them and renders an empty chip list instead. Use displayedUserMessage.elementContexts instead of elementContextState.contexts on lines 497-505.

Also found in 1 other location(s)

apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift:679

The displayOffset(forSourceOffset:) method calculates collapsed lengths using the raw tokens array, but makeAttributedDocument() filters tokens for validity (checking source substring matches). If any token in tokens has an invalid/mismatched source, displayOffset will include its collapsed length in calculations while the actual attributed document won't contain that attachment. This causes incorrect cursor/selection positioning. For example, if value=&#34;hello world&#34; and tokens contains a token with source=&#34;wrong&#34; (not matching the actual substring), the selection conversion will be off.

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

Line 449 calls `extractTrailingElementContexts(visibleText)` on text that has already had element contexts stripped by `deriveDisplayedUserMessageState` on line 439, so `elementContextState.contexts` is always empty. The element contexts are actually stored in `displayedUserMessage.elementContexts`, but the code ignores them and renders an empty chip list instead. Use `displayedUserMessage.elementContexts` instead of `elementContextState.contexts` on lines 497-505.

Evidence trail:
apps/web/src/components/chat/MessagesTimeline.tsx lines 436-510 (REVIEWED_COMMIT) — UserTimelineRow component showing the double extraction. apps/web/src/lib/terminalContext.ts lines 248-262 (REVIEWED_COMMIT) — deriveDisplayedUserMessageState calls extractTrailingElementContexts internally and stores results in elementContexts, returning visibleText with element contexts already stripped.

Also found in 1 other location(s):
- apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift:679 -- The `displayOffset(forSourceOffset:)` method calculates collapsed lengths using the raw `tokens` array, but `makeAttributedDocument()` filters tokens for validity (checking `source` substring matches). If any token in `tokens` has an invalid/mismatched `source`, `displayOffset` will include its collapsed length in calculations while the actual attributed document won't contain that attachment. This causes incorrect cursor/selection positioning. For example, if `value="hello world"` and `tokens` contains a token with `source="wrong"` (not matching the actual substring), the selection conversion will be off.

Comment on lines +131 to +165
const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
clientId: string,
queue: ClientConnection["queue"],
) {
const toFail = yield* SynchronizedRef.modify(state, (current) => {
if (current.clients.get(clientId)?.queue !== queue) {
return [[] as ReadonlyArray<PendingRequest>, current] as const;
}
const clients = new Map(current.clients);
const owners = new Map(current.owners);
const pending = new Map(current.pending);
const disconnected: PendingRequest[] = [];
clients.delete(clientId);
owners.delete(clientId);
for (const [requestId, entry] of pending) {
if (entry.clientId === clientId) {
pending.delete(requestId);
disconnected.push(entry);
}
}
return [disconnected, { ...current, clients, owners, pending }] as const;
});
yield* Effect.forEach(
toFail,
({ deferred }) =>
Deferred.fail(
deferred,
new PreviewAutomationUnavailableError({
message: "The preview automation client disconnected.",
}),
),
{ discard: true },
);
yield* Queue.shutdown(queue);
});

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.

🟠 High mcp/PreviewAutomationBroker.ts:131

When a client reconnects with the same clientId, disconnect is called with the old queue but checks against the new queue stored in state, causing the equality check at line 136 to fail. The function returns early without shutting down the old queue or failing its pending requests, leaving them hanging indefinitely and leaking the queue.

  const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
    clientId: string,
    queue: ClientConnection["queue"],
  ) {
    const toFail = yield* SynchronizedRef.modify(state, (current) => {
-      if (current.clients.get(clientId)?.queue !== queue) {
+      const stored = current.clients.get(clientId);
+      // Only skip if this exact connection is still registered
+      if (stored?.queue === queue) {
         return [[] as ReadonlyArray<PendingRequest>, current] as const;
       }
       const clients = new Map(current.clients);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/mcp/PreviewAutomationBroker.ts around lines 131-165:

When a client reconnects with the same `clientId`, `disconnect` is called with the old queue but checks against the new queue stored in state, causing the equality check at line 136 to fail. The function returns early without shutting down the old queue or failing its pending requests, leaving them hanging indefinitely and leaking the queue.

Comment thread apps/mobile/src/state/thread-outbox.ts
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch from 67ca323 to d83dfa7 Compare June 15, 2026 05:31
@juliusmarminge juliusmarminge changed the base branch from codex/hosted-web-tracing-poc to main June 15, 2026 05:31
Comment thread packages/client-runtime/src/authorization/layer.ts
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch from d83dfa7 to 7edd34a Compare June 15, 2026 21:56

@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 and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Markdown links ignore taps
    • Changed getTouchChild to iterate _view.subviews (the Fabric contentView where T3MarkdownTextRun children are mounted) instead of self.subviews, which only contained _textView and the empty content container.
  • ✅ Fixed: Catalog reads empty without encryption
    • Replaced the silent Option.none() return with a dedicated DesktopConnectionCatalogStoreEncryptionUnavailableError when an encrypted catalog file exists on disk but safe-storage encryption is unavailable, so the client correctly surfaces the error instead of treating saved connections as missing.

Create PR

Or push these changes by commenting:

@cursor push 10af3a0dce
Preview (10af3a0dce)
diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts
--- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts
+++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts
@@ -88,11 +88,20 @@
   }
 }
 
+export class DesktopConnectionCatalogStoreEncryptionUnavailableError extends Data.TaggedError(
+  "DesktopConnectionCatalogStoreEncryptionUnavailableError",
+)<{}> {
+  override get message() {
+    return "Cannot read the encrypted connection catalog because encryption is unavailable.";
+  }
+}
+
 export interface DesktopConnectionCatalogStoreShape {
   readonly get: Effect.Effect<
     Option.Option<string>,
     | DesktopConnectionCatalogStoreReadError
     | DesktopConnectionCatalogStoreDecodeError
+    | DesktopConnectionCatalogStoreEncryptionUnavailableError
     | DesktopConnectionCatalogStoreMigrationError
     | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
     | ElectronSafeStorage.ElectronSafeStorageDecryptError
@@ -300,7 +309,7 @@
           return yield* migrateLegacyCatalog;
         }
         if (!(yield* safeStorage.isEncryptionAvailable)) {
-          return Option.none<string>();
+          return yield* new DesktopConnectionCatalogStoreEncryptionUnavailableError();
         }
         const decrypted = yield* decodeSecretBytes(document.value.encryptedCatalog).pipe(
           Effect.flatMap(safeStorage.decryptString),

diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm
--- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm
+++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm
@@ -616,7 +616,7 @@
   ];
 
   int currIndex = -1;
-  for (UIView* child in self.subviews) {
+  for (UIView* child in _view.subviews) {
     if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
       continue;
     }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 7edd34a. Configure here.

Comment thread apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm
Comment thread apps/desktop/src/app/DesktopConnectionCatalogStore.ts
ignoreWhitespace: false,
});

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.

🟢 Low review/useReviewSections.ts:133

When the user switches from section A to section B, loadingTurnIds[A] remains true forever because the effect on lines 133-138 only updates the entry for the current activeSectionId. If the user returns to section A, the UI still shows it as loading even though no request is in flight. Consider clearing the previous section's loading state when activeSectionId changes, or using a lookup keyed by section id that reflects the actual pending query for that specific section.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/review/useReviewSections.ts around line 133:

When the user switches from section A to section B, `loadingTurnIds[A]` remains `true` forever because the effect on lines 133-138 only updates the entry for the current `activeSectionId`. If the user returns to section A, the UI still shows it as loading even though no request is in flight. Consider clearing the previous section's loading state when `activeSectionId` changes, or using a lookup keyed by section id that reflects the actual pending query for that specific section.

Evidence trail:
apps/mobile/src/features/review/useReviewSections.ts lines 133-138 (effect only writes current activeSectionId, no cleanup); apps/mobile/src/features/review/reviewState.ts lines 201-218 (setReviewTurnDiffLoading sets/deletes by sectionId); apps/mobile/src/features/review/reviewModel.ts line 544 (loadingTurnIds[id] === true used to set isLoading on UI items)

Comment on lines +218 to +248
export async function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise<void> {
await serializeThreadOutboxMutation(async () => {
const current = flattenQueues(appAtomRegistry.get(queuedMessagesByThreadKeyAtom));
const persisted = await loadPersistedMessages().catch((error) => {
console.warn("[thread-outbox] failed to load messages while clearing environment", error);
return [];
});
const allMessages = flattenQueues(groupQueuedThreadMessages([...persisted, ...current]));
const removed = allMessages.filter((message) => message.environmentId === environmentId);

await Promise.all(
removed.map(async (message) => {
try {
const file = await getMessageFile(message.messageId);
if (file.exists) {
file.delete();
}
} catch (error) {
console.warn("[thread-outbox] failed to clear persisted message", error);
}
}),
);

appAtomRegistry.set(
queuedMessagesByThreadKeyAtom,
groupQueuedThreadMessages(
allMessages.filter((message) => message.environmentId !== environmentId),
),
);
});
}

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 state/thread-outbox.ts:218

clearThreadOutboxEnvironment can race with the pending loadPromise from ensureThreadOutboxLoaded. If loading is in progress, this function deletes files and clears the atom, but then the pending disk read (which captured the old file contents before deletion) completes and adds those cleared messages back into the atom. This causes messages for the cleared environment to reappear in-memory after the function returns, until the next app restart.

Consider awaiting loadPromise before proceeding, or ensuring the load operation respects concurrent deletions.

export async function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise<void> {
+  if (loadPromise !== null) {
+    await loadPromise;
+  }
   await serializeThreadOutboxMutation(async () => {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/state/thread-outbox.ts around lines 218-248:

`clearThreadOutboxEnvironment` can race with the pending `loadPromise` from `ensureThreadOutboxLoaded`. If loading is in progress, this function deletes files and clears the atom, but then the pending disk read (which captured the old file contents before deletion) completes and adds those cleared messages back into the atom. This causes messages for the cleared environment to reappear in-memory after the function returns, until the next app restart.

Consider awaiting `loadPromise` before proceeding, or ensuring the load operation respects concurrent deletions.

Evidence trail:
apps/mobile/src/state/thread-outbox.ts lines 55-65 (mutationQueue and serializeThreadOutboxMutation), lines 164-182 (ensureThreadOutboxLoaded - loadPersistedMessages resolves first, THEN queues the mutation via .then), lines 218-248 (clearThreadOutboxEnvironment - immediately queues mutation). The race: load starts disk I/O → clear queues and runs mutation → disk I/O resolves → load queues mutation AFTER clear → load re-adds cleared messages from stale persistedMessages.

Comment on lines +183 to +186
Effect.all(
[
Effect.promise(() => clearThreadOutboxEnvironment(environmentId)),
Effect.promise(() => clearComposerDraftsEnvironment(environmentId)),

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.

🟢 Low connection/platform.ts:183

Effect.catch on line 190 will never execute its error handler because Effect.promise produces an effect with error type never. When clearThreadOutboxEnvironment or clearComposerDraftsEnvironment reject, the rejection becomes an untyped defect rather than a typed error, so the warning log never records the failure. Consider using Effect.tryPromise instead of Effect.promise to capture rejections as typed errors that Effect.catch can handle.

-          Effect.promise(() => clearThreadOutboxEnvironment(environmentId)),
-          Effect.promise(() => clearComposerDraftsEnvironment(environmentId)),
+          Effect.tryPromise({
+            try: () => clearThreadOutboxEnvironment(environmentId),
+            catch: (error) => error,
+          }),
+          Effect.tryPromise({
+            try: () => clearComposerDraftsEnvironment(environmentId),
+            catch: (error) => error,
+          }),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/connection/platform.ts around lines 183-186:

`Effect.catch` on line 190 will never execute its error handler because `Effect.promise` produces an effect with error type `never`. When `clearThreadOutboxEnvironment` or `clearComposerDraftsEnvironment` reject, the rejection becomes an untyped defect rather than a typed error, so the warning log never records the failure. Consider using `Effect.tryPromise` instead of `Effect.promise` to capture rejections as typed errors that `Effect.catch` can handle.

Evidence trail:
apps/mobile/src/connection/platform.ts lines 183-196 (Effect.promise and Effect.catch usage); pnpm-workspace.yaml line 19 (effect version 4.0.0-beta.78); Effect v4 migration docs at https://github.com/Effect-TS/effect-smol/blob/main/migration/error-handling.md (Effect.catchAll → Effect.catch, only handles recoverable errors); Effect documentation at https://effect.website/docs/error-management/expected-errors/ ('Effect.catchAll only handles recoverable errors. It will not recover from unrecoverable defects.')

@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch 2 times, most recently from f4d8453 to c8fcac4 Compare June 16, 2026 07:07
@juliusmarminge juliusmarminge changed the base branch from main to codex/hosted-web-tracing-poc June 16, 2026 07:08
Base automatically changed from codex/hosted-web-tracing-poc to main June 16, 2026 07:11
// Even if the uiTextView prop is set, we can still default to using
// normal selection (i.e. base RN text) if the text doesn't need to be
// selectable
if ((!props.selectable || !props.uiTextView) && !isAncestor) {

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.

🟢 Low src/MarkdownTextPrimitive.tsx:98

MarkdownTextPrimitiveInner checks !props.selectable on line 98, but selectable defaults to true via textDefaults. When a caller sets uiTextView={true} without explicitly passing selectable, the component incorrectly returns RNText instead of MarkdownTextPrimitiveChild because undefined is treated as falsy. Consider checking props.selectable === false to respect the intended default behavior.

-  if ((!props.selectable || !props.uiTextView) && !isAncestor) {
+  if ((props.selectable === false || !props.uiTextView) && !isAncestor) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx around line 98:

`MarkdownTextPrimitiveInner` checks `!props.selectable` on line 98, but `selectable` defaults to `true` via `textDefaults`. When a caller sets `uiTextView={true}` without explicitly passing `selectable`, the component incorrectly returns `RNText` instead of `MarkdownTextPrimitiveChild` because `undefined` is treated as falsy. Consider checking `props.selectable === false` to respect the intended default behavior.

@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch from c8fcac4 to 1584952 Compare June 16, 2026 07:38
@juliusmarminge juliusmarminge changed the base branch from main to codex/mobile-native-composer-markdown June 16, 2026 07:38
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch from 1584952 to a5670a8 Compare June 16, 2026 16:26
Base automatically changed from codex/mobile-native-composer-markdown to main June 16, 2026 16:32
Comment thread packages/client-runtime/src/state/vcs.ts
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch 5 times, most recently from 6a22a8d to fc808e6 Compare June 16, 2026 18:08
Move web, desktop, server, and mobile connection state onto the shared client runtime while preserving the separately reviewed native mobile UI stack.

Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the codex/connection-state-audit branch from fc808e6 to 2eaface Compare June 16, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ 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