Open History to see the last commit
1import { useActiveNewTask } from "../../../state/active-new-task";
2import { Toolbar } from "../../../components/ui/Toolbar";
3import { useFocusEffect, useIsFocused, useNavigationState } from "@react-navigation/native";
4import { use, useCallback, useEffect, useRef } from "react";
5import { isDuoDevice } from "../../../lib/device-type";
6import { useDeviceType } from "../../../native/native-layout-metrics";
7import { createNativeHeaderMenu } from "../../../components/ui/nativeHeaderMenu.ios";
8import { useSettingsEnvironmentMenu } from "../../settings/components/SettingsEnvironmentFilterHeader";
9import type { SearchBarCommands } from "react-native-screens";
10
11import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass";
12import { NativeStackScreenOptions } from "../../../native/StackHeader";
13import {
14 NativePrimaryColumnContext,
15 NativeWorkspaceTabActivityContext,
16} from "../../../native/v5-workspace-context";
17import { useHardwareKeyboardCommand } from "../../keyboard/hardwareKeyboardCommands";
18import { useAdaptiveWorkspaceLayout, useWorkspacePaneState } from "../AdaptiveWorkspaceLayout";
19import { useWorkspaceTabs } from "./WorkspaceTabsContext";
20import { shouldRestoreNativeSearch } from "./workspace-tabs-state";
21import { useFloatingWorkspaceTabs, useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar";
22import { WorkspaceTabToolbarButtons } from "./WorkspaceSectionToolbar";
23
24const SEARCH_LABELS = {
25 threads: "Search threads",
26 pullRequests: "Search pull requests",
27 settings: "Search settings",
28} as const;
29
30export function WorkspaceToolbar(props: {
31 readonly onNewTask: () => void;
32 readonly sidebar?: boolean;
33}) {
34 const workspace = useWorkspaceTabs()!;
35 const activeNewTask = useActiveNewTask();
36 const deviceType = useDeviceType();
37 const sidebarTabs = useSidebarWorkspaceTabs();
38 const floatingTabs = useFloatingWorkspaceTabs();
39 const duoSearch = NATIVE_LIQUID_GLASS_SUPPORTED && isDuoDevice(deviceType);
40 const bottomSearch = duoSearch && sidebarTabs !== "picker";
41 // On the Duo, compose belongs to the threads tab, in the detail column's bar when unfolded.
42 const nativeToolbarCompose =
43 deviceType === "duo-folded" &&
44 !workspace.settingsOpen &&
45 workspace.state.selectedTab === "threads" &&
46 activeNewTask === null;
47 const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true;
48 const focused = useIsFocused();
49 const { layout, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
50 const { panes } = useWorkspacePaneState();
51 const searchRef = useRef<SearchBarCommands>(null);
52 const settingsMenu = useSettingsEnvironmentMenu();
53 // The Settings list takes the selected tab's place, so search follows it.
54 const selectedTab = workspace.settingsOpen ? "settings" : workspace.state.selectedTab;
55 const tab = workspace.state.tabs[selectedTab];
56 const searchLabel = SEARCH_LABELS[selectedTab === "usage" ? "threads" : selectedTab];
57 const { isTabActive: isListTabActive } = workspace;
58 // A retained tab slot stays mounted while another is shown.
59 const isTabActive = (tab: typeof selectedTab) =>
60 tab === "settings" ? tabActive : isListTabActive(tab);
61 const foldedSearch = deviceType === "duo-folded";
62 const primaryColumn = use(NativePrimaryColumnContext);
63 // UIKit hosts integrated search outside the sidebar's sliding content.
64 // Mount it after native appearance so it cannot precede the pane's reveal.
65 const searchVisible = foldedSearch
66 ? tab.searchMode !== "resting"
67 : deviceType !== "duo-unfolded" || primaryColumn?.presented !== false;
68 const enterSearch = useCallback(() => {
69 workspace.dispatch({
70 type: "scroll",
71 tab: selectedTab,
72 offset: workspace.getScrollOffset(selectedTab),
73 });
74 workspace.dispatch({ type: "search", tab: selectedTab });
75 if (props.sidebar && !panes.primarySidebarVisible) togglePrimarySidebar();
76 }, [workspace, selectedTab, props.sidebar, panes.primarySidebarVisible, togglePrimarySidebar]);
77 const focusSearch = () => {
78 if (!isTabActive(selectedTab)) return false;
79 if (duoSearch || !NATIVE_LIQUID_GLASS_SUPPORTED) enterSearch();
80 searchRef.current?.focus();
81 return true;
82 };
83 const cancelSearch = () => workspace.cancelSearch(selectedTab);
84 const searchFocused = () =>
85 tab.searchMode === "resting"
86 ? enterSearch()
87 : workspace.dispatch({ type: "search", tab: selectedTab });
88 useHardwareKeyboardCommand("focusSearch", focusSearch);
89 // Home draws the Settings list under the `Settings` route, which holds navigation focus.
90 const settingsListOnTop = useNavigationState(
91 (state) => state.routes[state.index]?.name === "Settings",
92 );
93 // The sidebar remains visible while its detail owns navigation focus.
94 const searchHostVisible = focused || settingsListOnTop || primaryColumn?.presented === true;
95 const restoreSearch = () => {
96 // A search cancelled while its screen was covered leaves its text in the native field.
97 if (tab.searchMode === "resting") searchRef.current?.setText("");
98 if (
99 searchHostVisible &&
100 searchVisible &&
101 isTabActive(selectedTab) &&
102 shouldRestoreNativeSearch(tab, duoSearch)
103 ) {
104 searchRef.current?.setText(tab.query);
105 // Integrated search collapses to its button when its screen reappears,
106 // which would hide the query that still filters the list.
107 if (NATIVE_LIQUID_GLASS_SUPPORTED) {
108 searchRef.current?.setActive(true);
109 if (tab.searchMode === "active") searchRef.current?.focus();
110 }
111 }
112 };
113 useFocusEffect(
114 useCallback(() => {
115 const frame = requestAnimationFrame(restoreSearch);
116 return () => cancelAnimationFrame(frame);
117 }, [restoreSearch]),
118 );
119 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame to restore the native search bar's text and focus when its tab becomes active
120 useEffect(() => {
121 if (!tabActive) return;
122 const frame = requestAnimationFrame(restoreSearch);
123 return () => cancelAnimationFrame(frame);
124 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Moving the search bar to the bottom toolbar restores its focus and query.
125 }, [restoreSearch, tabActive, bottomSearch]);
126 const composeButton = nativeToolbarCompose ? (
127 <Toolbar.Button
128 identifier="workspace-compose"
129 accessibilityLabel="New chat"
130 icon="square.and.pencil"
131 onPress={props.onNewTask}
132 separateBackground
133 />
134 ) : null;
135 const searchButton = foldedSearch ? (
136 <Toolbar.Button
137 identifier="workspace-search"
138 accessibilityLabel={searchVisible ? "Close search" : searchLabel}
139 icon={searchVisible ? "xmark" : "magnifyingglass"}
140 onPress={searchVisible ? () => searchRef.current?.cancelSearch() : focusSearch}
141 separateBackground
142 />
143 ) : null;
144 return (
145 <>
146 <NativeStackScreenOptions
147 optionsVersion={[selectedTab, tab.searchMode]}
148 listeners={{ transitionEnd: restoreSearch }}
149 options={{
150 // Without Liquid Glass, Settings keeps its search field out instead of a search button,
151 // which would take the header's trailing items from the scope filter.
152 headerSearchBarOptions:
153 (NATIVE_LIQUID_GLASS_SUPPORTED ||
154 tab.searchMode !== "resting" ||
155 workspace.settingsOpen) &&
156 searchVisible
157 ? {
158 ref: searchRef,
159 autoFocus: tab.searchMode === "active",
160 autoCapitalize: "none",
161 hideNavigationBar: false,
162 hideWhenScrolling: false,
163 obscureBackground: false,
164 placement: NATIVE_LIQUID_GLASS_SUPPORTED
165 ? bottomSearch
166 ? "integrated"
167 : "integratedButton"
168 : "stacked",
169 allowToolbarIntegration:
170 NATIVE_LIQUID_GLASS_SUPPORTED &&
171 deviceType !== "phone" &&
172 sidebarTabs !== "picker",
173 placeholder: searchLabel,
174 cancelButtonHidden: foldedSearch,
175 onFocus: searchFocused,
176 onBlur: () => workspace.dispatch({ type: "expand", tab: selectedTab }),
177 onSearchButtonPress: () => searchRef.current?.blur(),
178 onChangeText: ({ nativeEvent }) =>
179 workspace.dispatch({
180 type: "query",
181 query: nativeEvent.text,
182 tab: selectedTab,
183 }),
184 onCancelButtonPress: cancelSearch,
185 }
186 : undefined,
187 }}
188 />
189 {/* UIKit lays trailing items out from the edge inward. */}
190 {workspace.settingsOpen ? (
191 <Toolbar placement="right">
192 {searchButton}
193 {createNativeHeaderMenu(settingsMenu)}
194 </Toolbar>
195 ) : (
196 <Toolbar placement="right">
197 {searchButton}
198 {nativeToolbarCompose && foldedSearch ? <Toolbar.Spacer width={8} /> : null}
199 {composeButton}
200 {selectedTab !== "threads" ||
201 isDuoDevice(deviceType) ||
202 (deviceType === "pad" && layout.usesSplitView) ||
203 sidebarTabs === "picker" ||
204 floatingTabs ? null : (
205 <Toolbar.Button
206 accessibilityLabel="New chat"
207 icon="square.and.pencil"
208 onPress={props.onNewTask}
209 />
210 )}
211 {NATIVE_LIQUID_GLASS_SUPPORTED ? null : (
212 <Toolbar.Button
213 accessibilityLabel={searchLabel}
214 icon="magnifyingglass"
215 onPress={focusSearch}
216 />
217 )}
218 </Toolbar>
219 )}
220 <Toolbar placement="bottom">
221 {sidebarTabs === "toolbar" ? <WorkspaceTabToolbarButtons /> : null}
222 {bottomSearch && searchVisible ? <Toolbar.SearchBarSlot /> : null}
223 </Toolbar>
224 </>
225 );
226}