diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index df96bfa182..69256cbdba 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -131,7 +131,9 @@
"@pierre/trees": "1.0.0-beta.4",
"@types/react": "~19.2.0",
"@types/react-dom": "~19.2.3",
+ "@types/react-test-renderer": "19.1.0",
"babel-preset-expo": "~57.0.9",
+ "react-test-renderer": "19.2.3",
"tailwindcss": "^4.0.0",
"typescript": "catalog:"
},
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 22c02e9957..9f2b81b6b7 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -10,12 +10,17 @@ import {
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, type ReactNode } from "react";
import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
import { useResolveClassNames } from "uniwind";
import { AppText as Text } from "./components/AppText";
import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
+import {
+ RenderErrorBoundary,
+ RenderFailureView,
+ type RenderFailureProps,
+} from "./components/RenderErrorBoundary";
import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
@@ -503,7 +508,7 @@ function NotFoundScreen() {
);
}
-export const RootStack = createNativeStackNavigator({
+const RootStackConfig = createNativeStackNavigator({
initialRouteName: "Home",
layout: RootStackLayout,
screenOptions: {
@@ -686,10 +691,15 @@ export const RootStack = createNativeStackNavigator({
// The whole new-task flow (choose project → draft → add project) shares
// draft state via NewTaskFlowProvider. The expo-router era mounted it in
// app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
- layout: ({ children }) => (
-
- {children}
-
+ // A screen's layout replaces the navigator's screenLayout.
+ layout: ({ children, route }) => (
+
+
+
+ {children}
+
+
+
),
options: {
gestureEnabled: true,
@@ -711,6 +721,38 @@ export const RootStack = createNativeStackNavigator({
}),
},
});
+
+function GuardedScreenLayout(props: {
+ readonly children: ReactNode;
+ readonly route: { readonly name: string; readonly params?: object | undefined };
+}) {
+ return (
+ (
+
+ )}
+ >
+ {props.children}
+
+ );
+}
+
+function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: string }) {
+ const navigation = useNavigation();
+ const exit = navigation.canGoBack()
+ ? { label: "Go back", onPress: () => navigation.goBack() }
+ : props.routeName === "Home"
+ ? { label: "Open settings", onPress: () => navigation.navigate("SettingsSheet") }
+ : { label: "Return home", onPress: () => navigation.dispatch(StackActions.replace("Home")) };
+
+ return ;
+}
+
+export const RootStack = RootStackConfig.with(function GuardedRootStack({ Navigator }) {
+ return ;
+});
+
type RootStackType = typeof RootStack;
const navigationPathConfig = {
diff --git a/apps/mobile/src/components/RenderErrorBoundary.test.tsx b/apps/mobile/src/components/RenderErrorBoundary.test.tsx
new file mode 100644
index 0000000000..a2e17e4655
--- /dev/null
+++ b/apps/mobile/src/components/RenderErrorBoundary.test.tsx
@@ -0,0 +1,94 @@
+import { useEffect } from "react";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { Pressable, View } from "react-native";
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import { RenderErrorBoundary } from "./RenderErrorBoundary";
+
+vi.mock("react-native", () => ({
+ Pressable: "Pressable",
+ ScrollView: "ScrollView",
+ View: "View",
+}));
+vi.mock("./AppText", () => ({ AppText: "Text" }));
+vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() }));
+
+// React 19's test renderer requires an explicit act environment for updates.
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+describe("RenderErrorBoundary", () => {
+ it("contains a failed child and retries without remounting its sibling", async () => {
+ let throwOnRender = true;
+ let siblingMounts = 0;
+ function Feed() {
+ if (throwOnRender) throw new Error("feed failed");
+ return feed ready;
+ }
+ function Composer() {
+ useEffect(() => {
+ siblingMounts += 1;
+ }, []);
+ return draft retained;
+ }
+ let renderer: ReactTestRenderer | undefined;
+ const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ await act(async () => {
+ renderer = create(
+ <>
+ Try again}
+ >
+
+
+
+ >,
+ );
+ });
+ expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1);
+ expect(renderer?.root.findAllByType(View)).toHaveLength(1);
+ throwOnRender = false;
+ await act(async () => {
+ renderer?.root.findByType(Pressable).props.onPress();
+ });
+ expect(renderer?.root.findAllByType(Pressable)).toHaveLength(0);
+ expect(renderer?.root.findAllByType(View)).toHaveLength(2);
+ expect(siblingMounts).toBe(1);
+ } finally {
+ errorLog.mockRestore();
+ await act(async () => renderer?.unmount());
+ }
+ });
+
+ it("clears the failure when route identity changes", async () => {
+ let throwOnRender = true;
+ function Screen() {
+ if (throwOnRender) throw new Error("route failed");
+ return route ready;
+ }
+ const render = (route: string) => (
+ Unavailable}
+ >
+
+
+ );
+ let renderer: ReactTestRenderer | undefined;
+ const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ await act(async () => {
+ renderer = create(render("thread-a"));
+ });
+ expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1);
+ throwOnRender = false;
+ await act(async () => renderer?.update(render("thread-a")));
+ expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1);
+ await act(async () => renderer?.update(render("thread-b")));
+ expect(renderer?.root.findAllByType(View)).toHaveLength(1);
+ } finally {
+ errorLog.mockRestore();
+ await act(async () => renderer?.unmount());
+ }
+ });
+});
diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx
new file mode 100644
index 0000000000..e670f3b5d7
--- /dev/null
+++ b/apps/mobile/src/components/RenderErrorBoundary.tsx
@@ -0,0 +1,148 @@
+import { Component, type ReactNode } from "react";
+import { Pressable, ScrollView, View } from "react-native";
+
+import { AppText as Text } from "./AppText";
+import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
+
+interface RenderErrorBoundaryProps {
+ readonly children: ReactNode;
+ readonly resetKeys?: ReadonlyArray;
+ readonly renderFallback?: (props: RenderFailureProps) => ReactNode;
+}
+
+interface RenderErrorBoundaryState {
+ readonly failed: boolean;
+ readonly error: unknown;
+ readonly componentStack?: string;
+ readonly resetKeys?: ReadonlyArray;
+}
+
+export interface RenderFailureProps {
+ readonly details: string;
+ readonly retry: () => void;
+}
+
+function errorDetails(error: unknown, componentStack?: string): string {
+ let description: string;
+ try {
+ description = error instanceof Error ? (error.stack ?? error.message) : String(error);
+ } catch {
+ description = "Unknown render error";
+ }
+ return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description;
+}
+
+/** A failed subtree remounts on retry or when its identifying inputs change. */
+export class RenderErrorBoundary extends Component<
+ RenderErrorBoundaryProps,
+ RenderErrorBoundaryState
+> {
+ override state: RenderErrorBoundaryState = {
+ failed: false,
+ error: null,
+ resetKeys: this.props.resetKeys,
+ };
+
+ static getDerivedStateFromProps(
+ { resetKeys }: RenderErrorBoundaryProps,
+ state: RenderErrorBoundaryState,
+ ): Partial | null {
+ if (
+ resetKeys?.length !== state.resetKeys?.length ||
+ resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index]))
+ ) {
+ return { failed: false, error: null, componentStack: undefined, resetKeys };
+ }
+ return null;
+ }
+
+ static getDerivedStateFromError(error: unknown): Partial {
+ return { failed: true, error };
+ }
+
+ override componentDidCatch(_error: unknown, info: { componentStack?: string }) {
+ this.setState({ componentStack: info.componentStack });
+ }
+
+ private readonly retry = () => {
+ this.setState({ failed: false, error: null, componentStack: undefined });
+ };
+
+ override render() {
+ if (!this.state.failed) return this.props.children;
+ const fallback =
+ this.props.renderFallback ??
+ ((props: RenderFailureProps) => );
+ return fallback({
+ details: errorDetails(this.state.error, this.state.componentStack),
+ retry: this.retry,
+ });
+ }
+}
+
+export function RenderFailureView(
+ props: RenderFailureProps & {
+ readonly title?: string;
+ readonly bottomInset?: number;
+ readonly exit?: { readonly label: string; readonly onPress: () => void };
+ },
+) {
+ const title = props.title ?? "This screen couldn't be displayed";
+ return (
+
+
+ {title}
+
+
+ Try again. If it keeps happening, copy the details for a bug report.
+
+
+ {props.details.split("\n", 1)[0]?.slice(0, 300)}
+
+
+
+ copyTextWithHaptic(props.details, { target: "error details" })}
+ />
+ {props.exit ? (
+
+ ) : null}
+
+
+ );
+}
+
+function RenderFailureAction(props: {
+ readonly label: string;
+ readonly onPress: () => void;
+ readonly primary?: boolean;
+}) {
+ return (
+
+
+ {props.label}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
index c030e49c2b..93d8ad9a1f 100644
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -54,6 +54,7 @@ import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
import { HomeListOptionsProvider } from "../home/home-list-options";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
+import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
import { WorkspaceContentWidthContext } from "./workspace-content-width";
@@ -561,21 +562,31 @@ function AdaptiveWorkspaceLayoutContent(
style={sidebarAnimatedStyle}
>
-
-
-
+ (
+
+ )}
+ >
+
+
+
+
) : null}
@@ -601,6 +612,7 @@ function AdaptiveWorkspaceLayoutContent(
;
/**
* When false the pane animates closed but keeps its content mounted for the
@@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: {
style={inspectorStyle}
>
- {props.renderInspector?.()}
+ (
+
+ )}
+ >
+
+
) : null}
>
);
}
+
+// The render callback must run inside the boundary's child, not while its
+// parent constructs the boundary element.
+function InspectorRenderer(props: { readonly render?: () => ReactNode }) {
+ return <>{props.render?.()}>;
+}
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index ad5c6c4a52..9c37a20fe8 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -89,6 +89,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerAttachment } from "../../lib/composerImages";
+import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
@@ -991,43 +992,55 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
: "absolute inset-0 bg-screen"
}
/>
-
+ resetKeys={[props.threadCwd]}
+ renderFallback={(fallback) => (
+
+ )}
+ >
+
+
) : (
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 729430f1f7..3cd8b9e975 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -544,10 +544,19 @@ function ThreadRouteContent(
Files={FilesInspector}
Git={GitInspector}
mode={inspectorMode}
+ resetKeys={[routeThreadIdentity, selectedThreadCwd]}
Route={props.renderInspector ? RouteInspector : undefined}
/>
),
- [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector],
+ [
+ FilesInspector,
+ GitInspector,
+ RouteInspector,
+ inspectorMode,
+ props.renderInspector,
+ routeThreadIdentity,
+ selectedThreadCwd,
+ ],
);
const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack;
// Hand the inspector to the workspace so it renders beside the navigator,
diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx
index 9b41ed4e36..5a1601b0e5 100644
--- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx
+++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx
@@ -1,6 +1,8 @@
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { View } from "react-native";
+import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
+
export type ThreadInspectorMode = "route" | "git" | "files";
const INSPECTOR_PREWARM_DELAY_MS = 350;
@@ -8,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350;
function InspectorContentPane(props: {
readonly children: ReactNode;
readonly mounted: boolean;
+ readonly resetKeys: readonly [string | null, string | null];
readonly visible: boolean;
}) {
if (!props.mounted) {
@@ -27,7 +30,14 @@ function InspectorContentPane(props: {
zIndex: props.visible ? 1 : 0,
}}
>
- {props.children}
+ (
+
+ )}
+ >
+ {props.children}
+
);
}
@@ -36,6 +46,7 @@ export function ThreadInspectorContentStack(props: {
readonly Files: ComponentType;
readonly Git: ComponentType;
readonly mode: ThreadInspectorMode;
+ readonly resetKeys: readonly [string | null, string | null];
readonly Route?: ComponentType;
}) {
const [mountedModes, setMountedModes] = useState>(
@@ -78,12 +89,14 @@ export function ThreadInspectorContentStack(props: {
@@ -91,6 +104,7 @@ export function ThreadInspectorContentStack(props: {
{Route ? (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fbdf753057..7c1cbcf06b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -490,9 +490,15 @@ importers:
'@types/react-dom':
specifier: ~19.2.3
version: 19.2.3(@types/react@19.2.16)
+ '@types/react-test-renderer':
+ specifier: 19.1.0
+ version: 19.1.0
babel-preset-expo:
specifier: ~57.0.9
version: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2)
+ react-test-renderer:
+ specifier: 19.2.3
+ version: 19.2.3(react@19.2.3)
tailwindcss:
specifier: 4.3.3
version: 4.3.3
@@ -10009,6 +10015,11 @@ packages:
'@types/react':
optional: true
+ react-test-renderer@19.2.3:
+ resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==}
+ peerDependencies:
+ react: ^19.2.3
+
react-test-renderer@19.2.6:
resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==}
peerDependencies:
@@ -21524,6 +21535,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.16
+ react-test-renderer@19.2.3(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-is: 19.2.7
+ scheduler: 0.27.0
+
react-test-renderer@19.2.6(react@19.2.6):
dependencies:
react: 19.2.6