authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 12:55:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 12:55:15-07:00
log989bde889dd119f5919ce27c9d56db77efedec4c
tree86333beaf772cd1ed6c3efaa764670b491708051
parent4fb9dfc8b8600d830b36da1c15c5f0cd21627e77
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): configure thread headers through the shared core (#12389)


3 files changed, 250 insertions(+), 207 deletions(-)

apps/mobile/src/features/threads/ThreadRouteScreen.tsx+120-207
......@@ -6,7 +6,10 @@ import {
66} from "../../state/use-composer-drafts";
77import { useWorktreeSetup } from "./use-worktree-setup";
88import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
9import { NativeStackScreenOptions } from "../../native/StackHeader";
9import { ScreenHeader } from "../../components/ScreenHeader";
10import { ScreenHeaderButton } from "../../components/ScreenHeaderButton";
11import type { ScreenHeaderAction } from "../../components/ScreenHeader.types";
12import { useThreadHeaderOptions } from "./useThreadHeaderOptions";
1013import {
1114 StackActions,
1215 useFocusEffect,
......@@ -43,12 +46,6 @@ import { useEnvironmentQuery } from "../../state/query";
4346import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
4447import { vcsEnvironment } from "../../state/vcs";
4548import { EmptyState } from "../../components/EmptyState";
46import {
47 AndroidHeaderIconButton,
48 AndroidScreenHeader,
49 type AndroidHeaderAction,
50} from "../../components/AndroidScreenHeader";
51import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
5249import { LoadingScreen } from "../../components/LoadingScreen";
5350import { scopedThreadKey } from "../../lib/scopedEntities";
5451import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
......@@ -73,11 +70,6 @@ import {
7370} from "../terminal/terminalLaunchContext";
7471import { terminalDebugLog } from "../terminal/terminalDebugLog";
7572import { ThreadDetailScreen, type ThreadDetailScreenProps } from "./ThreadDetailScreen";
76import {
77 ThreadGitControls,
78 useThreadGitCenterHeaderItems,
79 useThreadGitRightHeaderItems,
80} from "./ThreadGitControls";
8173import { GitOverviewSheet } from "./git/GitOverviewSheet";
8274import { useAtomCommand } from "../../state/use-atom-command";
8375import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
......@@ -93,7 +85,6 @@ import {
9385 useAdaptiveWorkspacePaneRole,
9486 useRegisterWorkspaceInspector,
9587} from "../layout/AdaptiveWorkspaceLayout";
96import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
9788import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";
9889import {
9990 ThreadInspectorContentStack,
......@@ -101,13 +92,109 @@ import {
10192} from "./thread-inspector-content-stack";
10293import { threadRouteIsHydrating } from "./thread-route-hydration";
10394
95function ThreadHeader(
96 props: Parameters<typeof useThreadHeaderOptions>[0] & {
97 readonly hasThreadCwd: boolean;
98 readonly hasWorkspaceRoot: boolean;
99 readonly fileInspectorSupported: boolean;
100 readonly inspectorMode: ThreadInspectorMode | null;
101 readonly onToggleInspector: () => void;
102 readonly onOpenGitInspector: () => void;
103 readonly onOpenFilesInspector: () => void;
104 },
105) {
106 const navigation = useNavigation();
107 const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
108 const { onOpenTerminal } = props.gitControls;
109 const native = useThreadHeaderOptions(props);
110 const androidHeaderActions = useMemo<ReadonlyArray<ScreenHeaderAction>>(() => {
111 const actions: ScreenHeaderAction[] = [];
112 if (props.onReturnToThread) {
113 actions.push({
114 accessibilityLabel: "Return to chat",
115 icon: "chevron.left",
116 onPress: props.onReturnToThread,
117 });
118 }
119 if (props.hasThreadCwd) {
120 const filesVisible = props.inspectorMode === "files" && panes.auxiliaryPaneVisible;
121 actions.push({
122 accessibilityLabel: filesVisible ? "Close files" : "Open files",
123 selected: filesVisible,
124 icon: "folder",
125 onPress: filesVisible ? toggleAuxiliaryPane : props.onOpenFilesInspector,
126 });
127 }
128 if (props.hasWorkspaceRoot) {
129 actions.push({
130 accessibilityLabel: "Open terminal",
131 icon: "terminal",
132 onPress: () => onOpenTerminal(null),
133 });
134 }
135 actions.push({
136 accessibilityLabel: "Open git controls",
137 icon: "point.topleft.down.curvedto.point.bottomright.up",
138 onPress: props.onOpenGitInspector,
139 });
140 return actions;
141 }, [
142 props.inspectorMode,
143 panes.auxiliaryPaneVisible,
144 props.onOpenFilesInspector,
145 onOpenTerminal,
146 props.onOpenGitInspector,
147 toggleAuxiliaryPane,
148 props.onReturnToThread,
149 props.hasThreadCwd,
150 props.hasWorkspaceRoot,
151 ]);
152
153 return (
154 <>
155 <ScreenHeader
156 title={props.title}
157 subtitle={props.subtitle}
158 sidebar={native.sidebar}
159 options={native.options}
160 optionsVersion={props.gitControls.projectScripts}
161 trailing={
162 props.fileInspectorSupported && props.hasThreadCwd ? (
163 <ScreenHeaderButton
164 accessibilityLabel={
165 props.inspectorMode !== null && panes.auxiliaryPaneVisible
166 ? "Hide inspector"
167 : "Show inspector"
168 }
169 icon="sidebar.right"
170 selected={props.inspectorMode !== null && panes.auxiliaryPaneVisible}
171 onPress={props.onToggleInspector}
172 />
173 ) : null
174 }
175 onBack={
176 layout.usesSplitView
177 ? undefined
178 : () => {
179 // A deep link or cold start has no previous route; Home is the way out.
180 // Read the history at press time: it changes without re-rendering this screen.
181 if (navigation.canGoBack()) navigation.goBack();
182 else navigation.dispatch(StackActions.replace("Home"));
183 }
184 }
185 actions={androidHeaderActions}
186 hideBottomBorder
187 />
188 {native.fallback}
189 </>
190 );
191}
192
104193interface ThreadInspectorSelection {
105194 readonly routeThreadIdentity: string | null;
106195 readonly mode: ThreadInspectorMode;
107196}
108197
109type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>;
110
111198function InspectorPaneRoleActivation() {
112199 useAdaptiveWorkspacePaneRole("inspector");
113200 return null;
......@@ -237,14 +324,8 @@ function ThreadRouteContent(
237324) {
238325 const { themeVariables } = useAppearancePreferences();
239326 const headerColor = themeVariables["--color-header"];
240 const {
241 fileInspector,
242 layout,
243 panes,
244 showAuxiliaryPane,
245 toggleAuxiliaryPane,
246 togglePrimarySidebar,
247 } = useAdaptiveWorkspaceLayout();
327 const { fileInspector, layout, panes, showAuxiliaryPane, toggleAuxiliaryPane } =
328 useAdaptiveWorkspaceLayout();
248329 const { connectionState } = useRemoteConnectionStatus();
249330 const { onReconnectEnvironment } = useRemoteConnections();
250331 const {
......@@ -701,94 +782,6 @@ function ThreadRouteContent(
701782 onPull: gitActions.onPullSelectedThreadBranch,
702783 onRunAction: gitActions.onRunSelectedThreadGitAction,
703784 };
704 const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps);
705 const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps);
706 const splitLeftHeaderItems = useMemo<NativeHeaderItems>(
707 () => [
708 {
709 // Match Mail's split-view detail toolbar: the first detail action sits
710 // inside the content pane, not flush against the sidebar divider.
711 spacing: 18,
712 type: "spacing" as const,
713 },
714 ...(props.onReturnToThread
715 ? [
716 withNativeGlassHeaderItem({
717 accessibilityLabel: "Return to chat",
718 icon: { name: "chevron.left", type: "sfSymbol" as const },
719 identifier: "thread-left-return",
720 onPress: props.onReturnToThread,
721 type: "button" as const,
722 }),
723 ]
724 : []),
725 withNativeGlassHeaderItem({
726 accessibilityLabel: panes.primarySidebarVisible
727 ? "Maximize content"
728 : "Show thread sidebar",
729 icon: {
730 name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left",
731 type: "sfSymbol" as const,
732 },
733 identifier: "thread-left-sidebar",
734 onPress: togglePrimarySidebar,
735 type: "button" as const,
736 }),
737 withNativeGlassHeaderItem({
738 accessibilityLabel: "New task",
739 icon: { name: "square.and.pencil", type: "sfSymbol" as const },
740 identifier: "thread-left-new-task",
741 onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
742 type: "button" as const,
743 }),
744 ],
745 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
746 );
747 const androidHeaderActions = useMemo<ReadonlyArray<AndroidHeaderAction>>(() => {
748 if (Platform.OS !== "android") return [];
749
750 const actions: AndroidHeaderAction[] = [];
751 if (props.onReturnToThread) {
752 actions.push({
753 accessibilityLabel: "Return to chat",
754 icon: "chevron.left",
755 onPress: props.onReturnToThread,
756 });
757 }
758 if (selectedThreadCwd !== null) {
759 const filesVisible = inspectorMode === "files" && panes.auxiliaryPaneVisible;
760 actions.push({
761 accessibilityLabel: filesVisible ? "Close files" : "Open files",
762 selected: filesVisible,
763 icon: "folder",
764 onPress: filesVisible ? toggleAuxiliaryPane : handleOpenFilesInspector,
765 });
766 }
767 if (selectedThreadProject?.workspaceRoot) {
768 actions.push({
769 accessibilityLabel: "Open terminal",
770 icon: "terminal",
771 onPress: () => handleOpenTerminal(null),
772 });
773 }
774 actions.push({
775 accessibilityLabel: "Open git controls",
776 icon: "point.topleft.down.curvedto.point.bottomright.up",
777 onPress: handleOpenGitInspector,
778 });
779 return actions;
780 }, [
781 inspectorMode,
782 panes.auxiliaryPaneVisible,
783 handleOpenFilesInspector,
784 handleOpenTerminal,
785 handleOpenGitInspector,
786 toggleAuxiliaryPane,
787 props.onReturnToThread,
788 selectedThreadCwd,
789 selectedThreadProject?.workspaceRoot,
790 ]);
791
792785 const handleEditFailedCreation = useCallback(async () => {
793786 const creation = selectedThreadCreation?.message;
794787 if (!creation?.creation || routeThreadIdentity === null) {
......@@ -929,23 +922,6 @@ function ThreadRouteContent(
929922 preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree",
930923 };
931924 })();
932 // Deep links / cold starts land with Thread as the ONLY route, where the
933 // native back button does not render. Provide an explicit Home escape for
934 // that case; when history exists the native back button is used instead.
935 const canGoBack = navigation.canGoBack();
936 const compactHomeHeaderItems = useMemo<NativeHeaderItems>(
937 () => [
938 withNativeGlassHeaderItem({
939 accessibilityLabel: "Go to threads list",
940 icon: { name: "list.bullet", type: "sfSymbol" as const },
941 identifier: "thread-left-home",
942 onPress: () => navigation.dispatch(StackActions.replace("Home")),
943 type: "button" as const,
944 }),
945 ],
946 [navigation],
947 );
948
949925 if (!environmentId || !threadId) {
950926 return <OpeningThreadLoadingScreen />;
951927 }
......@@ -967,10 +943,8 @@ function ThreadRouteContent(
967943 connectionState: routeConnectionState,
968944 });
969945 const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
970 const renderThreadRouteBody = (showActionControls: boolean) => (
946 const renderThreadRouteBody = () => (
971947 <>
972 <ThreadGitControls {...threadGitControlProps} showActionControls={showActionControls} />
973
974948 <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />
975949
976950 <View
......@@ -1069,84 +1043,23 @@ function ThreadRouteContent(
10691043 return (
10701044 <>
10711045 {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null}
1072 <NativeStackScreenOptions
1073 optionsVersion={threadGitControlProps.projectScripts}
1074 options={{
1075 // Android draws its own in-flow header (AndroidScreenHeader below);
1076 // the native stack header stays iOS-only.
1077 headerShown: Platform.OS !== "android",
1078 headerTitle: selectedThread.title,
1079 headerTitleStyle: usesNativeHeaderGlass
1080 ? {
1081 fontSize: 17,
1082 fontWeight: "800",
1083 }
1084 : undefined,
1085 title: selectedThread.title,
1086 headerBackVisible: !layout.usesSplitView,
1087 // Compact uses the NATIVE back button when a previous route exists;
1088 // deep links / cold starts get an explicit Home button instead.
1089 // Split view always uses its custom left items.
1090 unstable_headerLeftItems:
1091 Platform.OS === "ios"
1092 ? layout.usesSplitView
1093 ? () => splitLeftHeaderItems
1094 : canGoBack
1095 ? undefined
1096 : () => compactHomeHeaderItems
1097 : undefined,
1098 // Search lives in the persistent sidebar, so the split header keeps
1099 // the git controls on the RIGHT (no center items — center space is
1100 // reserved for future breadcrumbs/status).
1101 unstable_headerRightItems:
1102 Platform.OS === "ios"
1103 ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems)
1104 : undefined,
1105 unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined,
1106 contentStyle:
1107 Platform.OS === "android" && true ? { backgroundColor: headerColor } : undefined,
1108 }}
1046 <ThreadHeader
1047 title={selectedThread.title}
1048 subtitle={headerSubtitle}
1049 headerColor={headerColor}
1050 usesNativeHeaderGlass={usesNativeHeaderGlass}
1051 gitControls={threadGitControlProps}
1052 hasThreadCwd={selectedThreadCwd !== null}
1053 hasWorkspaceRoot={Boolean(selectedThreadProject?.workspaceRoot)}
1054 fileInspectorSupported={fileInspector.supported}
1055 inspectorMode={inspectorMode}
1056 onToggleInspector={handleToggleInspector}
1057 onOpenGitInspector={handleOpenGitInspector}
1058 onOpenFilesInspector={handleOpenFilesInspector}
1059 onReturnToThread={props.onReturnToThread}
11091060 />
11101061
1111 {Platform.OS === "android" ? (
1112 <AndroidScreenHeader
1113 title={selectedThread.title}
1114 subtitle={headerSubtitle}
1115 leading={<AndroidWorkspaceSidebarButton />}
1116 trailing={
1117 fileInspector.supported && selectedThreadCwd !== null ? (
1118 <AndroidHeaderIconButton
1119 accessibilityLabel={
1120 inspectorMode !== null && panes.auxiliaryPaneVisible
1121 ? "Hide inspector"
1122 : "Show inspector"
1123 }
1124 icon="sidebar.right"
1125 selected={inspectorMode !== null && panes.auxiliaryPaneVisible}
1126 onPress={handleToggleInspector}
1127 />
1128 ) : null
1129 }
1130 onBack={
1131 layout.usesSplitView
1132 ? undefined
1133 : () => {
1134 // A deep link or cold start has no previous route; Home is the way out.
1135 // Read the history at press time: it changes without re-rendering this screen.
1136 if (navigation.canGoBack()) navigation.goBack();
1137 else navigation.dispatch(StackActions.replace("Home"));
1138 }
1139 }
1140 actions={androidHeaderActions}
1141 hideBottomBorder
1142 />
1143 ) : null}
1144
1145 {/* Android surfaces the git/files/inspector actions in its in-flow
1146 header above, so the fallback action toolbar stays iOS-only. */}
1147 {renderThreadRouteBody(
1148 Platform.OS !== "android" && !layout.usesSplitView && !usesNativeHeaderGlass,
1149 )}
1062 {renderThreadRouteBody()}
11501063 </>
11511064 );
11521065}
apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts created+11
......@@ -0,0 +1,11 @@
1import type { useThreadHeaderOptions as useIosThreadHeaderOptions } from "./useThreadHeaderOptions";
2
3export function useThreadHeaderOptions(
4 props: Parameters<typeof useIosThreadHeaderOptions>[0],
5): ReturnType<typeof useIosThreadHeaderOptions> {
6 return {
7 options: { contentStyle: { backgroundColor: props.headerColor } },
8 sidebar: true,
9 fallback: null,
10 };
11}
apps/mobile/src/features/threads/useThreadHeaderOptions.tsx created+119
......@@ -0,0 +1,119 @@
1import { StackActions, useNavigation } from "@react-navigation/native";
2import { useMemo } from "react";
3import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";
4import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
5import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
6import {
7 ThreadGitControls,
8 useThreadGitCenterHeaderItems,
9 useThreadGitRightHeaderItems,
10} from "./ThreadGitControls";
11
12type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>;
13
14export function useThreadHeaderOptions(props: {
15 readonly title: string;
16 readonly subtitle: string;
17 readonly headerColor: string;
18 readonly usesNativeHeaderGlass: boolean;
19 readonly gitControls: Parameters<typeof ThreadGitControls>[0];
20 readonly onReturnToThread?: () => void;
21}) {
22 const navigation = useNavigation();
23 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
24 const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls);
25 const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls);
26 const splitLeftHeaderItems = useMemo<NativeHeaderItems>(
27 () => [
28 {
29 // Match Mail's split-view detail toolbar: the first detail action sits
30 // inside the content pane, not flush against the sidebar divider.
31 spacing: 18,
32 type: "spacing" as const,
33 },
34 ...(props.onReturnToThread
35 ? [
36 withNativeGlassHeaderItem({
37 accessibilityLabel: "Return to chat",
38 icon: { name: "chevron.left", type: "sfSymbol" as const },
39 identifier: "thread-left-return",
40 onPress: props.onReturnToThread,
41 type: "button" as const,
42 }),
43 ]
44 : []),
45 withNativeGlassHeaderItem({
46 accessibilityLabel: panes.primarySidebarVisible
47 ? "Maximize content"
48 : "Show thread sidebar",
49 icon: {
50 name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left",
51 type: "sfSymbol" as const,
52 },
53 identifier: "thread-left-sidebar",
54 onPress: togglePrimarySidebar,
55 type: "button" as const,
56 }),
57 withNativeGlassHeaderItem({
58 accessibilityLabel: "New task",
59 icon: { name: "square.and.pencil", type: "sfSymbol" as const },
60 identifier: "thread-left-new-task",
61 onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
62 type: "button" as const,
63 }),
64 ],
65 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
66 );
67 // Deep links / cold starts land with Thread as the ONLY route, where the
68 // native back button does not render. Provide an explicit Home escape for
69 // that case; when history exists the native back button is used instead.
70 const canGoBack = navigation.canGoBack();
71 const compactHomeHeaderItems = useMemo<NativeHeaderItems>(
72 () => [
73 withNativeGlassHeaderItem({
74 accessibilityLabel: "Go to threads list",
75 icon: { name: "list.bullet", type: "sfSymbol" as const },
76 identifier: "thread-left-home",
77 onPress: () => navigation.dispatch(StackActions.replace("Home")),
78 type: "button" as const,
79 }),
80 ],
81 [navigation],
82 );
83
84 const options: AppNativeStackNavigationOptions = {
85 headerShown: true,
86 headerTitle: props.title,
87 headerTitleStyle: props.usesNativeHeaderGlass
88 ? {
89 fontSize: 17,
90 fontWeight: "800",
91 }
92 : undefined,
93 title: props.title,
94 headerBackVisible: !layout.usesSplitView,
95 // Compact uses the NATIVE back button when a previous route exists;
96 // deep links / cold starts get an explicit Home button instead.
97 // Split view always uses its custom left items.
98 unstable_headerLeftItems: layout.usesSplitView
99 ? () => splitLeftHeaderItems
100 : canGoBack
101 ? undefined
102 : () => compactHomeHeaderItems,
103 // Search lives in the persistent sidebar, so the split header keeps
104 // the git controls on the RIGHT (no center items — center space is
105 // reserved for future breadcrumbs/status).
106 unstable_headerRightItems: () =>
107 layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems,
108 unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined,
109 contentStyle: undefined,
110 };
111 return {
112 options,
113 sidebar: false,
114 fallback:
115 !layout.usesSplitView && !props.usesNativeHeaderGlass ? (
116 <ThreadGitControls {...props.gitControls} showActionControls />
117 ) : null,
118 };
119}