diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 9d0410c7e9c209292930eb04bcee8e06a093cfe2..e5bfc1590cf002cc1748b08e9aad61ea2b9ed4c4 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -6,7 +6,10 @@ import { } from "../../state/use-composer-drafts"; import { useWorktreeSetup } from "./use-worktree-setup"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { ScreenHeader } from "../../components/ScreenHeader"; +import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; +import type { ScreenHeaderAction } from "../../components/ScreenHeader.types"; +import { useThreadHeaderOptions } from "./useThreadHeaderOptions"; import { StackActions, useFocusEffect, @@ -43,12 +46,6 @@ import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; -import { - AndroidHeaderIconButton, - AndroidScreenHeader, - type AndroidHeaderAction, -} from "../../components/AndroidScreenHeader"; -import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; import { LoadingScreen } from "../../components/LoadingScreen"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; @@ -73,11 +70,6 @@ import { } from "../terminal/terminalLaunchContext"; import { terminalDebugLog } from "../terminal/terminalDebugLog"; import { ThreadDetailScreen, type ThreadDetailScreenProps } from "./ThreadDetailScreen"; -import { - ThreadGitControls, - useThreadGitCenterHeaderItems, - useThreadGitRightHeaderItems, -} from "./ThreadGitControls"; import { GitOverviewSheet } from "./git/GitOverviewSheet"; import { useAtomCommand } from "../../state/use-atom-command"; import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; @@ -93,7 +85,6 @@ import { useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; -import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane"; import { ThreadInspectorContentStack, @@ -101,13 +92,109 @@ import { } from "./thread-inspector-content-stack"; import { threadRouteIsHydrating } from "./thread-route-hydration"; +function ThreadHeader( + props: Parameters[0] & { + readonly hasThreadCwd: boolean; + readonly hasWorkspaceRoot: boolean; + readonly fileInspectorSupported: boolean; + readonly inspectorMode: ThreadInspectorMode | null; + readonly onToggleInspector: () => void; + readonly onOpenGitInspector: () => void; + readonly onOpenFilesInspector: () => void; + }, +) { + const navigation = useNavigation(); + const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { onOpenTerminal } = props.gitControls; + const native = useThreadHeaderOptions(props); + const androidHeaderActions = useMemo>(() => { + const actions: ScreenHeaderAction[] = []; + if (props.onReturnToThread) { + actions.push({ + accessibilityLabel: "Return to chat", + icon: "chevron.left", + onPress: props.onReturnToThread, + }); + } + if (props.hasThreadCwd) { + const filesVisible = props.inspectorMode === "files" && panes.auxiliaryPaneVisible; + actions.push({ + accessibilityLabel: filesVisible ? "Close files" : "Open files", + selected: filesVisible, + icon: "folder", + onPress: filesVisible ? toggleAuxiliaryPane : props.onOpenFilesInspector, + }); + } + if (props.hasWorkspaceRoot) { + actions.push({ + accessibilityLabel: "Open terminal", + icon: "terminal", + onPress: () => onOpenTerminal(null), + }); + } + actions.push({ + accessibilityLabel: "Open git controls", + icon: "point.topleft.down.curvedto.point.bottomright.up", + onPress: props.onOpenGitInspector, + }); + return actions; + }, [ + props.inspectorMode, + panes.auxiliaryPaneVisible, + props.onOpenFilesInspector, + onOpenTerminal, + props.onOpenGitInspector, + toggleAuxiliaryPane, + props.onReturnToThread, + props.hasThreadCwd, + props.hasWorkspaceRoot, + ]); + + return ( + <> + + ) : null + } + onBack={ + layout.usesSplitView + ? undefined + : () => { + // A deep link or cold start has no previous route; Home is the way out. + // Read the history at press time: it changes without re-rendering this screen. + if (navigation.canGoBack()) navigation.goBack(); + else navigation.dispatch(StackActions.replace("Home")); + } + } + actions={androidHeaderActions} + hideBottomBorder + /> + {native.fallback} + + ); +} + interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; readonly mode: ThreadInspectorMode; } -type NativeHeaderItems = ReadonlyArray>; - function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; @@ -237,14 +324,8 @@ function ThreadRouteContent( ) { const { themeVariables } = useAppearancePreferences(); const headerColor = themeVariables["--color-header"]; - const { - fileInspector, - layout, - panes, - showAuxiliaryPane, - toggleAuxiliaryPane, - togglePrimarySidebar, - } = useAdaptiveWorkspaceLayout(); + const { fileInspector, layout, panes, showAuxiliaryPane, toggleAuxiliaryPane } = + useAdaptiveWorkspaceLayout(); const { connectionState } = useRemoteConnectionStatus(); const { onReconnectEnvironment } = useRemoteConnections(); const { @@ -701,94 +782,6 @@ function ThreadRouteContent( onPull: gitActions.onPullSelectedThreadBranch, onRunAction: gitActions.onRunSelectedThreadGitAction, }; - const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps); - const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps); - const splitLeftHeaderItems = useMemo( - () => [ - { - // Match Mail's split-view detail toolbar: the first detail action sits - // inside the content pane, not flush against the sidebar divider. - spacing: 18, - type: "spacing" as const, - }, - ...(props.onReturnToThread - ? [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Return to chat", - icon: { name: "chevron.left", type: "sfSymbol" as const }, - identifier: "thread-left-return", - onPress: props.onReturnToThread, - type: "button" as const, - }), - ] - : []), - withNativeGlassHeaderItem({ - accessibilityLabel: panes.primarySidebarVisible - ? "Maximize content" - : "Show thread sidebar", - icon: { - name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", - type: "sfSymbol" as const, - }, - identifier: "thread-left-sidebar", - onPress: togglePrimarySidebar, - type: "button" as const, - }), - withNativeGlassHeaderItem({ - accessibilityLabel: "New task", - icon: { name: "square.and.pencil", type: "sfSymbol" as const }, - identifier: "thread-left-new-task", - onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), - type: "button" as const, - }), - ], - [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], - ); - const androidHeaderActions = useMemo>(() => { - if (Platform.OS !== "android") return []; - - const actions: AndroidHeaderAction[] = []; - if (props.onReturnToThread) { - actions.push({ - accessibilityLabel: "Return to chat", - icon: "chevron.left", - onPress: props.onReturnToThread, - }); - } - if (selectedThreadCwd !== null) { - const filesVisible = inspectorMode === "files" && panes.auxiliaryPaneVisible; - actions.push({ - accessibilityLabel: filesVisible ? "Close files" : "Open files", - selected: filesVisible, - icon: "folder", - onPress: filesVisible ? toggleAuxiliaryPane : handleOpenFilesInspector, - }); - } - if (selectedThreadProject?.workspaceRoot) { - actions.push({ - accessibilityLabel: "Open terminal", - icon: "terminal", - onPress: () => handleOpenTerminal(null), - }); - } - actions.push({ - accessibilityLabel: "Open git controls", - icon: "point.topleft.down.curvedto.point.bottomright.up", - onPress: handleOpenGitInspector, - }); - return actions; - }, [ - inspectorMode, - panes.auxiliaryPaneVisible, - handleOpenFilesInspector, - handleOpenTerminal, - handleOpenGitInspector, - toggleAuxiliaryPane, - props.onReturnToThread, - selectedThreadCwd, - selectedThreadProject?.workspaceRoot, - ]); - const handleEditFailedCreation = useCallback(async () => { const creation = selectedThreadCreation?.message; if (!creation?.creation || routeThreadIdentity === null) { @@ -929,23 +922,6 @@ function ThreadRouteContent( preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree", }; })(); - // Deep links / cold starts land with Thread as the ONLY route, where the - // native back button does not render. Provide an explicit Home escape for - // that case; when history exists the native back button is used instead. - const canGoBack = navigation.canGoBack(); - const compactHomeHeaderItems = useMemo( - () => [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Go to threads list", - icon: { name: "list.bullet", type: "sfSymbol" as const }, - identifier: "thread-left-home", - onPress: () => navigation.dispatch(StackActions.replace("Home")), - type: "button" as const, - }), - ], - [navigation], - ); - if (!environmentId || !threadId) { return ; } @@ -967,10 +943,8 @@ function ThreadRouteContent( connectionState: routeConnectionState, }); const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; - const renderThreadRouteBody = (showActionControls: boolean) => ( + const renderThreadRouteBody = () => ( <> - - {activeInspectorRenderer ? : null} - splitLeftHeaderItems - : canGoBack - ? undefined - : () => compactHomeHeaderItems - : undefined, - // Search lives in the persistent sidebar, so the split header keeps - // the git controls on the RIGHT (no center items — center space is - // reserved for future breadcrumbs/status). - unstable_headerRightItems: - Platform.OS === "ios" - ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) - : undefined, - unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, - contentStyle: - Platform.OS === "android" && true ? { backgroundColor: headerColor } : undefined, - }} + - {Platform.OS === "android" ? ( - } - trailing={ - fileInspector.supported && selectedThreadCwd !== null ? ( - - ) : null - } - onBack={ - layout.usesSplitView - ? undefined - : () => { - // A deep link or cold start has no previous route; Home is the way out. - // Read the history at press time: it changes without re-rendering this screen. - if (navigation.canGoBack()) navigation.goBack(); - else navigation.dispatch(StackActions.replace("Home")); - } - } - actions={androidHeaderActions} - hideBottomBorder - /> - ) : null} - - {/* Android surfaces the git/files/inspector actions in its in-flow - header above, so the fallback action toolbar stays iOS-only. */} - {renderThreadRouteBody( - Platform.OS !== "android" && !layout.usesSplitView && !usesNativeHeaderGlass, - )} + {renderThreadRouteBody()} ); } diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts b/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ddf716fce0f271e2f457663618d6ad3573d8540 --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts @@ -0,0 +1,11 @@ +import type { useThreadHeaderOptions as useIosThreadHeaderOptions } from "./useThreadHeaderOptions"; + +export function useThreadHeaderOptions( + props: Parameters[0], +): ReturnType { + return { + options: { contentStyle: { backgroundColor: props.headerColor } }, + sidebar: true, + fallback: null, + }; +} diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b8dd5112a36e009839d69eb6cdf21e9c5b98f06a --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx @@ -0,0 +1,119 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { useMemo } from "react"; +import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; +import { + ThreadGitControls, + useThreadGitCenterHeaderItems, + useThreadGitRightHeaderItems, +} from "./ThreadGitControls"; + +type NativeHeaderItems = ReadonlyArray>; + +export function useThreadHeaderOptions(props: { + readonly title: string; + readonly subtitle: string; + readonly headerColor: string; + readonly usesNativeHeaderGlass: boolean; + readonly gitControls: Parameters[0]; + readonly onReturnToThread?: () => void; +}) { + const navigation = useNavigation(); + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls); + const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls); + const splitLeftHeaderItems = useMemo( + () => [ + { + // Match Mail's split-view detail toolbar: the first detail action sits + // inside the content pane, not flush against the sidebar divider. + spacing: 18, + type: "spacing" as const, + }, + ...(props.onReturnToThread + ? [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Return to chat", + icon: { name: "chevron.left", type: "sfSymbol" as const }, + identifier: "thread-left-return", + onPress: props.onReturnToThread, + type: "button" as const, + }), + ] + : []), + withNativeGlassHeaderItem({ + accessibilityLabel: panes.primarySidebarVisible + ? "Maximize content" + : "Show thread sidebar", + icon: { + name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", + type: "sfSymbol" as const, + }, + identifier: "thread-left-sidebar", + onPress: togglePrimarySidebar, + type: "button" as const, + }), + withNativeGlassHeaderItem({ + accessibilityLabel: "New task", + icon: { name: "square.and.pencil", type: "sfSymbol" as const }, + identifier: "thread-left-new-task", + onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + type: "button" as const, + }), + ], + [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], + ); + // Deep links / cold starts land with Thread as the ONLY route, where the + // native back button does not render. Provide an explicit Home escape for + // that case; when history exists the native back button is used instead. + const canGoBack = navigation.canGoBack(); + const compactHomeHeaderItems = useMemo( + () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Go to threads list", + icon: { name: "list.bullet", type: "sfSymbol" as const }, + identifier: "thread-left-home", + onPress: () => navigation.dispatch(StackActions.replace("Home")), + type: "button" as const, + }), + ], + [navigation], + ); + + const options: AppNativeStackNavigationOptions = { + headerShown: true, + headerTitle: props.title, + headerTitleStyle: props.usesNativeHeaderGlass + ? { + fontSize: 17, + fontWeight: "800", + } + : undefined, + title: props.title, + headerBackVisible: !layout.usesSplitView, + // Compact uses the NATIVE back button when a previous route exists; + // deep links / cold starts get an explicit Home button instead. + // Split view always uses its custom left items. + unstable_headerLeftItems: layout.usesSplitView + ? () => splitLeftHeaderItems + : canGoBack + ? undefined + : () => compactHomeHeaderItems, + // Search lives in the persistent sidebar, so the split header keeps + // the git controls on the RIGHT (no center items — center space is + // reserved for future breadcrumbs/status). + unstable_headerRightItems: () => + layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, + unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, + contentStyle: undefined, + }; + return { + options, + sidebar: false, + fallback: + !layout.usesSplitView && !props.usesNativeHeaderGlass ? ( + + ) : null, + }; +}