authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 21:59:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 21:59:58-07:00
log23c7ab901afa66dc8e791a3873b600726a6ba8c7
tree5dda066ac437f3cb10409391cebe006314b3ad5d
parent10882bea739a98f11b942c20c6570eb6126dd010
signature Signed by PGP key B5690EEEBB952194

fix(mobile): recover from screen render errors (#13197)


7 files changed, 281 insertions(+), 55 deletions(-)

apps/mobile/src/Stack.tsx+41-5
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
10 createNativeStackScreen,10 createNativeStackScreen,
11 type NativeStackNavigationOptions,11 type NativeStackNavigationOptions,
12} from "@react-navigation/native-stack";12} from "@react-navigation/native-stack";
13import { useEffect, useRef } from "react";13import { useEffect, useRef, type ReactNode } from "react";
14import {14import {
15 Platform,15 Platform,
16 Pressable,16 Pressable,
...@@ -23,6 +23,11 @@ import { useResolveClassNames } from "uniwind";...@@ -23,6 +23,11 @@ import { useResolveClassNames } from "uniwind";
2323
24import { AppText as Text } from "./components/AppText";24import { AppText as Text } from "./components/AppText";
25import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";25import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
26import {
27 RenderErrorBoundary,
28 RenderFailureView,
29 type RenderFailureProps,
30} from "./components/RenderErrorBoundary";
26import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";31import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
27import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";32import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
28import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";33import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
...@@ -777,10 +782,13 @@ const RootStackConfig = createNativeStackNavigator({...@@ -777,10 +782,13 @@ const RootStackConfig = createNativeStackNavigator({
777 // The whole new-task flow (choose project → draft → add project) shares782 // The whole new-task flow (choose project → draft → add project) shares
778 // draft state via NewTaskFlowProvider. The expo-router era mounted it in783 // draft state via NewTaskFlowProvider. The expo-router era mounted it in
779 // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.784 // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
780 layout: ({ children }) => (785 // A screen's layout replaces the navigator's screenLayout.
781 <NewTaskFlowProvider>786 layout: ({ children, route }) => (
782 <View className="flex-1 bg-sheet-solid">{children}</View>787 <GuardedScreenLayout route={route}>
783 </NewTaskFlowProvider>788 <NewTaskFlowProvider>
789 <View className="flex-1 bg-sheet-solid">{children}</View>
790 </NewTaskFlowProvider>
791 </GuardedScreenLayout>
784 ),792 ),
785 options: {793 options: {
786 gestureEnabled: true,794 gestureEnabled: true,
...@@ -794,6 +802,33 @@ const RootStackConfig = createNativeStackNavigator({...@@ -794,6 +802,33 @@ const RootStackConfig = createNativeStackNavigator({
794 },802 },
795});803});
796804
805function GuardedScreenLayout(props: {
806 readonly children: ReactNode;
807 readonly route: { readonly name: string; readonly params?: object | undefined };
808}) {
809 return (
810 <RenderErrorBoundary
811 resetKeys={[props.route.params]}
812 renderFallback={(fallback) => (
813 <ScreenRenderFallback {...fallback} routeName={props.route.name} />
814 )}
815 >
816 {props.children}
817 </RenderErrorBoundary>
818 );
819}
820
821function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: string }) {
822 const navigation = useNavigation();
823 const exit = navigation.canGoBack()
824 ? { label: "Go back", onPress: () => navigation.goBack() }
825 : props.routeName === "Home"
826 ? { label: "Open settings", onPress: () => navigation.navigate("SettingsSheet") }
827 : { label: "Return home", onPress: () => navigation.dispatch(StackActions.replace("Home")) };
828
829 return <RenderFailureView {...props} exit={exit} />;
830}
831
797export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {832export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
798 const { width, height } = useWindowDimensions();833 const { width, height } = useWindowDimensions();
799 const usesWorkspaceFlowScreens =834 const usesWorkspaceFlowScreens =
...@@ -801,6 +836,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig...@@ -801,6 +836,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig
801836
802 return (837 return (
803 <Navigator838 <Navigator
839 screenLayout={GuardedScreenLayout}
804 screenOptions={({ route }) => {840 screenOptions={({ route }) => {
805 if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {841 if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {
806 return {};842 return {};
apps/mobile/src/components/RenderErrorBoundary.tsx created+127
...@@ -0,0 +1,127 @@
1import { Component, type ReactNode } from "react";
2import { ScrollView, View } from "react-native";
3
4import { AppText as Text } from "./AppText";
5import { MaterialButton } from "./MaterialButton";
6import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
7
8interface RenderErrorBoundaryProps {
9 readonly children: ReactNode;
10 readonly resetKeys?: ReadonlyArray<unknown>;
11 readonly renderFallback?: (props: RenderFailureProps) => ReactNode;
12}
13
14interface RenderErrorBoundaryState {
15 readonly failed: boolean;
16 readonly error: unknown;
17 readonly componentStack?: string;
18 readonly resetKeys?: ReadonlyArray<unknown>;
19}
20
21export interface RenderFailureProps {
22 readonly details: string;
23 readonly retry: () => void;
24}
25
26function errorDetails(error: unknown, componentStack?: string): string {
27 let description: string;
28 try {
29 description = error instanceof Error ? (error.stack ?? error.message) : String(error);
30 } catch {
31 description = "Unknown render error";
32 }
33 return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description;
34}
35
36/** A failed subtree remounts on retry or when its identifying inputs change. */
37export class RenderErrorBoundary extends Component<
38 RenderErrorBoundaryProps,
39 RenderErrorBoundaryState
40> {
41 override state: RenderErrorBoundaryState = {
42 failed: false,
43 error: null,
44 resetKeys: this.props.resetKeys,
45 };
46
47 static getDerivedStateFromProps(
48 { resetKeys }: RenderErrorBoundaryProps,
49 state: RenderErrorBoundaryState,
50 ): Partial<RenderErrorBoundaryState> | null {
51 if (
52 resetKeys?.length !== state.resetKeys?.length ||
53 resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index]))
54 ) {
55 return { failed: false, error: null, componentStack: undefined, resetKeys };
56 }
57 return null;
58 }
59
60 static getDerivedStateFromError(error: unknown): Partial<RenderErrorBoundaryState> {
61 return { failed: true, error };
62 }
63
64 override componentDidCatch(_error: unknown, info: { componentStack?: string }) {
65 this.setState({ componentStack: info.componentStack });
66 }
67
68 private readonly retry = () => {
69 this.setState({ failed: false, error: null, componentStack: undefined });
70 };
71
72 override render() {
73 if (!this.state.failed) return this.props.children;
74 const fallback =
75 this.props.renderFallback ??
76 ((props: RenderFailureProps) => <RenderFailureView {...props} />);
77 return fallback({
78 details: errorDetails(this.state.error, this.state.componentStack),
79 retry: this.retry,
80 });
81 }
82}
83
84export function RenderFailureView(
85 props: RenderFailureProps & {
86 readonly title?: string;
87 readonly bottomInset?: number;
88 readonly exit?: { readonly label: string; readonly onPress: () => void };
89 },
90) {
91 const title = props.title ?? "This screen couldn't be displayed";
92 return (
93 <ScrollView
94 className="flex-1 bg-screen"
95 contentContainerClassName="flex-grow items-center justify-center gap-5 px-6 py-8"
96 contentContainerStyle={
97 props.bottomInset ? { paddingBottom: 32 + props.bottomInset } : undefined
98 }
99 >
100 <Text accessibilityRole="header" className="text-center text-xl font-t3-bold">
101 {title}
102 </Text>
103 <Text className="text-center text-sm text-foreground-muted">
104 Try again. If it keeps happening, copy the details for a bug report.
105 </Text>
106 <Text selectable className="text-center font-mono text-xs text-danger-foreground">
107 {props.details.split("\n", 1)[0]?.slice(0, 300)}
108 </Text>
109 <View className="w-full max-w-xs gap-2">
110 <MaterialButton label="Try again" onPress={props.retry} tone="primary" fullWidth />
111 <MaterialButton
112 label="Copy details"
113 onPress={() => copyTextWithHaptic(props.details, { target: "error details" })}
114 fullWidth
115 />
116 {props.exit ? (
117 <MaterialButton
118 label={props.exit.label}
119 onPress={props.exit.onPress}
120 tone="text"
121 fullWidth
122 />
123 ) : null}
124 </View>
125 </ScrollView>
126 );
127}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+27-15
...@@ -57,6 +57,7 @@ import {...@@ -57,6 +57,7 @@ import {
57import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";57import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
58import { HomeListOptionsProvider } from "../home/home-list-options";58import { HomeListOptionsProvider } from "../home/home-list-options";
59import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";59import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
60import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
60import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";61import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
61import { WorkspaceInspectorPane } from "./workspace-inspector-pane";62import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
62import { WorkspaceContentWidthContext } from "./workspace-content-width";63import { WorkspaceContentWidthContext } from "./workspace-content-width";
...@@ -580,21 +581,31 @@ function AdaptiveWorkspaceLayoutContent(...@@ -580,21 +581,31 @@ function AdaptiveWorkspaceLayoutContent(
580 style={sidebarAnimatedStyle}581 style={sidebarAnimatedStyle}
581 >582 >
582 <View className="flex-1" style={{ width: layout.listPaneWidth }}>583 <View className="flex-1" style={{ width: layout.listPaneWidth }}>
583 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>584 <RenderErrorBoundary
584 <ThreadNavigationSidebar585 renderFallback={(fallback) => (
585 width={layout.listPaneWidth}586 <RenderFailureView
586 visible={panes.primarySidebarVisible}587 {...fallback}
587 onRequestVisibility={revealPrimarySidebar}588 title="The sidebar couldn't be displayed"
588 selectedThreadKey={selectedThreadKey}589 exit={{ label: "Open settings", onPress: handleOpenSettings }}
589 onOpenSettings={handleOpenSettings}590 />
590 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}591 )}
591 onNewThreadInProject={handleNewThreadInProject}592 >
592 onNewThreadOnBranch={handleNewThreadOnBranch}593 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
593 onSelectThread={handleSelectThread}594 <ThreadNavigationSidebar
594 onSearchQueryChange={setPrimarySidebarSearchQuery}595 width={layout.listPaneWidth}
595 searchQuery={primarySidebarSearchQuery}596 visible={panes.primarySidebarVisible}
596 />597 onRequestVisibility={revealPrimarySidebar}
597 </AndroidHomeFabLayout>598 selectedThreadKey={selectedThreadKey}
599 onOpenSettings={handleOpenSettings}
600 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
601 onNewThreadInProject={handleNewThreadInProject}
602 onNewThreadOnBranch={handleNewThreadOnBranch}
603 onSelectThread={handleSelectThread}
604 onSearchQueryChange={setPrimarySidebarSearchQuery}
605 searchQuery={primarySidebarSearchQuery}
606 />
607 </AndroidHomeFabLayout>
608 </RenderErrorBoundary>
598 </View>609 </View>
599 </Animated.View>610 </Animated.View>
600 ) : null}611 ) : null}
...@@ -625,6 +636,7 @@ function AdaptiveWorkspaceLayoutContent(...@@ -625,6 +636,7 @@ function AdaptiveWorkspaceLayoutContent(
625 </View>636 </View>
626 </View>637 </View>
627 <WorkspaceInspectorPane638 <WorkspaceInspectorPane
639 pathname={props.pathname}
628 renderedInspectorWidth={renderedInspectorWidth}640 renderedInspectorWidth={renderedInspectorWidth}
629 active={workspaceInspector?.active ?? false}641 active={workspaceInspector?.active ?? false}
630 panes={panes}642 panes={panes}
apps/mobile/src/features/layout/workspace-inspector-pane.tsx+16-1
...@@ -8,6 +8,7 @@ import Animated, {...@@ -8,6 +8,7 @@ import Animated, {
8} from "react-native-reanimated";8} from "react-native-reanimated";
99
10import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";10import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";
11import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
11import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";12import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
12import { WorkspacePaneDivider } from "./workspace-pane-divider";13import { WorkspacePaneDivider } from "./workspace-pane-divider";
1314
...@@ -23,6 +24,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider";...@@ -23,6 +24,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider";
23 * module stays import-cycle-free with AdaptiveWorkspaceLayout.24 * module stays import-cycle-free with AdaptiveWorkspaceLayout.
24 */25 */
25export function WorkspaceInspectorPane(props: {26export function WorkspaceInspectorPane(props: {
27 readonly pathname: string;
26 readonly renderedInspectorWidth: SharedValue<number>;28 readonly renderedInspectorWidth: SharedValue<number>;
27 /**29 /**
28 * When false the pane animates closed but keeps its content mounted for the30 * When false the pane animates closed but keeps its content mounted for the
...@@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: {...@@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: {
139 style={inspectorStyle}141 style={inspectorStyle}
140 >142 >
141 <Animated.View className="flex-1" style={inspectorContentStyle}>143 <Animated.View className="flex-1" style={inspectorContentStyle}>
142 {props.renderInspector?.()}144 <RenderErrorBoundary
145 resetKeys={[props.pathname]}
146 renderFallback={(fallback) => (
147 <RenderFailureView {...fallback} title="The inspector couldn't be displayed" />
148 )}
149 >
150 <InspectorRenderer render={props.renderInspector} />
151 </RenderErrorBoundary>
143 </Animated.View>152 </Animated.View>
144 </Animated.View>153 </Animated.View>
145 ) : null}154 ) : null}
146 </>155 </>
147 );156 );
148}157}
158
159// The render callback must run inside the boundary's child, not while its
160// parent constructs the boundary element.
161function InspectorRenderer(props: { readonly render?: () => ReactNode }) {
162 return <>{props.render?.()}</>;
163}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+45-32
...@@ -70,6 +70,7 @@ import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";...@@ -70,6 +70,7 @@ import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
70import type { ComposerEditorHandle } from "../../components/ComposerEditor";70import type { ComposerEditorHandle } from "../../components/ComposerEditor";
71import type { StatusTone } from "../../components/StatusPill";71import type { StatusTone } from "../../components/StatusPill";
72import type { DraftComposerAttachment } from "../../lib/composerImages";72import type { DraftComposerAttachment } from "../../lib/composerImages";
73import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
73import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";74import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
74import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";75import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
75import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";76import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
...@@ -892,39 +893,51 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -892,39 +893,51 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
892 : "absolute inset-0 bg-screen"893 : "absolute inset-0 bg-screen"
893 }894 }
894 />895 />
895 <ThreadFeed896 <RenderErrorBoundary
896 key={selectedThreadKey}897 key={selectedThreadKey}
897 environmentId={props.environmentId}898 resetKeys={[props.threadCwd]}
898 threadId={props.selectedThread.id}899 renderFallback={(fallback) => (
899 workspaceRoot={props.threadCwd}900 <RenderFailureView
900 feed={props.selectedThreadFeed}901 {...fallback}
901 worktreeSetup={props.worktreeSetup}902 title="The conversation couldn't be displayed"
902 setupWorkingStartedAt={props.setupWorkingStartedAt}903 bottomInset={estimatedOverlayHeight}
903 queuedMessages={props.queuedMessages}904 />
904 dispatchingMessageId={props.dispatchingMessageId}905 )}
905 onEditPendingMessage={handleEditPendingMessage}906 >
906 contentPresentation={props.contentPresentation}907 <ThreadFeed
907 agentLabel={agentLabel}908 environmentId={props.environmentId}
908 latestTurn={props.selectedThread.latestTurn}909 threadId={props.selectedThread.id}
909 activeWorkStartedAt={props.activeWorkStartedAt}910 workspaceRoot={props.threadCwd}
910 listRef={listRef}911 feed={props.selectedThreadFeed}
911 freeze={freeze}912 worktreeSetup={props.worktreeSetup}
912 anchorMessageId={anchorMessageId}913 setupWorkingStartedAt={props.setupWorkingStartedAt}
913 submittedMessageId={submittedMessageId}914 queuedMessages={props.queuedMessages}
914 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}915 dispatchingMessageId={props.dispatchingMessageId}
915 contentTopInset={0}916 onEditPendingMessage={handleEditPendingMessage}
916 contentBottomInset={917 contentPresentation={props.contentPresentation}
917 estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)918 agentLabel={agentLabel}
918 }919 latestTurn={props.selectedThread.latestTurn}
919 contentMaxWidth={contentMaxWidth}920 activeWorkStartedAt={props.activeWorkStartedAt}
920 layoutVariant={layoutVariant}921 listRef={listRef}
921 usesAutomaticContentInsets={props.usesAutomaticContentInsets}922 freeze={freeze}
922 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}923 anchorMessageId={anchorMessageId}
923 onEndFollowEnabledChange={setEndFollowEnabled}924 submittedMessageId={submittedMessageId}
924 skills={selectedProviderSkills}925 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
925 onUseArtifactTemplate={handleUseArtifactTemplate}926 contentTopInset={0}
926 loadEarlier={props.loadEarlier ?? null}927 contentBottomInset={
927 />928 estimatedOverlayHeight +
929 (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
930 }
931 contentMaxWidth={contentMaxWidth}
932 layoutVariant={layoutVariant}
933 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
934 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
935 onEndFollowEnabledChange={setEndFollowEnabled}
936 skills={selectedProviderSkills}
937 onUseArtifactTemplate={handleUseArtifactTemplate}
938 loadEarlier={props.loadEarlier ?? null}
939 />
940 </RenderErrorBoundary>
928 </View>941 </View>
929 ) : (942 ) : (
930 <View className="flex-1" />943 <View className="flex-1" />
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+10-1
...@@ -606,10 +606,19 @@ function ThreadRouteContent(...@@ -606,10 +606,19 @@ function ThreadRouteContent(
606 Files={FilesInspector}606 Files={FilesInspector}
607 Git={GitInspector}607 Git={GitInspector}
608 mode={inspectorMode}608 mode={inspectorMode}
609 resetKeys={[routeThreadIdentity, selectedThreadCwd]}
609 Route={props.renderInspector ? RouteInspector : undefined}610 Route={props.renderInspector ? RouteInspector : undefined}
610 />611 />
611 ),612 ),
612 [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector],613 [
614 FilesInspector,
615 GitInspector,
616 RouteInspector,
617 inspectorMode,
618 props.renderInspector,
619 routeThreadIdentity,
620 selectedThreadCwd,
621 ],
613 );622 );
614 const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack;623 const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack;
615 // Hand the inspector to the workspace so it renders beside the navigator,624 // Hand the inspector to the workspace so it renders beside the navigator,
apps/mobile/src/features/threads/thread-inspector-content-stack.tsx+15-1
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { useEffect, useState, type ComponentType, type ReactNode } from "react";1import { useEffect, useState, type ComponentType, type ReactNode } from "react";
2import { View } from "react-native";2import { View } from "react-native";
33
4import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
5
4export type ThreadInspectorMode = "route" | "git" | "files";6export type ThreadInspectorMode = "route" | "git" | "files";
57
6const INSPECTOR_PREWARM_DELAY_MS = 350;8const INSPECTOR_PREWARM_DELAY_MS = 350;
...@@ -8,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350;...@@ -8,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350;
8function InspectorContentPane(props: {10function InspectorContentPane(props: {
9 readonly children: ReactNode;11 readonly children: ReactNode;
10 readonly mounted: boolean;12 readonly mounted: boolean;
13 readonly resetKeys: readonly [string | null, string | null];
11 readonly visible: boolean;14 readonly visible: boolean;
12}) {15}) {
13 if (!props.mounted) {16 if (!props.mounted) {
...@@ -27,7 +30,14 @@ function InspectorContentPane(props: {...@@ -27,7 +30,14 @@ function InspectorContentPane(props: {
27 zIndex: props.visible ? 1 : 0,30 zIndex: props.visible ? 1 : 0,
28 }}31 }}
29 >32 >
30 {props.children}33 <RenderErrorBoundary
34 resetKeys={props.resetKeys}
35 renderFallback={(fallback) => (
36 <RenderFailureView {...fallback} title="The inspector couldn't be displayed" />
37 )}
38 >
39 {props.children}
40 </RenderErrorBoundary>
31 </View>41 </View>
32 );42 );
33}43}
...@@ -36,6 +46,7 @@ export function ThreadInspectorContentStack(props: {...@@ -36,6 +46,7 @@ export function ThreadInspectorContentStack(props: {
36 readonly Files: ComponentType;46 readonly Files: ComponentType;
37 readonly Git: ComponentType;47 readonly Git: ComponentType;
38 readonly mode: ThreadInspectorMode;48 readonly mode: ThreadInspectorMode;
49 readonly resetKeys: readonly [string | null, string | null];
39 readonly Route?: ComponentType;50 readonly Route?: ComponentType;
40}) {51}) {
41 const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>(52 const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>(
...@@ -78,12 +89,14 @@ export function ThreadInspectorContentStack(props: {...@@ -78,12 +89,14 @@ export function ThreadInspectorContentStack(props: {
78 <View className="flex-1">89 <View className="flex-1">
79 <InspectorContentPane90 <InspectorContentPane
80 mounted={mountedModes.has("files") || props.mode === "files"}91 mounted={mountedModes.has("files") || props.mode === "files"}
92 resetKeys={props.resetKeys}
81 visible={props.mode === "files"}93 visible={props.mode === "files"}
82 >94 >
83 <Files />95 <Files />
84 </InspectorContentPane>96 </InspectorContentPane>
85 <InspectorContentPane97 <InspectorContentPane
86 mounted={mountedModes.has("git") || props.mode === "git"}98 mounted={mountedModes.has("git") || props.mode === "git"}
99 resetKeys={props.resetKeys}
87 visible={props.mode === "git"}100 visible={props.mode === "git"}
88 >101 >
89 <Git />102 <Git />
...@@ -91,6 +104,7 @@ export function ThreadInspectorContentStack(props: {...@@ -91,6 +104,7 @@ export function ThreadInspectorContentStack(props: {
91 {Route ? (104 {Route ? (
92 <InspectorContentPane105 <InspectorContentPane
93 mounted={mountedModes.has("route") || props.mode === "route"}106 mounted={mountedModes.has("route") || props.mode === "route"}
107 resetKeys={props.resetKeys}
94 visible={props.mode === "route"}108 visible={props.mode === "route"}
95 >109 >
96 <Route />110 <Route />