Skip to content

Improve iPad mobile navigation and offline caching#3794

Closed
juliusmarminge wants to merge 71 commits into
mainfrom
t3code/ipad-responsive-mobile-layout
Closed

Improve iPad mobile navigation and offline caching#3794
juliusmarminge wants to merge 71 commits into
mainfrom
t3code/ipad-responsive-mobile-layout

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jul 8, 2026

Copy link
Copy Markdown
Member

Summary

  • Replace Expo Router with typed native-stack navigation and adaptive iPad split-view workspace behavior.
  • Refine mobile headers, toolbar/search controls, inspector routing, and empty-connection actions.
  • Persist the last server configuration and unfiltered Git refs on mobile and web so offline task drafts can still select models and branches.

Why

The mobile navigation stack needed a native, predictable structure for iPad and iPhone layouts. Separately, cached project snapshots were insufficient for offline pending tasks because models and Git branches were only available from live server requests.

Impact

The mobile app has native-stack navigation and iPad-aware workspace surfaces. Offline users retain the last known provider/model and branch state; live server data replaces cached values after reconnecting, and environment removal clears the cache.

Validation

  • vp check
  • vp run typecheck
  • vp run lint:mobile
  • vp test packages/client-runtime/src/state/server.test.ts packages/client-runtime/src/state/vcs.test.ts

juliusmarminge and others added 30 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
- Update Clerk packages across web, desktop, relay, and mobile
- Refresh lockfile entries for related Expo and native packages
@coderabbitai

coderabbitai Bot commented Jul 8, 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: 309aec54-af30-439f-b1d8-12000fb1831b

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:XXL 1,000+ changed lines (additions + deletions). labels Jul 8, 2026
@juliusmarminge juliusmarminge changed the title Improve mobile settings navigation and cached connection state Improve iPad mobile navigation and offline caching Jul 8, 2026
@juliusmarminge

Copy link
Copy Markdown
Member Author

Superseded by #3795, which is rebased on current main and contains only the offline-cache change.

<NativeTerminalSurfaceView
appearanceScheme={appearanceScheme}
backgroundColor={theme.background}
focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 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:221

On Android native terminals, incrementing keyboardFocusRequest no longer reopens the keyboard after the user dismisses it. This PR replaced the hidden React TextInput focus workaround with a focusRequest prop on NativeTerminalSurfaceView, but the Android native module (T3TerminalModule.kt) does not export a focusRequest prop, so the prop is silently ignored. After the keyboard is dismissed, the floating "Show keyboard" button increments keyboardFocusRequest, which now has no effect — terminal input is unavailable until the user finds another focus path. Add a Prop("focusRequest") handler to the Android native module that focuses the terminal surface, or restore the hidden TextInput fallback for platforms that lack native focusRequest support.

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

On Android native terminals, incrementing `keyboardFocusRequest` no longer reopens the keyboard after the user dismisses it. This PR replaced the hidden React `TextInput` focus workaround with a `focusRequest` prop on `NativeTerminalSurfaceView`, but the Android native module (`T3TerminalModule.kt`) does not export a `focusRequest` prop, so the prop is silently ignored. After the keyboard is dismissed, the floating "Show keyboard" button increments `keyboardFocusRequest`, which now has no effect — terminal input is unavailable until the user finds another focus path. Add a `Prop("focusRequest")` handler to the Android native module that focuses the terminal surface, or restore the hidden `TextInput` fallback for platforms that lack native `focusRequest` support.

headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right')
: undefined;
+ const preparedHeaderCenterBarButtonItems =

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 patches/react-native-screens@4.25.2.patch:1357

headerCenterBarButtonItems and headerToolbarItems are prepared with side hard-coded to 'right', so prepareMenu derives buttonId/menuId values using the same side-plus-index scheme as headerRightBarButtonItems. When a screen has both real right items and center or toolbar items at the same index, the IDs collide, causing presses and menu selections to be dispatched to the wrong item callback. Use distinct side values (e.g. 'center' and 'toolbar') for the two new prepareHeaderBarButtonItems calls so the generated IDs don't overlap with right or left items.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @patches/react-native-screens@4.25.2.patch around line 1357:

`headerCenterBarButtonItems` and `headerToolbarItems` are prepared with `side` hard-coded to `'right'`, so `prepareMenu` derives `buttonId`/`menuId` values using the same `side`-plus-index scheme as `headerRightBarButtonItems`. When a screen has both real right items and center or toolbar items at the same index, the IDs collide, causing presses and menu selections to be dispatched to the wrong item callback. Use distinct `side` values (e.g. `'center'` and `'toolbar'`) for the two new `prepareHeaderBarButtonItems` calls so the generated IDs don't overlap with right or left items.

return <OpeningThreadLoadingScreen />;
}

if (selectedThread !== null && selectedThreadKey === routeThreadKey) {

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/ThreadRouteScreen.tsx:192

ThreadRouteScreen renders ThreadUnavailableScreen for a valid thread that is still synchronizing, because the early-return that checks selectedThread !== null && selectedThreadKey === routeThreadKey is skipped when useThreadSelection() returns null during hydration. The code then falls through to the stillHydrating branch, which only checks connection state and ignores useSelectedThreadDetailState(), so a thread that loads a moment later is shown as unavailable instead of showing the loading screen. Consider falling back to the loading screen when the route references a known environment/thread but selectedThread is still null, rather than treating that state as a hydration failure.

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

`ThreadRouteScreen` renders `ThreadUnavailableScreen` for a valid thread that is still synchronizing, because the early-return that checks `selectedThread !== null && selectedThreadKey === routeThreadKey` is skipped when `useThreadSelection()` returns `null` during hydration. The code then falls through to the `stillHydrating` branch, which only checks connection state and ignores `useSelectedThreadDetailState()`, so a thread that loads a moment later is shown as unavailable instead of showing the loading screen. Consider falling back to the loading screen when the route references a known environment/thread but `selectedThread` is still `null`, rather than treating that state as a hydration failure.

Comment on lines +830 to +835
guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else {
if !rows.isEmpty {
pendingScrollFileId = nil
}
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium ios/T3ReviewDiffView.swift:830

applyPendingScrollIfNeeded silently drops a queued scrollToFile request when the requested fileId is not present in the current rows and rows is non-empty. During a diff switch, the JS inspector updates immediately to the new file list while native rows still reflect the previous diff until setRowsJson finishes decoding. If the user taps a file in that window, fileHeaderOffset(forFileId:) returns nil, so pendingScrollFileId is cleared — and when the new rows arrive, the queued scroll is gone and the navigation never happens. Consider keeping the pending request until the new rows match (or until an explicit reset clears it) instead of clearing it just because the current rows are non-empty.

    guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else {
-      if !rows.isEmpty {
-        pendingScrollFileId = nil
-      }
      return
-    }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift around lines 830-835:

`applyPendingScrollIfNeeded` silently drops a queued `scrollToFile` request when the requested `fileId` is not present in the current `rows` and `rows` is non-empty. During a diff switch, the JS inspector updates immediately to the new file list while native `rows` still reflect the previous diff until `setRowsJson` finishes decoding. If the user taps a file in that window, `fileHeaderOffset(forFileId:)` returns `nil`, so `pendingScrollFileId` is cleared — and when the new rows arrive, the queued scroll is gone and the navigation never happens. Consider keeping the pending request until the new rows match (or until an explicit reset clears it) instead of clearing it just because the current rows are non-empty.

Comment on lines +61 to +65
if (event.nativeEvent.actionName === "increment") {
props.onResizeBy(ACCESSIBILITY_RESIZE_STEP);
} else if (event.nativeEvent.actionName === "decrement") {
props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP);
}

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 layout/workspace-pane-divider.tsx:61

handleAccessibilityAction ignores the resizeDirection prop, so accessibility "increment" and "decrement" actions move the pane in the wrong direction when resizeDirection is -1. The only caller passes resizeDirection={-1}, meaning VoiceOver/keyboard users who activate "Make pane wider" actually shrink the pane and vice versa. The drag gesture multiplies by resizeDirection, but the accessibility handler does not. Consider applying props.resizeDirection in handleAccessibilityAction the same way handleResize does.

Suggested change
if (event.nativeEvent.actionName === "increment") {
props.onResizeBy(ACCESSIBILITY_RESIZE_STEP);
} else if (event.nativeEvent.actionName === "decrement") {
props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP);
}
if (event.nativeEvent.actionName === "increment") {
props.onResizeBy(ACCESSIBILITY_RESIZE_STEP * props.resizeDirection);
} else if (event.nativeEvent.actionName === "decrement") {
props.onResizeBy(-ACCESSIBILITY_RESIZE_STEP * props.resizeDirection);
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/layout/workspace-pane-divider.tsx around lines 61-65:

`handleAccessibilityAction` ignores the `resizeDirection` prop, so accessibility "increment" and "decrement" actions move the pane in the wrong direction when `resizeDirection` is `-1`. The only caller passes `resizeDirection={-1}`, meaning VoiceOver/keyboard users who activate "Make pane wider" actually shrink the pane and vice versa. The drag gesture multiplies by `resizeDirection`, but the accessibility handler does not. Consider applying `props.resizeDirection` in `handleAccessibilityAction` the same way `handleResize` does.

readonly threads: ReadonlyArray<EnvironmentThreadShell>;
}

const threadActivityOrder = Order.mapInput(

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/thread-navigation-groups.ts:16

threadActivityOrder sorts threads by new Date(thread.updatedAt ?? thread.createdAt).getTime(), but the canonical thread sorting elsewhere uses the latest user-message timestamp for recency. When a thread has an older updatedAt but a newer latest user message, this ordering places it below older conversations, so the navigation drawer displays threads in the wrong recency order. Consider deriving the sort key from the same latest-user-message timestamp used by the canonical sort, or document why updatedAt is intentionally used here.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/thread-navigation-groups.ts around line 16:

`threadActivityOrder` sorts threads by `new Date(thread.updatedAt ?? thread.createdAt).getTime()`, but the canonical thread sorting elsewhere uses the latest user-message timestamp for recency. When a thread has an older `updatedAt` but a newer latest user message, this ordering places it below older conversations, so the navigation drawer displays threads in the wrong recency order. Consider deriving the sort key from the same latest-user-message timestamp used by the canonical sort, or document why `updatedAt` is intentionally used here.

const hasTerminalDetailState =
selectedThreadDetailState.status === "deleted" ||
Option.isSome(selectedThreadDetailState.error);

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 threads/ThreadRouteScreen.tsx:187

Opening a thread on iOS crashes immediately with Couldn't find the header height. Are you inside a screen in a navigator with a header? because ThreadDetailScreen calls useHeaderHeight() unconditionally, but the new usesNativeHeaderGlass branch renders inside a standalone ScreenStack with the outer native-stack headerShown: false. There is no HeaderHeightContext provider above ThreadDetailScreen on this path, so the hook throws. Consider guarding the useHeaderHeight() call (falling back to 0 or insets.top when usesAutomaticContentInsets is false) so the screen can render outside a React Navigation header context.

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

Opening a thread on iOS crashes immediately with `Couldn't find the header height. Are you inside a screen in a navigator with a header?` because `ThreadDetailScreen` calls `useHeaderHeight()` unconditionally, but the new `usesNativeHeaderGlass` branch renders inside a standalone `ScreenStack` with the outer native-stack `headerShown: false`. There is no `HeaderHeightContext` provider above `ThreadDetailScreen` on this path, so the hook throws. Consider guarding the `useHeaderHeight()` call (falling back to `0` or `insets.top` when `usesAutomaticContentInsets` is `false`) so the screen can render outside a React Navigation header context.

);
});

yield* Stream.concat(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium state/vcs.ts:85

makeCachedVcsRefsState swallows every vcsListRefs refresh failure in the Stream.runForEach handler and leaves state unchanged. For uncached requests (any searched or paginated branch list), state starts as Option.none(), and cachedVcsRefsChanges filters out None via Result.failVoid, so the stream never emits anything. A failed branch-list RPC therefore leaves the atom permanently stuck with no error surfaced to callers — the previous query-based implementation returned an AsyncResult failure that UI code could show. Consider propagating refresh failures so callers can observe and display them.

Also found in 1 other location(s)

apps/mobile/src/App.tsx:21

App ignores the error value from useFonts and only checks fontsLoaded. Expo's font hook returns [loaded, error]; on a font load failure loaded stays false while error is set. In that case line 34 keeps rendering LoadingScreen forever, so the app never reaches RootNavigator and startup is stuck until restart.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/vcs.ts around line 85:

`makeCachedVcsRefsState` swallows every `vcsListRefs` refresh failure in the `Stream.runForEach` handler and leaves `state` unchanged. For uncached requests (any searched or paginated branch list), `state` starts as `Option.none()`, and `cachedVcsRefsChanges` filters out `None` via `Result.failVoid`, so the stream never emits anything. A failed branch-list RPC therefore leaves the atom permanently stuck with no error surfaced to callers — the previous query-based implementation returned an `AsyncResult` failure that UI code could show. Consider propagating refresh failures so callers can observe and display them.

Also found in 1 other location(s):
- apps/mobile/src/App.tsx:21 -- `App` ignores the error value from `useFonts` and only checks `fontsLoaded`. Expo's font hook returns `[loaded, error]`; on a font load failure `loaded` stays false while `error` is set. In that case line 34 keeps rendering `LoadingScreen` forever, so the app never reaches `RootNavigator` and startup is stuck until restart.

};
}

export function AppNavigationProvider(props: { readonly children: ReactNode }) {

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 navigation/app-navigation.tsx:157

When the app opens via a deep link to /settings/auth or /settings/archive, useCurrentPathname() returns "/" on the first render because pathname is initialized to "/" and syncState only runs in onReady/onStateChange, which fire after the navigation tree has already mounted. This means one-time consumers of useCurrentPathname() read the stale root value: ClerkSettingsSheetDetentProvider captures the initial pathname in local state, so it mounts with initiallyExpanded=false and never expands for those routes. syncState needs to compute the pathname synchronously before the first render (e.g. derive it from the initial navigation state) rather than waiting for the onReady callback.

Also found in 1 other location(s)

apps/mobile/src/navigation/RootNavigator.tsx:145

ClerkSettingsSheetDetentProvider only reads initiallyExpanded once in its useState initializer, but RootNavigator computes that prop from pathname before the navigation container has synced the real initial route. AppNavigationProvider starts pathname as &#34;/&#34;, so on a cold launch or deep link to /settings/auth this provider mounts with false and never updates. As a result the auth settings sheet opens at the collapsed detent instead of the intended expanded one.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/navigation/app-navigation.tsx around line 157:

When the app opens via a deep link to `/settings/auth` or `/settings/archive`, `useCurrentPathname()` returns `"/"` on the first render because `pathname` is initialized to `"/"` and `syncState` only runs in `onReady`/`onStateChange`, which fire after the navigation tree has already mounted. This means one-time consumers of `useCurrentPathname()` read the stale root value: `ClerkSettingsSheetDetentProvider` captures the initial pathname in local state, so it mounts with `initiallyExpanded=false` and never expands for those routes. `syncState` needs to compute the pathname synchronously before the first render (e.g. derive it from the initial navigation state) rather than waiting for the `onReady` callback.

Also found in 1 other location(s):
- apps/mobile/src/navigation/RootNavigator.tsx:145 -- `ClerkSettingsSheetDetentProvider` only reads `initiallyExpanded` once in its `useState` initializer, but `RootNavigator` computes that prop from `pathname` before the navigation container has synced the real initial route. `AppNavigationProvider` starts `pathname` as `"/"`, so on a cold launch or deep link to `/settings/auth` this provider mounts with `false` and never updates. As a result the auth settings sheet opens at the collapsed detent instead of the intended expanded one.

);
}

export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems {

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/ThreadGitControls.tsx:397

useThreadGitHeaderActionItems never surfaces props.auxiliaryPaneControl, so when ThreadGitControls's inline toolbar is replaced by native header items on compact screens, there is no toggle/close button for the inspector pane. ThreadGitControls still renders an auxiliary-pane toggle button, but the native-header path served by useThreadGitRightHeaderItems/useThreadGitCenterHeaderItems omits it, regressing the compact inspector workflow. Consider including the auxiliaryPaneControl as a header item in useThreadGitHeaderActionItems (e.g., inserting it at the start of useThreadGitRightHeaderItems) so the toggle remains reachable.

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

`useThreadGitHeaderActionItems` never surfaces `props.auxiliaryPaneControl`, so when `ThreadGitControls`'s inline toolbar is replaced by native header items on compact screens, there is no toggle/close button for the inspector pane. `ThreadGitControls` still renders an auxiliary-pane toggle button, but the native-header path served by `useThreadGitRightHeaderItems`/`useThreadGitCenterHeaderItems` omits it, regressing the compact inspector workflow. Consider including the `auxiliaryPaneControl` as a header item in `useThreadGitHeaderActionItems` (e.g., inserting it at the start of `useThreadGitRightHeaderItems`) so the toggle remains reachable.

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