From 833a03afe32b2f332ab19ce72c0e0dc3cf72c341 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:48:58 -0700 Subject: [PATCH] fix(mobile): update the iOS Git header menu when status changes (#16330) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/threads/ThreadHeader.tsx | 115 +++++++++++ .../features/threads/ThreadRouteScreen.tsx | 112 +---------- .../threads/useThreadHeaderOptions.android.ts | 2 + .../threads/useThreadHeaderOptions.test.tsx | 190 ++++++++++++++++++ .../threads/useThreadHeaderOptions.tsx | 13 ++ 5 files changed, 321 insertions(+), 111 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadHeader.tsx create mode 100644 apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b0696a5d3f7e0cf32ac7407b98ac6e72bc35f7d --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -0,0 +1,115 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { useMemo } from "react"; +import { ScreenHeader } from "../../components/ScreenHeader"; +import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; +import type { ScreenHeaderAction } from "../../components/ScreenHeader.types"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import type { ThreadInspectorMode } from "./thread-inspector-content-stack"; +import { useThreadHeaderOptions } from "./useThreadHeaderOptions"; + +export 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, onMergeBack } = 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 && props.gitControls.canOpenTerminal) { + 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, + }); + if (onMergeBack) { + actions.push({ + accessibilityLabel: "Merge back to source", + icon: "arrow.triangle.merge", + onPress: onMergeBack, + }); + } + return actions; + }, [ + props.inspectorMode, + panes.auxiliaryPaneVisible, + props.onOpenFilesInspector, + onOpenTerminal, + onMergeBack, + props.onOpenGitInspector, + toggleAuxiliaryPane, + props.onReturnToThread, + props.hasThreadCwd, + props.hasWorkspaceRoot, + props.gitControls.canOpenTerminal, + ]); + + 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} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 5ff9397a179d98436493414c6f8403d0079daf67..8a579fc9a2f64d2eff2f88405a61fb40f8233050 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -2,10 +2,6 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; import { useWorktreeSetup } from "./use-worktree-setup"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; -import { ScreenHeader } from "../../components/ScreenHeader"; -import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; -import type { ScreenHeaderAction } from "../../components/ScreenHeader.types"; -import { useThreadHeaderOptions } from "./useThreadHeaderOptions"; import { StackActions, useFocusEffect, @@ -88,113 +84,7 @@ import { type ThreadInspectorMode, } 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, onMergeBack } = 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 && props.gitControls.canOpenTerminal) { - 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, - }); - if (onMergeBack) { - actions.push({ - accessibilityLabel: "Merge back to source", - icon: "arrow.triangle.merge", - onPress: onMergeBack, - }); - } - return actions; - }, [ - props.inspectorMode, - panes.auxiliaryPaneVisible, - props.onOpenFilesInspector, - onOpenTerminal, - onMergeBack, - props.onOpenGitInspector, - toggleAuxiliaryPane, - props.onReturnToThread, - props.hasThreadCwd, - props.hasWorkspaceRoot, - props.gitControls.canOpenTerminal, - ]); - - 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} - - ); -} +import { ThreadHeader } from "./ThreadHeader"; interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts b/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts index 7ddf716fce0f271e2f457663618d6ad3573d8540..40594d45c13f10c962a9b362e0a9d73407b355bb 100644 --- a/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts @@ -5,6 +5,8 @@ export function useThreadHeaderOptions( ): ReturnType { return { options: { contentStyle: { backgroundColor: props.headerColor } }, + // Android renders header actions as children, not native item factories. + optionsVersion: [], sidebar: true, fallback: null, }; diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..467c81eb09b997d7a3930a604acc8231760d5e5c --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom +import type { VcsStatusResult } from "@t3tools/contracts"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +// Native header item factories are only read when options are applied, so the fake +// navigation snapshots the right items at each setOptions, as native-stack does. +const harness = vi.hoisted(() => ({ + setOptionsCalls: 0, + renderedRightItems: [] as Array>>, + navigatedRoutes: [] as string[], +})); + +vi.mock("@react-navigation/native", () => { + const navigation = { + canGoBack: () => true, + dispatch: () => {}, + goBack: () => {}, + navigate: (route: string) => { + harness.navigatedRoutes.push(route); + }, + addListener: () => () => {}, + setOptions: (options: { unstable_headerRightItems?: () => Array> }) => { + harness.setOptionsCalls += 1; + harness.renderedRightItems.push(options.unstable_headerRightItems?.() ?? []); + }, + }; + return { + StackActions: { replace: () => ({}) }, + useNavigation: () => navigation, + }; +}); +vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("react-native", () => ({ Alert: { alert: () => {} }, Linking: {}, Platform: {} })); +vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ + useAdaptiveWorkspaceLayout: () => ({ + layout: { usesSplitView: false }, + panes: { primarySidebarVisible: false, auxiliaryPaneVisible: false }, + togglePrimarySidebar: () => {}, + toggleAuxiliaryPane: () => {}, + }), +})); +vi.mock("../layout/native-mail-search-toolbar", () => ({ + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED: true, + createNativeMailSearchToolbarItem: () => ({}), +})); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ themeVariables: {} }), +})); + +import { ThreadHeader } from "./ThreadHeader"; + +const projectScripts: never[] = []; + +function status( + files: ReadonlyArray, + overrides: Partial = {}, +): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: true, + refName: "master", + hasWorkingTreeChanges: files.length > 0, + workingTree: { + files: files.map((path) => ({ path, insertions: 0, deletions: 0 })), + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + ...overrides, + } as unknown as VcsStatusResult; +} + +// Mirrors the route screen: every render passes fresh gitControls callbacks. +function Header(props: { readonly gitStatus: VcsStatusResult | null; readonly title?: string }) { + return ( + {}, + onRunAction: async () => null, + canOpenTerminal: true, + canOperateTerminal: true, + canOpenFiles: true, + projectScripts, + terminalSessions: [], + onOpenTerminal: () => {}, + onOpenNewTerminal: () => {}, + onRunProjectScript: async () => {}, + }} + hasThreadCwd + hasWorkspaceRoot + fileInspectorSupported={false} + inspectorMode={null} + onToggleInspector={() => {}} + onOpenGitInspector={() => {}} + onOpenFilesInspector={() => {}} + /> + ); +} + +function renderedGitMenuItems(): Array<{ + description?: unknown; + label?: unknown; + onPress?: () => void; +}> { + const items = harness.renderedRightItems.at(-1) ?? []; + const git = items.find((item) => item.identifier === "thread-right-git") as + | { menu: { items: Array<{ description?: unknown; label?: unknown; onPress?: () => void }> } } + | undefined; + return git?.menu.items ?? []; +} + +function renderedGitStatusDescription(): unknown { + return renderedGitMenuItems()[0]?.description; +} + +let root: Root | null = null; + +function render(element: React.ReactElement) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root ??= createRoot(document.createElement("div")); + act(() => root!.render(element)); +} + +afterEach(() => { + act(() => root?.unmount()); + root = null; + harness.setOptionsCalls = 0; + harness.renderedRightItems = []; + harness.navigatedRoutes = []; +}); + +describe("ThreadHeader", () => { + it("re-applies native header items when Git status changes", () => { + render(
); + expect(renderedGitStatusDescription()).toBe("Clean"); + + // A refreshed status (e.g. after reconnect) must reach the Git popover even + // though nothing else in the header changed. + render(
); + expect(renderedGitStatusDescription()).toBe("1 changed"); + + render(
); + expect(renderedGitStatusDescription()).toBe("Clean"); + }); + + it("does not re-apply options for re-renders with equivalent header content", () => { + render(
); + const applied = harness.setOptionsCalls; + + // Streaming re-renders the route with fresh callbacks and equal Git content. + for (let index = 0; index < 20; index += 1) { + render(
); + } + expect(harness.setOptionsCalls).toBe(applied); + }); + + it("re-applies menu actions when the default ref changes behind the same label", async () => { + // An unpushed commit on a branch with an open PR shows "Push" whether or not the + // branch is the default one, but only the default branch asks for confirmation. + const pushable = (isDefaultRef: boolean) => + status([], { + isDefaultRef, + aheadCount: 1, + hasPrimaryRemote: true, + pr: { state: "open", url: "https://example.com/pr/1" }, + } as Partial); + render(
); + expect(renderedGitMenuItems()[1]?.label).toBe("Push"); + + render(
); + expect(renderedGitMenuItems()[1]?.label).toBe("Push"); + await act(async () => renderedGitMenuItems()[1]?.onPress?.()); + expect(harness.navigatedRoutes).toEqual(["GitConfirm"]); + }); +}); diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx index b8dd5112a36e009839d69eb6cdf21e9c5b98f06a..a3d08e55cb2bf23f4f36d55fd7dfb72035e816ce 100644 --- a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx @@ -108,8 +108,21 @@ export function useThreadHeaderOptions(props: { unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, contentStyle: undefined, }; + const { environmentId, threadId, gitStatus } = props.gitControls; return { options, + // Header item factories are stabilized, so the native header only re-reads them when + // this version changes. Keying on the items keeps the Git menu status live; the menu + // callbacks also read state the items do not display (a "Push" item runs `push` or + // `commit_push` depending on the default ref), so that state is keyed too. + optionsVersion: [ + layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, + environmentId, + threadId, + gitStatus?.isDefaultRef, + gitStatus?.refName, + gitStatus?.pr?.url, + ], sidebar: false, fallback: !layout.usesSplitView && !props.usesNativeHeaderGlass ? ( -- 2.54.0