Add adaptive split-view layout for iPad/mobile workspace#3514
Add adaptive split-view layout for iPad/mobile workspace#3514juliusmarminge wants to merge 4 commits into
Conversation
- Enable adaptive sidebar navigation on wide mobile windows - Keep compact single-pane behavior on phones - Extract and test shared thread grouping and layout logic
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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)inbuildThreadNavigationGroupsbefore sorting, matching the same filtering logic used bybuildHomeThreadGroups.
- Added
- ✅ Fixed: Split feed uses window width
- Changed
viewportWidthinitial state to0whenlayoutVariant === "split"so the first render doesn't use the full window width, and addedviewportWidthto LegendList'sextraDataso rows repaint afteronLayoutcorrects it.
- Changed
- ✅ Fixed: Split view hides archive
- Added
useThreadListActionshook and a long-press handler with Archive/Delete options toThreadNavigationSidebarthread rows, restoring thread management actions in split view.
- Added
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.
ApprovabilityVerdict: Needs human review This PR introduces a major new feature (adaptive split-view layout for iPad/mobile) with new native iOS modules, new layout system, and significant navigation changes. The scope and introduction of new user-facing capabilities warrants human review, especially given the 4 unresolved medium-severity bug findings. You can customize Macroscope's approvability policy. Learn more. |
- 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
There was a problem hiding this comment.
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 = [:]insetContentResetKeyto clear token state when content resets, matching the pattern already used bysetTokensResetKey.
- Added
- ✅ Fixed: All files selection reverts
- Added an
isAllFilesSelectedRefthat suppressesonVisibleFileChangescroll-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.
- Added an
- ✅ Fixed: Thread feed reveals before scroll
- Added
setRevealedThreadId(null)to thethreadIdchange effect so returning to a previously revealed thread correctly hides the feed until the scroll-to-end sequence completes.
- Added
- ✅ Fixed: File tree stuck selection highlight
- Added
setPendingSelection(null)whencontrolledSelectedPathchanges and a 1-second timeout fallback inhandleSelectFileto clear optimistic state when the controlled path never catches up.
- Added
- ✅ Fixed: Native payload retry mismatch
- Changed the hardcoded
'T3ReviewDiffView'string inisPendingNativeViewRegistrationto use theNATIVE_REVIEW_DIFF_MODULE_NAMEconstant ('T3ReviewDiffSurface') so the retry path correctly matches the registered module name.
- Changed the hardcoded
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.
| initialScrollReadyRef.current = false; | ||
| isNearEndRef.current = true; | ||
| lastContentHeightRef.current = 0; | ||
| }, [props.threadId]); |
There was a problem hiding this comment.
Thread feed reveals before scroll
Medium Severity
The threadId effect clears scroll bookkeeping but not revealedThreadId. If the user returns to a thread whose id already matches revealedThreadId, the list renders at full opacity before onListLoad runs its scroll-to-end sequence, so the chat can briefly appear at the wrong scroll offset.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 7a04404. Configure here.
- Keep sidebar threads swipeable with archive/delete actions - Hide archived threads from navigation groups - Stabilize review diff visibility and optimistic file selection
There was a problem hiding this comment.
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).
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.currenthas caught up to the tapped path before clearing pending selection, preventing the highlight from reverting during slow navigation.
- The timeout handler now checks whether
- ✅ Fixed: Archive hides active thread
- Added an
onCompletedcallback touseThreadListActionsand anonThreadRemovedprop to the sidebar that triggersrouter.back()when the currently-selected thread is archived or deleted.
- Added an
- ✅ Fixed: Review navigator stale after reset
- Added an explicit
onVisibleFileChangeemission withNSNull()insetContentResetKeyso React always receives the 'All files' sync after a content reset, regardless of thelastVisibleFileIdguard.
- Added an explicit
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.
Reviewed by Cursor Bugbot for commit ee9b1af. Configure here.
| pendingSelectionTimeoutRef.current = setTimeout(() => { | ||
| pendingSelectionTimeoutRef.current = null; | ||
| setPendingSelection((current) => (current?.path === path ? null : current)); | ||
| }, OPTIMISTIC_SELECTION_TIMEOUT_MS); |
There was a problem hiding this comment.
Selection timeout reverts highlight
Medium Severity
The optimistic file selection clears after 1 second, even if the parent's selectedPath prop hasn't updated. This can briefly show the wrong or previous selection during slow navigation, negating the immediate tap feedback.
Reviewed by Cursor Bugbot for commit ee9b1af. Configure here.
| icon: "archivebox", | ||
| label: "Archive", | ||
| onPress: () => archiveThread(thread), | ||
| }} |
There was a problem hiding this comment.
Archive hides active thread
Medium Severity
Archiving or deleting a thread from the split-view sidebar removes it from the list, but the main detail pane's route doesn't update. This can leave the main pane displaying a thread no longer present in the sidebar.
Reviewed by Cursor Bugbot for commit ee9b1af. Configure here.
| } | ||
| lastVisibleFileId = nil | ||
| onVisibleFileChange(["fileId": NSNull()]) | ||
| return |
There was a problem hiding this comment.
Review navigator stale after reset
Medium Severity
After a contentResetKey change scrolls the diff to the top, native code clears lastVisibleFileId but skips onVisibleFileChange when offset is near zero because lastVisibleFileId is already nil. If the review file navigator still holds a prior file for the same section, its highlight can disagree with the reset scroll position until the user scrolls again.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit ee9b1af. Configure here.
- Stabilize thread selection routing in the adaptive workspace layout - Extract and memoize thread sidebar rows to reduce unnecessary rerenders



Summary
Testing
vp checkvp run typecheckvp testNote
Medium Risk
Large cross-cutting navigation and native bridge changes affect thread/file/review flows on all form factors; orientation default enables phone landscape, which is a visible behavior change.
Overview
Adds an adaptive workspace shell that turns on split view when the window is wide/tall enough: a persistent thread sidebar (search, grouping, swipe actions), a detail area, and optional inspector columns for files, git, and review changed-files. Root navigation, sheets, and thread routes now branch on
usesSplitView(e.g. settings as card vs sheet, no slide animation between threads, empty “select a thread” home). Orientation moves from portrait-only to default so tablets can use landscape split layout.Review and files gain side-by-side navigators (
AdaptiveInspectorLayout), scroll sync between native diff and file list (onVisibleFileChange,scrollToFile/scrollToTop), diff section toolbar restructuring, and idle prewarming plus caching for parsed diff / native row models and source file documents. File tree adds preview-on-press-in preloads, optimistic selection, and list windowing; file routes usesetParams/ replace where split view keeps one screen mounted.Native review diff stops sending huge
rowsJson/ token props through Fabric; payloads go through async imperative calls with off-main decode, generation guards,contentResetKey, and stale-token-patch rejection. Newt3-native-controlsExpo module exposes UIKit header buttons for the sidebar chrome.Thread feed in split layout caps chat width, measures pane width for layout, and defers list reveal until scroll-to-end settles to avoid jumpy initial scroll.
Reviewed by Cursor Bugbot for commit fcaaab4. 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 navigation, and orientation support
AdaptiveWorkspaceLayoutand related hooks/components to drive a responsive two-pane layout on iPad, gating split view on both width ≥ 720 and height ≥ 600.ThreadNavigationSidebarwith search, grouping by repository/project, and swipe-to-archive/delete; refactors drawer to use sharedbuildThreadNavigationGroupsutility that excludes archived threads.ThreadRouteScreenandReviewSheetwith an auxiliary inspector pane (Files/Git) viaAdaptiveInspectorLayout; inspector visibility animates with width collapse, opacity, and translation.T3ReviewDiffView,NativeReviewDiffView) to deliver rows/tokens via async imperative calls with background decoding, generation guards, and retry logic, replacing Fabric props; addsonVisibleFileChangeevent and programmatic scroll.preloadWorkspaceFileContentsandprepareSourceFileDocumentto warm file content and syntax highlighting caches ahead of selection.T3NativeControlsExpo module exposing a native iOS header button (T3HeaderButtonView) using SF Symbols.app.config.tsorientation fromportraittodefault, enabling landscape on iPad.useNativeReviewDiffPayloadmay silently drop updates if all 4 retries fail.Macroscope summarized fcaaab4.