1import { useNavigation } from "@react-navigation/native";
2import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
3import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
4import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
5import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6import {
7 FlatList,
8 KeyboardAvoidingView,
9 Modal,
10 Pressable,
11 Text as NativeText,
12 TextInput,
13 useWindowDimensions,
14 View,
15 type TextInputInstance,
16} from "react-native";
17
18import { GestureHandlerRootView } from "react-native-gesture-handler";
19
20import { GlassSurface } from "../../components/GlassSurface";
21import { RowPressable } from "../../components/RowPressable";
22import { AppText as Text } from "../../components/AppText";
23import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
24import { cn } from "../../lib/cn";
25import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
26import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
27import { useProjects, useThreadShell, useThreadShells } from "../../state/entities";
28import { useThreadSearch } from "../../state/queries";
29import { useWorkspaceEnvironments } from "../../state/workspace";
30import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
31import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
32import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
33import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match";
34import {
35 filterCommandPaletteItems,
36 nextPaletteIndex,
37 type CommandPaletteItem,
38} from "./commandPaletteItems";
39import { parseActiveThreadPath, type HardwareKeyboardCommand } from "./hardwareKeyboardCommands";
40import { threadJumpIndex } from "./threadKeyboardShortcuts";
41
42const PALETTE_COMMANDS: ReadonlyArray<HardwareKeyboardCommand> = [
43 "commandPalette",
44 "paletteDismiss",
45 "paletteNext",
46 "palettePrevious",
47 ...THREAD_JUMP_KEYBINDING_COMMANDS,
48];
49const ROW_HEIGHT = 50;
50
51const ACTION_ICONS: Record<string, AppSymbolName> = {
52 newTask: "square.and.pencil",
53 newThread: "square.and.pencil",
54 addProject: "folder.badge.plus",
55 settings: "gearshape",
56 appearance: "paintbrush",
57 environments: "desktopcomputer",
58 usage: "chart.bar.xaxis",
59 archive: "archivebox",
60 files: "doc.text",
61 terminal: "terminal",
62 review: "arrow.triangle.pull",
63 copyThreadReference: "link",
64};
65
66function itemIcon(item: CommandPaletteItem): AppSymbolName {
67 if (item.kind === "project") return "folder";
68 if (item.kind === "thread") return "text.bubble";
69 return ACTION_ICONS[item.key] ?? "ellipsis";
70}
71
72function PaletteRow(props: {
73 readonly item: CommandPaletteItem;
74 readonly index: number;
75 readonly selected: boolean;
76 readonly searchMatch?: EnvironmentThreadSearchMatch;
77 readonly searchQuery: string;
78 readonly onSelect: () => void;
79}) {
80 const foregroundClassName = props.selected
81 ? "text-thread-selected-foreground"
82 : "text-foreground";
83 const mutedForegroundClassName = props.selected
84 ? "text-thread-selected-foreground-muted"
85 : "text-foreground-muted";
86 return (
87 <RowPressable
88 accessibilityRole="button"
89 interactionOpacity={props.selected ? 0 : 1}
90 accessibilityState={{ selected: props.selected }}
91 onPress={props.onSelect}
92 className={
93 props.selected
94 ? "mx-2 flex-row items-center gap-3 rounded-xl bg-thread-selected px-3"
95 : "mx-2 flex-row items-center gap-3 rounded-xl px-3"
96 }
97 style={{ height: ROW_HEIGHT }}
98 >
99 <View className="w-7 items-center">
100 <SymbolView
101 name={itemIcon(props.item)}
102 size={20}
103 tintColorClassName={props.selected ? "accent-thread-selected-foreground" : "accent-icon"}
104 />
105 </View>
106 <View className="flex-1">
107 <Text numberOfLines={1} className={cn("text-base", foregroundClassName)}>
108 {props.item.title}
109 </Text>
110 {props.searchMatch ? (
111 <ThreadSearchMatchExcerpt
112 match={props.searchMatch}
113 query={props.searchQuery}
114 selected={props.selected}
115 compact
116 />
117 ) : props.item.detail ? (
118 <Text numberOfLines={1} className={cn("text-sm", mutedForegroundClassName)}>
119 {props.item.detail}
120 </Text>
121 ) : null}
122 </View>
123 {props.index < 9 ? (
124 <NativeText
125 className={cn("w-8 shrink-0 text-right text-sm tabular-nums", mutedForegroundClassName)}
126 >
127 ⌘{props.index + 1}
128 </NativeText>
129 ) : null}
130 </RowPressable>
131 );
132}
133
134/** Mounted only while open, so the app root does not subscribe to the full thread catalog. */
135export function CommandPalette(props: {
136 readonly pathname: string;
137 readonly onClose: () => void;
138 readonly onCommand: (command: HardwareKeyboardCommand) => void;
139}) {
140 const navigation = useNavigation();
141 const { themeVariables } = useAppearancePreferences();
142 const { selectThread } = useAdaptiveWorkspaceLayout();
143 const runCommand = props.onCommand;
144 const projects = useProjects();
145 const threads = useThreadShells();
146 const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]);
147 const activeThread = useThreadShell(activeThreadRef);
148 const environments = useWorkspaceEnvironments();
149 const { savedConnectionsById } = useSavedRemoteConnections();
150 const [query, setQuery] = useState("");
151 const [selection, setSelection] = useState<string | null>(null);
152 const [visible, setVisible] = useState(true);
153 const pendingAction = useRef<(() => void) | null>(null);
154 const closing = useRef(false);
155 const inputRef = useRef<TextInputInstance>(null);
156 const listRef = useRef<FlatList<CommandPaletteItem>>(null);
157 const { width, height } = useWindowDimensions();
158 const searchEnvironmentIds = useMemo(
159 () =>
160 environments
161 .filter((environment) => environment.connectionState === "connected")
162 .map((environment) => environment.environmentId),
163 [environments],
164 );
165 const search = useThreadSearch(searchEnvironmentIds, query.startsWith(">") ? "" : query);
166 const matchedThreadKeys = useMemo(
167 () =>
168 new Set(search.matches.map((match) => scopedThreadKey(match.environmentId, match.threadId))),
169 [search.matches],
170 );
171 const contentMatchByKey = useMemo(
172 () =>
173 new Map(
174 search.matches
175 .filter((match) => match.source === "user" || match.source === "assistant")
176 .map((match) => [scopedThreadKey(match.environmentId, match.threadId), match]),
177 ),
178 [search.matches],
179 );
180 const items = useMemo(() => {
181 const actions: CommandPaletteItem[] = [
182 {
183 key: "newTask",
184 kind: "action",
185 title: "New thread in…",
186 searchTerms: ["new task", "chat", "create", "project"],
187 run: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
188 },
189 {
190 key: "addProject",
191 kind: "action",
192 title: "Add project",
193 searchTerms: ["folder", "clone", "repository", "git"],
194 run: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }),
195 },
196 {
197 key: "settings",
198 kind: "action",
199 title: "Open settings",
200 searchTerms: ["preferences", "configuration"],
201 run: () =>
202 navigation.navigate("SettingsSheet", {
203 screen: "SettingsContent",
204 params: { screen: "Settings" },
205 }),
206 },
207 {
208 key: "appearance",
209 kind: "action",
210 title: "Appearance",
211 searchTerms: ["theme", "colors", "dark", "light"],
212 run: () =>
213 navigation.navigate("SettingsSheet", {
214 screen: "SettingsContent",
215 params: { screen: "SettingsAppearance" },
216 }),
217 },
218 {
219 key: "environments",
220 kind: "action",
221 title: "Manage environments",
222 searchTerms: ["connections", "server", "remote"],
223 run: () =>
224 navigation.navigate("SettingsSheet", {
225 screen: "SettingsContent",
226 params: { screen: "SettingsEnvironments" },
227 }),
228 },
229 {
230 key: "scheduledTasks",
231 kind: "action",
232 title: "Scheduled tasks",
233 searchTerms: ["schedule", "automations", "recurring"],
234 run: () =>
235 navigation.navigate("SettingsSheet", {
236 screen: "SettingsContent",
237 params: { screen: "SettingsScheduledTasks" },
238 }),
239 },
240 {
241 key: "usage",
242 kind: "action",
243 title: "Usage",
244 searchTerms: ["limits", "accounts", "quota"],
245 run: () =>
246 navigation.navigate("SettingsSheet", {
247 screen: "SettingsContent",
248 params: { screen: "SettingsUsage" },
249 }),
250 },
251 {
252 key: "archive",
253 kind: "action",
254 title: "Archived threads",
255 searchTerms: ["restore", "history"],
256 run: () =>
257 navigation.navigate("SettingsSheet", {
258 screen: "SettingsContent",
259 params: { screen: "SettingsArchive" },
260 }),
261 },
262 ];
263 const projectByKey = new Map(
264 projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]),
265 );
266 const activeProject = activeThread
267 ? projectByKey.get(scopedProjectKey(activeThread.environmentId, activeThread.projectId))
268 : null;
269 if (activeProject) {
270 actions.unshift({
271 key: "newThread",
272 kind: "action",
273 title: `New thread in ${activeProject.title}`,
274 searchTerms: ["new task", "chat", "create"],
275 run: () =>
276 navigation.navigate("NewTaskSheet", {
277 screen: "NewTaskDraft",
278 params: {
279 environmentId: activeProject.environmentId,
280 projectId: activeProject.id,
281 title: activeProject.title,
282 },
283 }),
284 });
285 }
286 if (activeThreadRef) {
287 const threadActions = [
288 ["files", "Go to file", ["open", "files", "browse", "search"]],
289 ["terminal", "Open terminal", ["shell", "console"]],
290 ["review", "Review changes", ["diff", "git", "pull request"]],
291 ["copyThreadReference", "Copy PR link or thread ID", ["reference", "clipboard"]],
292 ] as const;
293 actions.push(
294 ...threadActions.map(([command, title, searchTerms]) => ({
295 key: command,
296 kind: "action" as const,
297 title,
298 searchTerms,
299 run: () => runCommand(command),
300 })),
301 );
302 }
303 const projectItems: CommandPaletteItem[] = projects.map((project) => ({
304 key: `project:${scopedProjectKey(project.environmentId, project.id)}`,
305 kind: "project",
306 title: project.title,
307 detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`,
308 searchTerms: [project.workspaceRoot, "new thread", "project"],
309 run: () =>
310 navigation.navigate("NewTaskSheet", {
311 screen: "NewTaskDraft",
312 params: {
313 environmentId: project.environmentId,
314 projectId: project.id,
315 title: project.title,
316 },
317 }),
318 }));
319 const threadItems: CommandPaletteItem[] = threads
320 .filter((thread) => thread.archivedAt === null)
321 .sort((left, right) =>
322 (right.latestUserMessageAt ?? right.updatedAt).localeCompare(
323 left.latestUserMessageAt ?? left.updatedAt,
324 ),
325 )
326 .map((thread) => {
327 const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId));
328 const environment =
329 savedConnectionsById[thread.environmentId]?.environmentLabel ?? thread.environmentId;
330 return {
331 key: scopedThreadKey(thread.environmentId, thread.id),
332 kind: "thread",
333 title: thread.title || "Untitled thread",
334 detail: [project?.title, environment].filter(Boolean).join(" · "),
335 searchTerms: [
336 project?.title ?? "",
337 environment,
338 thread.branch ?? "",
339 ...threadPullRequestSearchTerms(thread),
340 ],
341 run: () => selectThread(thread),
342 };
343 });
344 return [...actions, ...projectItems, ...threadItems];
345 }, [
346 activeThread,
347 activeThreadRef,
348 navigation,
349 projects,
350 runCommand,
351 savedConnectionsById,
352 selectThread,
353 threads,
354 ]);
355 const results = useMemo(
356 () => filterCommandPaletteItems(items, query, matchedThreadKeys),
357 [items, matchedThreadKeys, query],
358 );
359 const selectedIndex = Math.max(
360 0,
361 results.findIndex((item) => item.key === selection),
362 );
363 const selectedKey = results[selectedIndex]?.key;
364 useEffect(() => {
365 if (selectedIndex === 0) {
366 // Centering before the list measures its height scrolls half the first row out of view.
367 listRef.current?.scrollToOffset({ offset: 0, animated: false });
368 } else if (selectedKey !== undefined) {
369 listRef.current?.scrollToIndex({ index: selectedIndex, animated: false, viewPosition: 0.5 });
370 }
371 }, [selectedIndex, selectedKey]);
372
373 const dismissed = useRef(false);
374 const handleDismissed = useCallback(() => {
375 if (dismissed.current) return;
376 dismissed.current = true;
377 // Present navigation sheets only after UIKit has dismissed this modal.
378 props.onClose();
379 pendingAction.current?.();
380 }, [props]);
381
382 // iOS drops Modal onDismiss when the VC is dismissed mid-presentation (e.g.
383 // ⌘K during the fade-in) or raced by another sheet — without a fallback the
384 // palette stays mounted-but-invisible and ⌘K dead-ends on a stale open state.
385 useEffect(() => {
386 if (visible) return;
387 const fallback = setTimeout(handleDismissed, 400);
388 return () => clearTimeout(fallback);
389 }, [visible, handleDismissed]);
390
391 function close(run?: () => void) {
392 if (closing.current) return;
393 closing.current = true;
394 pendingAction.current = run ?? null;
395 setVisible(false);
396 }
397
398 function onCommand(command: HardwareKeyboardCommand) {
399 if (command === "commandPalette" || command === "paletteDismiss") {
400 close();
401 } else if (command === "paletteNext" || command === "palettePrevious") {
402 setSelection(
403 results[nextPaletteIndex(selectedIndex, command === "paletteNext" ? 1 : -1, results.length)]
404 ?.key ?? null,
405 );
406 } else {
407 const item = results[threadJumpIndex(command)];
408 if (item) close(item.run);
409 }
410 }
411
412 return (
413 <Modal
414 visible={visible}
415 transparent
416 animationType="fade"
417 onShow={() => inputRef.current?.focus()}
418 onRequestClose={() => close()}
419 onDismiss={handleDismissed}
420 >
421 <GestureHandlerRootView className="flex-1">
422 <T3KeyboardCommands enabledCommands={PALETTE_COMMANDS} onCommand={onCommand}>
423 <KeyboardAvoidingView
424 behavior="padding"
425 className="flex-1 items-center justify-center p-4"
426 >
427 <Pressable
428 className="absolute inset-0 bg-backdrop"
429 accessibilityLabel="Close command palette"
430 onPress={() => close()}
431 />
432 <GlassSurface
433 accessibilityViewIsModal
434 className="bg-sheet"
435 tintColorClassName="accent-sheet/20"
436 fallbackColor={themeVariables["--color-sheet"]}
437 style={{
438 width: Math.min(600, width - 32),
439 height: Math.min(520, height - 80),
440 borderRadius: 20,
441 }}
442 >
443 <View className="px-3 pb-2.5 pt-3.5">
444 <View className="h-[38px] flex-row items-center gap-1.5 pr-2.5 pl-[11px]">
445 <SymbolView
446 name="magnifyingglass"
447 size={15}
448 tintColorClassName="accent-foreground-muted"
449 type="monochrome"
450 />
451 <TextInput
452 ref={inputRef}
453 accessibilityLabel="Search commands, projects, and threads"
454 placeholder="Search commands, projects, and threads…"
455 placeholderTextColorClassName="accent-placeholder"
456 autoCorrect={false}
457 autoCapitalize="none"
458 clearButtonMode="while-editing"
459 className="h-[34px] flex-1 px-0 py-0 font-sans text-base text-foreground"
460 value={query}
461 onChangeText={(value) => {
462 setQuery(value);
463 setSelection(null);
464 listRef.current?.scrollToOffset({ offset: 0, animated: false });
465 }}
466 returnKeyType="go"
467 submitBehavior="submit"
468 onSubmitEditing={() => {
469 const item = results[selectedIndex];
470 if (item) close(item.run);
471 }}
472 />
473 </View>
474 </View>
475 <FlatList
476 ref={listRef}
477 data={results}
478 extraData={selectedKey}
479 keyboardShouldPersistTaps="handled"
480 keyExtractor={(item) => item.key}
481 getItemLayout={(_, index) => ({
482 length: ROW_HEIGHT,
483 offset: ROW_HEIGHT * index,
484 index,
485 })}
486 contentContainerClassName="pb-2"
487 ListEmptyComponent={
488 <Text className="p-5 text-center text-foreground-muted">
489 {search.isPending ? "Searching…" : "No results"}
490 </Text>
491 }
492 renderItem={({ item, index }) => (
493 <PaletteRow
494 item={item}
495 index={index}
496 selected={item.key === selectedKey}
497 searchMatch={
498 item.kind === "thread" ? contentMatchByKey.get(item.key) : undefined
499 }
500 searchQuery={query}
501 onSelect={() => close(item.run)}
502 />
503 )}
504 />
505 </GlassSurface>
506 </KeyboardAvoidingView>
507 </T3KeyboardCommands>
508 </GestureHandlerRootView>
509 </Modal>
510 );
511}