From ef3c8b511bf59afa59621fa86b484ce58537f407 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 20:06:07 -0700 Subject: [PATCH 01/11] fix(mobile): recover from screen render errors --- apps/mobile/src/Stack.tsx | 46 ++++++- .../src/components/RenderErrorBoundary.tsx | 128 ++++++++++++++++++ .../layout/AdaptiveWorkspaceLayout.tsx | 38 ++++-- .../layout/workspace-inspector-pane.tsx | 17 ++- .../features/threads/ThreadDetailScreen.tsx | 77 ++++++----- 5 files changed, 253 insertions(+), 53 deletions(-) create mode 100644 apps/mobile/src/components/RenderErrorBoundary.tsx 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..dcf015fdbaf2 --- /dev/null +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -0,0 +1,128 @@ +import { Component, useEffect, type ReactNode } from "react"; +import { AccessibilityInfo, 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"; + useEffect(() => { + AccessibilityInfo.announceForAccessibility(title); + }, [title]); + + 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..7d29579a4952 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,27 @@ function AdaptiveWorkspaceLayoutContent( style={sidebarAnimatedStyle} > - - - + ( + + )} + > + + + + ) : null} @@ -625,6 +632,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) => ( + + )} + > + + ) : ( From 48e3d51e362f796fe8f92cac8983128dd35a0629 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 20:18:53 -0700 Subject: [PATCH 02/11] fix(mobile): keep settings reachable after sidebar failure --- apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 7d29579a4952..60df38b30357 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -583,7 +583,11 @@ function AdaptiveWorkspaceLayoutContent( ( - + )} > From 0c55d337241e455e36e060001814b5b6ec7619e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 20:24:23 -0700 Subject: [PATCH 03/11] fix(mobile): keep recovery actions reachable at large text sizes --- apps/mobile/src/components/RenderErrorBoundary.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx index dcf015fdbaf2..49aa37f64bee 100644 --- a/apps/mobile/src/components/RenderErrorBoundary.tsx +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -1,5 +1,5 @@ import { Component, useEffect, type ReactNode } from "react"; -import { AccessibilityInfo, View } from "react-native"; +import { AccessibilityInfo, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; import { MaterialButton } from "./MaterialButton"; @@ -94,9 +94,12 @@ export function RenderFailureView( }, [title]); return ( - {title} @@ -123,6 +126,6 @@ export function RenderFailureView( /> ) : null} - + ); } From 1e9da2822dfd1aa1f475a456c2ea78f51b336ce6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 20:28:02 -0700 Subject: [PATCH 04/11] fix(mobile): reset inspector failure when its content changes --- apps/mobile/src/features/layout/workspace-inspector-pane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index 8f9da74f40ad..941b1b3041b3 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -142,7 +142,7 @@ export function WorkspaceInspectorPane(props: { > ( )} From 9af65ccc552d645a3728f8d7f7a2e3d55289a278 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 20:33:31 -0700 Subject: [PATCH 05/11] fix(mobile): reset inspector errors only on content mode changes --- .../features/layout/AdaptiveWorkspaceLayout.tsx | 17 +++++++++++------ .../layout/workspace-inspector-pane.tsx | 3 ++- .../src/features/threads/ThreadRouteScreen.tsx | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 60df38b30357..44f8b1f59e14 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -77,7 +77,7 @@ interface AdaptiveWorkspaceContextValue { * registration already took over — stale deactivates never clobber it. * Prefer useRegisterWorkspaceInspector over calling this directly. */ - readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; + readonly registerWorkspaceInspector: (render: () => ReactNode, identity?: string) => () => void; readonly setPrimarySidebarSearchQuery: (query: string) => void; readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; readonly toggleAuxiliaryPane: () => void; @@ -139,7 +139,10 @@ export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { * animates closed, or is replaced seamlessly when the next route registers in * the same commit); focus re-registers it. */ -export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) { +export function useRegisterWorkspaceInspector( + render: (() => ReactNode) | undefined, + identity?: string, +) { const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); // Raw context values (not the useNavigation/useRoute wrappers) so the // portal re-provides exactly what this screen sees. @@ -167,8 +170,8 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi deactivateRef.current?.(); return; } - deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current); - }, [registerWorkspaceInspector]); + deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current, identity); + }, [identity, registerWorkspaceInspector]); // Focus lifecycle. Blur/focus events fire even when the blurred subtree is // frozen (events are navigation-driven, renders are not). @@ -316,13 +319,14 @@ function AdaptiveWorkspaceLayoutContent( // seamlessly by the next route's registration in the same commit). const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; + readonly identity?: string; readonly active: boolean; } | null>(null); const workspaceInspectorOwner = useRef(null); - const registerWorkspaceInspector = useCallback((render: () => ReactNode) => { + const registerWorkspaceInspector = useCallback((render: () => ReactNode, identity?: string) => { const owner = Symbol("workspace-inspector"); workspaceInspectorOwner.current = owner; - setWorkspaceInspector({ render, active: true }); + setWorkspaceInspector({ render, identity, active: true }); return () => { // During a push/replace the outgoing screen deactivates AFTER the @@ -637,6 +641,7 @@ function AdaptiveWorkspaceLayoutContent( ; /** * When false the pane animates closed but keeps its content mounted for the @@ -142,7 +143,7 @@ export function WorkspaceInspectorPane(props: { > ( )} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 3cc9852ea8a3..56a0950497e3 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -615,7 +615,7 @@ function ThreadRouteContent( // Hand the inspector to the workspace so it renders beside the navigator, // outside this screen's native header — the terminal/git/files toolbar // stays anchored to the chat pane instead of floating above the inspector. - useRegisterWorkspaceInspector(activeInspectorRenderer); + useRegisterWorkspaceInspector(activeInspectorRenderer, inspectorMode ?? undefined); const handleOpenConnectionEditor = useCallback(() => { void navigation.navigate("Connections"); From 6e6c0c1f16e3e0070beb5ec7c2a1a92ab2e2329d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 20:37:19 -0700 Subject: [PATCH 06/11] fix(mobile): contain inspector pane render failures separately --- .../features/layout/AdaptiveWorkspaceLayout.tsx | 17 ++++++----------- .../layout/workspace-inspector-pane.tsx | 3 +-- .../src/features/threads/ThreadRouteScreen.tsx | 2 +- .../threads/thread-inspector-content-stack.tsx | 10 +++++++++- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 44f8b1f59e14..60df38b30357 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -77,7 +77,7 @@ interface AdaptiveWorkspaceContextValue { * registration already took over — stale deactivates never clobber it. * Prefer useRegisterWorkspaceInspector over calling this directly. */ - readonly registerWorkspaceInspector: (render: () => ReactNode, identity?: string) => () => void; + readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; readonly setPrimarySidebarSearchQuery: (query: string) => void; readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; readonly toggleAuxiliaryPane: () => void; @@ -139,10 +139,7 @@ export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { * animates closed, or is replaced seamlessly when the next route registers in * the same commit); focus re-registers it. */ -export function useRegisterWorkspaceInspector( - render: (() => ReactNode) | undefined, - identity?: string, -) { +export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) { const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout(); // Raw context values (not the useNavigation/useRoute wrappers) so the // portal re-provides exactly what this screen sees. @@ -170,8 +167,8 @@ export function useRegisterWorkspaceInspector( deactivateRef.current?.(); return; } - deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current, identity); - }, [identity, registerWorkspaceInspector]); + deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current); + }, [registerWorkspaceInspector]); // Focus lifecycle. Blur/focus events fire even when the blurred subtree is // frozen (events are navigation-driven, renders are not). @@ -319,14 +316,13 @@ function AdaptiveWorkspaceLayoutContent( // seamlessly by the next route's registration in the same commit). const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; - readonly identity?: string; readonly active: boolean; } | null>(null); const workspaceInspectorOwner = useRef(null); - const registerWorkspaceInspector = useCallback((render: () => ReactNode, identity?: string) => { + const registerWorkspaceInspector = useCallback((render: () => ReactNode) => { const owner = Symbol("workspace-inspector"); workspaceInspectorOwner.current = owner; - setWorkspaceInspector({ render, identity, active: true }); + setWorkspaceInspector({ render, active: true }); return () => { // During a push/replace the outgoing screen deactivates AFTER the @@ -641,7 +637,6 @@ function AdaptiveWorkspaceLayoutContent( ; /** * When false the pane animates closed but keeps its content mounted for the @@ -143,7 +142,7 @@ export function WorkspaceInspectorPane(props: { > ( )} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 56a0950497e3..3cc9852ea8a3 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -615,7 +615,7 @@ function ThreadRouteContent( // Hand the inspector to the workspace so it renders beside the navigator, // outside this screen's native header — the terminal/git/files toolbar // stays anchored to the chat pane instead of floating above the inspector. - useRegisterWorkspaceInspector(activeInspectorRenderer, inspectorMode ?? undefined); + useRegisterWorkspaceInspector(activeInspectorRenderer); const handleOpenConnectionEditor = useCallback(() => { void navigation.navigate("Connections"); 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..b9ce6209090e 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; @@ -27,7 +29,13 @@ function InspectorContentPane(props: { zIndex: props.visible ? 1 : 0, }} > - {props.children} + ( + + )} + > + {props.children} + ); } From 754b6b07b9ae9d70c83c37b74b1edf914feb7ad5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 20:55:05 -0700 Subject: [PATCH 07/11] fix(mobile): preserve healthy inspector state during recovery --- apps/mobile/src/components/RenderErrorBoundary.tsx | 5 +++-- .../src/features/layout/workspace-inspector-pane.tsx | 2 +- .../src/features/threads/ThreadRouteScreen.tsx | 10 +++++++++- .../threads/thread-inspector-content-stack.tsx | 12 +++++++++++- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx index 49aa37f64bee..9d99c6ec6a34 100644 --- a/apps/mobile/src/components/RenderErrorBoundary.tsx +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -83,6 +83,7 @@ export class RenderErrorBoundary extends Component< export function RenderFailureView( props: RenderFailureProps & { + readonly announce?: boolean; readonly title?: string; readonly bottomInset?: number; readonly exit?: { readonly label: string; readonly onPress: () => void }; @@ -90,8 +91,8 @@ export function RenderFailureView( ) { const title = props.title ?? "This screen couldn't be displayed"; useEffect(() => { - AccessibilityInfo.announceForAccessibility(title); - }, [title]); + if (props.announce !== false) AccessibilityInfo.announceForAccessibility(title); + }, [props.announce, title]); return ( ( )} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 3cc9852ea8a3..d2bf180b89b8 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -606,10 +606,18 @@ function ThreadRouteContent( Files={FilesInspector} Git={GitInspector} mode={inspectorMode} + resetKey={routeThreadIdentity} Route={props.renderInspector ? RouteInspector : undefined} /> ), - [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], + [ + FilesInspector, + GitInspector, + RouteInspector, + inspectorMode, + props.renderInspector, + routeThreadIdentity, + ], ); 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 b9ce6209090e..ea2509dfd982 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -10,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350; function InspectorContentPane(props: { readonly children: ReactNode; readonly mounted: boolean; + readonly resetKey: string | null; readonly visible: boolean; }) { if (!props.mounted) { @@ -30,8 +31,13 @@ function InspectorContentPane(props: { }} > ( - + )} > {props.children} @@ -44,6 +50,7 @@ export function ThreadInspectorContentStack(props: { readonly Files: ComponentType; readonly Git: ComponentType; readonly mode: ThreadInspectorMode; + readonly resetKey: string | null; readonly Route?: ComponentType; }) { const [mountedModes, setMountedModes] = useState>( @@ -86,12 +93,14 @@ export function ThreadInspectorContentStack(props: { @@ -99,6 +108,7 @@ export function ThreadInspectorContentStack(props: { {Route ? ( From 663dc77afebcfd4aa974f479e0a81365ef59fa43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 21:10:24 -0700 Subject: [PATCH 08/11] fix(mobile): keep hidden pane errors silent --- apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx | 1 + .../mobile/src/features/layout/workspace-inspector-pane.tsx | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 60df38b30357..a9ebcfb9ab95 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -585,6 +585,7 @@ function AdaptiveWorkspaceLayoutContent( renderFallback={(fallback) => ( diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index 8f9da74f40ad..89a070372c39 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -144,7 +144,11 @@ export function WorkspaceInspectorPane(props: { ( - + )} > From 69f750403d535fb25358da0872a65c65f0a5c45b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 21:14:20 -0700 Subject: [PATCH 09/11] fix(mobile): reset failed inspector after worktree changes --- .../src/features/threads/ThreadRouteScreen.tsx | 3 ++- .../threads/thread-inspector-content-stack.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d2bf180b89b8..4fb22d152496 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -606,7 +606,7 @@ function ThreadRouteContent( Files={FilesInspector} Git={GitInspector} mode={inspectorMode} - resetKey={routeThreadIdentity} + resetKeys={[routeThreadIdentity, selectedThreadCwd]} Route={props.renderInspector ? RouteInspector : undefined} /> ), @@ -617,6 +617,7 @@ function ThreadRouteContent( inspectorMode, props.renderInspector, routeThreadIdentity, + selectedThreadCwd, ], ); const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; 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 ea2509dfd982..b97b513fc19d 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -10,7 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350; function InspectorContentPane(props: { readonly children: ReactNode; readonly mounted: boolean; - readonly resetKey: string | null; + readonly resetKeys: readonly [string | null, string | null]; readonly visible: boolean; }) { if (!props.mounted) { @@ -31,7 +31,7 @@ function InspectorContentPane(props: { }} > ( >( @@ -93,14 +93,14 @@ export function ThreadInspectorContentStack(props: { @@ -108,7 +108,7 @@ export function ThreadInspectorContentStack(props: { {Route ? ( From a86704d3c9da21d1bcda9bf759268be5cc844c32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 21:17:08 -0700 Subject: [PATCH 10/11] fix(mobile): silence closed inspector recovery --- apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 2 ++ .../features/threads/thread-inspector-content-stack.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 4fb22d152496..02f7bd459634 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -605,6 +605,7 @@ function ThreadRouteContent( ( )} @@ -49,6 +50,7 @@ function InspectorContentPane(props: { export function ThreadInspectorContentStack(props: { readonly Files: ComponentType; readonly Git: ComponentType; + readonly inspectorVisible: boolean; readonly mode: ThreadInspectorMode; readonly resetKeys: readonly [string | null, string | null]; readonly Route?: ComponentType; @@ -92,6 +94,7 @@ export function ThreadInspectorContentStack(props: { return ( {Route ? ( Date: Tue, 22 Sep 2026 21:18:10 -0700 Subject: [PATCH 11/11] fix(mobile): keep recovery announcements in the visible UI --- apps/mobile/src/components/RenderErrorBoundary.tsx | 9 ++------- .../src/features/layout/AdaptiveWorkspaceLayout.tsx | 1 - .../src/features/layout/workspace-inspector-pane.tsx | 6 +----- .../mobile/src/features/threads/ThreadRouteScreen.tsx | 2 -- .../threads/thread-inspector-content-stack.tsx | 11 +---------- 5 files changed, 4 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx index 9d99c6ec6a34..9b71916d01e7 100644 --- a/apps/mobile/src/components/RenderErrorBoundary.tsx +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -1,5 +1,5 @@ -import { Component, useEffect, type ReactNode } from "react"; -import { AccessibilityInfo, ScrollView, View } from "react-native"; +import { Component, type ReactNode } from "react"; +import { ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; import { MaterialButton } from "./MaterialButton"; @@ -83,17 +83,12 @@ export class RenderErrorBoundary extends Component< export function RenderFailureView( props: RenderFailureProps & { - readonly announce?: boolean; readonly title?: string; readonly bottomInset?: number; readonly exit?: { readonly label: string; readonly onPress: () => void }; }, ) { const title = props.title ?? "This screen couldn't be displayed"; - useEffect(() => { - if (props.announce !== false) AccessibilityInfo.announceForAccessibility(title); - }, [props.announce, title]); - return ( ( diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index 89a070372c39..8f9da74f40ad 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -144,11 +144,7 @@ export function WorkspaceInspectorPane(props: { ( - + )} > diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 02f7bd459634..4fb22d152496 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -605,7 +605,6 @@ function ThreadRouteContent( ( - + )} > {props.children} @@ -50,7 +45,6 @@ function InspectorContentPane(props: { export function ThreadInspectorContentStack(props: { readonly Files: ComponentType; readonly Git: ComponentType; - readonly inspectorVisible: boolean; readonly mode: ThreadInspectorMode; readonly resetKeys: readonly [string | null, string | null]; readonly Route?: ComponentType; @@ -94,7 +88,6 @@ export function ThreadInspectorContentStack(props: { return ( {Route ? (