| author | |
| committer | |
| log | 833a03afe32b2f332ab19ce72c0e0dc3cf72c341 |
| tree | 071e0ee5a2f0b2c66d37bb50a1570859360d2cb0 |
| parent | 4fec120b75bb397bda3c09b7317f79c1d26811c0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>5 files changed, 321 insertions(+), 111 deletions(-)
apps/mobile/src/features/threads/ThreadHeader.tsx created+115| ... | @@ -0,0 +1,115 @@ | ||
| 1 | import { StackActions, useNavigation } from "@react-navigation/native"; | ||
| 2 | import { useMemo } from "react"; | ||
| 3 | import { ScreenHeader } from "../../components/ScreenHeader"; | ||
| 4 | import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; | ||
| 5 | import type { ScreenHeaderAction } from "../../components/ScreenHeader.types"; | ||
| 6 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 7 | import type { ThreadInspectorMode } from "./thread-inspector-content-stack"; | ||
| 8 | import { useThreadHeaderOptions } from "./useThreadHeaderOptions"; | ||
| 9 | |||
| 10 | export function ThreadHeader( | ||
| 11 | props: Parameters<typeof useThreadHeaderOptions>[0] & { | ||
| 12 | readonly hasThreadCwd: boolean; | ||
| 13 | readonly hasWorkspaceRoot: boolean; | ||
| 14 | readonly fileInspectorSupported: boolean; | ||
| 15 | readonly inspectorMode: ThreadInspectorMode | null; | ||
| 16 | readonly onToggleInspector: () => void; | ||
| 17 | readonly onOpenGitInspector: () => void; | ||
| 18 | readonly onOpenFilesInspector: () => void; | ||
| 19 | }, | ||
| 20 | ) { | ||
| 21 | const navigation = useNavigation(); | ||
| 22 | const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); | ||
| 23 | const { onOpenTerminal, onMergeBack } = props.gitControls; | ||
| 24 | const native = useThreadHeaderOptions(props); | ||
| 25 | const androidHeaderActions = useMemo<ReadonlyArray<ScreenHeaderAction>>(() => { | ||
| 26 | const actions: ScreenHeaderAction[] = []; | ||
| 27 | if (props.onReturnToThread) { | ||
| 28 | actions.push({ | ||
| 29 | accessibilityLabel: "Return to chat", | ||
| 30 | icon: "chevron.left", | ||
| 31 | onPress: props.onReturnToThread, | ||
| 32 | }); | ||
| 33 | } | ||
| 34 | if (props.hasThreadCwd) { | ||
| 35 | const filesVisible = props.inspectorMode === "files" && panes.auxiliaryPaneVisible; | ||
| 36 | actions.push({ | ||
| 37 | accessibilityLabel: filesVisible ? "Close files" : "Open files", | ||
| 38 | selected: filesVisible, | ||
| 39 | icon: "folder", | ||
| 40 | onPress: filesVisible ? toggleAuxiliaryPane : props.onOpenFilesInspector, | ||
| 41 | }); | ||
| 42 | } | ||
| 43 | if (props.hasWorkspaceRoot && props.gitControls.canOpenTerminal) { | ||
| 44 | actions.push({ | ||
| 45 | accessibilityLabel: "Open terminal", | ||
| 46 | icon: "terminal", | ||
| 47 | onPress: () => onOpenTerminal(null), | ||
| 48 | }); | ||
| 49 | } | ||
| 50 | actions.push({ | ||
| 51 | accessibilityLabel: "Open git controls", | ||
| 52 | icon: "point.topleft.down.curvedto.point.bottomright.up", | ||
| 53 | onPress: props.onOpenGitInspector, | ||
| 54 | }); | ||
| 55 | if (onMergeBack) { | ||
| 56 | actions.push({ | ||
| 57 | accessibilityLabel: "Merge back to source", | ||
| 58 | icon: "arrow.triangle.merge", | ||
| 59 | onPress: onMergeBack, | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | return actions; | ||
| 63 | }, [ | ||
| 64 | props.inspectorMode, | ||
| 65 | panes.auxiliaryPaneVisible, | ||
| 66 | props.onOpenFilesInspector, | ||
| 67 | onOpenTerminal, | ||
| 68 | onMergeBack, | ||
| 69 | props.onOpenGitInspector, | ||
| 70 | toggleAuxiliaryPane, | ||
| 71 | props.onReturnToThread, | ||
| 72 | props.hasThreadCwd, | ||
| 73 | props.hasWorkspaceRoot, | ||
| 74 | props.gitControls.canOpenTerminal, | ||
| 75 | ]); | ||
| 76 | |||
| 77 | return ( | ||
| 78 | <> | ||
| 79 | <ScreenHeader | ||
| 80 | title={props.title} | ||
| 81 | subtitle={props.subtitle} | ||
| 82 | sidebar={native.sidebar} | ||
| 83 | options={native.options} | ||
| 84 | optionsVersion={native.optionsVersion} | ||
| 85 | trailing={ | ||
| 86 | props.fileInspectorSupported && props.hasThreadCwd ? ( | ||
| 87 | <ScreenHeaderButton | ||
| 88 | accessibilityLabel={ | ||
| 89 | props.inspectorMode !== null && panes.auxiliaryPaneVisible | ||
| 90 | ? "Hide inspector" | ||
| 91 | : "Show inspector" | ||
| 92 | } | ||
| 93 | icon="sidebar.right" | ||
| 94 | selected={props.inspectorMode !== null && panes.auxiliaryPaneVisible} | ||
| 95 | onPress={props.onToggleInspector} | ||
| 96 | /> | ||
| 97 | ) : null | ||
| 98 | } | ||
| 99 | onBack={ | ||
| 100 | layout.usesSplitView | ||
| 101 | ? undefined | ||
| 102 | : () => { | ||
| 103 | // A deep link or cold start has no previous route; Home is the way out. | ||
| 104 | // Read the history at press time: it changes without re-rendering this screen. | ||
| 105 | if (navigation.canGoBack()) navigation.goBack(); | ||
| 106 | else navigation.dispatch(StackActions.replace("Home")); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | actions={androidHeaderActions} | ||
| 110 | hideBottomBorder | ||
| 111 | /> | ||
| 112 | {native.fallback} | ||
| 113 | </> | ||
| 114 | ); | ||
| 115 | } | ||
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1-111| ... | @@ -2,10 +2,6 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | ... | @@ -2,10 +2,6 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 2 | import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; | 2 | import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; |
| 3 | import { useWorktreeSetup } from "./use-worktree-setup"; | 3 | import { useWorktreeSetup } from "./use-worktree-setup"; |
| 4 | import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; | 4 | import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; |
| 5 | import { ScreenHeader } from "../../components/ScreenHeader"; | ||
| 6 | import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; | ||
| 7 | import type { ScreenHeaderAction } from "../../components/ScreenHeader.types"; | ||
| 8 | import { useThreadHeaderOptions } from "./useThreadHeaderOptions"; | ||
| 9 | import { | 5 | import { |
| 10 | StackActions, | 6 | StackActions, |
| 11 | useFocusEffect, | 7 | useFocusEffect, |
| ... | @@ -88,113 +84,7 @@ import { | ... | @@ -88,113 +84,7 @@ import { |
| 88 | type ThreadInspectorMode, | 84 | type ThreadInspectorMode, |
| 89 | } from "./thread-inspector-content-stack"; | 85 | } from "./thread-inspector-content-stack"; |
| 90 | import { threadRouteIsHydrating } from "./thread-route-hydration"; | 86 | import { threadRouteIsHydrating } from "./thread-route-hydration"; |
| 91 | 87 | import { ThreadHeader } from "./ThreadHeader"; | |
| 92 | function ThreadHeader( | ||
| 93 | props: Parameters<typeof useThreadHeaderOptions>[0] & { | ||
| 94 | readonly hasThreadCwd: boolean; | ||
| 95 | readonly hasWorkspaceRoot: boolean; | ||
| 96 | readonly fileInspectorSupported: boolean; | ||
| 97 | readonly inspectorMode: ThreadInspectorMode | null; | ||
| 98 | readonly onToggleInspector: () => void; | ||
| 99 | readonly onOpenGitInspector: () => void; | ||
| 100 | readonly onOpenFilesInspector: () => void; | ||
| 101 | }, | ||
| 102 | ) { | ||
| 103 | const navigation = useNavigation(); | ||
| 104 | const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); | ||
| 105 | const { onOpenTerminal, onMergeBack } = props.gitControls; | ||
| 106 | const native = useThreadHeaderOptions(props); | ||
| 107 | const androidHeaderActions = useMemo<ReadonlyArray<ScreenHeaderAction>>(() => { | ||
| 108 | const actions: ScreenHeaderAction[] = []; | ||
| 109 | if (props.onReturnToThread) { | ||
| 110 | actions.push({ | ||
| 111 | accessibilityLabel: "Return to chat", | ||
| 112 | icon: "chevron.left", | ||
| 113 | onPress: props.onReturnToThread, | ||
| 114 | }); | ||
| 115 | } | ||
| 116 | if (props.hasThreadCwd) { | ||
| 117 | const filesVisible = props.inspectorMode === "files" && panes.auxiliaryPaneVisible; | ||
| 118 | actions.push({ | ||
| 119 | accessibilityLabel: filesVisible ? "Close files" : "Open files", | ||
| 120 | selected: filesVisible, | ||
| 121 | icon: "folder", | ||
| 122 | onPress: filesVisible ? toggleAuxiliaryPane : props.onOpenFilesInspector, | ||
| 123 | }); | ||
| 124 | } | ||
| 125 | if (props.hasWorkspaceRoot && props.gitControls.canOpenTerminal) { | ||
| 126 | actions.push({ | ||
| 127 | accessibilityLabel: "Open terminal", | ||
| 128 | icon: "terminal", | ||
| 129 | onPress: () => onOpenTerminal(null), | ||
| 130 | }); | ||
| 131 | } | ||
| 132 | actions.push({ | ||
| 133 | accessibilityLabel: "Open git controls", | ||
| 134 | icon: "point.topleft.down.curvedto.point.bottomright.up", | ||
| 135 | onPress: props.onOpenGitInspector, | ||
| 136 | }); | ||
| 137 | if (onMergeBack) { | ||
| 138 | actions.push({ | ||
| 139 | accessibilityLabel: "Merge back to source", | ||
| 140 | icon: "arrow.triangle.merge", | ||
| 141 | onPress: onMergeBack, | ||
| 142 | }); | ||
| 143 | } | ||
| 144 | return actions; | ||
| 145 | }, [ | ||
| 146 | props.inspectorMode, | ||
| 147 | panes.auxiliaryPaneVisible, | ||
| 148 | props.onOpenFilesInspector, | ||
| 149 | onOpenTerminal, | ||
| 150 | onMergeBack, | ||
| 151 | props.onOpenGitInspector, | ||
| 152 | toggleAuxiliaryPane, | ||
| 153 | props.onReturnToThread, | ||
| 154 | props.hasThreadCwd, | ||
| 155 | props.hasWorkspaceRoot, | ||
| 156 | props.gitControls.canOpenTerminal, | ||
| 157 | ]); | ||
| 158 | |||
| 159 | return ( | ||
| 160 | <> | ||
| 161 | <ScreenHeader | ||
| 162 | title={props.title} | ||
| 163 | subtitle={props.subtitle} | ||
| 164 | sidebar={native.sidebar} | ||
| 165 | options={native.options} | ||
| 166 | optionsVersion={props.gitControls.projectScripts} | ||
| 167 | trailing={ | ||
| 168 | props.fileInspectorSupported && props.hasThreadCwd ? ( | ||
| 169 | <ScreenHeaderButton | ||
| 170 | accessibilityLabel={ | ||
| 171 | props.inspectorMode !== null && panes.auxiliaryPaneVisible | ||
| 172 | ? "Hide inspector" | ||
| 173 | : "Show inspector" | ||
| 174 | } | ||
| 175 | icon="sidebar.right" | ||
| 176 | selected={props.inspectorMode !== null && panes.auxiliaryPaneVisible} | ||
| 177 | onPress={props.onToggleInspector} | ||
| 178 | /> | ||
| 179 | ) : null | ||
| 180 | } | ||
| 181 | onBack={ | ||
| 182 | layout.usesSplitView | ||
| 183 | ? undefined | ||
| 184 | : () => { | ||
| 185 | // A deep link or cold start has no previous route; Home is the way out. | ||
| 186 | // Read the history at press time: it changes without re-rendering this screen. | ||
| 187 | if (navigation.canGoBack()) navigation.goBack(); | ||
| 188 | else navigation.dispatch(StackActions.replace("Home")); | ||
| 189 | } | ||
| 190 | } | ||
| 191 | actions={androidHeaderActions} | ||
| 192 | hideBottomBorder | ||
| 193 | /> | ||
| 194 | {native.fallback} | ||
| 195 | </> | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | 88 | ||
| 199 | interface ThreadInspectorSelection { | 89 | interface ThreadInspectorSelection { |
| 200 | readonly routeThreadIdentity: string | null; | 90 | readonly routeThreadIdentity: string | null; |
apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts+2| ... | @@ -5,6 +5,8 @@ export function useThreadHeaderOptions( | ... | @@ -5,6 +5,8 @@ export function useThreadHeaderOptions( |
| 5 | ): ReturnType<typeof useIosThreadHeaderOptions> { | 5 | ): ReturnType<typeof useIosThreadHeaderOptions> { |
| 6 | return { | 6 | return { |
| 7 | options: { contentStyle: { backgroundColor: props.headerColor } }, | 7 | options: { contentStyle: { backgroundColor: props.headerColor } }, |
| 8 | // Android renders header actions as children, not native item factories. | ||
| 9 | optionsVersion: [], | ||
| 8 | sidebar: true, | 10 | sidebar: true, |
| 9 | fallback: null, | 11 | fallback: null, |
| 10 | }; | 12 | }; |
apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx created+190| ... | @@ -0,0 +1,190 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | import type { VcsStatusResult } from "@t3tools/contracts"; | ||
| 3 | import { act } from "react"; | ||
| 4 | import { createRoot, type Root } from "react-dom/client"; | ||
| 5 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | // Native header item factories are only read when options are applied, so the fake | ||
| 8 | // navigation snapshots the right items at each setOptions, as native-stack does. | ||
| 9 | const harness = vi.hoisted(() => ({ | ||
| 10 | setOptionsCalls: 0, | ||
| 11 | renderedRightItems: [] as Array<ReadonlyArray<Record<string, unknown>>>, | ||
| 12 | navigatedRoutes: [] as string[], | ||
| 13 | })); | ||
| 14 | |||
| 15 | vi.mock("@react-navigation/native", () => { | ||
| 16 | const navigation = { | ||
| 17 | canGoBack: () => true, | ||
| 18 | dispatch: () => {}, | ||
| 19 | goBack: () => {}, | ||
| 20 | navigate: (route: string) => { | ||
| 21 | harness.navigatedRoutes.push(route); | ||
| 22 | }, | ||
| 23 | addListener: () => () => {}, | ||
| 24 | setOptions: (options: { unstable_headerRightItems?: () => Array<Record<string, unknown>> }) => { | ||
| 25 | harness.setOptionsCalls += 1; | ||
| 26 | harness.renderedRightItems.push(options.unstable_headerRightItems?.() ?? []); | ||
| 27 | }, | ||
| 28 | }; | ||
| 29 | return { | ||
| 30 | StackActions: { replace: () => ({}) }, | ||
| 31 | useNavigation: () => navigation, | ||
| 32 | }; | ||
| 33 | }); | ||
| 34 | vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true })); | ||
| 35 | vi.mock("react-native", () => ({ Alert: { alert: () => {} }, Linking: {}, Platform: {} })); | ||
| 36 | vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ | ||
| 37 | useAdaptiveWorkspaceLayout: () => ({ | ||
| 38 | layout: { usesSplitView: false }, | ||
| 39 | panes: { primarySidebarVisible: false, auxiliaryPaneVisible: false }, | ||
| 40 | togglePrimarySidebar: () => {}, | ||
| 41 | toggleAuxiliaryPane: () => {}, | ||
| 42 | }), | ||
| 43 | })); | ||
| 44 | vi.mock("../layout/native-mail-search-toolbar", () => ({ | ||
| 45 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED: true, | ||
| 46 | createNativeMailSearchToolbarItem: () => ({}), | ||
| 47 | })); | ||
| 48 | vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ | ||
| 49 | useAppearancePreferences: () => ({ themeVariables: {} }), | ||
| 50 | })); | ||
| 51 | |||
| 52 | import { ThreadHeader } from "./ThreadHeader"; | ||
| 53 | |||
| 54 | const projectScripts: never[] = []; | ||
| 55 | |||
| 56 | function status( | ||
| 57 | files: ReadonlyArray<string>, | ||
| 58 | overrides: Partial<VcsStatusResult> = {}, | ||
| 59 | ): VcsStatusResult { | ||
| 60 | return { | ||
| 61 | isRepo: true, | ||
| 62 | hasPrimaryRemote: false, | ||
| 63 | isDefaultRef: true, | ||
| 64 | refName: "master", | ||
| 65 | hasWorkingTreeChanges: files.length > 0, | ||
| 66 | workingTree: { | ||
| 67 | files: files.map((path) => ({ path, insertions: 0, deletions: 0 })), | ||
| 68 | insertions: 0, | ||
| 69 | deletions: 0, | ||
| 70 | }, | ||
| 71 | hasUpstream: false, | ||
| 72 | aheadCount: 0, | ||
| 73 | behindCount: 0, | ||
| 74 | pr: null, | ||
| 75 | ...overrides, | ||
| 76 | } as unknown as VcsStatusResult; | ||
| 77 | } | ||
| 78 | |||
| 79 | // Mirrors the route screen: every render passes fresh gitControls callbacks. | ||
| 80 | function Header(props: { readonly gitStatus: VcsStatusResult | null; readonly title?: string }) { | ||
| 81 | return ( | ||
| 82 | <ThreadHeader | ||
| 83 | title={props.title ?? "Thread"} | ||
| 84 | subtitle="" | ||
| 85 | headerColor="#000" | ||
| 86 | usesNativeHeaderGlass | ||
| 87 | gitControls={{ | ||
| 88 | environmentId: "environment-1", | ||
| 89 | threadId: "thread-1", | ||
| 90 | currentBranch: "master", | ||
| 91 | gitStatus: props.gitStatus, | ||
| 92 | gitOperationLabel: null, | ||
| 93 | onPull: async () => {}, | ||
| 94 | onRunAction: async () => null, | ||
| 95 | canOpenTerminal: true, | ||
| 96 | canOperateTerminal: true, | ||
| 97 | canOpenFiles: true, | ||
| 98 | projectScripts, | ||
| 99 | terminalSessions: [], | ||
| 100 | onOpenTerminal: () => {}, | ||
| 101 | onOpenNewTerminal: () => {}, | ||
| 102 | onRunProjectScript: async () => {}, | ||
| 103 | }} | ||
| 104 | hasThreadCwd | ||
| 105 | hasWorkspaceRoot | ||
| 106 | fileInspectorSupported={false} | ||
| 107 | inspectorMode={null} | ||
| 108 | onToggleInspector={() => {}} | ||
| 109 | onOpenGitInspector={() => {}} | ||
| 110 | onOpenFilesInspector={() => {}} | ||
| 111 | /> | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | |||
| 115 | function renderedGitMenuItems(): Array<{ | ||
| 116 | description?: unknown; | ||
| 117 | label?: unknown; | ||
| 118 | onPress?: () => void; | ||
| 119 | }> { | ||
| 120 | const items = harness.renderedRightItems.at(-1) ?? []; | ||
| 121 | const git = items.find((item) => item.identifier === "thread-right-git") as | ||
| 122 | | { menu: { items: Array<{ description?: unknown; label?: unknown; onPress?: () => void }> } } | ||
| 123 | | undefined; | ||
| 124 | return git?.menu.items ?? []; | ||
| 125 | } | ||
| 126 | |||
| 127 | function renderedGitStatusDescription(): unknown { | ||
| 128 | return renderedGitMenuItems()[0]?.description; | ||
| 129 | } | ||
| 130 | |||
| 131 | let root: Root | null = null; | ||
| 132 | |||
| 133 | function render(element: React.ReactElement) { | ||
| 134 | (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; | ||
| 135 | root ??= createRoot(document.createElement("div")); | ||
| 136 | act(() => root!.render(element)); | ||
| 137 | } | ||
| 138 | |||
| 139 | afterEach(() => { | ||
| 140 | act(() => root?.unmount()); | ||
| 141 | root = null; | ||
| 142 | harness.setOptionsCalls = 0; | ||
| 143 | harness.renderedRightItems = []; | ||
| 144 | harness.navigatedRoutes = []; | ||
| 145 | }); | ||
| 146 | |||
| 147 | describe("ThreadHeader", () => { | ||
| 148 | it("re-applies native header items when Git status changes", () => { | ||
| 149 | render(<Header gitStatus={status([])} />); | ||
| 150 | expect(renderedGitStatusDescription()).toBe("Clean"); | ||
| 151 | |||
| 152 | // A refreshed status (e.g. after reconnect) must reach the Git popover even | ||
| 153 | // though nothing else in the header changed. | ||
| 154 | render(<Header gitStatus={status(["audit.txt"])} />); | ||
| 155 | expect(renderedGitStatusDescription()).toBe("1 changed"); | ||
| 156 | |||
| 157 | render(<Header gitStatus={status([])} />); | ||
| 158 | expect(renderedGitStatusDescription()).toBe("Clean"); | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("does not re-apply options for re-renders with equivalent header content", () => { | ||
| 162 | render(<Header gitStatus={status([])} />); | ||
| 163 | const applied = harness.setOptionsCalls; | ||
| 164 | |||
| 165 | // Streaming re-renders the route with fresh callbacks and equal Git content. | ||
| 166 | for (let index = 0; index < 20; index += 1) { | ||
| 167 | render(<Header gitStatus={status([])} />); | ||
| 168 | } | ||
| 169 | expect(harness.setOptionsCalls).toBe(applied); | ||
| 170 | }); | ||
| 171 | |||
| 172 | it("re-applies menu actions when the default ref changes behind the same label", async () => { | ||
| 173 | // An unpushed commit on a branch with an open PR shows "Push" whether or not the | ||
| 174 | // branch is the default one, but only the default branch asks for confirmation. | ||
| 175 | const pushable = (isDefaultRef: boolean) => | ||
| 176 | status([], { | ||
| 177 | isDefaultRef, | ||
| 178 | aheadCount: 1, | ||
| 179 | hasPrimaryRemote: true, | ||
| 180 | pr: { state: "open", url: "https://example.com/pr/1" }, | ||
| 181 | } as Partial<VcsStatusResult>); | ||
| 182 | render(<Header gitStatus={pushable(false)} />); | ||
| 183 | expect(renderedGitMenuItems()[1]?.label).toBe("Push"); | ||
| 184 | |||
| 185 | render(<Header gitStatus={pushable(true)} />); | ||
| 186 | expect(renderedGitMenuItems()[1]?.label).toBe("Push"); | ||
| 187 | await act(async () => renderedGitMenuItems()[1]?.onPress?.()); | ||
| 188 | expect(harness.navigatedRoutes).toEqual(["GitConfirm"]); | ||
| 189 | }); | ||
| 190 | }); | ||
apps/mobile/src/features/threads/useThreadHeaderOptions.tsx+13| ... | @@ -108,8 +108,21 @@ export function useThreadHeaderOptions(props: { | ... | @@ -108,8 +108,21 @@ export function useThreadHeaderOptions(props: { |
| 108 | unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, | 108 | unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, |
| 109 | contentStyle: undefined, | 109 | contentStyle: undefined, |
| 110 | }; | 110 | }; |
| 111 | const { environmentId, threadId, gitStatus } = props.gitControls; | ||
| 111 | return { | 112 | return { |
| 112 | options, | 113 | options, |
| 114 | // Header item factories are stabilized, so the native header only re-reads them when | ||
| 115 | // this version changes. Keying on the items keeps the Git menu status live; the menu | ||
| 116 | // callbacks also read state the items do not display (a "Push" item runs `push` or | ||
| 117 | // `commit_push` depending on the default ref), so that state is keyed too. | ||
| 118 | optionsVersion: [ | ||
| 119 | layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, | ||
| 120 | environmentId, | ||
| 121 | threadId, | ||
| 122 | gitStatus?.isDefaultRef, | ||
| 123 | gitStatus?.refName, | ||
| 124 | gitStatus?.pr?.url, | ||
| 125 | ], | ||
| 113 | sidebar: false, | 126 | sidebar: false, |
| 114 | fallback: | 127 | fallback: |
| 115 | !layout.usesSplitView && !props.usesNativeHeaderGlass ? ( | 128 | !layout.usesSplitView && !props.usesNativeHeaderGlass ? ( |