authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 22:48:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 22:48:58-07:00
log833a03afe32b2f332ab19ce72c0e0dc3cf72c341
tree071e0ee5a2f0b2c66d37bb50a1570859360d2cb0
parent4fec120b75bb397bda3c09b7317f79c1d26811c0
signature Signed by PGP key B5690EEEBB952194

fix(mobile): update the iOS Git header menu when status changes (#16330)

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 @@
1import { StackActions, useNavigation } from "@react-navigation/native";
2import { useMemo } from "react";
3import { ScreenHeader } from "../../components/ScreenHeader";
4import { ScreenHeaderButton } from "../../components/ScreenHeaderButton";
5import type { ScreenHeaderAction } from "../../components/ScreenHeader.types";
6import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
7import type { ThreadInspectorMode } from "./thread-inspector-content-stack";
8import { useThreadHeaderOptions } from "./useThreadHeaderOptions";
9
10export 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";
22import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
33import { useWorktreeSetup } from "./use-worktree-setup";
44import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
5import { ScreenHeader } from "../../components/ScreenHeader";
6import { ScreenHeaderButton } from "../../components/ScreenHeaderButton";
7import type { ScreenHeaderAction } from "../../components/ScreenHeader.types";
8import { useThreadHeaderOptions } from "./useThreadHeaderOptions";
95import {
106 StackActions,
117 useFocusEffect,
......@@ -88,113 +84,7 @@ import {
8884 type ThreadInspectorMode,
8985} from "./thread-inspector-content-stack";
9086import { threadRouteIsHydrating } from "./thread-route-hydration";
91
92function 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}
87import { ThreadHeader } from "./ThreadHeader";
19888
19989interface ThreadInspectorSelection {
20090 readonly routeThreadIdentity: string | null;
apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts+2
......@@ -5,6 +5,8 @@ export function useThreadHeaderOptions(
55): ReturnType<typeof useIosThreadHeaderOptions> {
66 return {
77 options: { contentStyle: { backgroundColor: props.headerColor } },
8 // Android renders header actions as children, not native item factories.
9 optionsVersion: [],
810 sidebar: true,
911 fallback: null,
1012 };
apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx created+190
......@@ -0,0 +1,190 @@
1// @vitest-environment jsdom
2import type { VcsStatusResult } from "@t3tools/contracts";
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { 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.
9const harness = vi.hoisted(() => ({
10 setOptionsCalls: 0,
11 renderedRightItems: [] as Array<ReadonlyArray<Record<string, unknown>>>,
12 navigatedRoutes: [] as string[],
13}));
14
15vi.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});
34vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true }));
35vi.mock("react-native", () => ({ Alert: { alert: () => {} }, Linking: {}, Platform: {} }));
36vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({
37 useAdaptiveWorkspaceLayout: () => ({
38 layout: { usesSplitView: false },
39 panes: { primarySidebarVisible: false, auxiliaryPaneVisible: false },
40 togglePrimarySidebar: () => {},
41 toggleAuxiliaryPane: () => {},
42 }),
43}));
44vi.mock("../layout/native-mail-search-toolbar", () => ({
45 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED: true,
46 createNativeMailSearchToolbarItem: () => ({}),
47}));
48vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({
49 useAppearancePreferences: () => ({ themeVariables: {} }),
50}));
51
52import { ThreadHeader } from "./ThreadHeader";
53
54const projectScripts: never[] = [];
55
56function 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.
80function 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
115function 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
127function renderedGitStatusDescription(): unknown {
128 return renderedGitMenuItems()[0]?.description;
129}
130
131let root: Root | null = null;
132
133function 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
139afterEach(() => {
140 act(() => root?.unmount());
141 root = null;
142 harness.setOptionsCalls = 0;
143 harness.renderedRightItems = [];
144 harness.navigatedRoutes = [];
145});
146
147describe("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: {
108108 unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined,
109109 contentStyle: undefined,
110110 };
111 const { environmentId, threadId, gitStatus } = props.gitControls;
111112 return {
112113 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 ],
113126 sidebar: false,
114127 fallback:
115128 !layout.usesSplitView && !props.usesNativeHeaderGlass ? (