| author | |
| committer | |
| log | 23c7ab901afa66dc8e791a3873b600726a6ba8c7 |
| tree | 5dda066ac437f3cb10409391cebe006314b3ad5d |
| parent | 10882bea739a98f11b942c20c6570eb6126dd010 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 13 | import { useEffect, useRef } from "react"; | 13 | import { useEffect, useRef, type ReactNode } from "react"; |
| 14 | import { | 14 | import { |
| 15 | Platform, | 15 | Platform, |
| 16 | Pressable, | 16 | Pressable, |
| ... | @@ -23,6 +23,11 @@ import { useResolveClassNames } from "uniwind"; | ... | @@ -23,6 +23,11 @@ import { useResolveClassNames } from "uniwind"; |
| 23 | 23 | ||
| 24 | import { AppText as Text } from "./components/AppText"; | 24 | import { AppText as Text } from "./components/AppText"; |
| 25 | import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; | 25 | import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; |
| 26 | import { | ||
| 27 | RenderErrorBoundary, | ||
| 28 | RenderFailureView, | ||
| 29 | type RenderFailureProps, | ||
| 30 | } from "./components/RenderErrorBoundary"; | ||
| 26 | import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; | 31 | import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; |
| 27 | import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; | 32 | import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; |
| 28 | import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; | 33 | import { 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) shares | 782 | // The whole new-task flow (choose project → draft → add project) shares |
| 778 | // draft state via NewTaskFlowProvider. The expo-router era mounted it in | 783 | // 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 | }); |
| 796 | 804 | ||
| 805 | function 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 | |||
| 821 | function 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 | |||
| 797 | export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { | 832 | export 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 |
| 801 | 836 | ||
| 802 | return ( | 837 | return ( |
| 803 | <Navigator | 838 | <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 @@ | ||
| 1 | import { Component, type ReactNode } from "react"; | ||
| 2 | import { ScrollView, View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "./AppText"; | ||
| 5 | import { MaterialButton } from "./MaterialButton"; | ||
| 6 | import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; | ||
| 7 | |||
| 8 | interface RenderErrorBoundaryProps { | ||
| 9 | readonly children: ReactNode; | ||
| 10 | readonly resetKeys?: ReadonlyArray<unknown>; | ||
| 11 | readonly renderFallback?: (props: RenderFailureProps) => ReactNode; | ||
| 12 | } | ||
| 13 | |||
| 14 | interface RenderErrorBoundaryState { | ||
| 15 | readonly failed: boolean; | ||
| 16 | readonly error: unknown; | ||
| 17 | readonly componentStack?: string; | ||
| 18 | readonly resetKeys?: ReadonlyArray<unknown>; | ||
| 19 | } | ||
| 20 | |||
| 21 | export interface RenderFailureProps { | ||
| 22 | readonly details: string; | ||
| 23 | readonly retry: () => void; | ||
| 24 | } | ||
| 25 | |||
| 26 | function 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. */ | ||
| 37 | export 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 | |||
| 84 | export 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 { |
| 57 | import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; | 57 | import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; |
| 58 | import { HomeListOptionsProvider } from "../home/home-list-options"; | 58 | import { HomeListOptionsProvider } from "../home/home-list-options"; |
| 59 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; | 59 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; |
| 60 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; | ||
| 60 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; | 61 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 61 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; | 62 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; |
| 62 | import { WorkspaceContentWidthContext } from "./workspace-content-width"; | 63 | import { 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 | <ThreadNavigationSidebar | 585 | 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 | <WorkspaceInspectorPane | 638 | <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"; |
| 9 | 9 | ||
| 10 | import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout"; | 10 | import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout"; |
| 11 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; | ||
| 11 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; | 12 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 12 | import { WorkspacePaneDivider } from "./workspace-pane-divider"; | 13 | import { WorkspacePaneDivider } from "./workspace-pane-divider"; |
| 13 | 14 | ||
| ... | @@ -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 | */ |
| 25 | export function WorkspaceInspectorPane(props: { | 26 | export 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 the | 30 | * 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. | ||
| 161 | function 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"; |
| 70 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 70 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 71 | import type { StatusTone } from "../../components/StatusPill"; | 71 | import type { StatusTone } from "../../components/StatusPill"; |
| 72 | import type { DraftComposerAttachment } from "../../lib/composerImages"; | 72 | import type { DraftComposerAttachment } from "../../lib/composerImages"; |
| 73 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; | ||
| 73 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; | 74 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; |
| 74 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; | 75 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 75 | import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; | 76 | import { 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 | <ThreadFeed | 896 | <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 @@ |
| 1 | import { useEffect, useState, type ComponentType, type ReactNode } from "react"; | 1 | import { useEffect, useState, type ComponentType, type ReactNode } from "react"; |
| 2 | import { View } from "react-native"; | 2 | import { View } from "react-native"; |
| 3 | 3 | ||
| 4 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; | ||
| 5 | |||
| 4 | export type ThreadInspectorMode = "route" | "git" | "files"; | 6 | export type ThreadInspectorMode = "route" | "git" | "files"; |
| 5 | 7 | ||
| 6 | const INSPECTOR_PREWARM_DELAY_MS = 350; | 8 | const 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; |
| 8 | function InspectorContentPane(props: { | 10 | function 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 | <InspectorContentPane | 90 | <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 | <InspectorContentPane | 97 | <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 | <InspectorContentPane | 105 | <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 /> |