Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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 }) => (
<NewTaskFlowProvider>
<View className="flex-1 bg-sheet-solid">{children}</View>
</NewTaskFlowProvider>
// A screen's layout replaces the navigator's screenLayout.
layout: ({ children, route }) => (
<GuardedScreenLayout route={route}>
<NewTaskFlowProvider>
<View className="flex-1 bg-sheet-solid">{children}</View>
</NewTaskFlowProvider>
</GuardedScreenLayout>
),
options: {
gestureEnabled: true,
Expand All @@ -794,13 +802,41 @@ const RootStackConfig = createNativeStackNavigator({
},
});

function GuardedScreenLayout(props: {
readonly children: ReactNode;
readonly route: { readonly name: string; readonly params?: object | undefined };
}) {
return (
<RenderErrorBoundary
resetKeys={[props.route.params]}
renderFallback={(fallback) => (
<ScreenRenderFallback {...fallback} routeName={props.route.name} />
)}
>
{props.children}
</RenderErrorBoundary>
);
}

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 <RenderFailureView {...props} exit={exit} />;
}

export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
const { width, height } = useWindowDimensions();
const usesWorkspaceFlowScreens =
Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView;

return (
<Navigator
screenLayout={GuardedScreenLayout}
screenOptions={({ route }) => {
if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {
return {};
Expand Down
127 changes: 127 additions & 0 deletions apps/mobile/src/components/RenderErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>;
readonly renderFallback?: (props: RenderFailureProps) => ReactNode;
}

interface RenderErrorBoundaryState {
readonly failed: boolean;
readonly error: unknown;
readonly componentStack?: string;
readonly resetKeys?: ReadonlyArray<unknown>;
}

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<RenderErrorBoundaryState> | 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<RenderErrorBoundaryState> {
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) => <RenderFailureView {...props} />);
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 (
<ScrollView
className="flex-1 bg-screen"
contentContainerClassName="flex-grow items-center justify-center gap-5 px-6 py-8"
contentContainerStyle={
props.bottomInset ? { paddingBottom: 32 + props.bottomInset } : undefined
}
>
<Text accessibilityRole="header" className="text-center text-xl font-t3-bold">
{title}
</Text>
<Text className="text-center text-sm text-foreground-muted">
Try again. If it keeps happening, copy the details for a bug report.
</Text>
<Text selectable className="text-center font-mono text-xs text-danger-foreground">
{props.details.split("\n", 1)[0]?.slice(0, 300)}
</Text>
<View className="w-full max-w-xs gap-2">
<MaterialButton label="Try again" onPress={props.retry} tone="primary" fullWidth />
<MaterialButton
label="Copy details"
onPress={() => copyTextWithHaptic(props.details, { target: "error details" })}
fullWidth
/>
{props.exit ? (
<MaterialButton
label={props.exit.label}
onPress={props.exit.onPress}
tone="text"
fullWidth
/>
) : null}
</View>
</ScrollView>
);
}
42 changes: 27 additions & 15 deletions apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -580,21 +581,31 @@ function AdaptiveWorkspaceLayoutContent(
style={sidebarAnimatedStyle}
>
<View className="flex-1" style={{ width: layout.listPaneWidth }}>
<AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
<ThreadNavigationSidebar
width={layout.listPaneWidth}
visible={panes.primarySidebarVisible}
onRequestVisibility={revealPrimarySidebar}
selectedThreadKey={selectedThreadKey}
onOpenSettings={handleOpenSettings}
onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
onNewThreadInProject={handleNewThreadInProject}
onNewThreadOnBranch={handleNewThreadOnBranch}
onSelectThread={handleSelectThread}
onSearchQueryChange={setPrimarySidebarSearchQuery}
searchQuery={primarySidebarSearchQuery}
/>
</AndroidHomeFabLayout>
<RenderErrorBoundary
renderFallback={(fallback) => (
<RenderFailureView
{...fallback}
title="The sidebar couldn't be displayed"
exit={{ label: "Open settings", onPress: handleOpenSettings }}
/>
)}
>
<AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
<ThreadNavigationSidebar
width={layout.listPaneWidth}
visible={panes.primarySidebarVisible}
onRequestVisibility={revealPrimarySidebar}
selectedThreadKey={selectedThreadKey}
onOpenSettings={handleOpenSettings}
onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
onNewThreadInProject={handleNewThreadInProject}
onNewThreadOnBranch={handleNewThreadOnBranch}
onSelectThread={handleSelectThread}
onSearchQueryChange={setPrimarySidebarSearchQuery}
searchQuery={primarySidebarSearchQuery}
/>
</AndroidHomeFabLayout>
</RenderErrorBoundary>
</View>
</Animated.View>
) : null}
Expand Down Expand Up @@ -625,6 +636,7 @@ function AdaptiveWorkspaceLayoutContent(
</View>
</View>
<WorkspaceInspectorPane
pathname={props.pathname}
renderedInspectorWidth={renderedInspectorWidth}
active={workspaceInspector?.active ?? false}
panes={panes}
Expand Down
17 changes: 16 additions & 1 deletion apps/mobile/src/features/layout/workspace-inspector-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Animated, {
} from "react-native-reanimated";

import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";
import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
import { WorkspacePaneDivider } from "./workspace-pane-divider";

Expand All @@ -23,6 +24,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider";
* module stays import-cycle-free with AdaptiveWorkspaceLayout.
*/
export function WorkspaceInspectorPane(props: {
readonly pathname: string;
readonly renderedInspectorWidth: SharedValue<number>;
/**
* When false the pane animates closed but keeps its content mounted for the
Expand Down Expand Up @@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: {
style={inspectorStyle}
>
<Animated.View className="flex-1" style={inspectorContentStyle}>
{props.renderInspector?.()}
<RenderErrorBoundary
resetKeys={[props.pathname]}
renderFallback={(fallback) => (
<RenderFailureView {...fallback} title="The inspector couldn't be displayed" />
)}
>
<InspectorRenderer render={props.renderInspector} />
</RenderErrorBoundary>
</Animated.View>
</Animated.View>
) : 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?.()}</>;
}
Loading
Loading