diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 91845bb9d0ac..524eac860716 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -10,7 +10,7 @@ import {
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, type ReactNode } from "react";
import {
Platform,
Pressable,
@@ -23,6 +23,11 @@ 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";
@@ -777,10 +782,13 @@ const RootStackConfig = 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,
@@ -794,6 +802,33 @@ const RootStackConfig = 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 AdaptiveRootStack({ Navigator }) {
const { width, height } = useWindowDimensions();
const usesWorkspaceFlowScreens =
@@ -801,6 +836,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig
return (
{
if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {
return {};
diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx
new file mode 100644
index 000000000000..9b71916d01e7
--- /dev/null
+++ b/apps/mobile/src/components/RenderErrorBoundary.tsx
@@ -0,0 +1,127 @@
+import { Component, type ReactNode } from "react";
+import { ScrollView, View } from "react-native";
+
+import { AppText as Text } from "./AppText";
+import { MaterialButton } from "./MaterialButton";
+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" })}
+ fullWidth
+ />
+ {props.exit ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
index 6c6133c7ef13..60df38b30357 100644
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -57,6 +57,7 @@ import {
import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
import { HomeListOptionsProvider } from "../home/home-list-options";
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";
@@ -580,21 +581,31 @@ function AdaptiveWorkspaceLayoutContent(
style={sidebarAnimatedStyle}
>
-
-
-
+ (
+
+ )}
+ >
+
+
+
+
) : null}
@@ -625,6 +636,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 ad0872ea9116..845d67483c83 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -70,6 +70,7 @@ import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
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";
@@ -892,39 +893,51 @@ 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 3cc9852ea8a3..4fb22d152496 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -606,10 +606,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 9b41ed4e362a..5a1601b0e5ae 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 ? (