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 ? (