Skip to content

Add adaptive split-view layout for iPad/mobile workspace#3514

Open
juliusmarminge wants to merge 21 commits into
mainfrom
t3code/ipad-responsive-mobile-layout
Open

Add adaptive split-view layout for iPad/mobile workspace#3514
juliusmarminge wants to merge 21 commits into
mainfrom
t3code/ipad-responsive-mobile-layout

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

  • Add an adaptive workspace shell that switches between compact and split-view layouts based on available screen size.
  • Introduce a persistent thread sidebar for wider screens, with search, quick settings, and new-task actions.
  • Update thread and home routes to respect split-view behavior and hide redundant drawer/back navigation when the sidebar is present.
  • Refactor thread navigation grouping into shared logic and add coverage for layout and search filtering behavior.
  • Allow the mobile app to rotate beyond portrait so tablets and foldable-sized windows can use the split layout.

Testing

  • vp check
  • vp run typecheck
  • vp test
  • Added/updated tests for layout breakpoints and thread navigation grouping behavior
  • Not run: native/mobile device UI verification

Note

Medium Risk
Large navigation and layout refactor (including removing the root loading gate) plus native bridge changes to review diff and new iOS lifecycle/CocoaPods hooks; auth-related associated domains are additive but need correct entitlements.

Overview
Adds an adaptive workspace shell that switches to split view on large screens: a persistent thread sidebar, empty detail when no thread is selected, and route/navigation behavior that uses in-place param updates instead of stack pushes where the sidebar already provides context.

Inspector and files: A resizable auxiliary pane hosts file navigation and previews on wide layouts (AdaptiveInspectorLayout, ThreadFileNavigatorPane), with replace/setParams navigation to avoid remounting on every file click. File tree and source views gain caching, preloading, and optimistic selection for snappier browsing.

Hardware keyboard: New native t3-native-controls module exposes global shortcuts (new task, search, back, files/terminal/review, toggle sidebar) via HardwareKeyboardCommandProvider; composer adds ⌘↵ send.

Native review diff: Large rows/tokens JSON moves off Fabric props to async module methods with a JS bridge and retries; decoding runs on a background queue with stale-patch guards. Adds scroll-to-file/top, onVisibleFileChange, and contentResetKey for navigator sync without flashing.

iOS build/runtime: associatedDomains for Clerk, UIScene SceneDelegate plugin, and CocoaPods UUID repair for cached EAS builds. Terminal gains tab/back-tab and focusRequest; root layout drops the workspace loading gate before showing the stack.

Home/settings polish: Shared home list options provider, extracted connection status UI, and swipe + scroll gesture coordination on the thread list.

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

Note

Add adaptive split-view layout for iPad with inspector panes, sidebar, and hardware keyboard support

  • Introduces AdaptiveWorkspaceLayout context and layout utilities in layout.ts that derive split-view eligibility, sidebar widths, and auxiliary/inspector pane sizing from viewport dimensions.
  • Adds ThreadNavigationSidebar for split-view thread browsing with swipe actions, and WorkspaceEmptyDetail for the empty detail pane when no thread is selected.
  • Refactors ThreadRouteScreen and ThreadDetailScreen to support inspector panes (files/git), a glass/blur iOS header, centered content with max width, and suppressed drawer/transition gestures in split view.
  • Adds a resizable AdaptiveInspectorLayout with animated show/hide and a WorkspacePaneDivider for pointer/touch/accessibility resizing.
  • Introduces T3KeyboardCommandsModule (native) and HardwareKeyboardCommandProvider to handle hardware keyboard shortcuts (new task, focus search, back, tab switching) across iOS.
  • Moves heavy JSON decoding in T3ReviewDiffView to a background queue with generation tracking, adds programmatic scroll-to-file/top APIs, and emits onVisibleFileChange; the review sheet gains a file navigator inspector pane.
  • Adds preloadWorkspaceFileContents and prepareSourceFileDocument for file content preloading and cached row serialization to reduce perceived latency.
  • Adds a withIosSceneLifecycle config plugin enabling UIScene lifecycle with a SceneDelegate, and withIosCocoaPodsUuidCache to fix UUID reuse during cached pod installs.
  • Risk: Split-view layout requires both width and height to meet new thresholds, changing when split view activates compared to width-only checks previously.

Macroscope summarized b83af8a.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d9971607-ab72-496f-a8f1-c5e98366f394

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/ipad-responsive-mobile-layout

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

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

  • ✅ Fixed: Sidebar shows archived threads
    • Added .filter((thread) => thread.archivedAt === null) in buildThreadNavigationGroups before sorting, matching the same filtering logic used by buildHomeThreadGroups.
  • ✅ Fixed: Split feed uses window width
    • Changed viewportWidth initial state to 0 when layoutVariant === "split" so the first render doesn't use the full window width, and added viewportWidth to LegendList's extraData so rows repaint after onLayout corrects it.
  • ✅ Fixed: Split view hides archive
    • Added useThreadListActions hook and a long-press handler with Archive/Delete options to ThreadNavigationSidebar thread rows, restoring thread management actions in split view.

Create PR

Or push these changes by commenting:

@cursor push b06fdc835f
Preview (b06fdc835f)
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -1134,7 +1134,9 @@
   const initialScrollReadyRef = useRef(false);
   const lastContentHeightRef = useRef(0);
   const { width: windowWidth } = useWindowDimensions();
-  const [viewportWidth, setViewportWidth] = useState(windowWidth);
+  const [viewportWidth, setViewportWidth] = useState(() =>
+    props.layoutVariant === "split" ? 0 : windowWidth,
+  );
   const [interactionState, setInteractionState] = useState<{
     readonly copiedRowId: string | null;
     readonly expandedWorkGroups: Record<string, boolean>;
@@ -1206,6 +1208,7 @@
       markdownStyles,
       reviewCommentColors,
       userBubbleColor,
+      viewportWidth,
     }),
     [
       copiedRowId,
@@ -1215,6 +1218,7 @@
       markdownStyles,
       reviewCommentColors,
       userBubbleColor,
+      viewportWidth,
     ],
   );
   const presentedFeed = useMemo(

diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -1,7 +1,7 @@
 import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
 import { SymbolView } from "expo-symbols";
-import { useMemo, useState } from "react";
-import { Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native";
+import { useCallback, useMemo, useState } from "react";
+import { Alert, Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native";
 import { useSafeAreaInsets } from "react-native-safe-area-context";
 
 import { AppText as Text } from "../../components/AppText";
@@ -10,6 +10,7 @@
 import { relativeTime } from "../../lib/time";
 import { useThemeColor } from "../../lib/useThemeColor";
 import { useProjects, useThreadShells } from "../../state/entities";
+import { useThreadListActions } from "../home/useThreadListActions";
 import { buildThreadNavigationGroups } from "./thread-navigation-groups";
 import { threadStatusTone } from "./threadPresentation";
 
@@ -24,11 +25,23 @@
   const projects = useProjects();
   const threads = useThreadShells();
   const [searchQuery, setSearchQuery] = useState("");
+  const { archiveThread, confirmDeleteThread } = useThreadListActions();
   const groups = useMemo(
     () => buildThreadNavigationGroups({ projects, threads, searchQuery }),
     [projects, searchQuery, threads],
   );
 
+  const handleThreadLongPress = useCallback(
+    (thread: EnvironmentThreadShell) => {
+      Alert.alert(thread.title, undefined, [
+        { text: "Cancel", style: "cancel" },
+        { text: "Archive", onPress: () => archiveThread(thread) },
+        { text: "Delete", style: "destructive", onPress: () => confirmDeleteThread(thread) },
+      ]);
+    },
+    [archiveThread, confirmDeleteThread],
+  );
+
   const backgroundColor = useThemeColor("--color-drawer");
   const borderColor = useThemeColor("--color-border");
   const foregroundColor = useThemeColor("--color-foreground");
@@ -131,6 +144,7 @@
                       accessibilityLabel={thread.title}
                       accessibilityRole="button"
                       accessibilityState={{ selected }}
+                      onLongPress={() => handleThreadLongPress(thread)}
                       onPress={() => props.onSelectThread(thread)}
                       style={({ pressed }) => [
                         styles.threadRow,

diff --git a/apps/mobile/src/features/threads/thread-navigation-groups.ts b/apps/mobile/src/features/threads/thread-navigation-groups.ts
--- a/apps/mobile/src/features/threads/thread-navigation-groups.ts
+++ b/apps/mobile/src/features/threads/thread-navigation-groups.ts
@@ -33,7 +33,9 @@
 
   return groupProjectsByRepository(input).flatMap((group) => {
     const threads = Arr.sort(
-      group.projects.flatMap((projectGroup) => projectGroup.threads),
+      group.projects
+        .flatMap((projectGroup) => projectGroup.threads)
+        .filter((thread) => thread.archivedAt === null),
       threadActivityOrder,
     );
     const title = group.projects[0]?.project.title ?? group.title;

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

Comment thread apps/mobile/src/features/threads/thread-navigation-groups.ts
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated
Comment thread apps/mobile/src/app/index.tsx
@macroscopeapp

macroscopeapp Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jun 24, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

There are 8 total unresolved issues (including 3 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Stale tokens after file switch
    • Added contentView.tokensByRowId = [:] in setContentResetKey to clear token state when content resets, matching the pattern already used by setTokensResetKey.
  • ✅ Fixed: All files selection reverts
    • Added an isAllFilesSelectedRef that suppresses onVisibleFileChange scroll-sync events when the user has explicitly selected "All files", preventing the sidebar from reverting to a specific file after the programmatic scroll-to-top animation completes.
  • ✅ Fixed: Thread feed reveals before scroll
    • Added setRevealedThreadId(null) to the threadId change effect so returning to a previously revealed thread correctly hides the feed until the scroll-to-end sequence completes.
  • ✅ Fixed: File tree stuck selection highlight
    • Added setPendingSelection(null) when controlledSelectedPath changes and a 1-second timeout fallback in handleSelectFile to clear optimistic state when the controlled path never catches up.
  • ✅ Fixed: Native payload retry mismatch
    • Changed the hardcoded 'T3ReviewDiffView' string in isPendingNativeViewRegistration to use the NATIVE_REVIEW_DIFF_MODULE_NAME constant ('T3ReviewDiffSurface') so the retry path correctly matches the registered module name.

Create PR

Or push these changes by commenting:

@cursor push 032c0d2718
Preview (032c0d2718)
diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
--- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
+++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
@@ -554,6 +554,7 @@
     lastVisibleFileId = nil
     pendingScrollFileId = nil
     isProgrammaticScrollActive = false
+    contentView.tokensByRowId = [:]
     scrollView.setContentOffset(.zero, animated: false)
     updateViewportFrame()
     applyInitialRowIndexIfNeeded()

diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
--- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
+++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
@@ -168,7 +168,8 @@
 
 function isPendingNativeViewRegistration(error: unknown): boolean {
   return (
-    error instanceof Error && error.message.includes("Unable to find the 'T3ReviewDiffView' view")
+    error instanceof Error &&
+    error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`)
   );
 }
 

diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx
--- a/apps/mobile/src/features/files/FileTreeBrowser.tsx
+++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx
@@ -148,6 +148,7 @@
   }, [defaultExpanded]);
 
   useEffect(() => {
+    setPendingSelection(null);
     if (!controlledSelectedPath) {
       return;
     }
@@ -175,12 +176,25 @@
       return next;
     });
   }, []);
+  const pendingSelectionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const handleSelectFile = useCallback(
     (path: string) => {
+      if (pendingSelectionTimerRef.current !== null) {
+        clearTimeout(pendingSelectionTimerRef.current);
+      }
       setPendingSelection({
         path,
         selectedPathAtPress: controlledSelectedPathRef.current,
       });
+      pendingSelectionTimerRef.current = setTimeout(() => {
+        pendingSelectionTimerRef.current = null;
+        setPendingSelection((current) =>
+          current?.path === path &&
+          current.selectedPathAtPress === controlledSelectedPathRef.current
+            ? null
+            : current,
+        );
+      }, 1000);
       onSelectFile(path);
     },
     [onSelectFile],

diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx
--- a/apps/mobile/src/features/review/ReviewSheet.tsx
+++ b/apps/mobile/src/features/review/ReviewSheet.tsx
@@ -465,8 +465,10 @@
     canHighlight: parsedDiff.kind === "files",
   });
 
+  const isAllFilesSelectedRef = useRef(false);
   const handleSelectFile = useCallback(
     (fileId: string | null) => {
+      isAllFilesSelectedRef.current = fileId === null;
       commentSelection.clearSelection();
       if (fileId !== null && collapsedFileIds.includes(fileId)) {
         toggleExpandedFile(fileId);
@@ -484,7 +486,7 @@
   const handleVisibleFileChange = useCallback(
     (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
       const { fileId } = event.nativeEvent;
-      if (!fileId) {
+      if (!fileId || isAllFilesSelectedRef.current) {
         return;
       }
       reviewFileNavigatorRef.current?.setVisibleFile(fileId);

diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -1322,6 +1322,7 @@
       cancelAnimationFrame(revealSettleFrameRef.current);
       revealSettleFrameRef.current = null;
     }
+    setRevealedThreadId(null);
     initialScrollReadyRef.current = false;
     isNearEndRef.current = true;
     lastContentHeightRef.current = 0;

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

Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Comment thread apps/mobile/src/features/review/ReviewSheet.tsx
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated
Comment thread apps/mobile/src/features/files/FileTreeBrowser.tsx
Comment thread apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

  • ✅ Fixed: Selection timeout reverts highlight
    • The timeout handler now checks whether controlledSelectedPathRef.current has caught up to the tapped path before clearing pending selection, preventing the highlight from reverting during slow navigation.
  • ✅ Fixed: Archive hides active thread
    • Added an onCompleted callback to useThreadListActions and an onThreadRemoved prop to the sidebar that triggers router.back() when the currently-selected thread is archived or deleted.
  • ✅ Fixed: Review navigator stale after reset
    • Added an explicit onVisibleFileChange emission with NSNull() in setContentResetKey so React always receives the 'All files' sync after a content reset, regardless of the lastVisibleFileId guard.

Create PR

Or push these changes by commenting:

@cursor push 058e075f4c
Preview (058e075f4c)
diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
--- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
+++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
@@ -557,6 +557,7 @@
     pendingScrollFileId = nil
     isProgrammaticScrollActive = false
     scrollView.setContentOffset(.zero, animated: false)
+    onVisibleFileChange(["fileId": NSNull()])
     updateViewportFrame()
     applyInitialRowIndexIfNeeded()
   }

diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx
--- a/apps/mobile/src/features/files/FileTreeBrowser.tsx
+++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx
@@ -197,7 +197,11 @@
       });
       pendingSelectionTimeoutRef.current = setTimeout(() => {
         pendingSelectionTimeoutRef.current = null;
-        setPendingSelection((current) => (current?.path === path ? null : current));
+        setPendingSelection((current) => {
+          if (current?.path !== path) return current;
+          if (controlledSelectedPathRef.current === path) return null;
+          return current;
+        });
       }, OPTIMISTIC_SELECTION_TIMEOUT_MS);
       onSelectFile(path);
     },

diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts
--- a/apps/mobile/src/features/home/useThreadListActions.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.ts
@@ -99,11 +99,19 @@
   );
 }
 
-export function useThreadListActions(): {
+export function useThreadListActions(
+  onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
+): {
   readonly archiveThread: (thread: EnvironmentThreadShell) => void;
   readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void;
 } {
-  const executeAction = useThreadActionExecutor();
+  const handleCompleted = useCallback(
+    (action: ThreadListAction, thread: EnvironmentThreadShell) => {
+      onCompleted?.(action, thread);
+    },
+    [onCompleted],
+  );
+  const executeAction = useThreadActionExecutor(onCompleted ? handleCompleted : undefined);
 
   const archiveThread = useCallback(
     (thread: EnvironmentThreadShell) => {

diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -238,6 +238,7 @@
               onOpenSettings={() => router.push("/settings")}
               onSelectThread={handleSelectThread}
               onStartNewTask={() => router.push("/new")}
+              onThreadRemoved={() => router.back()}
             />
           </Animated.View>
         ) : null}

diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -23,13 +23,24 @@
   readonly onOpenSettings: () => void;
   readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
   readonly onStartNewTask: () => void;
+  readonly onThreadRemoved?: (thread: EnvironmentThreadShell) => void;
 }) {
   const insets = useSafeAreaInsets();
   const projects = useProjects();
   const threads = useThreadShells();
   const [searchQuery, setSearchQuery] = useState("");
   const openSwipeableRef = useRef<SwipeableMethods | null>(null);
-  const { archiveThread, confirmDeleteThread } = useThreadListActions();
+  const { onThreadRemoved, selectedThreadKey } = props;
+  const handleThreadActionCompleted = useCallback(
+    (_action: unknown, thread: EnvironmentThreadShell) => {
+      const threadKey = scopedThreadKey(thread.environmentId, thread.id);
+      if (threadKey === selectedThreadKey) {
+        onThreadRemoved?.(thread);
+      }
+    },
+    [onThreadRemoved, selectedThreadKey],
+  );
+  const { archiveThread, confirmDeleteThread } = useThreadListActions(handleThreadActionCompleted);
   const groups = useMemo(
     () => buildThreadNavigationGroups({ projects, threads, searchQuery }),
     [projects, searchQuery, threads],

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

Comment thread apps/mobile/src/features/files/FileTreeBrowser.tsx
Comment thread apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx Outdated
Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
@juliusmarminge juliusmarminge force-pushed the t3code/ipad-responsive-mobile-layout branch from fcaaab4 to 678ebfa Compare June 26, 2026 05:43

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

  • ✅ Fixed: Content reset keeps stale rows
    • Added rows clearing (rows=[], contentView.rows=[]) and rowsDecodeGeneration increment to setContentResetKey, plus replaced updateViewportFrame with updateContentMetrics to properly recalculate layout after clearing.
  • ✅ Fixed: Native payload dispatch gives up
    • Increased max retry attempts from 4 to 12 with exponential backoff using setTimeout after the initial 4 requestAnimationFrame attempts, and added a console.error when all retries are exhausted.
  • ✅ Fixed: Invalid rows JSON aborts silently
    • Added DispatchQueue.main.async blocks in the UTF-8 guard failure path for both setRowsJson and setTokensJson that clear state and emit debug errors, matching the existing catch-block error handling pattern.
  • ✅ Fixed: Inspector file pick always pushes
    • Updated handleSelectInspectorFile to use resolveFileSelectionNavigationAction and router.replace when fileInspector.supported is true, consistent with ThreadFilesRouteScreen behavior.
  • ✅ Fixed: Sidebar ignores environment filter
    • Added optional environmentId parameter to buildThreadNavigationGroups with project and thread filtering, exposed it via ThreadNavigationSidebar prop, and passed the current thread's environmentId from AdaptiveWorkspaceLayout.

Create PR

Or push these changes by commenting:

@cursor push 8aa37e0484
Preview (8aa37e0484)
diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
--- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
+++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
@@ -420,6 +420,18 @@
 
     payloadDecodeQueue.async { [weak self] in
       guard let data = rowsJson.data(using: .utf8) else {
+        DispatchQueue.main.async { [weak self] in
+          guard let self, generation == self.rowsDecodeGeneration else {
+            return
+          }
+          self.rows = []
+          self.contentView.rows = []
+          self.hasAppliedInitialRowIndex = false
+          self.lastVisibleFileId = nil
+          self.pendingScrollFileId = nil
+          self.updateContentMetrics()
+          self.emitDebug("rows-decode-failed", ["error": "invalid utf8"])
+        }
         return
       }
 
@@ -464,6 +476,13 @@
 
     payloadDecodeQueue.async { [weak self] in
       guard let data = tokensJson.data(using: .utf8) else {
+        DispatchQueue.main.async { [weak self] in
+          guard let self, generation == self.tokensDecodeGeneration else {
+            return
+          }
+          self.contentView.tokensByRowId = [:]
+          self.emitDebug("tokens-decode-failed", ["error": "invalid utf8"])
+        }
         return
       }
 
@@ -550,14 +569,17 @@
     }
 
     self.contentResetKey = contentResetKey
+    rowsDecodeGeneration += 1
     tokensDecodeGeneration += 1
+    rows = []
+    contentView.rows = []
     contentView.tokensByRowId = [:]
     hasAppliedInitialRowIndex = false
     lastVisibleFileId = nil
     pendingScrollFileId = nil
     isProgrammaticScrollActive = false
     scrollView.setContentOffset(.zero, animated: false)
-    updateViewportFrame()
+    updateContentMetrics()
     applyInitialRowIndexIfNeeded()
   }
 

diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
--- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
+++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
@@ -185,7 +185,9 @@
 
     let cancelled = false;
     let frame: number | null = null;
+    let timer: ReturnType<typeof setTimeout> | null = null;
     let attempts = 0;
+    const MAX_ATTEMPTS = 12;
 
     const dispatch = () => {
       if (cancelled) {
@@ -195,15 +197,22 @@
       const view = nativeRef.current;
       const command = view?.[method];
       if (!view || !command) {
-        if (attempts < 4) {
+        if (attempts < MAX_ATTEMPTS) {
           attempts += 1;
-          frame = requestAnimationFrame(dispatch);
+          if (attempts <= 4) {
+            frame = requestAnimationFrame(dispatch);
+          } else {
+            const delay = Math.min(50 * 2 ** (attempts - 5), 1000);
+            timer = setTimeout(dispatch, delay);
+          }
+        } else {
+          console.error(`[native-review-diff] ${method} gave up after ${MAX_ATTEMPTS} attempts`);
         }
         return;
       }
 
       void command.call(view, payload).catch((error: unknown) => {
-        if (!cancelled && attempts < 4 && isPendingNativeViewRegistration(error)) {
+        if (!cancelled && attempts < MAX_ATTEMPTS && isPendingNativeViewRegistration(error)) {
           attempts += 1;
           frame = requestAnimationFrame(dispatch);
           return;
@@ -221,6 +230,9 @@
       if (frame !== null) {
         cancelAnimationFrame(frame);
       }
+      if (timer !== null) {
+        clearTimeout(timer);
+      }
     };
   }, [method, nativeRef, payload]);
 }

diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -223,6 +223,9 @@
             <ThreadNavigationSidebar
               width={layout.listPaneWidth}
               selectedThreadKey={selectedThreadKey}
+              selectedEnvironmentId={
+                environmentId !== null ? EnvironmentId.make(environmentId) : null
+              }
               onOpenSettings={handleOpenSettings}
               onSelectThread={handleSelectThread}
               onStartNewTask={handleStartNewTask}

diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -1,4 +1,5 @@
 import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import type { EnvironmentId } from "@t3tools/contracts";
 import { SymbolView } from "expo-symbols";
 import { memo, useCallback, useMemo, useRef, useState } from "react";
 import type { ColorValue } from "react-native";
@@ -107,6 +108,7 @@
 export function ThreadNavigationSidebar(props: {
   readonly width: number;
   readonly selectedThreadKey: string | null;
+  readonly selectedEnvironmentId?: EnvironmentId | null;
   readonly onOpenSettings: () => void;
   readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
   readonly onStartNewTask: () => void;
@@ -118,8 +120,14 @@
   const openSwipeableRef = useRef<SwipeableMethods | null>(null);
   const { archiveThread, confirmDeleteThread } = useThreadListActions();
   const groups = useMemo(
-    () => buildThreadNavigationGroups({ projects, threads, searchQuery }),
-    [projects, searchQuery, threads],
+    () =>
+      buildThreadNavigationGroups({
+        projects,
+        threads,
+        searchQuery,
+        environmentId: props.selectedEnvironmentId,
+      }),
+    [projects, props.selectedEnvironmentId, searchQuery, threads],
   );
 
   const backgroundColor = useThemeColor("--color-drawer");

diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -20,6 +20,7 @@
   buildThreadTerminalNavigation,
 } from "../../lib/routes";
 import { scopedThreadKey } from "../../lib/scopedEntities";
+import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
 import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
 import { connectionTone } from "../connection/connectionTone";
 import { nativeTopScrollEdgeEffect } from "../../lib/native-scroll-edge-effect";
@@ -382,9 +383,17 @@
       if (selectedThread === null) {
         return;
       }
-      router.push(buildThreadFilesNavigation(selectedThread, path));
+      const destination = buildThreadFilesNavigation(selectedThread, path);
+      const navigationAction = resolveFileSelectionNavigationAction({
+        hasPersistentFileInspector: fileInspector.supported,
+      });
+      if (navigationAction === "replace") {
+        router.replace(destination);
+        return;
+      }
+      router.push(destination);
     },
-    [router, selectedThread],
+    [fileInspector.supported, router, selectedThread],
   );
   const GitInspector = useCallback(
     () => <GitOverviewSheet headerInset={headerHeight} presentation="inspector" />,

diff --git a/apps/mobile/src/features/threads/thread-navigation-groups.ts b/apps/mobile/src/features/threads/thread-navigation-groups.ts
--- a/apps/mobile/src/features/threads/thread-navigation-groups.ts
+++ b/apps/mobile/src/features/threads/thread-navigation-groups.ts
@@ -2,6 +2,7 @@
   EnvironmentProject,
   EnvironmentThreadShell,
 } from "@t3tools/client-runtime/state/shell";
+import type { EnvironmentId } from "@t3tools/contracts";
 import * as Arr from "effect/Array";
 import * as Order from "effect/Order";
 
@@ -28,37 +29,49 @@
   readonly projects: ReadonlyArray<EnvironmentProject>;
   readonly threads: ReadonlyArray<EnvironmentThreadShell>;
   readonly searchQuery?: string;
+  readonly environmentId?: EnvironmentId | null;
 }): ReadonlyArray<ThreadNavigationGroup> {
   const query = input.searchQuery?.trim().toLocaleLowerCase() ?? "";
-  const activeThreads = input.threads.filter((thread) => thread.archivedAt === null);
+  const environmentId = input.environmentId ?? null;
+  const activeThreads = input.threads.filter(
+    (thread) =>
+      thread.archivedAt === null &&
+      (environmentId === null || thread.environmentId === environmentId),
+  );
+  const filteredProjects =
+    environmentId === null
+      ? input.projects
+      : input.projects.filter((project) => project.environmentId === environmentId);
 
-  return groupProjectsByRepository({ ...input, threads: activeThreads }).flatMap((group) => {
-    const threads = Arr.sort(
-      group.projects.flatMap((projectGroup) => projectGroup.threads),
-      threadActivityOrder,
-    );
-    const title = group.projects[0]?.project.title ?? group.title;
-    const groupMatches =
-      query.length === 0 ||
-      title.toLocaleLowerCase().includes(query) ||
-      group.title.toLocaleLowerCase().includes(query) ||
-      group.projects.some((projectGroup) =>
-        projectGroup.project.title.toLocaleLowerCase().includes(query),
+  return groupProjectsByRepository({ projects: filteredProjects, threads: activeThreads }).flatMap(
+    (group) => {
+      const threads = Arr.sort(
+        group.projects.flatMap((projectGroup) => projectGroup.threads),
+        threadActivityOrder,
       );
-    const matchingThreads = groupMatches
-      ? threads
-      : threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));
+      const title = group.projects[0]?.project.title ?? group.title;
+      const groupMatches =
+        query.length === 0 ||
+        title.toLocaleLowerCase().includes(query) ||
+        group.title.toLocaleLowerCase().includes(query) ||
+        group.projects.some((projectGroup) =>
+          projectGroup.project.title.toLocaleLowerCase().includes(query),
+        );
+      const matchingThreads = groupMatches
+        ? threads
+        : threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));
 
-    if (query.length > 0 && matchingThreads.length === 0) {
-      return [];
-    }
+      if (query.length > 0 && matchingThreads.length === 0) {
+        return [];
+      }
 
-    return [
-      {
-        key: group.key,
-        title,
-        threads: matchingThreads,
-      },
-    ];
-  });
+      return [
+        {
+          key: group.key,
+          title,
+          threads: matchingThreads,
+        },
+      ];
+    },
+  );
 }

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

Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Comment thread apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
) : null}
</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.

🟡 Medium threads/ThreadFeed.tsx:825

The renderFeedEntry function no longer handles the queued-message entry type, so queued outbox messages are dropped from the thread feed and users lose the pending bubble text/attachment preview. They can only see a count in the composer, hiding what is actually waiting to send. If this removal is intentional, consider documenting the rationale; otherwise restore the queued-message branch to preserve the pending message UI.

Also found in 3 other location(s)

apps/mobile/src/lib/threadActivity.ts:1253

buildThreadFeed no longer merges locally queued outbox messages into the feed, even though useThreadComposerState still enqueues them and ThreadDetailScreen.handleSendMessage() immediately anchors to the returned messageId. After sending, the just-composed user message is absent from selectedThreadFeed until the backend echoes it back, so the conversation can appear to drop the user's message and the post-send scroll-to-anchor never runs on slow/offline connections.

apps/mobile/src/state/use-thread-composer-state.ts:93

useThreadComposerState no longer passes queued outbox messages into buildThreadFeed, so a newly queued send disappears from the chat transcript until the backend later echoes it back. Before this change buildThreadFeed emitted queued-message entries and ThreadFeed rendered them; now pressing send clears the draft and only increments queueCount, leaving no optimistic message bubble for queued/offline sends.

apps/mobile/src/features/threads/ThreadRouteScreen.tsx:652

ThreadRouteContent passes composer.selectedThreadFeed into ThreadDetailScreen, but the refactored composer state no longer merges queued outbox messages into that feed. After onSendMessage queues a message and clears the draft, the new MessageId never appears in selectedThreadFeed until the server snapshot catches up, so newly sent/offline messages disappear from the conversation UI even though they are still queued for delivery.

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

The `renderFeedEntry` function no longer handles the `queued-message` entry type, so queued outbox messages are dropped from the thread feed and users lose the pending bubble text/attachment preview. They can only see a count in the composer, hiding what is actually waiting to send. If this removal is intentional, consider documenting the rationale; otherwise restore the `queued-message` branch to preserve the pending message UI.

Also found in 3 other location(s):
- apps/mobile/src/lib/threadActivity.ts:1253 -- `buildThreadFeed` no longer merges locally queued outbox messages into the feed, even though `useThreadComposerState` still enqueues them and `ThreadDetailScreen.handleSendMessage()` immediately anchors to the returned `messageId`. After sending, the just-composed user message is absent from `selectedThreadFeed` until the backend echoes it back, so the conversation can appear to drop the user's message and the post-send scroll-to-anchor never runs on slow/offline connections.
- apps/mobile/src/state/use-thread-composer-state.ts:93 -- `useThreadComposerState` no longer passes queued outbox messages into `buildThreadFeed`, so a newly queued send disappears from the chat transcript until the backend later echoes it back. Before this change `buildThreadFeed` emitted `queued-message` entries and `ThreadFeed` rendered them; now pressing send clears the draft and only increments `queueCount`, leaving no optimistic message bubble for queued/offline sends.
- apps/mobile/src/features/threads/ThreadRouteScreen.tsx:652 -- `ThreadRouteContent` passes `composer.selectedThreadFeed` into `ThreadDetailScreen`, but the refactored composer state no longer merges queued outbox messages into that feed. After `onSendMessage` queues a message and clears the draft, the new `MessageId` never appears in `selectedThreadFeed` until the server snapshot catches up, so newly sent/offline messages disappear from the conversation UI even though they are still queued for delivery.

Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High

When timelineEntries transitions from empty to non-empty after mount, the component stays scrolled at the top instead of jumping to the live edge. The removed previousRowCountRef effect handled this case in commit 33dadb5a; with only initialScrollAtEnd remaining, users see stale/blank content and the scroll-to-bottom pill shows wrong state until manual scroll.

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

When `timelineEntries` transitions from empty to non-empty after mount, the component stays scrolled at the top instead of jumping to the live edge. The removed `previousRowCountRef` effect handled this case in commit `33dadb5a`; with only `initialScrollAtEnd` remaining, users see stale/blank content and the scroll-to-bottom pill shows wrong state until manual scroll.

Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Comment thread apps/mobile/src/app/new/_layout.tsx
Comment thread apps/mobile/src/app/index.tsx
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
juliusmarminge and others added 6 commits June 26, 2026 17:08
- Enable adaptive sidebar navigation on wide mobile windows
- Keep compact single-pane behavior on phones
- Extract and test shared thread grouping and layout logic

Co-authored-by: codex <codex@users.noreply.github.com>
- Adapt iPad/mobile layout for sidebar, inspector, and sheets
- Move review diff payloads off Fabric props and add native scrolling
- Add iOS header button module for consistent toolbar actions

Co-authored-by: codex <codex@users.noreply.github.com>
- Keep sidebar threads swipeable with archive/delete actions
- Hide archived threads from navigation groups
- Stabilize review diff visibility and optimistic file selection

Co-authored-by: codex <codex@users.noreply.github.com>
- Stabilize thread selection routing in the adaptive workspace layout
- Extract and memoize thread sidebar rows to reduce unnecessary rerenders

Co-authored-by: codex <codex@users.noreply.github.com>
- add thread selection context for split-view routing
- switch thread feeds to automatic content insets on iOS glass headers
- add native scroll-edge effect helper and app domain config
@juliusmarminge juliusmarminge force-pushed the t3code/ipad-responsive-mobile-layout branch from 678ebfa to babbf90 Compare June 27, 2026 00:25

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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

Autofix Details

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

  • ✅ Fixed: Stale token patches after reset
    • Added tokensDecodeGeneration capture and check in setTokensPatchJson to reject patches decoded after a content reset has bumped the generation counter, matching the same guard pattern already used in setTokensJson.

Create PR

Or push these changes by commenting:

@cursor push 925701f905
Preview (925701f905)
diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
--- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
+++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
@@ -492,6 +492,8 @@
   }
 
   func setTokensPatchJson(_ tokensPatchJson: String) {
+    let generation = tokensDecodeGeneration
+
     payloadDecodeQueue.async { [weak self] in
       guard let data = tokensPatchJson.data(using: .utf8) else {
         return
@@ -500,7 +502,7 @@
       do {
         let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
         DispatchQueue.main.async { [weak self] in
-          guard let self else {
+          guard let self, generation == self.tokensDecodeGeneration else {
             return
           }
           // A highlighter request from the previous file can finish after the view has

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

Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Comment thread apps/mobile/plugins/withIosSceneLifecycle.cjs
Comment thread apps/mobile/src/app/_layout.tsx Outdated
Comment thread apps/mobile/plugins/withIosSceneLifecycle.cjs
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx Outdated
Comment thread apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts Outdated
juliusmarminge and others added 2 commits June 26, 2026 17:55
- Update Clerk packages across web, desktop, relay, and mobile
- Refresh lockfile entries for related Expo and native packages

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

  • ✅ Fixed: Cmd+F focuses sidebar not composer
    • Suppressed focusSearch key commands (Cmd+F, Cmd+K) in T3KeyboardCommandsView when a UITextField or UITextView is the first responder, preventing the sidebar search from stealing focus from active text inputs.
  • ✅ Fixed: Inspector width ignores sidebar
    • Added an optional sidebarWidth parameter to deriveFileInspectorPaneLayout and passed the sidebar width from both callers, so the inspector constrains its width against the space remaining after the sidebar rather than the full viewport.
  • ✅ Fixed: Keyboard files bypass inspector
    • Registered a files keyboard command handler in ThreadRouteContent that opens the inspector pane via showAuxiliaryPane when the file inspector is supported in split view, falling through to the default router.push behavior only when it is not.

Create PR

Or push these changes by commenting:

@cursor push 07a67ed044
Preview (07a67ed044)
diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift
--- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift
+++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift
@@ -21,10 +21,13 @@
   public override var canBecomeFirstResponder: Bool { true }
 
   public override var keyCommands: [UIKeyCommand]? {
-    [
+    let responder = window?.t3FirstResponder
+    let textInputActive = responder is UITextField || responder is UITextView
+
+    return [
       enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"),
-      enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"),
-      enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"),
+      textInputActive ? nil : enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"),
+      textInputActive ? nil : enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"),
       enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"),
       enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
       enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),

diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -131,6 +131,7 @@
         layout,
         viewportWidth: width,
         preferredWidth: fileInspectorPreferredWidth ?? undefined,
+        sidebarWidth: layout.listPaneWidth ?? 0,
       }),
     [fileInspectorPreferredWidth, layout, width],
   );

diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -60,6 +60,7 @@
   useAdaptiveWorkspaceLayout,
   useAdaptiveWorkspacePaneRole,
 } from "../layout/AdaptiveWorkspaceLayout";
+import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
 import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
 import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";
 import {
@@ -377,6 +378,14 @@
     }
     action.toggleAuxiliaryPane();
   }, []);
+  const handleFilesKeyboardCommand = useCallback(() => {
+    if (fileInspector.supported && selectedThreadCwd !== null) {
+      handleOpenFilesInspector();
+      return true;
+    }
+    return false;
+  }, [fileInspector.supported, handleOpenFilesInspector, selectedThreadCwd]);
+  useHardwareKeyboardCommand("files", handleFilesKeyboardCommand);
   const handleSelectInspectorFile = useCallback(
     (path: string) => {
       if (selectedThread === null) {

diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts
--- a/apps/mobile/src/lib/layout.test.ts
+++ b/apps/mobile/src/lib/layout.test.ts
@@ -164,7 +164,7 @@
       contentPaneWidth: 1_024,
       supportsAuxiliaryPane: true,
       auxiliaryPaneVisible: true,
-      auxiliaryPaneWidth: 287,
+      auxiliaryPaneWidth: 260,
     });
   });
 
@@ -185,7 +185,7 @@
       contentPaneWidth: 986,
       supportsAuxiliaryPane: true,
       auxiliaryPaneVisible: true,
-      auxiliaryPaneWidth: 320,
+      auxiliaryPaneWidth: 276,
     });
   });
 

diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts
--- a/apps/mobile/src/lib/layout.ts
+++ b/apps/mobile/src/lib/layout.ts
@@ -100,6 +100,7 @@
       layout: input.layout,
       viewportWidth,
       preferredWidth: input.auxiliaryPanePreferredWidth,
+      sidebarWidth: preferredPrimarySidebarWidth,
     });
     const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible;
     const primarySidebarSuppressedByAuxiliary =
@@ -151,10 +152,13 @@
   readonly layout: Layout;
   readonly viewportWidth: number;
   readonly preferredWidth?: number;
+  readonly sidebarWidth?: number;
 }): FileInspectorPaneLayout {
   const viewportWidth = Math.max(0, input.viewportWidth);
+  const sidebarWidth = input.sidebarWidth ?? 0;
   const supported =
     input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH;
+  const availableWidth = Math.max(0, viewportWidth - sidebarWidth);
 
   return {
     supported,
@@ -163,11 +167,11 @@
           preferredWidth:
             input.preferredWidth ??
             clamp(
-              Math.round(viewportWidth * 0.28),
+              Math.round(availableWidth * 0.28),
               AUXILIARY_PANE_MIN_WIDTH,
               AUXILIARY_PANE_DEFAULT_MAX_WIDTH,
             ),
-          availableWidth: viewportWidth,
+          availableWidth,
         })
       : null,
   };

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

Comment thread apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Comment thread apps/mobile/src/lib/layout.ts
Comment thread apps/mobile/src/features/layout/adaptive-inspector-layout.tsx
Comment thread apps/mobile/src/features/home/workspace-connection-status.ts
Comment thread apps/mobile/src/features/layout/workspace-pane-divider.tsx
Comment thread apps/mobile/src/lib/layout.ts
Comment thread apps/mobile/src/features/threads/sidebar-filter-button.ios.tsx Outdated
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated
Comment thread apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
<NativeTerminalSurfaceView
appearanceScheme={appearanceScheme}
backgroundColor={theme.background}
focusRequest={props.keyboardFocusRequest ?? 0}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High terminal/NativeTerminalSurface.tsx:218

On Android, focusRequest has no effect because T3TerminalModule does not declare a @ReactProp(name = "focusRequest") handler. The JS side increments keyboardFocusRequest and forwards it via focusRequest, but the native Android view ignores the prop, so once the soft keyboard is dismissed it cannot be reopened. If Android support for this flow is intended, the native module needs to handle focusRequest and call requestFocus()/show the soft input accordingly.

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

On Android, `focusRequest` has no effect because `T3TerminalModule` does not declare a `@ReactProp(name = "focusRequest")` handler. The JS side increments `keyboardFocusRequest` and forwards it via `focusRequest`, but the native Android view ignores the prop, so once the soft keyboard is dismissed it cannot be reopened. If Android support for this flow is intended, the native module needs to handle `focusRequest` and call `requestFocus()`/show the soft input accordingly.

Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Comment thread apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Comment thread apps/mobile/src/features/home/workspace-connection-status.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

There are 6 total unresolved issues (including 2 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Files shortcut no-op split view
    • Added a guard to only intercept the keyboard shortcut when auxiliaryPaneRole is already 'inspector', otherwise falling through to route navigation which properly initializes inspector content and role.
  • ✅ Fixed: Connection banner misleading label
    • Added a 'Connection error' label case in workspaceConnectionStatusLabel when state.connectionError is non-null, so partial failures don't show misleading 'Not connected' text.
  • ✅ Fixed: VoiceOver widens pane incorrectly
    • Removed the resizeDirection multiplier from accessibility increment/decrement actions so they always widen and narrow respectively regardless of divider position, matching the fixed labels.
  • ✅ Fixed: Header blur ignores scroll position
    • Added a listScrollOffsetRef to track actual scroll position and use it in reportInitialHeaderMaterialVisibility when available, preventing content size changes from incorrectly enabling header material when user is at the top.

Create PR

Or push these changes by commenting:

@cursor push b75ca20d39
Preview (b75ca20d39)
diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts
--- a/apps/mobile/src/features/home/workspace-connection-status.ts
+++ b/apps/mobile/src/features/home/workspace-connection-status.ts
@@ -17,5 +17,6 @@
   if (state.connectingEnvironments.length > 1) {
     return `Reconnecting ${state.connectingEnvironments.length} environments`;
   }
+  if (state.connectionError !== null) return "Connection error";
   return "Not connected";
 }

diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -199,9 +199,12 @@
     if (!layout.usesSplitView || !fileInspector.supported || !parseActiveThreadPath(pathname)) {
       return false;
     }
+    if (auxiliaryPaneRole !== "inspector") {
+      return false;
+    }
     showAuxiliaryPane("inspector");
     return true;
-  }, [fileInspector.supported, layout.usesSplitView, pathname, showAuxiliaryPane]);
+  }, [auxiliaryPaneRole, fileInspector.supported, layout.usesSplitView, pathname, showAuxiliaryPane]);
   useHardwareKeyboardCommand("files", handleOpenFilesCommand);
   const toggleAuxiliaryPane = useCallback(() => {
     if (auxiliaryPaneRole === "inspector") {

diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx
--- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx
+++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx
@@ -59,9 +59,9 @@
   const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
     props.onResizeStart?.();
     if (event.nativeEvent.actionName === "increment") {
-      props.onResizeBy(ACCESSIBILITY_RESIZE_STEP * props.resizeDirection);
+      props.onResizeBy(ACCESSIBILITY_RESIZE_STEP);
     } else if (event.nativeEvent.actionName === "decrement") {
-      props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP * props.resizeDirection);
+      props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP);
     }
     props.onResizeEnd?.();
   };

diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -1128,6 +1128,7 @@
   const foldSettleSecondFrameRef = useRef<number | null>(null);
   const disclosureAnchorKeyRef = useRef<string | null>(null);
   const headerMaterialVisibleRef = useRef(false);
+  const listScrollOffsetRef = useRef<number | null>(null);
   const listContentHeightRef = useRef(0);
   const listViewportHeightRef = useRef(0);
   const previousLatestTurnRef = useRef(props.latestTurn);
@@ -1230,11 +1231,16 @@
   );
   const handleScroll = useCallback(
     (event: NativeSyntheticEvent<NativeScrollEvent>) => {
+      listScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
       reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6);
     },
     [reportHeaderMaterialVisibility, topContentInset],
   );
   const reportInitialHeaderMaterialVisibility = useCallback(() => {
+    if (listScrollOffsetRef.current !== null) {
+      reportHeaderMaterialVisibility(listScrollOffsetRef.current + topContentInset > 6);
+      return;
+    }
     const topInsetContribution = props.usesAutomaticContentInsets ? topContentInset : 0;
     reportHeaderMaterialVisibility(
       listContentHeightRef.current - listViewportHeightRef.current + topInsetContribution > 6,
@@ -1265,6 +1271,7 @@
 
   useEffect(() => {
     listContentHeightRef.current = 0;
+    listScrollOffsetRef.current = null;
     reportHeaderMaterialVisibility(false);
   }, [props.threadId, reportHeaderMaterialVisibility]);

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

Comment thread apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Comment thread apps/mobile/src/features/home/workspace-connection-status.ts
Comment thread apps/mobile/src/features/layout/workspace-pane-divider.tsx Outdated
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 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: Send guard cleared mid-flight
    • Added a generation counter (sendGenerationRef) that increments on thread switch; the finally block in handleSend now only clears sendInFlightRef when the generation matches, preventing a stale finally from clearing the guard for a different thread's in-flight send.

Create PR

Or push these changes by commenting:

@cursor push 67763defc2
Preview (67763defc2)
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
@@ -210,6 +210,7 @@
   const [isFocused, setIsFocused] = useState(false);
   const wasExpandedBeforePreviewRef = useRef(false);
   const sendInFlightRef = useRef(false);
+  const sendGenerationRef = useRef(0);
   const { onExpandedChange } = props;
 
   const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
@@ -218,6 +219,7 @@
   const canSend = hasContent;
 
   useEffect(() => {
+    sendGenerationRef.current++;
     sendInFlightRef.current = false;
   }, [props.selectedThread.id]);
 
@@ -456,10 +458,13 @@
   const handleSend = useCallback(async () => {
     if (!canSend || sendInFlightRef.current) return;
     sendInFlightRef.current = true;
+    const generation = sendGenerationRef.current;
     try {
       await onSendMessage();
     } finally {
-      sendInFlightRef.current = false;
+      if (sendGenerationRef.current === generation) {
+        sendInFlightRef.current = false;
+      }
     }
   }, [canSend, onSendMessage]);
   const handleCommandSelect = useCallback(

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

Comment thread apps/mobile/src/features/threads/ThreadComposer.tsx Outdated
Comment thread apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
renderInspector={renderInspector}
/>
);
}

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.

Split file route hides preview

High Severity

Moving the fileInspector.supported check to occur earlier causes ThreadRouteScreen to return prematurely. This prevents file content from rendering in the main pane and bypasses crucial checks for cwd or invalid paths, leading to an empty inspector and chat instead of the expected file view or error state.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5efa2df. Configure here.

Comment thread apps/mobile/src/app/_layout.tsx
Comment thread apps/mobile/src/app/new/_layout.tsx
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx Outdated
Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Comment thread apps/mobile/src/features/threads/ThreadComposer.tsx Outdated
Comment thread apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx Outdated
Comment thread apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Comment thread apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Comment thread apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

There are 7 total unresolved issues (including 5 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Stale scroll size after reset
    • Added updateContentMetrics() call in setContentResetKey to sync scrollView.contentSize with the cleared rows, replacing the insufficient updateViewportFrame() call.
  • ✅ Fixed: Files shortcut drops open file
    • Restored handleOpenFilesCommand to call showAuxiliaryPane("inspector") instead of router.replace to the files index route, preserving the current file and properly toggling the inspector pane.

Create PR

Or push these changes by commenting:

@cursor push 4a3f5a2cac
Preview (4a3f5a2cac)
diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
--- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
+++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
@@ -565,7 +565,7 @@
     pendingScrollFileId = nil
     isProgrammaticScrollActive = false
     scrollView.setContentOffset(.zero, animated: false)
-    updateViewportFrame()
+    updateContentMetrics()
     applyInitialRowIndexIfNeeded()
   }
 

diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -30,7 +30,7 @@
   type WorkspacePaneLayout,
 } from "../../lib/layout";
 import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation";
-import { buildThreadFilesNavigation, buildThreadRoutePath } from "../../lib/routes";
+import { buildThreadRoutePath } from "../../lib/routes";
 import { scopedThreadKey } from "../../lib/scopedEntities";
 import {
   parseActiveThreadPath,
@@ -204,13 +204,12 @@
     setSupplementaryPanePreferredVisible(true);
   }, []);
   const handleOpenFilesCommand = useCallback(() => {
-    const activeThread = parseActiveThreadPath(pathname);
-    if (!layout.usesSplitView || !fileInspector.supported || activeThread === null) {
+    if (!layout.usesSplitView || !fileInspector.supported || !parseActiveThreadPath(pathname)) {
       return false;
     }
-    router.replace(buildThreadFilesNavigation(activeThread));
+    showAuxiliaryPane("inspector");
     return true;
-  }, [fileInspector.supported, layout.usesSplitView, pathname, router]);
+  }, [fileInspector.supported, layout.usesSplitView, pathname, showAuxiliaryPane]);
   useHardwareKeyboardCommand("files", handleOpenFilesCommand);
   const toggleAuxiliaryPane = useCallback(() => {
     if (auxiliaryPaneRole === "inspector") {

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

Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Comment thread apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx Outdated
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread apps/mobile/src/features/threads/ThreadComposer.tsx Outdated
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx Outdated
Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 6 total unresolved issues (including 5 from previous reviews).

Fix All in Cursor

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

  • ✅ Fixed: Inspector reopens after cwd returns
    • Added a ref guard (inspectorShownRef) so showAuxiliaryPane("inspector") is only called once per component mount, preventing the inspector from reopening when cwd transitions from null back to a resolved path.

Create PR

Or push these changes by commenting:

@cursor push 8d02ecb7a7
Preview (8d02ecb7a7)
diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
--- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
+++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
@@ -536,8 +536,10 @@
     () => <FilesHeaderTitle projectName={projectName} />,
     [projectName],
   );
+  const inspectorShownRef = useRef(false);
   useEffect(() => {
-    if (fileInspector.supported && cwd !== null) {
+    if (fileInspector.supported && cwd !== null && !inspectorShownRef.current) {
+      inspectorShownRef.current = true;
       showAuxiliaryPane("inspector");
     }
   }, [cwd, fileInspector.supported, showAuxiliaryPane]);

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

Reviewed by Cursor Bugbot for commit 80208bc. Configure here.

Comment thread apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant