Skip to content
Source
Sign inSign up

CommandPalette.tsx

723 lines28.1 KB

1import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
2import { useAtomValue } from "@effect/atom-react";
3import { uuidv4 } from "../../lib/uuid";
4import { useNavigation } from "@react-navigation/native";
5import {
6 claimThreadOpen,
7 nextShownOrder,
8 threadSearchSubject,
9 type EnvironmentThreadSearchMatch,
10 type ShownOrder,
11} from "@t3tools/client-runtime/state/thread-search";
12import { isListedThreadLineage, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
13import {
14 foldSearchQuery,
15 PALETTE_MESSAGE_LIMIT,
16 rankMessageMatches,
17 splitSearchText,
18 pickedQuery,
19 threadRecentUse,
20} from "@t3tools/shared/paletteSearch";
21import { useEffect, useMemo, useRef, useState } from "react";
22import {
23 KeyboardAvoidingView,
24 Modal,
25 Pressable,
26 Text as NativeText,
27 TextInput,
28 useWindowDimensions,
29 View,
30 type TextInputInstance,
31} from "react-native";
32​
33import { GestureHandlerRootView } from "react-native-gesture-handler";
34​
35import { ProjectFavicon } from "../../components/ProjectFavicon";
36import { GlassSurface } from "../../components/ui/GlassSurface";
37import { RowPressable } from "../../components/ui/RowPressable";
38import { AppText as Text } from "../../components/ui/AppText";
39import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol";
40import { cn } from "../../lib/cn";
41import { deriveBottomControlInsets } from "../../lib/reserved-regions";
42import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
43import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
44import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
45import {
46 useProjects,
47 useServerConfigs,
48 useThreadShell,
49 useVisibleThreadShells,
50} from "../../state/entities";
51import { useThreadSearch } from "../../state/queries";
52import { requestThreadMessageJump } from "../threads/threadMessageJump";
53import { threadEnvironment } from "../../state/threads";
54import { useAtomCommand } from "../../state/use-atom-command";
55import { serverEnvironment } from "../../state/server";
56import { useWorkspaceEnvironments } from "../../state/workspace";
57import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
58import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
59import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match";
60import { pickTriageEnvironment } from "../triage/triageTarget";
61import { useStartTriage } from "../triage/useStartTriage";
62import { useNewTaskFlow } from "../threads/new-task-flow-provider";
63import { useActiveNewTask } from "../../state/active-new-task";
64import {
65 filterCommandPaletteItems,
66 nextPaletteIndex,
67 type CommandPaletteItem,
68 type CommandPaletteResult,
69} from "./commandPaletteItems";
70import { parseActiveThreadPath, type HardwareKeyboardCommand } from "./hardwareKeyboardCommands";
71import { threadJumpIndex } from "./threadKeyboardShortcuts";
72import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs";
73import { openSettings } from "../settings/settings-navigation";
74import { useSelectWorkspaceTab } from "../layout/workspace/useSelectWorkspaceTab";
75import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail";
76​
77const PALETTE_COMMANDS: ReadonlyArray<HardwareKeyboardCommand> = [
78 "commandPalette",
79 "paletteDismiss",
80 "paletteNext",
81 "palettePrevious",
82 ...THREAD_JUMP_KEYBINDING_COMMANDS,
83];
84const ROW_HEIGHT = 50;
85// The "In messages" label above the first message match; server results always come last.
86const SECTION_LABEL_HEIGHT = 26;
87​
88const ACTION_ICONS: Record<string, AppSymbolName> = {
89 newTask: "square.and.pencil",
90 newThread: "square.and.pencil",
91 addProject: "folder.badge.plus",
92 settings: "gearshape",
93 appearance: "paintbrush",
94 environments: "desktopcomputer",
95 usage: "chart.bar.xaxis",
96 archive: "archivebox",
97 files: "doc.text",
98 terminal: "terminal",
99 review: PullRequestGlyph.pullRequest,
100 copyThreadReference: "link",
101};
102​
103function itemIcon(item: CommandPaletteItem): AppSymbolName {
104 if (item.kind === "thread") return "text.bubble";
105 return ACTION_ICONS[item.key] ?? "ellipsis";
107​
108function PaletteRow(props: {
109 readonly item: CommandPaletteResult;
110 readonly index: number;
111 readonly selected: boolean;
112 readonly searchMatch?: EnvironmentThreadSearchMatch;
113 readonly searchQuery: string;
114 readonly onSelect: () => void;
115}) {
116 const foregroundClassName = props.selected
117 ? "text-thread-selected-foreground"
118 : "text-foreground";
119 const mutedForegroundClassName = props.selected
120 ? "text-thread-selected-foreground-muted"
121 : "text-foreground-muted";
122 return (
123 <RowPressable
124 accessibilityRole="button"
125 interactionOpacity={props.selected ? 0 : 1}
126 accessibilityState={{ selected: props.selected }}
127 onPress={props.onSelect}
128 className={
129 props.selected
130 ? "mx-2 flex-row items-center gap-3 rounded-xl bg-thread-selected px-3"
131 : "mx-2 flex-row items-center gap-3 rounded-xl px-3"
132 }
133 style={{ height: ROW_HEIGHT }}
134 >
135 <View className="w-7 items-center">
136 {props.item.project ? (
137 <ProjectFavicon
138 environmentId={props.item.project.environmentId}
139 faviconPath={props.item.project.faviconPath}
140 projectIcon={props.item.project.projectIcon}
141 projectTitle={props.item.project.title}
142 size={20}
143 workspaceRoot={props.item.project.workspaceRoot}
144 />
145 ) : (
146 <SymbolView
147 name={itemIcon(props.item)}
148 size={20}
149 tintColorClassName={
150 props.selected ? "accent-thread-selected-foreground" : "accent-icon"
151 }
152 />
153 )}
154 </View>
155 <View className="flex-1">
156 <View className="flex-row items-baseline gap-2">
157 <Text numberOfLines={1} className={cn("shrink text-base", foregroundClassName)}>
158 {props.item.titleRanges?.length
159 ? splitSearchText(props.item.title, props.item.titleRanges).map((part) => (
160 <Text
161 key={part.start}
162 className={cn("text-base", part.highlighted && "font-t3-bold")}
163 >
164 {part.text}
165 </Text>
166 ))
167 : props.item.title}
168 </Text>
169 {/* The second line shows the snippet or hint, so keep where the thread
170 lives, unless the hint already names it. */}
171 {(props.searchMatch ||
172 (props.item.matchHint &&
173 props.item.matchHint.label !== "Project" &&
174 props.item.matchHint.label !== "Environment")) &&
175 props.item.detail ? (
176 <Text
177 numberOfLines={1}
178 className={cn("max-w-[45%] shrink-0 text-sm", mutedForegroundClassName)}
179 >
180 {props.item.detail}
181 </Text>
182 ) : null}
183 </View>
184 {props.searchMatch ? (
185 <ThreadSearchMatchExcerpt
186 match={props.searchMatch}
187 query={props.searchQuery}
188 selected={props.selected}
189 compact
190 />
191 ) : props.item.matchHint ? (
192 <Text numberOfLines={1} className={cn("text-sm", mutedForegroundClassName)}>
193 {props.item.matchHint.label}:{" "}
194 {splitSearchText(props.item.matchHint.text, props.item.matchHint.ranges).map((part) => (
195 <Text key={part.start} className={cn("text-sm", part.highlighted && "font-t3-bold")}>
196 {part.text}
197 </Text>
198 ))}
199 </Text>
200 ) : props.item.detail ? (
201 <Text numberOfLines={1} className={cn("text-sm", mutedForegroundClassName)}>
202 {props.item.detail}
203 </Text>
204 ) : null}
205 </View>
206 {props.index < 9 ? (
207 <NativeText
208 className={cn("w-8 shrink-0 text-right text-sm tabular-nums", mutedForegroundClassName)}
209 >
210 ⌘{props.index + 1}
211 </NativeText>
212 ) : null}
213 </RowPressable>
214 );
216​
217/** Mounted only while open, so the app root does not subscribe to the full thread catalog. */
218export function CommandPalette(props: {
219 readonly pathname: string;
220 readonly onClose: () => void;
221 readonly onCommand: (command: HardwareKeyboardCommand) => void;
222}) {
223 const navigation = useNavigation();
224 const selectTab = useSelectWorkspaceTab();
225 const { themeVariables } = useAppearancePreferences();
226 const selectDetail = useSelectWorkspaceDetail();
227 const runCommand = props.onCommand;
228 const projects = useProjects();
229 const threads = useVisibleThreadShells();
230 const activeThreadRef = parseActiveThreadPath(props.pathname);
231 const activeThread = useThreadShell(activeThreadRef);
232 const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
233 const environments = useWorkspaceEnvironments();
234 const { savedConnectionsById } = useSavedRemoteConnections();
235 const [query, setQuery] = useState("");
236 // Ranking is relative to when the palette opened, so rows do not reorder as time passes.
237 const [now] = useState(() => Date.now());
238 const [selection, setSelection] = useState<string | null>(null);
239 const [visible, setVisible] = useState(true);
240 const pendingAction = useRef<(() => void) | null>(null);
241 const closing = useRef(false);
242 const inputRef = useRef<TextInputInstance>(null);
243 const listRef = useRef<LegendListRef>(null);
244 const { width, height } = useWindowDimensions();
245 // The palette floats above every column, so it keeps itself inside the window's side insets
246 // and to one side of a book fold.
247 const bounds = deriveBottomControlInsets(useNativeLayoutMetrics());
248 const searchEnvironmentIds = useMemo(
249 () =>
250 environments
251 .filter((environment) => environment.connectionState === "connected")
252 .map((environment) => environment.environmentId),
253 [environments],
254 );
255 // The palette matches environment names when there are several; older rows must match the same.
256 const environmentLabels =
257 environments.length > 1
258 ? Object.fromEntries(
259 searchEnvironmentIds.flatMap((environmentId) => {
260 const label = savedConnectionsById[environmentId]?.environmentLabel;
261 return label === undefined ? [] : [[environmentId, label]];
262 }),
263 )
264 : undefined;
265 const search = useThreadSearch(
266 searchEnvironmentIds,
267 query.startsWith(">") ? "" : query,
268 environmentLabels === undefined ? {} : { environmentLabels },
269 );
270 const serverConfigs = useServerConfigs();
271 const startTriage = useStartTriage();
272 const newTaskFlow = useNewTaskFlow();
273 const draftEnvironmentId =
274 useActiveNewTask() === null ? null : (newTaskFlow.selectedProject?.environmentId ?? null);
275 const triageEnvironmentId = pickTriageEnvironment({
276 // The route names the machine even before the thread shell loads.
277 onScreen: activeThreadRef?.environmentId ?? draftEnvironmentId,
278 connected: searchEnvironmentIds,
279 supportsTriage: (environmentId) =>
280 serverConfigs.get(environmentId)?.environment.capabilities.triage === true,
281 });
282 const canTriage =
283 useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) &&
284 triageEnvironmentId !== null &&
285 serverConfigs.get(triageEnvironmentId)?.environment.capabilities.triage === true;
286 const contentMatchByKey = new Map(
287 search.matches
288 .filter((match) => match.source === "user" || match.source === "assistant")
289 .map((match) => [scopedThreadKey(match.environmentId, match.threadId), match]),
290 );
291 const items = useMemo(() => {
292 const actions: CommandPaletteItem[] = [
293 {
294 key: "newTask",
295 kind: "action",
296 title: "New thread in…",
297 searchTerms: ["new task", "chat", "create", "project"],
298 run: () => runCommand("newTask"),
299 },
300 {
301 key: "addProject",
302 kind: "action",
303 title: "Add project",
304 searchTerms: ["folder", "clone", "repository", "git"],
305 run: () => navigation.navigate("AddProject"),
306 },
307 {
308 key: "settings",
309 kind: "action",
310 title: "Open settings",
311 searchTerms: ["preferences", "configuration"],
312 run: () => selectTab("settings"),
313 },
314 {
315 key: "appearance",
316 kind: "action",
317 title: "Appearance",
318 searchTerms: ["theme", "colors", "dark", "light"],
319 run: () => openSettings(navigation, "SettingsAppearance"),
320 },
321 {
322 key: "environments",
323 kind: "action",
324 title: "Manage environments",
325 searchTerms: ["connections", "server", "remote"],
326 run: () => openSettings(navigation, "SettingsEnvironments"),
327 },
328 {
329 key: "scheduledTasks",
330 kind: "action",
331 title: "Scheduled tasks",
332 searchTerms: ["schedule", "automations", "recurring"],
333 run: () => openSettings(navigation, "SettingsScheduledTasks"),
334 },
335 {
336 key: "usage",
337 kind: "action",
338 title: "Usage",
339 searchTerms: ["limits", "accounts", "quota"],
340 run: () => openSettings(navigation, "SettingsUsage"),
341 },
342 {
343 key: "archive",
344 kind: "action",
345 title: "Archived threads",
346 searchTerms: ["restore", "history"],
347 run: () => openSettings(navigation, "SettingsArchive"),
348 },
349 ];
350 if (canTriage) {
351 actions.push({
352 key: "triage",
353 kind: "action",
354 title: "Triage an issue",
355 // Mobile has no triage hero, so name the machine before it starts.
356 detail: `On ${savedConnectionsById[triageEnvironmentId]?.environmentLabel ?? "this machine"}`,
357 searchTerms: ["triage", "bug", "issue", "report", "broken", "debug", "help", "support"],
358 run: () => void startTriage(triageEnvironmentId),
359 });
360 }
361 const projectByKey = new Map(
362 projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]),
363 );
364 const activeProject = activeThread
365 ? projectByKey.get(scopedProjectKey(activeThread.environmentId, activeThread.projectId))
366 : null;
367 if (activeProject) {
368 actions.unshift({
369 key: "newThread",
370 kind: "action",
371 title: `New thread in ${activeProject.title}`,
372 searchTerms: ["new task", "chat", "create"],
373 run: () =>
374 selectDetail({
375 name: "Thread",
376 params: {
377 draft: {
378 requestId: uuidv4(),
379 environmentId: activeProject.environmentId,
380 projectId: activeProject.id,
381 title: activeProject.title,
382 },
383 },
384 }),
385 });
386 }
387 if (activeThreadRef) {
388 const threadActions = [
389 ["files", "Go to file", ["open", "files", "browse", "search"]],
390 ["terminal", "Open terminal", ["shell", "console"]],
391 ["review", "Review changes", ["diff", "git", "pull request"]],
392 ["copyThreadReference", "Copy PR link or thread", ["reference", "clipboard"]],
393 ] as const;
394 actions.push(
395 ...threadActions.map(([command, title, searchTerms]) => ({
396 key: command,
397 kind: "action" as const,
398 title,
399 searchTerms,
400 run: () => runCommand(command),
401 })),
402 );
403 }
404 const openProjects = projects.filter((project) => project.archivedAt == null);
405 const projectItems: CommandPaletteItem[] = [
406 ...openProjects.filter((project) => project.pinnedAt != null),
407 ...openProjects.filter((project) => project.pinnedAt == null),
408 ].map((project) => ({
409 key: `project:${scopedProjectKey(project.environmentId, project.id)}`,
410 kind: "project",
411 project,
412 title: project.title,
413 detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`,
414 searchTerms: [project.workspaceRoot, "new thread", "project"],
415 run: () =>
416 selectDetail({
417 name: "Thread",
418 params: {
419 draft: {
420 requestId: uuidv4(),
421 environmentId: project.environmentId,
422 projectId: project.id,
423 title: project.title,
424 },
425 },
426 }),
427 }));
428 const threadItems: CommandPaletteItem[] = threads
429 .filter((thread) => thread.archivedAt === null && isListedThreadLineage(thread.lineage))
430 .map((thread) => {
431 const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId));
432 const environment =
433 savedConnectionsById[thread.environmentId]?.environmentLabel ?? thread.environmentId;
434 return {
435 key: scopedThreadKey(thread.environmentId, thread.id),
436 kind: "thread",
437 title: thread.title || "Untitled thread",
438 detail: [project?.title, environment].filter(Boolean).join(" · "),
439 searchTerms: [],
440 threadSearch: threadSearchSubject(
441 thread,
442 project?.title,
443 environments.length > 1 ? environment : null,
444 ),
445 // Last among threads: picking the thread already open does nothing.
446 ...(thread.environmentId === activeThread?.environmentId && thread.id === activeThread.id
447 ? { secondary: true }
448 : {}),
449 run: () =>
450 selectDetail({
451 name: "Thread",
452 params: { environmentId: String(thread.environmentId), threadId: String(thread.id) },
453 }),
454 };
455 });
456 // Recent means used recently: written in or opened, whichever is later.
457 threadItems.sort(
458 (left, right) =>
459 (right.threadSearch ? threadRecentUse(right.threadSearch) : 0) -
460 (left.threadSearch ? threadRecentUse(left.threadSearch) : 0),
461 );
462 return [...actions, ...projectItems, ...threadItems];
463 }, [
464 selectTab,
465 activeThread,
466 activeThreadRef,
467 environments.length,
468 canTriage,
469 navigation,
470 projects,
471 runCommand,
472 savedConnectionsById,
473 selectDetail,
474 startTriage,
475 threads,
476 triageEnvironmentId,
477 ]);
478 const rankedMessageKeys = useMemo(() => {
479 const subjectByKey = new Map(
480 items.flatMap((item) => (item.threadSearch ? [[item.key, item.threadSearch] as const] : [])),
481 );
482 const keyOf = (match: EnvironmentThreadSearchMatch) =>
483 scopedThreadKey(match.environmentId, match.threadId);
484 return rankMessageMatches(
485 search.matches,
486 (match) => subjectByKey.get(keyOf(match)),
487 foldSearchQuery(query),
488 now,
489 { withoutTitleMatches: true },
490 ).map(keyOf);
491 }, [items, now, query, search.matches]);
492 // Rows already shown keep their places when a slower environment answers.
493 const [shownOrder, setShownOrder] = useState<ShownOrder | null>(null);
494 const messageOrder = nextShownOrder(
495 shownOrder,
496 search.query,
497 rankedMessageKeys,
498 PALETTE_MESSAGE_LIMIT,
499 );
500 if (messageOrder !== shownOrder) setShownOrder(messageOrder);
501 const messageMatchKeys = useMemo(
502 () => messageOrder.keys.slice(0, messageOrder.shown),
503 [messageOrder.keys, messageOrder.shown],
504 );
505 const results = useMemo(() => {
506 const ranked = filterCommandPaletteItems(items, query, messageMatchKeys, now);
507 // Recent threads leave out the open one, which would always lead them.
508 const activeKey = activeThread
509 ? scopedThreadKey(activeThread.environmentId, activeThread.id)
510 : null;
511 return query.trim() ? ranked : ranked.filter((item) => item.key !== activeKey);
512 }, [activeThread, items, messageMatchKeys, now, query]);
513 const runResult = (item: CommandPaletteResult) => {
514 const thread =
515 item.kind === "thread" && query.trim() && !query.startsWith(">")
516 ? threads.find(
517 (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === item.key,
518 )
519 : undefined;
520 // A message result opens the thread at that message.
521 const messageId = item.fromMessages ? contentMatchByKey.get(item.key)?.messageId : undefined;
522 if (messageId) requestThreadMessageJump(item.key, messageId);
523 // A pick sends its open with the query, always, so the server records it;
524 // the thread screen then skips its own open. The open thread is no pick.
525 if (thread && !item.secondary) claimThreadOpen(thread.environmentId, thread.id, Date.now());
526 if (thread && thread.lastVisitedAt !== undefined && !item.secondary) {
527 void visitThread({
528 environmentId: thread.environmentId,
529 input: {
530 threadId: thread.id,
531 visitedAt: thread.updatedAt,
532 opened: true,
533 searchQuery: pickedQuery(query),
534 },
535 });
536 }
537 item.run();
538 };
539 const firstMessageIndex = results.findIndex((item) => item.fromMessages);
540 const selectedIndex = Math.max(
541 0,
542 results.findIndex((item) => item.key === selection),
543 );
544 const selectedKey = results[selectedIndex]?.key;
545 // The native list follows the highlighted row. Key presses move it, but so do
546 // live thread updates and late message matches that reorder or drop the
547 // selected row under a kept selection, so no single handler owns the scroll.
548 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the native list to the highlighted row, which key presses and live result updates both move
549 useEffect(() => {
550 if (selectedIndex === 0) {
551 // Centering before the list measures its height scrolls half the first row out of view.
552 listRef.current?.scrollToOffset({ offset: 0, animated: false });
553 } else if (selectedKey !== undefined) {
554 listRef.current?.scrollToIndex({ index: selectedIndex, animated: false, viewPosition: 0.5 });
555 }
556 }, [selectedIndex, selectedKey]);
557​
558 const dismissed = useRef(false);
559 const dismissFallback = useRef<ReturnType<typeof setTimeout> | null>(null);
560 const handleDismissed = () => {
561 if (dismissFallback.current !== null) clearTimeout(dismissFallback.current);
562 dismissFallback.current = null;
563 if (dismissed.current) return;
564 dismissed.current = true;
565 // Present navigation sheets only after UIKit has dismissed this modal.
566 props.onClose();
567 pendingAction.current?.();
568 };
569 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the dismiss fallback timer on unmount
570 useEffect(
571 () => () => {
572 if (dismissFallback.current !== null) clearTimeout(dismissFallback.current);
573 },
574 [],
575 );
576​
577 function close(run?: () => void) {
578 if (closing.current) return;
579 closing.current = true;
580 pendingAction.current = run ?? null;
581 setVisible(false);
582 // iOS may drop onDismiss during presentation or competing sheets; a fallback clears stale palette state.
583 dismissFallback.current = setTimeout(handleDismissed, 400);
584 }
585​
586 function onCommand(command: HardwareKeyboardCommand) {
587 if (command === "commandPalette" || command === "paletteDismiss") {
588 close();
589 } else if (command === "paletteNext" || command === "palettePrevious") {
590 setSelection(
591 results[nextPaletteIndex(selectedIndex, command === "paletteNext" ? 1 : -1, results.length)]
592 ?.key ?? null,
593 );
594 } else {
595 const item = results[threadJumpIndex(command)];
596 if (item) close(() => runResult(item));
597 }
598 }
599​
600 return (
601 <Modal
602 visible={visible}
603 transparent
604 animationType="fade"
605 onShow={() => inputRef.current?.focus()}
606 onRequestClose={() => close()}
607 onDismiss={handleDismissed}
608 >
609 <GestureHandlerRootView className="flex-1">
610 <T3KeyboardCommands enabledCommands={PALETTE_COMMANDS} onCommand={onCommand}>
611 <KeyboardAvoidingView
612 behavior="padding"
613 className="flex-1 items-center justify-center py-4"
614 style={{ paddingLeft: 16 + bounds.left, paddingRight: 16 + bounds.right }}
615 >
616 <Pressable
617 className="absolute inset-0 bg-backdrop"
618 accessibilityLabel="Close command palette"
619 onPress={() => close()}
620 />
621 <GlassSurface
622 accessibilityViewIsModal
623 className="bg-sheet"
624 tintColorClassName="accent-sheet/20"
625 fallbackColor={themeVariables["--color-sheet"]}
626 style={{
627 width: Math.min(600, width - 32 - bounds.left - bounds.right),
628 height: Math.min(520, height - 80),
629 borderRadius: 20,
630 }}
631 >
632 <View className="px-3 pb-2.5 pt-3.5">
633 <View className="h-[38px] flex-row items-center gap-1.5 pr-2.5 pl-[11px]">
634 <SymbolView
635 name="magnifyingglass"
636 size={15}
637 tintColorClassName="accent-foreground-muted"
638 type="monochrome"
639 />
640 <TextInput
641 ref={inputRef}
642 accessibilityLabel="Search commands, projects, and threads"
643 placeholder="Search commands, projects, and threads…"
644 placeholderTextColorClassName="accent-placeholder"
645 autoCorrect={false}
646 autoCapitalize="none"
647 clearButtonMode="while-editing"
648 className="h-[34px] flex-1 px-0 py-0 font-sans text-base text-foreground"
649 value={query}
650 onChangeText={(value) => {
651 setQuery(value);
652 setSelection(null);
653 // The follow effect stays idle when the top row and its key are unchanged,
654 // so a touch-scrolled list would otherwise keep its old offset.
655 listRef.current?.scrollToOffset({ offset: 0, animated: false });
656 }}
657 returnKeyType="go"
658 submitBehavior="submit"
659 onSubmitEditing={() => {
660 const item = results[selectedIndex];
661 if (item) close(() => runResult(item));
662 }}
663 />
664 </View>
665 </View>
666 {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- The palette is a modal with no navigation header to register with. */}
667 <LegendList
668 ref={listRef}
669 data={results}
670 extraData={{
671 selectedKey,
672 firstMessageIndex,
673 contentMatchByKey,
674 query: search.query,
675 }}
676 keyboardShouldPersistTaps="handled"
677 keyExtractor={(item) => item.key}
678 getFixedItemSize={(_, index) =>
679 ROW_HEIGHT + (index === firstMessageIndex ? SECTION_LABEL_HEIGHT : 0)
680 }
681 recycleItems
682 contentContainerStyle={{ paddingBottom: 8 }}
683 ListEmptyComponent={
684 <Text className="p-5 text-center text-foreground-muted">
685 {search.isPending ? "Searching…" : "No results"}
686 </Text>
687 }
688 // Message results from slower environments may still arrive below.
689 ListFooterComponent={
690 results.length > 0 && search.isPending && !query.startsWith(">") ? (
691 <Text className="px-5 pt-2 text-xs text-foreground-muted">
692 Searching messages…
693 </Text>
694 ) : undefined
695 }
696 renderItem={({ item, index }) => (
697 <>
698 {index === firstMessageIndex ? (
699 <Text
700 className="px-5 pt-2 text-xs text-foreground-muted"
701 style={{ height: SECTION_LABEL_HEIGHT }}
702 >
703 In messages
704 </Text>
705 ) : null}
706 <PaletteRow
707 item={item}
708 index={index}
709 selected={item.key === selectedKey}
710 searchMatch={item.fromMessages ? contentMatchByKey.get(item.key) : undefined}
711 searchQuery={search.query}
712 onSelect={() => close(() => runResult(item))}
713 />
714 </>
715 )}
716 />
717 </GlassSurface>
718 </KeyboardAvoidingView>
719 </T3KeyboardCommands>
720 </GestureHandlerRootView>
721 </Modal>
722 );