1import {
2 NAVIGATION_TAB_ORDER,
3 navigationCustomizationAvailable,
4 resolveNavigationTabOrder,
5 type NavigationTab,
6} from "./navigation-tab-order";
7
8export function resolveWorkspaceTabOrder(saved: readonly unknown[] | undefined) {
9 return navigationCustomizationAvailable()
10 ? resolveNavigationTabOrder(saved)
11 : NAVIGATION_TAB_ORDER;
12}
13
14export type WorkspaceTab = NavigationTab;
15/** Lists with their own search: the searchable tabs and the Settings list. */
16export type WorkspaceSearchTab = WorkspaceTab | "settings";
17export type WorkspaceSearchMode = "resting" | "active" | "expanded";
18
19/** Settings, Chats and pull requests pair a list with a detail column; Usage is one page. */
20export function workspaceUsesColumns(tab: WorkspaceTab, settingsOpen = false) {
21 return settingsOpen || tab === "threads" || tab === "pullRequests";
22}
23
24export interface WorkspaceTabState {
25 readonly query: string;
26 readonly searchMode: WorkspaceSearchMode;
27 readonly scrollOffset: number;
28 readonly searchEntryOffset: number;
29}
30
31export interface WorkspaceTabsState {
32 readonly selectedTab: WorkspaceTab;
33 readonly selectionRevision: number;
34 readonly tabs: Readonly<Record<WorkspaceSearchTab, WorkspaceTabState>>;
35}
36
37function emptyTab(): WorkspaceTabState {
38 return { query: "", searchMode: "resting", scrollOffset: 0, searchEntryOffset: 0 };
39}
40
41/** The list a workspace page belongs to. */
42export function workspaceTabOf(routeName: string | undefined): WorkspaceTab {
43 return routeName === "PullRequest" ? "pullRequests" : "threads";
44}
45
46export function initialWorkspaceTabsState(
47 selectedTab: WorkspaceTab = "threads",
48): WorkspaceTabsState {
49 return {
50 selectedTab,
51 selectionRevision: 0,
52 tabs: {
53 threads: emptyTab(),
54 pullRequests: emptyTab(),
55 usage: emptyTab(),
56 settings: emptyTab(),
57 },
58 };
59}
60
61export type WorkspaceTabsAction =
62 | { readonly type: "select"; readonly tab: WorkspaceTab }
63 | { readonly type: "search"; readonly tab?: WorkspaceSearchTab }
64 | { readonly type: "query"; readonly query: string; readonly tab?: WorkspaceSearchTab }
65 | { readonly type: "expand"; readonly tab?: WorkspaceSearchTab }
66 | { readonly type: "cancel"; readonly tab?: WorkspaceSearchTab }
67 | { readonly type: "scroll"; readonly tab: WorkspaceSearchTab; readonly offset: number };
68
69export function workspaceTabsReducer(
70 state: WorkspaceTabsState,
71 action: WorkspaceTabsAction,
72): WorkspaceTabsState {
73 if (action.type === "select")
74 return action.tab === state.selectedTab
75 ? state
76 : {
77 ...state,
78 selectedTab: action.tab,
79 selectionRevision: state.selectionRevision + 1,
80 };
81 const tab = action.tab ?? state.selectedTab;
82 if (tab !== "threads" && tab !== "pullRequests" && tab !== "settings") return state;
83 const current = state.tabs[tab];
84 let next: WorkspaceTabState;
85 switch (action.type) {
86 case "search":
87 next = {
88 ...current,
89 searchMode: "active",
90 searchEntryOffset:
91 current.searchMode === "resting" ? current.scrollOffset : current.searchEntryOffset,
92 };
93 break;
94 case "query":
95 next = { ...current, query: action.query };
96 break;
97 case "expand":
98 if (current.searchMode === "resting") return state;
99 next = { ...current, searchMode: "expanded" };
100 break;
101 case "cancel":
102 next = {
103 ...current,
104 query: "",
105 searchMode: "resting",
106 scrollOffset: current.searchEntryOffset,
107 };
108 break;
109 case "scroll":
110 if (current.scrollOffset === action.offset) return state;
111 next = { ...current, scrollOffset: action.offset };
112 break;
113 }
114 return { ...state, tabs: { ...state.tabs, [tab]: next } };
115}
116
117/**
118 * iOS 27's integrated search X closes the field with only a blur, leaving `expanded`.
119 * An empty one has nothing to restore, except where the caller keeps it open (the Duo).
120 */
121export function shouldRestoreNativeSearch(tab: WorkspaceTabState, keepsEmptySearchOpen: boolean) {
122 if (tab.searchMode === "resting") return false;
123 return tab.searchMode === "active" || tab.query !== "" || keepsEmptySearchOpen;
124}
125
126export const WORKSPACE_TOOLBAR = { height: 48, expandedGap: 10, edge: 16, gap: 8 };
127export const WORKSPACE_DOCK = { height: 48, tabsWidth: 176, gap: 12, bottomGap: 6 };
128
129export function workspaceDockBottomClearance(input: {
130 readonly bottomInset: number;
131 readonly keyboardHeight: number;
132}) {
133 return 64 + Math.max(input.bottomInset, input.keyboardHeight) + 16;
134}
135
136export function workspaceSearchBottomClearance(input: {
137 readonly bottomInset: number;
138 readonly bottomBarInset?: number;
139 readonly keyboardHeight: number;
140}) {
141 const bottom = input.bottomBarInset ?? Math.max(input.bottomInset, 12);
142 // AboveKeyboard lifts the field to an 8-point gap above the keyboard.
143 return (
144 Math.max(bottom, input.keyboardHeight > 0 ? input.keyboardHeight + 8 : 0) +
145 WORKSPACE_TOOLBAR.height +
146 16
147 );
148}
149
150export function workspaceToolbarHeight(mode: WorkspaceSearchMode): number {
151 return mode === "expanded"
152 ? WORKSPACE_TOOLBAR.height * 2 + WORKSPACE_TOOLBAR.expandedGap
153 : WORKSPACE_TOOLBAR.height;
154}
155
156export const NATIVE_WORKSPACE_TAB_SLOTS = [
157 "first",
158 "second",
159 "third",
160 "fourth",
161 "settings",
162] as const;
163
164export function workspaceTabForNativeSlot(order: readonly WorkspaceTab[], slot: string) {
165 if (slot === "settings") return "settings";
166 const index = NATIVE_WORKSPACE_TAB_SLOTS.findIndex((candidate) => candidate === slot);
167 return order[index] ?? null;
168}