Skip to content

T3 Code Mobile [WIP]#2013

Open
juliusmarminge wants to merge 47 commits into
mainfrom
t3code/mobile-remote-connect
Open

T3 Code Mobile [WIP]#2013
juliusmarminge wants to merge 47 commits into
mainfrom
t3code/mobile-remote-connect

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 14, 2026

⚠️ WARNING :: VERY EARLY

Summary

  • Add a new Expo-based mobile client with remote connection setup, thread browsing, new-thread flows, composer UI, and git action sheets.
  • Move shared remote/runtime, git, thread-detail, and WebSocket state into packages/client-runtime and packages/shared so web and mobile can share the same behavior.
  • Refactor desktop startup and readiness handling to rely on HTTP session readiness, simplify window bootstrap, and remove the old listening-detector path.
  • Rework web connection, composer, sidebar, and git action flows to use the new shared runtime and state management.

Testing

  • Not run (PR content only).

Note

High Risk
High risk due to large new iOS native surface implementation (T3ReviewDiffView.swift) plus vendored native terminal framework, and new CI enforcement for native linting which can break builds/tooling on macOS runners.

Overview
Adds a new apps/mobile Expo app scaffold with variant-aware app.config.ts, Metro/Babel setup (including Uniwind + Shiki resolution), styling tokens in global.css, and supporting repo hygiene files (.gitignore, .editorconfig, README, eas.json).

Introduces native iOS modules for mobile: a native review diff surface (T3ReviewDiffSurface via T3ReviewDiffModule.swift + large T3ReviewDiffView.swift) and a terminal module podspec that vendors GhosttyKit.xcframework (with third-party notices).

Updates tooling/CI to support mobile native code: adds a macOS mobile_native_static_analysis GitHub Actions job that installs swiftlint/ktlint/detekt from apps/mobile/Brewfile and runs scripts/mobile-native-static-check.ts, expands oxfmt/oxlint ignore patterns for generated apps/mobile/android/ios folders, and documents the new bun lint:mobile requirement in AGENTS.md.

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

Note

Add mobile app foundation with terminal, review, and git screens to T3 Code

  • Introduces the React Native mobile app (apps/mobile) with Expo Router navigation, global atom registry, and environment connection management including QR-code pairing and secure storage of credentials.
  • Adds native terminal views for iOS (GhosttyKit-backed) and Android (ScrollView fallback) exposed as Expo modules (T3TerminalSurface), with a full ThreadTerminalRouteScreen managing session attach, input, resize, and font-size controls.
  • Adds a review diff system including a native T3ReviewDiffSurface iOS module, Shiki-based syntax highlighting, word-level diff ranges, and review comment composition with inline diff previews.
  • Adds git action sheets (overview, commit, branches, confirm) wired to a shared vcsActionManager and useSelectedThreadGitActions hook that orchestrates operations via the server.
  • Refactors the web app's terminal state from terminalStateStore to terminalUiStateStore, introduces terminalSessionState backed by attachStream/subscribeMetadata RPC, and replaces React Query-based git/VCS hooks with new useVcsStatus, useVcsRefs, and useCheckpointDiff state managers.
  • Adds server-side ReviewService and getReviewDiffPreview to GitVcsDriverCore, exposing working-tree and branch-range diff sources over a new review.getDiffPreview WS RPC endpoint.
  • Extends the WS RPC layer with terminal.attach and subscribeTerminalMetadata streaming endpoints; terminal session snapshots now carry monotonically increasing sequence numbers and dynamic labels (including child process names).
  • Adds shared client-runtime modules: WsTransport, wsRpcProtocol (configurable backoff and telemetry), threadDetailState, vcsStatusState, vcsRefState, vcsActionState, terminalSessionState, checkpointDiffState, composerPathSearchState, and archivedThreadsState.
  • Risk: DEFAULT_TERMINAL_ID changes from "default" to "term-1" and terminalId is now required on all TerminalSessionInput; any clients using the old default or omitting the field will break.

Macroscope summarized 554a1d2.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

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: ae18ea4f-bb4f-4438-8ba5-bd4cc85ff9af

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 t3code/mobile-remote-connect

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

@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Apr 14, 2026
Comment thread apps/desktop/src/updateState.ts Outdated
Comment thread apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Fast mode option applied to all providers indiscriminately
    • Added provider guards to the fast-mode handler in ThreadComposer so fastMode is only applied to claudeAgent and codex providers, matching the existing logic in NewTaskDraftScreen.

Create PR

Or push these changes by commenting:

@cursor push fb5f02f846
Preview (fb5f02f846)
diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -596,10 +596,15 @@
     }
     if (event.startsWith("options:fast-mode:")) {
       const fastMode = event.endsWith(":on");
-      const updated: ModelSelection = {
-        ...currentModelSelection,
-        options: { ...currentModelSelection.options, fastMode: fastMode || undefined },
-      };
+      const updated: ModelSelection =
+        currentModelSelection.provider === "claudeAgent"
+          ? {
+              ...currentModelSelection,
+              options: { ...currentModelSelection.options, fastMode: fastMode || undefined },
+            }
+          : currentModelSelection.provider === "codex"
+            ? { ...currentModelSelection, options: { fastMode: fastMode || undefined } }
+            : currentModelSelection;
       void props.onUpdateModelSelection(updated);
       return;
     }

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

Comment thread apps/mobile/src/features/threads/ThreadComposer.tsx
Comment thread packages/client-runtime/src/environmentConnection.ts
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented Apr 14, 2026

Approvability

Verdict: Needs human review

6 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

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

Comment thread packages/client-runtime/src/wsRpcProtocol.ts
Comment on lines +25 to +27
const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading",
);
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/ProjectFavicon.tsx:25

When faviconUrl changes due to prop updates (e.g., httpBaseUrl or workspaceRoot), status remains at its previous value because useState only initializes on mount. If the previous URL had finished loading (status === "loaded"), the new image renders immediately without showing the folder fallback during its load.

  const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
    faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading",
  );
+  useEffect(() => {
+    setStatus(
+      faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading",
+    );
+  }, [faviconUrl]);
Also found in 1 other location(s)

apps/desktop/src/main.ts:1675

The titleBarOverlay.symbolColor is set once at window creation time based on nativeTheme.shouldUseDarkColors, but is never updated when the theme changes. When SET_THEME_CHANNEL handler sets nativeTheme.themeSource, the titlebar symbol color on Windows/Linux will remain stale, potentially making the minimize/maximize/close buttons hard to see or invisible against the new theme background.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/mobile/src/components/ProjectFavicon.tsx around lines 25-27:

When `faviconUrl` changes due to prop updates (e.g., `httpBaseUrl` or `workspaceRoot`), `status` remains at its previous value because `useState` only initializes on mount. If the previous URL had finished loading (`status === "loaded"`), the new image renders immediately without showing the folder fallback during its load.

Evidence trail:
apps/mobile/src/components/ProjectFavicon.tsx at REVIEWED_COMMIT:
- Lines 19-22: faviconUrl derived from props
- Lines 24-26: useState initializer only runs on mount
- No useEffect to reset status when faviconUrl changes
- Line 28: showImage = faviconUrl && status === "loaded"
- Lines 42-43: folder fallback only shows when !showImage

Also found in 1 other location(s):
- apps/desktop/src/main.ts:1675 -- The `titleBarOverlay.symbolColor` is set once at window creation time based on `nativeTheme.shouldUseDarkColors`, but is never updated when the theme changes. When `SET_THEME_CHANNEL` handler sets `nativeTheme.themeSource`, the titlebar symbol color on Windows/Linux will remain stale, potentially making the minimize/maximize/close buttons hard to see or invisible against the new theme background.

Comment thread apps/mobile/src/features/threads/git/gitSheetComponents.tsx
@cursor
Copy link
Copy Markdown
Contributor

cursor Bot commented Apr 14, 2026

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

  • ✅ Fixed: Update feed check shadows more specific dev-build message
    • Swapped the isDevelopment/!isPackaged guard before the hasUpdateFeedConfig check so dev builds receive the specific 'packaged production builds' message, and updated the test to use hasUpdateFeedConfig: false reflecting real-world dev conditions.
  • ✅ Fixed: NewConnectionRouteScreen is unused duplicate of route screen
    • Deleted the unused NewConnectionRouteScreen.tsx which was a dead-code duplicate of the actual route screen at app/connections/new.tsx, confirmed by grep showing zero imports.

Create PR

Or push these changes by commenting:

@cursor push 07aefa635b
Preview (07aefa635b)
diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts
--- a/apps/desktop/src/updateState.test.ts
+++ b/apps/desktop/src/updateState.test.ts
@@ -71,7 +71,7 @@
         platform: "darwin",
         appImage: undefined,
         disabledByEnv: false,
-        hasUpdateFeedConfig: true,
+        hasUpdateFeedConfig: false,
       }),
     ).toContain("packaged production builds");
   });

diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts
--- a/apps/desktop/src/updateState.ts
+++ b/apps/desktop/src/updateState.ts
@@ -36,12 +36,12 @@
   disabledByEnv: boolean;
   hasUpdateFeedConfig: boolean;
 }): string | null {
+  if (args.isDevelopment || !args.isPackaged) {
+    return "Automatic updates are only available in packaged production builds.";
+  }
   if (!args.hasUpdateFeedConfig) {
     return "Automatic updates are not available because no update feed is configured.";
   }
-  if (args.isDevelopment || !args.isPackaged) {
-    return "Automatic updates are only available in packaged production builds.";
-  }
   if (args.disabledByEnv) {
     return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";
   }

diff --git a/apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx b/apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx
deleted file mode 100644
--- a/apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx
+++ /dev/null
@@ -1,253 +1,0 @@
-import { CameraView, useCameraPermissions } from "expo-camera";
-import { Stack, useLocalSearchParams, useRouter } from "expo-router";
-import { SymbolView } from "expo-symbols";
-import { useCallback, useEffect, useState } from "react";
-import { Alert, Pressable, ScrollView, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useThemeColor } from "../../lib/useThemeColor";
-
-import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
-import { ErrorBanner } from "../../components/ErrorBanner";
-import { dismissRoute } from "../../lib/routes";
-import { ConnectionSheetButton } from "./ConnectionSheetButton";
-import { extractPairingUrlFromQrPayload } from "./pairing";
-import { useRemoteConnections } from "../../state/use-remote-environment-registry";
-import { buildPairingUrl, parsePairingUrl } from "./pairing";
-
-export function NewConnectionRouteScreen() {
-  const {
-    connectionError,
-    connectionPairingUrl,
-    connectionState,
-    onChangeConnectionPairingUrl,
-    onConnectPress,
-  } = useRemoteConnections();
-  const router = useRouter();
-  const params = useLocalSearchParams<{ mode?: string }>();
-  const insets = useSafeAreaInsets();
-  const [hostInput, setHostInput] = useState("");
-  const [codeInput, setCodeInput] = useState("");
-  const [isSubmitting, setIsSubmitting] = useState(false);
-  const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
-  const [cameraPermission, requestCameraPermission] = useCameraPermissions();
-  const [scannerLocked, setScannerLocked] = useState(false);
-
-  const textColor = useThemeColor("--color-icon");
-  const placeholderColor = useThemeColor("--color-placeholder");
-
-  const connectDisabled =
-    isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0;
-
-  useEffect(() => {
-    const { host, code } = parsePairingUrl(connectionPairingUrl);
-    setHostInput(host);
-    setCodeInput(code);
-  }, [connectionPairingUrl]);
-
-  useEffect(() => {
-    if (connectionError) {
-      setIsSubmitting(false);
-    }
-  }, [connectionError]);
-
-  const handleHostChange = useCallback((value: string) => {
-    setHostInput(value);
-  }, []);
-
-  const handleCodeChange = useCallback((value: string) => {
-    setCodeInput(value);
-  }, []);
-
-  const openScanner = useCallback(async () => {
-    if (cameraPermission?.granted) {
-      setScannerLocked(false);
-      setShowScanner(true);
-      return;
-    }
-
-    const permission = await requestCameraPermission();
-    if (permission.granted) {
-      setScannerLocked(false);
-      setShowScanner(true);
-      return;
-    }
-
-    Alert.alert("Camera access needed", "Allow camera access to scan a backend pairing QR code.");
-  }, [cameraPermission?.granted, requestCameraPermission]);
-
-  const closeScanner = useCallback(() => {
-    setShowScanner(false);
-    setScannerLocked(false);
-  }, []);
-
-  const handleQrScan = useCallback(
-    ({ data }: { readonly data: string }) => {
-      if (scannerLocked) {
-        return;
-      }
-
-      setScannerLocked(true);
-
-      try {
-        const pairingUrl = extractPairingUrlFromQrPayload(data);
-        const { host, code } = parsePairingUrl(pairingUrl);
-        setHostInput(host);
-        setCodeInput(code);
-        onChangeConnectionPairingUrl(pairingUrl);
-        setShowScanner(false);
-      } catch (error) {
-        Alert.alert(
-          "Invalid QR code",
-          error instanceof Error ? error.message : "Scanned QR code was not recognized.",
-        );
-      } finally {
-        setTimeout(() => {
-          setScannerLocked(false);
-        }, 600);
-      }
-    },
-    [onChangeConnectionPairingUrl, scannerLocked],
-  );
-
-  const handleSubmit = useCallback(async () => {
-    setIsSubmitting(true);
-
-    try {
-      const pairingUrl = buildPairingUrl(hostInput, codeInput);
-      onChangeConnectionPairingUrl(pairingUrl);
-      await onConnectPress(pairingUrl);
-      dismissRoute(router);
-    } catch {
-      setIsSubmitting(false);
-    }
-  }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);
-
-  return (
-    <View collapsable={false} className="flex-1 bg-sheet">
-      <Stack.Screen
-        options={{
-          title: showScanner ? "Scan QR Code" : "Add Backend",
-          headerRight: () => (
-            <Pressable
-              className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary"
-              onPress={() => {
-                if (showScanner) {
-                  closeScanner();
-                } else {
-                  void openScanner();
-                }
-              }}
-            >
-              <SymbolView
-                name={showScanner ? "xmark" : "qrcode.viewfinder"}
-                size={showScanner ? 14 : 18}
-                tintColor={textColor}
-                type="monochrome"
-                weight="semibold"
-              />
-            </Pressable>
-          ),
-        }}
-      />
-
-      <ScrollView
-        contentInsetAdjustmentBehavior="automatic"
-        showsVerticalScrollIndicator={false}
-        style={{ flex: 1 }}
-        contentContainerStyle={{
-          paddingHorizontal: 20,
-          paddingTop: 16,
-          paddingBottom: Math.max(insets.bottom, 18) + 18,
-        }}
-      >
-        <View collapsable={false} className="gap-5">
-          {showScanner ? (
-            cameraPermission?.granted ? (
-              <View
-                className="overflow-hidden rounded-[24px]"
-                style={{ borderCurve: "continuous" }}
-              >
-                <CameraView
-                  barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
-                  onBarcodeScanned={handleQrScan}
-                  style={{ aspectRatio: 1, width: "100%" }}
-                />
-              </View>
-            ) : (
-              <View
-                className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"
-                style={{ borderCurve: "continuous" }}
-              >
-                <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
-                  Camera permission is required to scan a QR code.
-                </Text>
-                <ConnectionSheetButton
-                  compact
-                  icon="camera"
-                  label="Allow camera"
-                  tone="secondary"
-                  onPress={() => {
-                    void openScanner();
-                  }}
-                />
-              </View>
-            )
-          ) : (
-            <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4">
-              <View collapsable={false} className="gap-1.5">
-                <Text
-                  className="text-[11px] font-t3-bold uppercase text-foreground-muted"
-                  style={{ letterSpacing: 0.8 }}
-                >
-                  Host
-                </Text>
-                <TextInput
-                  autoCapitalize="none"
-                  autoCorrect={false}
-                  keyboardType="url"
-                  placeholder="192.168.1.100:8080"
-                  placeholderTextColor={placeholderColor}
-                  value={hostInput}
-                  onChangeText={handleHostChange}
-                  className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
-                />
-              </View>
-
-              <View collapsable={false} className="gap-1.5">
-                <Text
-                  className="text-[11px] font-t3-bold uppercase text-foreground-muted"
-                  style={{ letterSpacing: 0.8 }}
-                >
-                  Pairing code
-                </Text>
-                <TextInput
-                  autoCapitalize="none"
-                  autoCorrect={false}
-                  placeholder="abc-123-xyz"
-                  placeholderTextColor={placeholderColor}
-                  value={codeInput}
-                  onChangeText={handleCodeChange}
-                  className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
-                />
-              </View>
-
-              {connectionError ? <ErrorBanner message={connectionError} /> : null}
-
-              <ConnectionSheetButton
-                icon="plus"
-                label={
-                  isSubmitting || connectionState === "connecting" ? "Pairing..." : "Add backend"
-                }
-                disabled={connectDisabled}
-                tone="primary"
-                onPress={() => {
-                  void handleSubmit();
-                }}
-              />
-            </View>
-          )}
-        </View>
-      </ScrollView>
-    </View>
-  );
-}
\ No newline at end of file

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

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 5 total unresolved issues (including 3 from previous reviews).

Autofix Details

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

  • ✅ Fixed: CARD_SHADOW exports are defined but never imported
    • Removed the unused CARD_SHADOW and CARD_SHADOW_DARK constants and their export statement from ConnectionSheetButton.tsx, as they were never imported anywhere in the codebase.
  • ✅ Fixed: Debug console.log statements left in production code
    • Removed all three console.log statements with the [new task flow] prefix from new-task-flow-provider.tsx (in reset callback, environment init effect, and the dedicated state-logging effect).

Create PR

Or push these changes by commenting:

@cursor push 12dd1bcb97
Preview (12dd1bcb97)
diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx
--- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx
+++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx
@@ -5,28 +5,6 @@
 import { AppText as Text } from "../../components/AppText";
 import { cn } from "../../lib/cn";
 
-const CARD_SHADOW = Platform.select({
-  ios: {
-    shadowColor: "rgba(23,23,23,0.08)",
-    shadowOffset: { width: 0, height: 4 },
-    shadowOpacity: 1,
-    shadowRadius: 16,
-  },
-  android: { elevation: 3 },
-});
-
-const CARD_SHADOW_DARK = Platform.select({
-  ios: {
-    shadowColor: "#000",
-    shadowOffset: { width: 0, height: 2 },
-    shadowOpacity: 0.18,
-    shadowRadius: 8,
-  },
-  android: { elevation: 4 },
-});
-
-export { CARD_SHADOW, CARD_SHADOW_DARK };
-
 export function ConnectionSheetButton(props: {
   readonly icon: React.ComponentProps<typeof SymbolView>["name"];
   readonly label: string;

diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -189,10 +189,6 @@
   }, []);
 
   const reset = useCallback(() => {
-    console.log("[new task flow] reset", {
-      defaultEnvironmentId: projects[0]?.environmentId ?? null,
-      projectCount: projects.length,
-    });
     setSelectedEnvironmentId(projects[0]?.environmentId ?? "");
     setSelectedProjectKey(null);
     setSelectedModelKey(null);
@@ -216,9 +212,6 @@
       return;
     }
 
-    console.log("[new task flow] initializing environment", {
-      environmentId: projects[0]!.environmentId,
-    });
     setSelectedEnvironmentId(projects[0]!.environmentId);
   }, [projects, selectedEnvironmentId]);
 
@@ -470,24 +463,6 @@
     ],
   );
 
-  useEffect(() => {
-    console.log("[new task flow] state", {
-      availableBranchCount: availableBranches.length,
-      environmentCount: environments.length,
-      logicalProjectCount: logicalProjects.length,
-      selectedEnvironmentId,
-      selectedProjectKey,
-      selectedProjectTitle: selectedProject?.title ?? null,
-    });
-  }, [
-    availableBranches.length,
-    environments.length,
-    logicalProjects.length,
-    selectedEnvironmentId,
-    selectedProject?.title,
-    selectedProjectKey,
-  ]);
-
   return <NewTaskFlowContext.Provider value={value}>{props.children}</NewTaskFlowContext.Provider>;
 }

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

android: { elevation: 4 },
});

export { CARD_SHADOW, CARD_SHADOW_DARK };
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.

CARD_SHADOW exports are defined but never imported

Low Severity

CARD_SHADOW and CARD_SHADOW_DARK are defined and explicitly exported but never imported anywhere in the codebase. These are unused dead exports that add clutter without providing value.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3d3e847. Configure here.

defaultEnvironmentId: projects[0]?.environmentId ?? null,
projectCount: projects.length,
});
setSelectedEnvironmentId(projects[0]?.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.

Debug console.log statements left in production code

Medium Severity

Multiple console.log calls with the [new task flow] prefix are left in new-task-flow-provider.tsx. These log internal state like environmentId, selectedProjectKey, branch counts, and project titles on every state change — verbose debug output that shouldn't ship in a production mobile app.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3d3e847. Configure here.

@juliusmarminge juliusmarminge changed the title Add mobile remote client and reconnect handling T3 Code Mobile [WIP] Apr 14, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 6 total unresolved issues (including 5 from previous reviews).

Autofix Details

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

  • ✅ Fixed: QR scanner lock uses state instead of ref
    • Replaced useState with useRef for the scanner lock so the guard value is read synchronously, preventing duplicate scan processing from rapid native barcode callbacks before React re-renders.

Create PR

Or push these changes by commenting:

@cursor push 9d24bfb15f
Preview (9d24bfb15f)
diff --git a/apps/mobile/src/app/connections/new.tsx b/apps/mobile/src/app/connections/new.tsx
--- a/apps/mobile/src/app/connections/new.tsx
+++ b/apps/mobile/src/app/connections/new.tsx
@@ -1,7 +1,7 @@
 import { CameraView, useCameraPermissions } from "expo-camera";
 import { Stack, useLocalSearchParams, useRouter } from "expo-router";
 import { SymbolView } from "expo-symbols";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { Alert, Pressable, ScrollView, View } from "react-native";
 import { useSafeAreaInsets } from "react-native-safe-area-context";
 import { useThemeColor } from "../../lib/useThemeColor";
@@ -30,7 +30,7 @@
   const [isSubmitting, setIsSubmitting] = useState(false);
   const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
   const [cameraPermission, requestCameraPermission] = useCameraPermissions();
-  const [scannerLocked, setScannerLocked] = useState(false);
+  const scannerLockedRef = useRef(false);
 
   const textColor = useThemeColor("--color-icon");
   const placeholderColor = useThemeColor("--color-placeholder");
@@ -60,14 +60,14 @@
 
   const openScanner = useCallback(async () => {
     if (cameraPermission?.granted) {
-      setScannerLocked(false);
+      scannerLockedRef.current = false;
       setShowScanner(true);
       return;
     }
 
     const permission = await requestCameraPermission();
     if (permission.granted) {
-      setScannerLocked(false);
+      scannerLockedRef.current = false;
       setShowScanner(true);
       return;
     }
@@ -77,16 +77,16 @@
 
   const closeScanner = useCallback(() => {
     setShowScanner(false);
-    setScannerLocked(false);
+    scannerLockedRef.current = false;
   }, []);
 
   const handleQrScan = useCallback(
     ({ data }: { readonly data: string }) => {
-      if (scannerLocked) {
+      if (scannerLockedRef.current) {
         return;
       }
 
-      setScannerLocked(true);
+      scannerLockedRef.current = true;
 
       try {
         const pairingUrl = extractPairingUrlFromQrPayload(data);
@@ -102,11 +102,11 @@
         );
       } finally {
         setTimeout(() => {
-          setScannerLocked(false);
+          scannerLockedRef.current = false;
         }, 600);
       }
     },
-    [onChangeConnectionPairingUrl, scannerLocked],
+    [onChangeConnectionPairingUrl],
   );
 
   const handleSubmit = useCallback(async () => {

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

}
},
[onChangeConnectionPairingUrl, scannerLocked],
);
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.

QR scanner lock uses state instead of ref

Low Severity

handleQrScan reads scannerLocked from a stale closure since it's a useState value in a useCallback dependency array. Native barcode scanner callbacks can fire multiple times before React re-renders with the updated callback. A useRef would reliably prevent duplicate scan processing, since the ref value is always current across all closure instances.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit efc36b4. Configure here.

Comment thread apps/server/src/vcs/GitVcsDriverCore.ts Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 7 total unresolved issues (including 6 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Worktree path condition is inverted
    • The ternary condition was indeed inverted, passing null for worktree mode and the path for local mode; flipped the condition so worktree mode correctly receives the selected worktree path.

Create PR

Or push these changes by commenting:

@cursor push 677fe73be8
Preview (677fe73be8)
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -369,7 +369,7 @@
         modelSelection: modelWithOptions,
         envMode: flow.workspaceMode,
         branch: flow.selectedBranchName,
-        worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath,
+        worktreePath: flow.workspaceMode === "worktree" ? flow.selectedWorktreePath : null,
         runtimeMode: flow.runtimeMode,
         interactionMode: flow.interactionMode,
         initialMessageText: flow.prompt.trim(),

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

Comment thread apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
juliusmarminge added a commit that referenced this pull request Apr 14, 2026
Rebuild PR #2013 on top of current origin/main without the duplicated commit lineage.

Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the t3code/mobile-remote-connect branch from 1705361 to c799180 Compare April 14, 2026 21:23
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 7 total unresolved issues (including 6 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Route file duplicates entire feature component inline
    • Replaced the duplicated ~250-line inline implementation in apps/mobile/src/app/connections/new.tsx with a simple import and render of NewConnectionRouteScreen from the features/connection/ module, matching the delegation pattern used by other route files.

Create PR

Or push these changes by commenting:

@cursor push 59b1d755d9
Preview (59b1d755d9)
diff --git a/apps/mobile/src/app/connections/new.tsx b/apps/mobile/src/app/connections/new.tsx
--- a/apps/mobile/src/app/connections/new.tsx
+++ b/apps/mobile/src/app/connections/new.tsx
@@ -1,253 +1,5 @@
-import { CameraView, useCameraPermissions } from "expo-camera";
-import { Stack, useLocalSearchParams, useRouter } from "expo-router";
-import { SymbolView } from "expo-symbols";
-import { useCallback, useEffect, useState } from "react";
-import { Alert, Pressable, ScrollView, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { NewConnectionRouteScreen } from "../../features/connection/NewConnectionRouteScreen";
 
-import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
-import { ErrorBanner } from "../../components/ErrorBanner";
-import { dismissRoute } from "../../lib/routes";
-import { ConnectionSheetButton } from "../../features/connection/ConnectionSheetButton";
-import { extractPairingUrlFromQrPayload } from "../../features/connection/pairing";
-import { useRemoteConnections } from "../../state/use-remote-environment-registry";
-import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing";
-
-export default function ConnectionsNewRouteScreen() {
-  const {
-    connectionError,
-    connectionPairingUrl,
-    connectionState,
-    onChangeConnectionPairingUrl,
-    onConnectPress,
-  } = useRemoteConnections();
-  const router = useRouter();
-  const params = useLocalSearchParams<{ mode?: string }>();
-  const insets = useSafeAreaInsets();
-  const [hostInput, setHostInput] = useState("");
-  const [codeInput, setCodeInput] = useState("");
-  const [isSubmitting, setIsSubmitting] = useState(false);
-  const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
-  const [cameraPermission, requestCameraPermission] = useCameraPermissions();
-  const [scannerLocked, setScannerLocked] = useState(false);
-
-  const textColor = useThemeColor("--color-icon");
-  const placeholderColor = useThemeColor("--color-placeholder");
-
-  const connectDisabled =
-    isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0;
-
-  useEffect(() => {
-    const { host, code } = parsePairingUrl(connectionPairingUrl);
-    setHostInput(host);
-    setCodeInput(code);
-  }, [connectionPairingUrl]);
-
-  useEffect(() => {
-    if (connectionError) {
-      setIsSubmitting(false);
-    }
-  }, [connectionError]);
-
-  const handleHostChange = useCallback((value: string) => {
-    setHostInput(value);
-  }, []);
-
-  const handleCodeChange = useCallback((value: string) => {
-    setCodeInput(value);
-  }, []);
-
-  const openScanner = useCallback(async () => {
-    if (cameraPermission?.granted) {
-      setScannerLocked(false);
-      setShowScanner(true);
-      return;
-    }
-
-    const permission = await requestCameraPermission();
-    if (permission.granted) {
-      setScannerLocked(false);
-      setShowScanner(true);
-      return;
-    }
-
-    Alert.alert("Camera access needed", "Allow camera access to scan a backend pairing QR code.");
-  }, [cameraPermission?.granted, requestCameraPermission]);
-
-  const closeScanner = useCallback(() => {
-    setShowScanner(false);
-    setScannerLocked(false);
-  }, []);
-
-  const handleQrScan = useCallback(
-    ({ data }: { readonly data: string }) => {
-      if (scannerLocked) {
-        return;
-      }
-
-      setScannerLocked(true);
-
-      try {
-        const pairingUrl = extractPairingUrlFromQrPayload(data);
-        const { host, code } = parsePairingUrl(pairingUrl);
-        setHostInput(host);
-        setCodeInput(code);
-        onChangeConnectionPairingUrl(pairingUrl);
-        setShowScanner(false);
-      } catch (error) {
-        Alert.alert(
-          "Invalid QR code",
-          error instanceof Error ? error.message : "Scanned QR code was not recognized.",
-        );
-      } finally {
-        setTimeout(() => {
-          setScannerLocked(false);
-        }, 600);
-      }
-    },
-    [onChangeConnectionPairingUrl, scannerLocked],
-  );
-
-  const handleSubmit = useCallback(async () => {
-    setIsSubmitting(true);
-
-    try {
-      const pairingUrl = buildPairingUrl(hostInput, codeInput);
-      onChangeConnectionPairingUrl(pairingUrl);
-      await onConnectPress(pairingUrl);
-      dismissRoute(router);
-    } catch {
-      setIsSubmitting(false);
-    }
-  }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);
-
-  return (
-    <View collapsable={false} className="flex-1 bg-sheet">
-      <Stack.Screen
-        options={{
-          title: showScanner ? "Scan QR Code" : "Add Backend",
-          headerRight: () => (
-            <Pressable
-              className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary"
-              onPress={() => {
-                if (showScanner) {
-                  closeScanner();
-                } else {
-                  void openScanner();
-                }
-              }}
-            >
-              <SymbolView
-                name={showScanner ? "xmark" : "qrcode.viewfinder"}
-                size={showScanner ? 14 : 18}
-                tintColor={textColor}
-                type="monochrome"
-                weight="semibold"
-              />
-            </Pressable>
-          ),
-        }}
-      />
-
-      <ScrollView
-        contentInsetAdjustmentBehavior="automatic"
-        showsVerticalScrollIndicator={false}
-        style={{ flex: 1 }}
-        contentContainerStyle={{
-          paddingHorizontal: 20,
-          paddingTop: 16,
-          paddingBottom: Math.max(insets.bottom, 18) + 18,
-        }}
-      >
-        <View collapsable={false} className="gap-5">
-          {showScanner ? (
-            cameraPermission?.granted ? (
-              <View
-                className="overflow-hidden rounded-[24px]"
-                style={{ borderCurve: "continuous" }}
-              >
-                <CameraView
-                  barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
-                  onBarcodeScanned={handleQrScan}
-                  style={{ aspectRatio: 1, width: "100%" }}
-                />
-              </View>
-            ) : (
-              <View
-                className="items-center gap-3 rounded-[24px] bg-card px-5 py-8"
-                style={{ borderCurve: "continuous" }}
-              >
-                <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
-                  Camera permission is required to scan a QR code.
-                </Text>
-                <ConnectionSheetButton
-                  compact
-                  icon="camera"
-                  label="Allow camera"
-                  tone="secondary"
-                  onPress={() => {
-                    void openScanner();
-                  }}
-                />
-              </View>
-            )
-          ) : (
-            <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4">
-              <View collapsable={false} className="gap-1.5">
-                <Text
-                  className="text-[11px] font-t3-bold uppercase text-foreground-muted"
-                  style={{ letterSpacing: 0.8 }}
-                >
-                  Host
-                </Text>
-                <TextInput
-                  autoCapitalize="none"
-                  autoCorrect={false}
-                  keyboardType="url"
-                  placeholder="192.168.1.100:8080"
-                  placeholderTextColor={placeholderColor}
-                  value={hostInput}
-                  onChangeText={handleHostChange}
-                  className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
-                />
-              </View>
-
-              <View collapsable={false} className="gap-1.5">
-                <Text
-                  className="text-[11px] font-t3-bold uppercase text-foreground-muted"
-                  style={{ letterSpacing: 0.8 }}
-                >
-                  Pairing code
-                </Text>
-                <TextInput
-                  autoCapitalize="none"
-                  autoCorrect={false}
-                  placeholder="abc-123-xyz"
-                  placeholderTextColor={placeholderColor}
-                  value={codeInput}
-                  onChangeText={handleCodeChange}
-                  className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground"
-                />
-              </View>
-
-              {connectionError ? <ErrorBanner message={connectionError} /> : null}
-
-              <ConnectionSheetButton
-                icon="plus"
-                label={
-                  isSubmitting || connectionState === "connecting" ? "Pairing..." : "Add backend"
-                }
-                disabled={connectDisabled}
-                tone="primary"
-                onPress={() => {
-                  void handleSubmit();
-                }}
-              />
-            </View>
-          )}
-        </View>
-      </ScrollView>
-    </View>
-  );
+export default function ConnectionsNewRoute() {
+  return <NewConnectionRouteScreen />;
 }

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

</ScrollView>
</View>
);
}
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.

Route file duplicates entire feature component inline

Medium Severity

apps/mobile/src/app/connections/new.tsx is a near-identical copy of NewConnectionRouteScreen.tsx in features/connection/. The route file reimplements all QR scanning, form input, and submission logic instead of importing the existing feature component. Any future bug fix applied to one copy will be missed in the other, leading to divergent behavior.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c799180. Configure here.

Comment thread apps/mobile/src/lib/repositoryGroups.ts
Comment on lines +72 to +78
<SymbolView
name="xmark"
size={9}
tintColor="#ffffff"
type="monochrome"
weight="bold"
/>
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 components/ComposerAttachmentStrip.tsx:72

On Android, SymbolView with name="xmark" renders nothing because expo-symbols expects an object with platform-specific keys ({ android: string, web: string }), not a plain string. This causes the remove button to appear as an empty circle on Android. Pass an object with android and web keys to ensure the icon renders on both platforms.

-              <SymbolView
-                name="xmark"
-                size={9}
-                tintColor="#ffffff"
-                type="monochrome"
-                weight="bold"
-              />
Also found in 1 other location(s)

apps/mobile/src/features/threads/review/ReviewCommentComposerSheet.tsx:21

On iOS, ui-monospace is a CSS keyword that does not work as a fontFamily value in React Native. React Native requires actual iOS font family names like Menlo or Courier. Using ui-monospace will cause the text to fall back to the default proportional font, defeating the intent of displaying monospace code.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/mobile/src/components/ComposerAttachmentStrip.tsx around lines 72-78:

On Android, `SymbolView` with `name="xmark"` renders nothing because `expo-symbols` expects an object with platform-specific keys (`{ android: string, web: string }`), not a plain string. This causes the remove button to appear as an empty circle on Android. Pass an object with `android` and `web` keys to ensure the icon renders on both platforms.

Evidence trail:
1. apps/mobile/src/components/ComposerAttachmentStrip.tsx lines 72-78 - SymbolView uses `name="xmark"` (plain string)
2. https://docs.expo.dev/versions/latest/sdk/symbols/ - Official expo-symbols documentation states: "If you only pass a string, it is treated as an SF Symbol name and renders only on iOS. On Android and web, nothing will be rendered unless you provide a `fallback`" and shows the `name` prop type as `SFSymbol | { android: AndroidSymbol, ios: SFSymbol, web: AndroidSymbol }`

Also found in 1 other location(s):
- apps/mobile/src/features/threads/review/ReviewCommentComposerSheet.tsx:21 -- On iOS, `ui-monospace` is a CSS keyword that does not work as a `fontFamily` value in React Native. React Native requires actual iOS font family names like `Menlo` or `Courier`. Using `ui-monospace` will cause the text to fall back to the default proportional font, defeating the intent of displaying monospace code.

Comment thread packages/client-runtime/src/vcsStatusState.ts
Comment thread packages/client-runtime/src/environmentConnection.ts
Comment thread apps/mobile/src/features/threads/use-project-actions.ts
juliusmarminge added a commit that referenced this pull request Apr 15, 2026
Rebuild PR #2013 on top of current origin/main without the duplicated commit lineage.

Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the t3code/mobile-remote-connect branch from c799180 to f86d466 Compare April 15, 2026 17:37
Comment thread apps/mobile/src/lib/repositoryGroups.ts
Comment thread apps/mobile/src/features/threads/review/shikiReviewHighlighter.ts Outdated
Comment thread apps/mobile/src/lib/storage.ts Outdated
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 review/shikiReviewHighlighter.ts:83

When a file with an .svg extension is highlighted, resolveLanguageFromPath returns "svg" as the language identifier. However, the svg entry in languageImports loads the @shikijs/langs/xml module, which Shiki registers under the ID "xml". This causes highlighter.codeToTokensBase to request "svg", a language that was never registered, resulting in failed or fallback highlighting for SVG files.

-  svg: () => import("@shikijs/langs/xml"),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/mobile/src/features/threads/review/shikiReviewHighlighter.ts around line 83:

When a file with an `.svg` extension is highlighted, `resolveLanguageFromPath` returns `"svg"` as the language identifier. However, the `svg` entry in `languageImports` loads the `@shikijs/langs/xml` module, which Shiki registers under the ID `"xml"`. This causes `highlighter.codeToTokensBase` to request `"svg"`, a language that was never registered, resulting in failed or fallback highlighting for SVG files.

Evidence trail:
apps/mobile/src/features/threads/review/shikiReviewHighlighter.ts lines 84 (`svg: () => import("@shikijs/langs/xml")`), 108-136 (languageAliases without 'svg'), 327-357 (loadSingleLanguage adds 'language' to loadedLanguages but Shiki registers the module's internal ID), 359-383 (resolveLanguageFromPath returns 'candidate' not the registered ID). @shikijs/langs package.json (https://github.com/shikijs/shiki/blob/main/packages/langs/package.json) shows no './svg' export. tm-grammars README (https://github.com/shikijs/textmate-grammars-themes/tree/main/packages/tm-grammars) confirms xml has no 'svg' alias.

Comment thread apps/mobile/src/features/review/reviewWordDiffs.ts Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 9 total unresolved issues (including 8 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Cleartext HTTP allowed unconditionally in production builds
    • Gated NSAllowsArbitraryLoads and withAndroidCleartextTraffic plugin behind APP_VARIANT !== 'production' so cleartext HTTP is only allowed in development and preview builds.

Create PR

Or push these changes by commenting:

@cursor push f252a0a26f
Preview (f252a0a26f)
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -77,9 +77,11 @@
     supportsTablet: true,
     bundleIdentifier: variant.iosBundleIdentifier,
     infoPlist: {
-      NSAppTransportSecurity: {
-        NSAllowsArbitraryLoads: true,
-      },
+      ...(APP_VARIANT !== "production" && {
+        NSAppTransportSecurity: {
+          NSAllowsArbitraryLoads: true,
+        },
+      }),
       ITSAppUsesNonExemptEncryption: false,
     },
   },
@@ -128,7 +130,7 @@
     ],
     "expo-secure-store",
     "expo-router",
-    "./plugins/withAndroidCleartextTraffic.cjs",
+    ...(APP_VARIANT !== "production" ? ["./plugins/withAndroidCleartextTraffic.cjs"] : []),
   ],
   extra: {
     appVariant: APP_VARIANT,

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

Comment thread apps/mobile/app.config.ts
Comment thread packages/client-runtime/src/terminalSessionState.ts Outdated
Comment on lines +48 to +56
if (input.status === "exited") {
return "Exited";
}
if (input.status === "error") {
return "Error";
}

return "Not started";
}
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 threads/terminalMenu.ts:48

The status union includes "closed" but getTerminalStatusLabel has no explicit case for it, causing closed terminals to return "Not started". A terminal that was previously running and then closed will misleadingly show "Not started" to users.

  if (input.status === "error") {
    return "Error";
  }
+  if (input.status === "closed") {
+    return "Closed";
+  }

  return "Not started";
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/mobile/src/features/threads/terminalMenu.ts around lines 48-56:

The `status` union includes `"closed"` but `getTerminalStatusLabel` has no explicit case for it, causing closed terminals to return `"Not started"`. A terminal that was previously running and then closed will misleadingly show "Not started" to users.

Evidence trail:
apps/mobile/src/features/threads/terminalMenu.ts lines 7 (status union definition includes "closed"), lines 40-55 (getTerminalStatusLabel function with no case for "closed"), lines 75-79 (buildTerminalMenuSessions sets status: "closed" showing active use of this status)

Comment on lines +798 to +808
}
} finally {
highlightQueueActiveRef.current = false;
if (
generation === highlightQueueGenerationRef.current &&
highlightQueueRef.current.length > 0
) {
startHighlightQueueRunner();
}
}
})();
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 review/ReviewSheet.tsx:798

The highlight queue runner's finally block unconditionally sets highlightQueueActiveRef.current = false even when its generation is stale. This allows a third runner to start while Runner 2 is still processing: Runner 1 completes and clears the flag, then Runner 3 sees highlightQueueActiveRef === false and starts despite Runner 2 being active. Consider only clearing the flag when generation === highlightQueueGenerationRef.current so the active runner retains ownership.

      } finally {
-        highlightQueueActiveRef.current = false;
-        if (
+        if (generation === highlightQueueGenerationRef.current) {
+          highlightQueueActiveRef.current = false;
+        }
+        if (
           generation === highlightQueueGenerationRef.current &&
           highlightQueueRef.current.length > 0
         ) {
           startHighlightQueueRunner();
         }
       }
Also found in 1 other location(s)

apps/mobile/src/state/use-remote-environment-registry.ts:318

Race condition in cleanup: The void Promise.all(connections.map(...)) at line 318 starts async connectSavedEnvironment calls without awaiting them, but the cleanup function only disposes sessions present in environmentSessions at cleanup time. If unmount occurs while these connections are being established (e.g., during the await disconnectEnvironment() or await ensureBootstrapped() within each connectSavedEnvironment), the cleanup will run and clear the map, but the in-flight connectSavedEnvironment calls will continue and subsequently add new sessions to the already-cleared environmentSessions. These late-added sessions will never be disposed, leaking WebSocket connections.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/mobile/src/features/threads/review/ReviewSheet.tsx around lines 798-808:

The highlight queue runner's `finally` block unconditionally sets `highlightQueueActiveRef.current = false` even when its generation is stale. This allows a third runner to start while Runner 2 is still processing: Runner 1 completes and clears the flag, then Runner 3 sees `highlightQueueActiveRef === false` and starts despite Runner 2 being active. Consider only clearing the flag when `generation === highlightQueueGenerationRef.current` so the active runner retains ownership.

Evidence trail:
apps/mobile/src/features/threads/review/ReviewSheet.tsx lines 725-730 (guard and flag setting), line 735 (while loop generation check), lines 800-805 (unconditional flag clearing in finally), lines 891-894 (generation increment and flag reset on section/theme change), line 950 (external call to startHighlightQueueRunner)

Also found in 1 other location(s):
- apps/mobile/src/state/use-remote-environment-registry.ts:318 -- Race condition in cleanup: The `void Promise.all(connections.map(...))` at line 318 starts async `connectSavedEnvironment` calls without awaiting them, but the cleanup function only disposes sessions present in `environmentSessions` at cleanup time. If unmount occurs while these connections are being established (e.g., during the `await disconnectEnvironment()` or `await ensureBootstrapped()` within each `connectSavedEnvironment`), the cleanup will run and clear the map, but the in-flight `connectSavedEnvironment` calls will continue and subsequently add new sessions to the already-cleared `environmentSessions`. These late-added sessions will never be disposed, leaking WebSocket connections.

juliusmarminge added a commit that referenced this pull request Apr 17, 2026
Rebuild PR #2013 on top of current origin/main without the duplicated commit lineage.

Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the t3code/mobile-remote-connect branch from b7da92c to 4d137a1 Compare April 17, 2026 07:27
Comment thread apps/mobile/src/features/threads/ComposerCommandPopover.tsx
url.hash = new URLSearchParams([["token", c]]).toString();
return url.toString();
} catch {
return `${h}#token=${c}`;
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/pairing.ts:14

In buildPairingUrl, the fallback path on line 14 returns ${h}#token=${c} without URL-encoding c. When the code contains special characters like = or & (e.g., abc=123&xyz), and the URL constructor throws (e.g., due to invalid host formatting), the resulting fragment is malformed. Later, parsePairingUrl uses URLSearchParams to extract the token, which only reads the portion before the first = or &, corrupting the pairing code. Consider using encodeURIComponent(c) in the fallback to ensure special characters are preserved.

Suggested change
return `${h}#token=${c}`;
return `${h}#token=${encodeURIComponent(c)}`;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/mobile/src/features/connection/pairing.ts around line 14:

In `buildPairingUrl`, the fallback path on line 14 returns `${h}#token=${c}` without URL-encoding `c`. When the code contains special characters like `=` or `&` (e.g., `abc=123&xyz`), and the URL constructor throws (e.g., due to invalid host formatting), the resulting fragment is malformed. Later, `parsePairingUrl` uses `URLSearchParams` to extract the token, which only reads the portion before the first `=` or `&`, corrupting the pairing code. Consider using `encodeURIComponent(c)` in the fallback to ensure special characters are preserved.

Evidence trail:
apps/mobile/src/features/connection/pairing.ts (full file viewed at REVIEWED_COMMIT, commit baedbdbc6f):
- Line 11: try path uses `url.hash = new URLSearchParams([['token', c]]).toString()` which URL-encodes `c`
- Line 14: catch/fallback path uses `return `${h}#token=${c}`;` with no encoding
- Lines 22-23: `parsePairingUrl` uses `new URLSearchParams(parsed.hash.slice(1))` and `hashParams.get('token')` which will misparse unencoded special characters (`=`, `&`) in the token value

Comment thread apps/mobile/src/lib/repositoryGroups.ts
Comment thread apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Comment thread apps/mobile/src/lib/composerImages.ts
Comment on lines +18 to +36
export function parsePairingUrl(url: string): { host: string; code: string } {
const trimmed = url.trim();
if (!trimmed) return { host: "", code: "" };

try {
const parsed = new URL(trimmed);
const hashParams = new URLSearchParams(parsed.hash.slice(1));
const hashToken = hashParams.get("token");
const queryToken = parsed.searchParams.get("token");
const code = hashToken || queryToken || "";

parsed.hash = "";
parsed.search = "";
parsed.pathname = "/";
return { host: parsed.toString().replace(/\/$/, ""), code };
} catch {
return { host: trimmed, code: "" };
}
}
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/pairing.ts:18

parsePairingUrl unconditionally sets parsed.pathname = "/", which discards any path from the original URL. A pairing URL like https://192.168.1.100:8080/api/connect#token=abc returns host https://192.168.1.100:8080 without the /api/connect path, so subsequent calls to buildPairingUrl reconstruct the wrong endpoint. Consider preserving the original pathname instead of overwriting it.

+    parsed.hash = "";
+    parsed.search = "";
+    const pathname = parsed.pathname;
+    const hostUrl = pathname && pathname !== "/" ? parsed.toString() : parsed.toString().replace(/\/$/, "");
+    return { host: hostUrl, code };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/mobile/src/features/connection/pairing.ts around lines 18-36:

`parsePairingUrl` unconditionally sets `parsed.pathname = "/"`, which discards any path from the original URL. A pairing URL like `https://192.168.1.100:8080/api/connect#token=abc` returns host `https://192.168.1.100:8080` without the `/api/connect` path, so subsequent calls to `buildPairingUrl` reconstruct the wrong endpoint. Consider preserving the original pathname instead of overwriting it.

Evidence trail:
apps/mobile/src/features/connection/pairing.ts lines 18-36: `parsePairingUrl` function, specifically line 30 `parsed.pathname = "/";` unconditionally overwrites pathname. apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx lines 42-44: `parsePairingUrl` output populates `hostInput`. Line 116: `buildPairingUrl(hostInput, codeInput)` reconstructs URL from the stripped host.


export { CARD_SHADOW, CARD_SHADOW_DARK };

export function ConnectionSheetButton(props: {
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/ConnectionSheetButton.tsx:30

String icon names like "camera" and "plus" are passed to SymbolView, but SymbolView returns the fallback when name is not a platform object, and no fallback is provided. On Android and web, this renders nothing instead of the intended icon. Consider passing platform-specific icon objects like { ios: 'camera', android: 'camera_alt', web: 'camera' } instead of plain strings.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/mobile/src/features/connection/ConnectionSheetButton.tsx around line 30:

String icon names like `"camera"` and `"plus"` are passed to `SymbolView`, but `SymbolView` returns the `fallback` when `name` is not a platform object, and no `fallback` is provided. On Android and web, this renders nothing instead of the intended icon. Consider passing platform-specific icon objects like `{ ios: 'camera', android: 'camera_alt', web: 'camera' }` instead of plain strings.

Evidence trail:
1. apps/mobile/src/features/connection/ConnectionSheetButton.tsx lines 89-94 - SymbolView usage with no fallback prop
2. apps/mobile/src/app/connections/new.tsx lines 185-190 - ConnectionSheetButton called with icon="camera"
3. apps/mobile/src/app/connections/new.tsx lines 236-244 - ConnectionSheetButton called with icon="plus"
4. https://docs.expo.dev/versions/latest/sdk/symbols/ - "If you only pass a string, it is treated as an SF Symbol name and renders only on iOS. On Android and web, nothing will be rendered unless you provide a fallback"

Comment thread apps/mobile/src/features/review/reviewWordDiffs.ts Outdated
Comment thread apps/mobile/src/features/review/shikiReviewHighlighter.ts
Comment thread packages/client-runtime/src/terminalSessionState.ts
Comment thread apps/mobile/src/features/terminal/terminalMenu.ts
Comment thread apps/web/src/terminalUiStateStore.ts
Comment thread apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Comment thread apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
Yash-Singh1 and others added 17 commits May 20, 2026 11:02
- Reject diff preview requests outside the configured workspace
- Add tests covering allowed and denied cwd paths
- Split file visibility, diff data, and comment selection logic out of `ReviewSheet`
- Add tests for review file visibility and native diff bridge behavior
- Wire review selection and mobile remote connect state through shared hooks
Co-authored-by: codex <codex@users.noreply.github.com>
- Move composer path search and source-control discovery into shared managed state
- Persist review async/loading state in atoms and add coverage
- Update mobile/web composers and review highlighter initialization
- Drop unterminated paths from truncated NUL-separated git stdout
- Preserve truncation state when building untracked review diffs
- Switch mobile and client-runtime state to the generic VcsRef API
- Preserve background refreshes with client change handling and errors
- Migrate branch selector and git actions to useVcsRefs and new source control action hooks
- Remove gitReactQuery and projectReactQuery modules in favor of sourceControlActions and vcsRefState
- Add staleTimeMs to mobile vcs ref manager configuration
- Move archived threads state into shared client-runtime
- Add stale-aware refresh/invalidation for path search, discovery, and VCS refs
- Wire provider invalidations through the web runtime
- Replace git-scoped client/runtime state with VCS-scoped APIs
- Add checkpoint diff preview state and reuse cached diffs
- Update mobile and web review flows to use the new shared state
Co-authored-by: codex <codex@users.noreply.github.com>
- Scope cleartext mobile config to development
- Fix stale loads, ordering, and terminal reuse
- Clean up terminal metadata subscriptions on failure
- Deduplicate terminal IDs and favorite model slugs with trim-aware iteration
- Use effect-based filtering for local branch and built-in model lists
- Switch stable array handling to non-mutating `toSorted`
- Replace chained array transforms with explicit loops across mobile and server paths
- Tighten provider, git, and orchestration parsing while preserving behavior
- Improve remote-connect UI data shaping and thread activity filtering
- Replace ad hoc sorts with shared Effect order helpers
- Rework remote catalog and review/terminal lists for clearer data flow
- Keep threaded/review selection and diff adapters aligned with the new ordering logic
- Rename backends to environments across mobile UI
- Add richer loading and empty states for connections, home, and new task flows
- Improve review section loading handling and terminal keyboard layout
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the t3code/mobile-remote-connect branch from 399b13d to 6b48288 Compare May 20, 2026 11:18
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 13 total unresolved issues (including 11 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Native diff ignores highlight engine
    • Replaced hardcoded engine: "native" with a module-level CONFIGURED_ENGINE constant that resolves EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE via the shared resolveReviewHighlighterEnginePreference helper, matching the pattern used in shikiReviewHighlighter.ts.
  • ✅ Fixed: Unset variant resolves production config
    • Changed resolveAppVariant's default return from "production" to "development" so local dev scripts without an explicit APP_VARIANT get development config (cleartext HTTP, dev bundle IDs), while EAS builds are unaffected since all profiles set APP_VARIANT explicitly.

Create PR

Or push these changes by commenting:

@cursor push 7294763bfe
Preview (7294763bfe)
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -44,7 +44,7 @@
     case "production":
       return value;
     default:
-      return "production";
+      return "development";
   }
 }
 

diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts
--- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts
+++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts
@@ -7,7 +7,13 @@
 } from "../diffs/nativeReviewDiffHighlighter";
 import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface";
 import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes";
+import { resolveReviewHighlighterEnginePreference } from "./reviewHighlighterEngine";
 
+const CONFIGURED_ENGINE: NativeReviewDiffHighlightEngine = resolveReviewHighlighterEnginePreference(
+  process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ??
+    (process.env.NODE_ENV === "test" ? "javascript" : "native"),
+);
+
 interface NativeReviewVisibleRange {
   readonly firstRowIndex: number;
   readonly lastRowIndex: number;
@@ -68,7 +74,7 @@
 
     const abortController = new AbortController();
     const requestRange = visibleRangeRef.current;
-    const engine: NativeReviewDiffHighlightEngine = "native";
+    const engine: NativeReviewDiffHighlightEngine = CONFIGURED_ENGINE;
 
     void (async () => {
       try {

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

firstRowIndex: requestRange.firstRowIndex,
lastRowIndex: requestRange.lastRowIndex,
alreadyHighlightedRowIds: highlightedRowIdsRef.current,
signal: abortController.signal,
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.

Native diff ignores highlight engine

Medium Severity

The native review diff visible-row highlighter always uses the native Shiki path because engine is fixed to "native", so EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE and the shared getActiveReviewHighlighterEngine preference are never applied on this code path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6b48288. Configure here.

Comment thread apps/mobile/app.config.ts
return value;
default:
return "production";
}
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.

Unset variant resolves production config

Low Severity

When APP_VARIANT is missing, resolveAppVariant returns production, so local runs without an explicit variant pick production bundle IDs and disable development-only settings such as cleartext HTTP, even though several npm scripts start Expo without setting APP_VARIANT.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6b48288. Configure here.

Comment on lines +206 to +217
if (token.content.length === 0) {
nextTokens.push(token);
return;
}

while (rangeIndex < ranges.length && ranges[rangeIndex]!.end <= tokenStart) {
rangeIndex += 1;
}

if ((ranges[rangeIndex]?.start ?? Number.POSITIVE_INFINITY) >= tokenEnd) {
nextTokens.push(token.diffHighlight ? { ...token, diffHighlight: false } : token);
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.

🟡 Medium review/reviewWordDiffs.ts:206

When a token falls outside all highlight ranges, the original token reference is pushed to nextTokens unchanged (lines 207, 216). If the next token's unhighlighted prefix has matching color/fontStyle/diffHighlight, appendTokenSegment mutates it via previous.content += content — this corrupts the input tokens array that may be referenced elsewhere. Consider cloning tokens before pushing (e.g., { ...token }) to prevent mutation of the input array.

-    if (token.content.length === 0) {
-      nextTokens.push(token);
+    if (token.content.length === 0) {
+      nextTokens.push({ ...token });
       return;
     }
 
     while (rangeIndex < ranges.length && ranges[rangeIndex]!.end <= tokenStart) {
       rangeIndex += 1;
     }
 
     if ((ranges[rangeIndex]?.start ?? Number.POSITIVE_INFINITY) >= tokenEnd) {
-      nextTokens.push(token.diffHighlight ? { ...token, diffHighlight: false } : token);
+      nextTokens.push(token.diffHighlight ? { ...token, diffHighlight: false } : { ...token });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/mobile/src/features/review/reviewWordDiffs.ts around lines 206-217:

When a token falls outside all highlight ranges, the original token reference is pushed to `nextTokens` unchanged (lines 207, 216). If the next token's unhighlighted prefix has matching `color`/`fontStyle`/`diffHighlight`, `appendTokenSegment` mutates it via `previous.content += content` — this corrupts the input `tokens` array that may be referenced elsewhere. Consider cloning tokens before pushing (e.g., `{ ...token }`) to prevent mutation of the input array.

Evidence trail:
apps/mobile/src/features/review/reviewWordDiffs.ts lines 87-114 (appendTokenSegment with mutation at line 104), lines 189-266 (applyDiffRangesToTokens), specifically lines 206-208 and 215-217 pushing original references, apps/mobile/src/features/review/shikiReviewHighlighter.ts lines 493-548 (caller doing shallow array copy at lines 497-498 then passing shared token objects)

Co-authored-by: codex <codex@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 14 total unresolved issues (including 13 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Heartbeat check always false
    • Added activity tracking (lastActivityAt) to WsTransport updated on successful requests and stream values, exposed isHeartbeatFresh() with a 30-second threshold, and wired createWsRpcClient to delegate to it instead of returning a hardcoded false.

Create PR

Or push these changes by commenting:

@cursor push 75d703ba56
Preview (75d703ba56)
diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts
--- a/packages/client-runtime/src/wsRpcClient.ts
+++ b/packages/client-runtime/src/wsRpcClient.ts
@@ -171,7 +171,7 @@
 ): WsRpcClient {
   return {
     dispose: () => transport.dispose(),
-    isHeartbeatFresh: () => false,
+    isHeartbeatFresh: () => transport.isHeartbeatFresh(),
     reconnect: async () => {
       options?.beforeReconnect?.();
       await transport.reconnect();

diff --git a/packages/client-runtime/src/wsTransport.ts b/packages/client-runtime/src/wsTransport.ts
--- a/packages/client-runtime/src/wsTransport.ts
+++ b/packages/client-runtime/src/wsTransport.ts
@@ -44,6 +44,7 @@
 }
 
 const DEFAULT_SUBSCRIPTION_RETRY_DELAY = Duration.millis(250);
+const HEARTBEAT_FRESH_THRESHOLD_MS = 30_000;
 const NOOP: () => void = () => undefined;
 
 interface TransportSession {
@@ -68,6 +69,7 @@
   private hasReportedTransportDisconnect = false;
   private reconnectChain: Promise<void> = Promise.resolve();
   private session: TransportSession;
+  private lastActivityAt = performance.now();
 
   constructor(
     url: WsRpcProtocolSocketUrlProvider,
@@ -89,7 +91,9 @@
 
     const session = this.session;
     const client = await session.clientPromise;
-    return await session.runtime.runPromise(Effect.suspend(() => execute(client)));
+    const result = await session.runtime.runPromise(Effect.suspend(() => execute(client)));
+    this.lastActivityAt = performance.now();
+    return result;
   }
 
   async requestStream<TValue>(
@@ -105,6 +109,7 @@
     await session.runtime.runPromise(
       Stream.runForEach(connect(client), (value) =>
         Effect.sync(() => {
+          this.lastActivityAt = performance.now();
           try {
             listener(value);
           } catch {
@@ -228,6 +233,10 @@
     await this.closeSession(this.session);
   }
 
+  isHeartbeatFresh(): boolean {
+    return performance.now() - this.lastActivityAt < HEARTBEAT_FRESH_THRESHOLD_MS;
+  }
+
   private closeSession(session: TransportSession) {
     return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => {
       session.runtime.dispose();
@@ -283,6 +292,7 @@
                 return;
               }
 
+              this.lastActivityAt = performance.now();
               markValueReceived();
               try {
                 listener(value);

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

return {
dispose: () => transport.dispose(),
isHeartbeatFresh: () => false,
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.

Heartbeat check always false

Medium Severity

createWsRpcClient hardcodes isHeartbeatFresh to always return false, so browser-resume reconnect logic never treats any environment connection as healthy and always triggers reconnect() for every connected environment after tab focus returns.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3ee6fbb. Configure here.

- Ignore intentional close events during transport disposal
- Track heartbeat freshness from pong traffic and reset on reconnect
- Propagate request lifecycle hooks through the RPC client
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 16 total unresolved issues (including 14 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Missing persisted terminal id migration
    • Added migrateTerminalId and migrateThreadTerminalUiState helpers to the migration function that remap the legacy 'default' terminal id to 'term-1' in terminalIds, activeTerminalId, and group terminalIds during store hydration.
  • ✅ Fixed: Browser tests use stale terminal id
    • Updated the terminalOpen mock fallback and the seeded terminal UI state in the browser test to use DEFAULT_TERMINAL_ID instead of the hardcoded legacy 'default' string.

Create PR

Or push these changes by commenting:

@cursor push 7c3f8ee714
Preview (7c3f8ee714)
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -1087,7 +1087,7 @@
   if (tag === WS_METHODS.terminalOpen) {
     return {
       threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID,
-      terminalId: typeof body.terminalId === "string" ? body.terminalId : "default",
+      terminalId: typeof body.terminalId === "string" ? body.terminalId : DEFAULT_TERMINAL_ID,
       cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project",
       worktreePath:
         typeof body.worktreePath === "string"
@@ -1944,10 +1944,12 @@
         [THREAD_KEY]: {
           terminalOpen: true,
           terminalHeight: 280,
-          terminalIds: ["default"],
-          activeTerminalId: "default",
-          terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
-          activeTerminalGroupId: "group-default",
+          terminalIds: [DEFAULT_TERMINAL_ID],
+          activeTerminalId: DEFAULT_TERMINAL_ID,
+          terminalGroups: [
+            { id: `group-${DEFAULT_TERMINAL_ID}`, terminalIds: [DEFAULT_TERMINAL_ID] },
+          ],
+          activeTerminalGroupId: `group-${DEFAULT_TERMINAL_ID}`,
         },
       },
     });

diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts
--- a/apps/web/src/terminalUiStateStore.ts
+++ b/apps/web/src/terminalUiStateStore.ts
@@ -12,6 +12,7 @@
 import { resolveStorage } from "./lib/storage";
 import {
   DEFAULT_THREAD_TERMINAL_HEIGHT,
+  DEFAULT_THREAD_TERMINAL_ID,
   MAX_TERMINALS_PER_GROUP,
   type ThreadTerminalGroup,
 } from "./types";
@@ -33,6 +34,20 @@
   terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>;
 }
 
+function migrateTerminalId(id: string): string {
+  return id === "default" ? DEFAULT_THREAD_TERMINAL_ID : id;
+}
+
+function migrateThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState {
+  const terminalIds = state.terminalIds.map(migrateTerminalId);
+  const activeTerminalId = migrateTerminalId(state.activeTerminalId);
+  const terminalGroups: ThreadTerminalGroup[] = state.terminalGroups.map((group) => ({
+    id: group.id,
+    terminalIds: group.terminalIds.map(migrateTerminalId),
+  }));
+  return { ...state, terminalIds, activeTerminalId, terminalGroups };
+}
+
 export function migratePersistedTerminalUiStateStoreState(
   persistedState: unknown,
   _version: number,
@@ -45,9 +60,9 @@
   const persistedUiStateByThreadKey =
     candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {};
   const terminalUiStateByThreadKey = Object.fromEntries(
-    Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) =>
-      parseScopedThreadKey(threadKey),
-    ),
+    Object.entries(persistedUiStateByThreadKey)
+      .filter(([threadKey]) => parseScopedThreadKey(threadKey))
+      .map(([threadKey, state]) => [threadKey, migrateThreadTerminalUiState(state)]),
   );
 
   return { terminalUiStateByThreadKey };

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

);

return { terminalUiStateByThreadKey };
}
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.

Missing persisted terminal id migration

Medium Severity

Persisted terminal UI state can still use the old primary id default after the default becomes term-1. The store migration only filters thread keys and never rewrites terminalIds, activeTerminalId, or group membership. Until server metadata reconciliation runs, attach, write, and resize can target default while the server’s primary session is term-1, so the drawer can show the wrong or empty shell after upgrade.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1e929ad. Configure here.

@@ -1068,6 +1100,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
history: "",
exitCode: null,
exitSignal: null,
label: "Terminal 1",
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.

Browser tests use stale terminal id

Low Severity

Browser harnesses still seed and mock the legacy id default while terminal metadata fixtures use DEFAULT_TERMINAL_ID (term-1). The WS terminalOpen mock also defaults missing terminalId to default. Tests can pass even when UI and RPC layers disagree on the primary shell id, which no longer matches the contract enforced elsewhere in this PR.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1e929ad. Configure here.

- Add new project screens for local folders and remote repositories
- Support source-control provider lookup, clone destination selection, and folder browsing
- Extract shared add-project path helpers and add coverage for the new runtime logic
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 3 potential issues.

There are 19 total unresolved issues (including 16 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Metadata overrides attach exit status
    • Changed combineSessionState to prefer buffer terminal statuses (exited/closed/error) over stale metadata summary status when the buffer has been updated by the attach stream (version > 0).
  • ✅ Fixed: Attach locks before worktree loads
    • Added selectedThreadDetail?.worktreePath to the bootstrap useEffect dependency array so the terminal re-attaches with the correct worktree path when thread detail hydrates after the initial attach.
  • ✅ Fixed: Unused ThreadTerminalPanel export
    • Deleted the unused ThreadTerminalPanel.tsx file which contained duplicate attach logic not imported anywhere in the codebase.

Create PR

Or push these changes by commenting:

@cursor push 34938f8253
Preview (34938f8253)
diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx
deleted file mode 100644
--- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx
+++ /dev/null
@@ -1,175 +1,0 @@
-import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
-import { SymbolView } from "expo-symbols";
-import { memo, useCallback, useEffect, useRef, useState } from "react";
-import { Pressable, View } from "react-native";
-
-import { AppText as Text } from "../../components/AppText";
-import { getEnvironmentClient } from "../../state/environment-session-registry";
-import {
-  attachTerminalSession,
-  useTerminalSession,
-  useTerminalSessionTarget,
-} from "../../state/use-terminal-session";
-import { TerminalSurface } from "./NativeTerminalSurface";
-import { hasNativeTerminalSurface } from "./nativeTerminalModule";
-import { terminalDebugLog } from "./terminalDebugLog";
-
-interface ThreadTerminalPanelProps {
-  readonly environmentId: EnvironmentId;
-  readonly threadId: ThreadId;
-  readonly cwd: string;
-  readonly worktreePath: string | null;
-  readonly visible: boolean;
-  readonly onClose: () => void;
-}
-
-const DEFAULT_TERMINAL_COLS = 80;
-const DEFAULT_TERMINAL_ROWS = 24;
-
-export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
-  props: ThreadTerminalPanelProps,
-) {
-  const nativeTerminalAvailable = hasNativeTerminalSurface();
-  const terminalId = DEFAULT_TERMINAL_ID;
-  const target = useTerminalSessionTarget({
-    environmentId: props.environmentId,
-    threadId: props.threadId,
-    terminalId,
-  });
-  const terminal = useTerminalSession(target);
-  const [lastGridSize, setLastGridSize] = useState({
-    cols: DEFAULT_TERMINAL_COLS,
-    rows: DEFAULT_TERMINAL_ROWS,
-  });
-  const lastGridSizeRef = useRef(lastGridSize);
-  lastGridSizeRef.current = lastGridSize;
-
-  const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`;
-  const isRunning = terminal.status === "running" || terminal.status === "starting";
-
-  useEffect(() => {
-    if (!props.visible) {
-      return;
-    }
-
-    const client = getEnvironmentClient(props.environmentId);
-    if (!client) {
-      terminalDebugLog("panel:attach-skip", {
-        reason: "no-environment-client",
-        environmentId: props.environmentId,
-      });
-      return;
-    }
-
-    terminalDebugLog("panel:attach", {
-      environmentId: props.environmentId,
-      threadId: props.threadId,
-      terminalId,
-    });
-
-    return attachTerminalSession({
-      environmentId: props.environmentId,
-      client,
-      terminal: {
-        threadId: props.threadId,
-        terminalId,
-        cwd: props.cwd,
-        worktreePath: props.worktreePath,
-        cols: lastGridSizeRef.current.cols,
-        rows: lastGridSizeRef.current.rows,
-      },
-    });
-  }, [
-    props.cwd,
-    props.environmentId,
-    props.threadId,
-    props.worktreePath,
-    props.visible,
-    terminalId,
-  ]);
-
-  const handleInput = useCallback(
-    (data: string) => {
-      const client = getEnvironmentClient(props.environmentId);
-      if (!client || !isRunning) {
-        return;
-      }
-
-      void client.terminal.write({
-        threadId: props.threadId,
-        terminalId,
-        data,
-      });
-    },
-    [isRunning, props.environmentId, props.threadId, terminalId],
-  );
-
-  const handleResize = useCallback(
-    (size: { readonly cols: number; readonly rows: number }) => {
-      if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) {
-        return;
-      }
-
-      setLastGridSize(size);
-      const client = getEnvironmentClient(props.environmentId);
-      if (!client || !isRunning) {
-        return;
-      }
-
-      void client.terminal.resize({
-        threadId: props.threadId,
-        terminalId,
-        cols: size.cols,
-        rows: size.rows,
-      });
-    },
-    [
-      isRunning,
-      lastGridSize.cols,
-      lastGridSize.rows,
-      props.environmentId,
-      props.threadId,
-      terminalId,
-    ],
-  );
-
-  if (!props.visible) {
-    return null;
-  }
-
-  return (
-    <View className="absolute inset-x-3 bottom-28 top-28 overflow-hidden rounded-[8px] border border-white/10 bg-neutral-950 shadow-2xl">
-      <View className="flex-row items-center justify-between border-b border-white/10 px-3 py-2">
-        <View className="min-w-0 flex-1">
-          <Text className="font-t3-bold text-[13px] text-neutral-100" numberOfLines={1}>
-            Terminal
-          </Text>
-          <Text className="text-[11px] text-neutral-500" numberOfLines={1}>
-            {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"}
-          </Text>
-        </View>
-        <View className="flex-row items-center gap-2">
-          {terminal.error ? (
-            <Text className="max-w-44 text-right text-[11px] text-red-300" numberOfLines={1}>
-              {terminal.error}
-            </Text>
-          ) : null}
-          <Pressable
-            className="h-8 w-8 items-center justify-center rounded-[8px] bg-white/10"
-            onPress={props.onClose}
-          >
-            <SymbolView name="xmark" size={13} tintColor="#e5e5e5" type="monochrome" />
-          </Pressable>
-        </View>
-      </View>
-      <TerminalSurface
-        terminalKey={terminalKey}
-        buffer={terminal.buffer}
-        isRunning={isRunning}
-        onInput={handleInput}
-        onResize={handleResize}
-        style={{ flex: 1 }}
-      />
-    </View>
-  );
-});
\ No newline at end of file

diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
--- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
+++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
@@ -725,6 +725,7 @@
     router,
     selectedThread?.environmentId,
     selectedThread?.id,
+    selectedThreadDetail?.worktreePath,
     selectedThreadProject?.workspaceRoot,
     terminalId,
   ]);

diff --git a/packages/client-runtime/src/terminalSessionState.ts b/packages/client-runtime/src/terminalSessionState.ts
--- a/packages/client-runtime/src/terminalSessionState.ts
+++ b/packages/client-runtime/src/terminalSessionState.ts
@@ -242,16 +242,24 @@
   return Date.parse(left) >= Date.parse(right) ? left : right;
 }
 
+function isBufferTerminalStatus(buffer: TerminalBufferState): boolean {
+  return (
+    buffer.version > 0 &&
+    (buffer.status === "exited" || buffer.status === "closed" || buffer.status === "error")
+  );
+}
+
 function combineSessionState(
   summary: TerminalSummary | null,
   buffer: TerminalBufferState,
 ): TerminalSessionState {
+  const bufferTerminal = isBufferTerminalStatus(buffer);
   return {
     summary,
     buffer: buffer.buffer,
-    status: summary?.status ?? buffer.status,
+    status: bufferTerminal ? buffer.status : (summary?.status ?? buffer.status),
     error: buffer.error,
-    hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,
+    hasRunningSubprocess: bufferTerminal ? false : (summary?.hasRunningSubprocess ?? false),
     updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt),
     version: buffer.version,
   };

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

hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,
updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt),
version: buffer.version,
};
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.

Metadata overrides attach exit status

Medium Severity

In combineSessionState, when metadata summary is present, status always comes from summary.status and ignores the attach buffer’s status. After an attach-stream exited or error event, stale metadata can still say running, so clients show a live terminal and accept input while the attach buffer already recorded exit.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 907f4d7. Configure here.

selectedThread?.id,
selectedThreadProject?.workspaceRoot,
terminalId,
]);
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.

Attach locks before worktree loads

Medium Severity

The bootstrap useEffect sets hasOpenedRef and attaches once, but its dependency list omits selectedThreadDetail?.worktreePath and thread worktree fields that attachTerminal uses in resolveTerminalOpenLocation. If thread detail arrives after the first attach, the shell can stay bound to the workspace root instead of the thread worktree.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 907f4d7. Configure here.

/>
</View>
);
});
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.

Unused ThreadTerminalPanel export

Low Severity

ThreadTerminalPanel is exported and implements its own attachTerminalSession subscription, but nothing in the mobile app imports it. That leaves duplicate attach logic alongside ThreadTerminalRouteScreen and risks future drift if one path is updated and the other is not.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 907f4d7. Configure here.

- Extract shared new-task sheet header
- Refresh add-project form spacing and controls
- Align project creation screens with the new sheet layout
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 4 potential issues.

There are 23 total unresolved issues (including 19 from previous reviews).

Fix All in Cursor

Bugbot Autofix prepared fixes for 2 of the 4 issues found in the latest run.

  • ✅ Fixed: Idle global terminal event stream
    • Made the terminal.onEvent subscription conditional on applyTerminalEvent being provided, so connections without a handler no longer open a redundant WebSocket stream.
  • ✅ Fixed: Terminal buffers leak after remove
    • Added buffer reset to EMPTY_TERMINAL_BUFFER_STATE in the metadata snapshot pruning path so terminals no longer reported by the server have their retained string data (up to 512KB each) released; the per-terminal remove path intentionally preserves the buffer as local closed state per existing design.

Create PR

Or push these changes by commenting:

@cursor push 1cc9019479
Preview (1cc9019479)
diff --git a/apps/web/src/environments/runtime/connection.ts b/apps/web/src/environments/runtime/connection.ts
--- a/apps/web/src/environments/runtime/connection.ts
+++ b/apps/web/src/environments/runtime/connection.ts
@@ -143,11 +143,13 @@
     },
   );
 
-  const unsubTerminalEvent = input.client.terminal.onEvent(
-    (event: Parameters<Parameters<WsRpcClient["terminal"]["onEvent"]>[0]>[0]) => {
-      input.applyTerminalEvent?.(event, environmentId);
-    },
-  );
+  const unsubTerminalEvent = input.applyTerminalEvent
+    ? input.client.terminal.onEvent(
+        (event: Parameters<Parameters<WsRpcClient["terminal"]["onEvent"]>[0]>[0]) => {
+          input.applyTerminalEvent!(event, environmentId);
+        },
+      )
+    : () => undefined;
 
   const cleanup = () => {
     disposed = true;

diff --git a/packages/client-runtime/src/terminalSessionState.ts b/packages/client-runtime/src/terminalSessionState.ts
--- a/packages/client-runtime/src/terminalSessionState.ts
+++ b/packages/client-runtime/src/terminalSessionState.ts
@@ -402,6 +402,10 @@
 
       for (const key of Object.keys(next)) {
         if (!retainedKeys.has(key)) {
+          const removedEntry = next[key];
+          if (removedEntry) {
+            setBuffer(removedEntry.target, EMPTY_TERMINAL_BUFFER_STATE);
+          }
           delete next[key];
         }
       }

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

Reviewed by Cursor Bugbot for commit 4db082a. Configure here.

| { readonly kind: "open" } {
if (!input.hasThread || !input.hasWorkspaceRoot || input.hasOpened) {
return { kind: "idle" };
}
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.

Opened flag blocks re-attach

High Severity

resolveTerminalRouteBootstrap returns idle whenever hasOpened is true, before it checks whether the attach stream still needs to run. After a session ends or the stream stops while the route stays mounted, hasOpenedRef can remain true with an empty buffer, so terminal.attach never runs again and the terminal stays blank.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4db082a. Configure here.

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.

Bugbot Autofix determined this is a false positive.

The attach subscription is a persistent WebSocket stream that stays open even after PTY exit (receiving restarted/activity events); hasOpenedRef is properly reset on effect cleanup, terminalKey change, and error, and the transport layer handles reconnection with automatic resubscription.

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

@@ -145,7 +145,7 @@ export function createEnvironmentConnection(

const unsubTerminalEvent = input.client.terminal.onEvent(
(event: Parameters<Parameters<WsRpcClient["terminal"]["onEvent"]>[0]>[0]) => {
input.applyTerminalEvent(event, environmentId);
input.applyTerminalEvent?.(event, 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.

Idle global terminal event stream

Medium Severity

Every web environment connection still subscribes to client.terminal.onEvent, but createEnvironmentConnectionHandlers no longer supplies applyTerminalEvent, so those events are discarded. Terminal output and presence now use subscribeTerminalMetadata plus per-session terminal.attach, leaving a redundant global stream on each connection.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4db082a. Configure here.

* `term-N`; there's no "default" intrinsic. Kept as a named constant so callers
* that want "the primary shell" don't hardcode `"term-1"`.
*/
export const DEFAULT_TERMINAL_ID = "term-1";
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.

Terminal history orphaned after ID

High Severity

The default client terminal id changed from default to term-1, but persisted server terminal history files are still named from the encoded terminalId. Existing installs keep history under the old id, so attach/open with term-1 starts with an empty buffer instead of prior scrollback.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4db082a. Configure here.

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.

Bugbot Autofix determined this is a false positive.

The server's historyPath function maps DEFAULT_TERMINAL_ID to the unsuffixed path ${threadPart}.log regardless of the constant's string value, so both "default" and "term-1" resolve to the same file path when they are the default terminal.

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


const next = { ...getMetadata(environmentId) };
delete next[keyFromKnownTarget(knownTarget)];
setMetadata(environmentId, next);
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.

Terminal buffers leak after remove

Medium Severity

When terminal metadata is removed (per-terminal remove events or metadata snapshot pruning), only the metadata map entry is deleted. The corresponding terminalSessionBufferAtom keeps its trimmed history (up to 512KB per session), so deleted or archived threads can leave retained buffers in memory.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4db082a. Configure here.

- Move remote HTTP calls into `@t3tools/client-runtime`
- Let mobile parse hosted pairing links and use shared runtime
- Keep web and mobile remote connection flows aligned
const url = new URL(normalizedInput);
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/remote.ts:18

normalizeRemoteBaseUrl throws TypeError: Invalid URL when given a protocol-relative URL like //example.com. The condition on line 15 allows these through unchanged, but new URL("//example.com") without a base URL is invalid. Previously, window.location.origin was passed as the base. Consider restoring the base URL parameter to resolve protocol-relative URLs correctly.

-  const url = new URL(normalizedInput);
+  const url = new URL(normalizedInput, window.location.origin);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/shared/src/remote.ts around line 18:

`normalizeRemoteBaseUrl` throws `TypeError: Invalid URL` when given a protocol-relative URL like `//example.com`. The condition on line 15 allows these through unchanged, but `new URL("//example.com")` without a base URL is invalid. Previously, `window.location.origin` was passed as the base. Consider restoring the base URL parameter to resolve protocol-relative URLs correctly.

Evidence trail:
packages/shared/src/remote.ts lines 8-23 (REVIEWED_COMMIT) — normalizeRemoteBaseUrl function. Line 15: `trimmed.startsWith('//')` passes protocol-relative URLs through unchanged. Line 18: `new URL(normalizedInput)` called without a base URL argument. WHATWG URL spec: `new URL('//example.com')` without a base throws TypeError. Node.js docs confirm WHATWG URL parsing: https://nodejs.org/api/url.html#new-urlinput-base

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.

2 participants