Add adaptive split-view layout for iPad/mobile workspace#3514
Add adaptive split-view layout for iPad/mobile workspace#3514juliusmarminge wants to merge 21 commits into
Conversation
|
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 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. |
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.
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).
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.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.
fcaaab4 to
678ebfa
Compare
There was a problem hiding this comment.
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.
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.
| ) : null} | ||
| </View> | ||
| ); | ||
| } |
There was a problem hiding this comment.
🟡 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
buildThreadFeedno longer merges locally queued outbox messages into the feed, even thoughuseThreadComposerStatestill enqueues them andThreadDetailScreen.handleSendMessage()immediately anchors to the returnedmessageId. After sending, the just-composed user message is absent fromselectedThreadFeeduntil 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
useThreadComposerStateno longer passes queued outbox messages intobuildThreadFeed, so a newly queued send disappears from the chat transcript until the backend later echoes it back. Before this changebuildThreadFeedemittedqueued-messageentries andThreadFeedrendered them; now pressing send clears the draft and only incrementsqueueCount, leaving no optimistic message bubble for queued/offline sends.
apps/mobile/src/features/threads/ThreadRouteScreen.tsx:652
ThreadRouteContentpassescomposer.selectedThreadFeedintoThreadDetailScreen, but the refactored composer state no longer merges queued outbox messages into that feed. AfteronSendMessagequeues a message and clears the draft, the newMessageIdnever appears inselectedThreadFeeduntil 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.
There was a problem hiding this comment.
🟠 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.
- 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
678ebfa to
babbf90
Compare
There was a problem hiding this comment.
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.
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 hasYou can send follow-ups to the cloud agent here.
- Update Clerk packages across web, desktop, relay, and mobile - Refresh lockfile entries for related Expo and native packages
There was a problem hiding this comment.
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.
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.
| <NativeTerminalSurfaceView | ||
| appearanceScheme={appearanceScheme} | ||
| backgroundColor={theme.background} | ||
| focusRequest={props.keyboardFocusRequest ?? 0} |
There was a problem hiding this comment.
🟠 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.
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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.
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.
| renderInspector={renderInspector} | ||
| /> | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 5efa2df. Configure here.
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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).
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.
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.



Summary
Testing
vp checkvp run typecheckvp testNote
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-controlsmodule exposes global shortcuts (new task, search, back, files/terminal/review, toggle sidebar) viaHardwareKeyboardCommandProvider; composer adds ⌘↵ send.Native review diff: Large
rows/tokensJSON 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, andcontentResetKeyfor navigator sync without flashing.iOS build/runtime:
associatedDomainsfor Clerk, UISceneSceneDelegateplugin, 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
AdaptiveWorkspaceLayoutcontext and layout utilities inlayout.tsthat derive split-view eligibility, sidebar widths, and auxiliary/inspector pane sizing from viewport dimensions.ThreadNavigationSidebarfor split-view thread browsing with swipe actions, andWorkspaceEmptyDetailfor the empty detail pane when no thread is selected.ThreadRouteScreenandThreadDetailScreento support inspector panes (files/git), a glass/blur iOS header, centered content with max width, and suppressed drawer/transition gestures in split view.AdaptiveInspectorLayoutwith animated show/hide and aWorkspacePaneDividerfor pointer/touch/accessibility resizing.T3KeyboardCommandsModule(native) andHardwareKeyboardCommandProviderto handle hardware keyboard shortcuts (new task, focus search, back, tab switching) across iOS.T3ReviewDiffViewto a background queue with generation tracking, adds programmatic scroll-to-file/top APIs, and emitsonVisibleFileChange; the review sheet gains a file navigator inspector pane.preloadWorkspaceFileContentsandprepareSourceFileDocumentfor file content preloading and cached row serialization to reduce perceived latency.withIosSceneLifecycleconfig plugin enabling UIScene lifecycle with aSceneDelegate, andwithIosCocoaPodsUuidCacheto fix UUID reuse during cached pod installs.Macroscope summarized b83af8a.