1import { useFocusEffect, useNavigation } from "@react-navigation/native";
2import { use, useCallback, useEffect, useRef } from "react";
3import { Platform } from "react-native";
4import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
5import type { SearchBarCommands } from "react-native-screens";
6
7import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
8import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
9import { NativeWorkspaceTabActivityContext } from "../../native/v5-workspace-context";
10import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
11import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
12import { useWorkspaceTabs } from "./WorkspaceTabsContext";
13
14/** Root actions stay in the header; UIKit owns the bottom compose button. */
15export function WorkspaceToolbar(props: {
16 readonly onNewTask: () => void;
17 readonly sidebar?: boolean;
18}) {
19 const workspace = useWorkspaceTabs()!;
20 const navigation = useNavigation();
21 const metrics = useNativeLayoutMetrics();
22 const nativeToolbarCompose =
23 Platform.OS === "ios" && (Platform.isPad || (metrics?.verticalBarEdge ?? "none") !== "none");
24 const { isTabActive } = workspace;
25 const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true;
26 const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
27 const searchRef = useRef<SearchBarCommands>(null);
28 const tab = workspace.state.tabs[workspace.state.selectedTab];
29 const selectedTab = workspace.state.selectedTab;
30 const enterSearch = useCallback(() => {
31 workspace.dispatch({
32 type: "scroll",
33 tab: selectedTab,
34 offset: workspace.getScrollOffset(selectedTab),
35 });
36 workspace.dispatch({ type: "search", tab: selectedTab });
37 if (props.sidebar && !panes.primarySidebarVisible) togglePrimarySidebar();
38 }, [workspace, selectedTab, props.sidebar, panes.primarySidebarVisible, togglePrimarySidebar]);
39 const focusSearch = useCallback(() => {
40 if (!workspace.isTabActive(selectedTab)) return false;
41 // A mounted bar enters search from its own onFocus.
42 if (!NATIVE_LIQUID_GLASS_SUPPORTED) enterSearch();
43 searchRef.current?.focus();
44 return true;
45 }, [workspace, selectedTab, enterSearch]);
46 useHardwareKeyboardCommand("focusSearch", focusSearch);
47 const restoreSearch = useCallback(() => {
48 if (isTabActive(selectedTab) && tab.searchMode !== "resting")
49 searchRef.current?.setText(tab.query);
50 }, [isTabActive, selectedTab, tab.searchMode, tab.query]);
51 useFocusEffect(
52 useCallback(() => {
53 const frame = requestAnimationFrame(restoreSearch);
54 return () => cancelAnimationFrame(frame);
55 }, [restoreSearch]),
56 );
57 useEffect(() => {
58 if (!tabActive) return;
59 const frame = requestAnimationFrame(restoreSearch);
60 return () => cancelAnimationFrame(frame);
61 }, [restoreSearch, tabActive]);
62 return (
63 <>
64 <NativeStackScreenOptions
65 optionsVersion={[selectedTab, tab.searchMode]}
66 options={{
67 // Liquid Glass keeps search mounted as UIKit's own header button, which
68 // morphs into the field; swapping a stacked bar in and out cannot animate.
69 headerSearchBarOptions:
70 NATIVE_LIQUID_GLASS_SUPPORTED || tab.searchMode !== "resting"
71 ? {
72 ref: searchRef,
73 autoFocus: tab.searchMode === "active",
74 autoCapitalize: "none",
75 hideNavigationBar: false,
76 hideWhenScrolling: false,
77 obscureBackground: false,
78 placement: NATIVE_LIQUID_GLASS_SUPPORTED ? "integratedButton" : "stacked",
79 allowToolbarIntegration: NATIVE_LIQUID_GLASS_SUPPORTED,
80 placeholder:
81 selectedTab === "pullRequests" ? "Search pull requests" : "Search threads",
82 cancelButtonText: "✕",
83 onFocus: () =>
84 tab.searchMode === "resting"
85 ? enterSearch()
86 : workspace.dispatch({ type: "search", tab: selectedTab }),
87 onBlur: () => workspace.dispatch({ type: "expand", tab: selectedTab }),
88 onSearchButtonPress: () => searchRef.current?.blur(),
89 onChangeText: ({ nativeEvent }) =>
90 workspace.dispatch({
91 type: "query",
92 query: nativeEvent.text,
93 tab: selectedTab,
94 }),
95 onCancelButtonPress: () => {
96 workspace.recordScrollOffset(selectedTab, tab.searchEntryOffset);
97 workspace.dispatch({ type: "cancel", tab: selectedTab });
98 },
99 }
100 : undefined,
101 }}
102 />
103 {/* UIKit lays trailing items out from the edge inward. */}
104 <NativeHeaderToolbar placement="right">
105 <NativeHeaderToolbar.Button
106 accessibilityLabel="Settings"
107 icon="gearshape"
108 onPress={() =>
109 navigation.navigate("SettingsSheet", {
110 screen: "SettingsContent",
111 params: { screen: "Settings" },
112 })
113 }
114 />
115 {NATIVE_LIQUID_GLASS_SUPPORTED ? null : (
116 <NativeHeaderToolbar.Button
117 accessibilityLabel={
118 selectedTab === "pullRequests" ? "Search pull requests" : "Search threads"
119 }
120 icon="magnifyingglass"
121 onPress={focusSearch}
122 />
123 )}
124 </NativeHeaderToolbar>
125 <NativeHeaderToolbar placement="bottom">
126 {nativeToolbarCompose ? <NativeHeaderToolbar.Spacer flexible /> : null}
127 {nativeToolbarCompose ? (
128 <NativeHeaderToolbar.Button
129 identifier="workspace-compose"
130 accessibilityLabel="New chat"
131 icon="square.and.pencil"
132 onPress={props.onNewTask}
133 separateBackground
134 />
135 ) : null}
136 </NativeHeaderToolbar>
137 </>
138 );
139}