import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { useExtensionCommandPrompt } from "../extensions/useExtensionCommandPrompt"; import { extensionEnvironment } from "../../state/extensions"; import { useEnvironmentQuery } from "../../state/query"; import { useAtomValue } from "@effect/atom-react"; import { uuidv4 } from "../../lib/uuid"; import { useNavigation } from "@react-navigation/native"; import { claimThreadOpen, nextShownOrder, threadSearchSubject, type EnvironmentThreadSearchMatch, type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; import { isListedThreadLineage, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { foldSearchQuery, PALETTE_MESSAGE_LIMIT, rankMessageMatches, splitSearchText, pickedQuery, threadRecentUse, } from "@t3tools/shared/paletteSearch"; import { useEffect, useMemo, useRef, useState } from "react"; import { KeyboardAvoidingView, Modal, Pressable, Text as NativeText, TextInput, useWindowDimensions, View, type TextInputInstance, } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { GlassSurface } from "../../components/ui/GlassSurface"; import { RowPressable } from "../../components/ui/RowPressable"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { cn } from "../../lib/cn"; import { deriveBottomControlInsets } from "../../lib/reserved-regions"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useProjects, useServerConfigs, useThreadShell, useVisibleThreadShells, } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { requestThreadMessageJump } from "../threads/threadMessageJump"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { serverEnvironment } from "../../state/server"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match"; import { pickTriageEnvironment } from "../triage/triageTarget"; import { useStartTriage } from "../triage/useStartTriage"; import { useNewTaskFlow } from "../threads/new-task-flow-provider"; import { useActiveNewTask } from "../../state/active-new-task"; import { filterCommandPaletteItems, nextPaletteIndex, type CommandPaletteItem, type CommandPaletteResult, } from "./commandPaletteItems"; import { parseActiveThreadPath, type HardwareKeyboardCommand } from "./hardwareKeyboardCommands"; import { threadJumpIndex } from "./threadKeyboardShortcuts"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceTab } from "../layout/workspace/useSelectWorkspaceTab"; import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; const PALETTE_COMMANDS: ReadonlyArray = [ "commandPalette", "paletteDismiss", "paletteNext", "palettePrevious", ...THREAD_JUMP_KEYBINDING_COMMANDS, ]; const ROW_HEIGHT = 50; // The "In messages" label above the first message match; server results always come last. const SECTION_LABEL_HEIGHT = 26; const ACTION_ICONS: Record = { newTask: "square.and.pencil", newThread: "square.and.pencil", addProject: "folder.badge.plus", settings: "gearshape", appearance: "paintbrush", environments: "desktopcomputer", usage: "chart.bar.xaxis", archive: "archivebox", files: "doc.text", terminal: "terminal", review: PullRequestGlyph.pullRequest, copyThreadReference: "link", }; function itemIcon(item: CommandPaletteItem): AppSymbolName { if (item.kind === "thread") return "text.bubble"; return ACTION_ICONS[item.key] ?? "ellipsis"; } function PaletteRow(props: { readonly item: CommandPaletteResult; readonly index: number; readonly selected: boolean; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery: string; readonly onSelect: () => void; }) { const foregroundClassName = props.selected ? "text-thread-selected-foreground" : "text-foreground"; const mutedForegroundClassName = props.selected ? "text-thread-selected-foreground-muted" : "text-foreground-muted"; return ( {props.item.project ? ( ) : ( )} {props.item.titleRanges?.length ? splitSearchText(props.item.title, props.item.titleRanges).map((part) => ( {part.text} )) : props.item.title} {/* The second line shows the snippet or hint, so keep where the thread lives, unless the hint already names it. */} {(props.searchMatch || (props.item.matchHint && props.item.matchHint.label !== "Project" && props.item.matchHint.label !== "Environment")) && props.item.detail ? ( {props.item.detail} ) : null} {props.searchMatch ? ( ) : props.item.matchHint ? ( {props.item.matchHint.label}:{" "} {splitSearchText(props.item.matchHint.text, props.item.matchHint.ranges).map((part) => ( {part.text} ))} ) : props.item.detail ? ( {props.item.detail} ) : null} {props.index < 9 ? ( ⌘{props.index + 1} ) : null} ); } /** Mounted only while open, so the app root does not subscribe to the full thread catalog. */ export function CommandPalette(props: { readonly pathname: string; readonly onClose: () => void; readonly onCommand: (command: HardwareKeyboardCommand) => void; }) { const navigation = useNavigation(); const selectTab = useSelectWorkspaceTab(); const { themeVariables } = useAppearancePreferences(); const selectDetail = useSelectWorkspaceDetail(); const runCommand = props.onCommand; const projects = useProjects(); const threads = useVisibleThreadShells(); const activeThreadRef = parseActiveThreadPath(props.pathname); const activeThread = useThreadShell(activeThreadRef); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const environments = useWorkspaceEnvironments(); const { savedConnectionsById } = useSavedRemoteConnections(); const [query, setQuery] = useState(""); // Ranking is relative to when the palette opened, so rows do not reorder as time passes. const [now] = useState(() => Date.now()); const [selection, setSelection] = useState(null); const [visible, setVisible] = useState(true); const pendingAction = useRef<(() => void) | null>(null); const closing = useRef(false); const inputRef = useRef(null); const listRef = useRef(null); const { width, height } = useWindowDimensions(); // The palette floats above every column, so it keeps itself inside the window's side insets // and to one side of a book fold. const bounds = deriveBottomControlInsets(useNativeLayoutMetrics()); const searchEnvironmentIds = useMemo( () => environments .filter((environment) => environment.connectionState === "connected") .map((environment) => environment.environmentId), [environments], ); // The palette matches environment names when there are several; older rows must match the same. const environmentLabels = environments.length > 1 ? Object.fromEntries( searchEnvironmentIds.flatMap((environmentId) => { const label = savedConnectionsById[environmentId]?.environmentLabel; return label === undefined ? [] : [[environmentId, label]]; }), ) : undefined; const search = useThreadSearch( searchEnvironmentIds, query.startsWith(">") ? "" : query, environmentLabels === undefined ? {} : { environmentLabels }, ); const serverConfigs = useServerConfigs(); const extensionEnvironmentId = activeThreadRef?.environmentId ?? searchEnvironmentIds[0]; const extensionSupported = extensionEnvironmentId && serverConfigs.get(extensionEnvironmentId)?.environment.capabilities.extensions === true; const extensions = useEnvironmentQuery( extensionEnvironmentId && extensionSupported ? extensionEnvironment.state({ environmentId: extensionEnvironmentId, input: {} }) : null, ); const invokeExtension = useExtensionCommandPrompt(); const startTriage = useStartTriage(); const newTaskFlow = useNewTaskFlow(); const draftEnvironmentId = useActiveNewTask() === null ? null : (newTaskFlow.selectedProject?.environmentId ?? null); const triageEnvironmentId = pickTriageEnvironment({ // The route names the machine even before the thread shell loads. onScreen: activeThreadRef?.environmentId ?? draftEnvironmentId, connected: searchEnvironmentIds, supportsTriage: (environmentId) => serverConfigs.get(environmentId)?.environment.capabilities.triage === true, }); const canTriage = useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) && triageEnvironmentId !== null && serverConfigs.get(triageEnvironmentId)?.environment.capabilities.triage === true; const contentMatchByKey = new Map( search.matches .filter((match) => match.source === "user" || match.source === "assistant") .map((match) => [scopedThreadKey(match.environmentId, match.threadId), match]), ); const items = useMemo(() => { const actions: CommandPaletteItem[] = [ { key: "extensions", kind: "action", title: "Manage extensions", searchTerms: ["plugins", "tools"], run: () => openSettings(navigation, "SettingsExtensions"), }, { key: "newTask", kind: "action", title: "New thread in…", searchTerms: ["new task", "chat", "create", "project"], run: () => runCommand("newTask"), }, { key: "addProject", kind: "action", title: "Add project", searchTerms: ["folder", "clone", "repository", "git"], run: () => navigation.navigate("AddProject"), }, { key: "settings", kind: "action", title: "Open settings", searchTerms: ["preferences", "configuration"], run: () => selectTab("settings"), }, { key: "appearance", kind: "action", title: "Appearance", searchTerms: ["theme", "colors", "dark", "light"], run: () => openSettings(navigation, "SettingsAppearance"), }, { key: "environments", kind: "action", title: "Manage environments", searchTerms: ["connections", "server", "remote"], run: () => openSettings(navigation, "SettingsEnvironments"), }, { key: "scheduledTasks", kind: "action", title: "Scheduled tasks", searchTerms: ["schedule", "automations", "recurring"], run: () => openSettings(navigation, "SettingsScheduledTasks"), }, { key: "usage", kind: "action", title: "Usage", searchTerms: ["limits", "accounts", "quota"], run: () => openSettings(navigation, "SettingsUsage"), }, { key: "archive", kind: "action", title: "Archived threads", searchTerms: ["restore", "history"], run: () => openSettings(navigation, "SettingsArchive"), }, ]; if (canTriage) { actions.push({ key: "triage", kind: "action", title: "Triage an issue", // Mobile has no triage hero, so name the machine before it starts. detail: `On ${savedConnectionsById[triageEnvironmentId]?.environmentLabel ?? "this machine"}`, searchTerms: ["triage", "bug", "issue", "report", "broken", "debug", "help", "support"], run: () => void startTriage(triageEnvironmentId), }); } const projectByKey = new Map( projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]), ); const activeProject = activeThread ? projectByKey.get(scopedProjectKey(activeThread.environmentId, activeThread.projectId)) : null; if (activeProject) { actions.unshift({ key: "newThread", kind: "action", title: `New thread in ${activeProject.title}`, searchTerms: ["new task", "chat", "create"], run: () => selectDetail({ name: "Thread", params: { draft: { requestId: uuidv4(), environmentId: activeProject.environmentId, projectId: activeProject.id, title: activeProject.title, }, }, }), }); } if (activeThreadRef) { const threadActions = [ ["files", "Go to file", ["open", "files", "browse", "search"]], ["terminal", "Open terminal", ["shell", "console"]], ["review", "Review changes", ["diff", "git", "pull request"]], ["copyThreadReference", "Copy PR link or thread", ["reference", "clipboard"]], ] as const; actions.push( ...threadActions.map(([command, title, searchTerms]) => ({ key: command, kind: "action" as const, title, searchTerms, run: () => runCommand(command), })), ); } const openProjects = projects.filter((project) => project.archivedAt == null); const projectItems: CommandPaletteItem[] = [ ...openProjects.filter((project) => project.pinnedAt != null), ...openProjects.filter((project) => project.pinnedAt == null), ].map((project) => ({ key: `project:${scopedProjectKey(project.environmentId, project.id)}`, kind: "project", project, title: project.title, detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`, searchTerms: [project.workspaceRoot, "new thread", "project"], run: () => selectDetail({ name: "Thread", params: { draft: { requestId: uuidv4(), environmentId: project.environmentId, projectId: project.id, title: project.title, }, }, }), })); const threadItems: CommandPaletteItem[] = threads .filter((thread) => thread.archivedAt === null && isListedThreadLineage(thread.lineage)) .map((thread) => { const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); const environment = savedConnectionsById[thread.environmentId]?.environmentLabel ?? thread.environmentId; return { key: scopedThreadKey(thread.environmentId, thread.id), kind: "thread", title: thread.title || "Untitled thread", detail: [project?.title, environment].filter(Boolean).join(" · "), searchTerms: [], threadSearch: threadSearchSubject( thread, project?.title, environments.length > 1 ? environment : null, ), // Last among threads: picking the thread already open does nothing. ...(thread.environmentId === activeThread?.environmentId && thread.id === activeThread.id ? { secondary: true } : {}), run: () => selectDetail({ name: "Thread", params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, }), }; }); // Recent means used recently: written in or opened, whichever is later. threadItems.sort( (left, right) => (right.threadSearch ? threadRecentUse(right.threadSearch) : 0) - (left.threadSearch ? threadRecentUse(left.threadSearch) : 0), ); if (extensionEnvironmentId) for (const extension of extensions.data ?? []) { if (extension.status !== "running") continue; for (const command of extension.manifest.commands ?? []) actions.push({ key: `extension:${extension.manifest.id}:${command.id}`, kind: "action", title: command.title, searchTerms: [extension.manifest.name, "extension"], run: () => invokeExtension( extensionEnvironmentId, extension, command, activeThread ? { threadId: activeThread.id, projectId: activeThread.projectId } : undefined, ), }); } return [...actions, ...projectItems, ...threadItems]; }, [ extensionEnvironmentId, extensions.data, invokeExtension, selectTab, activeThread, activeThreadRef, environments.length, canTriage, navigation, projects, runCommand, savedConnectionsById, selectDetail, startTriage, threads, triageEnvironmentId, ]); const rankedMessageKeys = useMemo(() => { const subjectByKey = new Map( items.flatMap((item) => (item.threadSearch ? [[item.key, item.threadSearch] as const] : [])), ); const keyOf = (match: EnvironmentThreadSearchMatch) => scopedThreadKey(match.environmentId, match.threadId); return rankMessageMatches( search.matches, (match) => subjectByKey.get(keyOf(match)), foldSearchQuery(query), now, { withoutTitleMatches: true }, ).map(keyOf); }, [items, now, query, search.matches]); // Rows already shown keep their places when a slower environment answers. const [shownOrder, setShownOrder] = useState(null); const messageOrder = nextShownOrder( shownOrder, search.query, rankedMessageKeys, PALETTE_MESSAGE_LIMIT, ); if (messageOrder !== shownOrder) setShownOrder(messageOrder); const messageMatchKeys = useMemo( () => messageOrder.keys.slice(0, messageOrder.shown), [messageOrder.keys, messageOrder.shown], ); const results = useMemo(() => { const ranked = filterCommandPaletteItems(items, query, messageMatchKeys, now); // Recent threads leave out the open one, which would always lead them. const activeKey = activeThread ? scopedThreadKey(activeThread.environmentId, activeThread.id) : null; return query.trim() ? ranked : ranked.filter((item) => item.key !== activeKey); }, [activeThread, items, messageMatchKeys, now, query]); const runResult = (item: CommandPaletteResult) => { const thread = item.kind === "thread" && query.trim() && !query.startsWith(">") ? threads.find( (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === item.key, ) : undefined; // A message result opens the thread at that message. const messageId = item.fromMessages ? contentMatchByKey.get(item.key)?.messageId : undefined; if (messageId) requestThreadMessageJump(item.key, messageId); // A pick sends its open with the query, always, so the server records it; // the thread screen then skips its own open. The open thread is no pick. if (thread && !item.secondary) claimThreadOpen(thread.environmentId, thread.id, Date.now()); if (thread && thread.lastVisitedAt !== undefined && !item.secondary) { void visitThread({ environmentId: thread.environmentId, input: { threadId: thread.id, visitedAt: thread.updatedAt, opened: true, searchQuery: pickedQuery(query), }, }); } item.run(); }; const firstMessageIndex = results.findIndex((item) => item.fromMessages); const selectedIndex = Math.max( 0, results.findIndex((item) => item.key === selection), ); const selectedKey = results[selectedIndex]?.key; // The native list follows the highlighted row. Key presses move it, but so do // live thread updates and late message matches that reorder or drop the // selected row under a kept selection, so no single handler owns the scroll. // 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 useEffect(() => { if (selectedIndex === 0) { // Centering before the list measures its height scrolls half the first row out of view. listRef.current?.scrollToOffset({ offset: 0, animated: false }); } else if (selectedKey !== undefined) { listRef.current?.scrollToIndex({ index: selectedIndex, animated: false, viewPosition: 0.5 }); } }, [selectedIndex, selectedKey]); const dismissed = useRef(false); const dismissFallback = useRef | null>(null); const handleDismissed = () => { if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); dismissFallback.current = null; if (dismissed.current) return; dismissed.current = true; // Present navigation sheets only after UIKit has dismissed this modal. props.onClose(); pendingAction.current?.(); }; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the dismiss fallback timer on unmount useEffect( () => () => { if (dismissFallback.current !== null) clearTimeout(dismissFallback.current); }, [], ); function close(run?: () => void) { if (closing.current) return; closing.current = true; pendingAction.current = run ?? null; setVisible(false); // iOS may drop onDismiss during presentation or competing sheets; a fallback clears stale palette state. dismissFallback.current = setTimeout(handleDismissed, 400); } function onCommand(command: HardwareKeyboardCommand) { if (command === "commandPalette" || command === "paletteDismiss") { close(); } else if (command === "paletteNext" || command === "palettePrevious") { setSelection( results[nextPaletteIndex(selectedIndex, command === "paletteNext" ? 1 : -1, results.length)] ?.key ?? null, ); } else { const item = results[threadJumpIndex(command)]; if (item) close(() => runResult(item)); } } return ( inputRef.current?.focus()} onRequestClose={() => close()} onDismiss={handleDismissed} > close()} /> { setQuery(value); setSelection(null); // The follow effect stays idle when the top row and its key are unchanged, // so a touch-scrolled list would otherwise keep its old offset. listRef.current?.scrollToOffset({ offset: 0, animated: false }); }} returnKeyType="go" submitBehavior="submit" onSubmitEditing={() => { const item = results[selectedIndex]; if (item) close(() => runResult(item)); }} /> {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- The palette is a modal with no navigation header to register with. */} item.key} getFixedItemSize={(_, index) => ROW_HEIGHT + (index === firstMessageIndex ? SECTION_LABEL_HEIGHT : 0) } recycleItems contentContainerStyle={{ paddingBottom: 8 }} ListEmptyComponent={ {search.isPending ? "Searching…" : "No results"} } // Message results from slower environments may still arrive below. ListFooterComponent={ results.length > 0 && search.isPending && !query.startsWith(">") ? ( Searching messages… ) : undefined } renderItem={({ item, index }) => ( <> {index === firstMessageIndex ? ( In messages ) : null} close(() => runResult(item))} /> )} /> ); }