/** * CommandPalette - Main shell. * * A light view router over: * - SearchView: search, results, actions popover * - FormView: inline forms for actions that need input * - Custom views: host-provided views (e.g. an AI chat), reachable via Tab * * All the heavy logic lives in the child views and hooks. */ "use client"; import { useCallback, useEffect, useMemo, useState, type ReactNode, } from "react"; import { useMobileLayout } from "@/lib/use-mobile-layout"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { DismissableLayer, FocusScope } from "radix-ui/internal"; import { PaletteConfigProvider, buildCategoryOrder, type ResolvedPaletteConfig } from "./config"; import { createTranslate, type PaletteStrings } from "./i18n"; import { CheckIcon } from "./icons"; import { createActionRegistry } from "./registry/ActionRegistry"; import { ItemActionsProvider } from "./registry/providers/ItemActionsProvider"; import { configureSearchStorage, recordUsage, } from "./search/engine"; import { usePaletteStore } from "./store"; import { useQueryHistory } from "./hooks/useQueryHistory"; import { listenToNewKeydowns } from "./hooks/new-keydowns"; import styles from "./styles/CommandPalette.module.css"; import { FormView } from "./views/FormView"; import { SearchView } from "./views/SearchView"; import type { ActionExecutionContext, ActionProvider } from "./registry/types"; import type { CategoryDefinition, PaletteItem, PaletteViewRenderer, } from "./types"; // ============================================================================= // PROPS // ============================================================================= export interface CommandPaletteProps { /** Whether the palette is open. */ isOpen: boolean; /** Callback when the palette should close. */ onClose: () => void; /** All available items. */ items: PaletteItem[]; /** * Results found outside the text engine (e.g. by meaning). Appended after * the ordered results, in their own category, while a query is typed: the * existing rows keep their position, so the active row never jumps. Items * already in the results (same id) are skipped. `categories` restricts the * filter tabs they appear under (besides "all"). `status` is shown under * the search bar when nothing else matches: they are still coming, or none * came. */ supplemental?: { items: PaletteItem[]; categories?: string[]; status?: ReactNode }; // === Behavior === /** Action providers supplying contextual actions per entity type. */ providers?: ActionProvider[]; /** * Host data/callbacks exposed to actions via ctx.meta * (+ optional navigate/showToast overrides below). */ actionContext?: Record; /** Navigate callback exposed to actions (e.g. router.push). */ onNavigate?: (path: string) => void; /** Toast callback exposed to actions. */ onToast?: (message: string, type: "success" | "error" | "info") => void; /** Override item selection entirely (skips the action registry). */ onSelectItem?: (item: PaletteItem) => void; /** Called for the highlighted/hovered item (host-side destination prefetch). */ onHoverPrefetch?: (item: PaletteItem) => void; // === Configuration === /** Locale ("en", "fr", "fr-FR"…). Default "en". */ locale?: string; /** Override any built-in UI string. */ strings?: PaletteStrings; /** Category definitions (tabs order, labels, boosts). */ categories?: CategoryDefinition[]; /** localStorage key prefix for usage/history. Default "command-palette". */ storagePrefix?: string; /** Actions popover shortcut key (with ⌘/Ctrl). Default "m". */ actionsShortcutKey?: string; /** Compact mode: shows only the search bar until the user types. */ compactMode?: boolean; /** Enable query history recall (ArrowUp). Default true. */ history?: boolean; /** * Quick-AI affordance at the right of the search input (icon + Tab kbd). * Empty query → onSelect opens the AI surface; filled query → it auto-sends * the typed text. */ quickAi?: { icon: ReactNode; onSelect: (query: string) => void }; // === View extension === /** Custom views, keyed by view id. */ views?: Record; /** * Custom view entered when pressing Tab in the search field * (e.g. an AI chat view). Must exist in `views`. */ tabView?: { view: string; hint: string }; // === Misc === /** Initial query when opening. */ initialQuery?: string; /** Initial view when opening. */ initialView?: string; /** Show the success indicator overlay. */ isSuccess?: boolean; /** Called when a form action completes successfully. */ onFormSuccess?: () => void; } // ============================================================================= // COMPONENT // ============================================================================= /** Matches `paletteSlideOut` in CommandPalette.module.css. */ const EXIT_MS = 120; /** * On mobile the palette opens on its search field with the keyboard up: * searching is what it is opened for. Other sheets keep the keyboard down * until a tap (lib/mobile-sheet-focus.ts); the search tap primes it. */ function focusSearchField(event: Event) { if (!(event.target instanceof HTMLElement)) return; const field = event.target.querySelector("input[data-palette-search]"); if (!field) return; event.preventDefault(); field.focus({ preventScroll: true }); } /** Covers the mobile sheet's own close transition. */ const MOBILE_EXIT_MS = 300; export function CommandPalette({ isOpen, supplemental, onClose, items, providers, actionContext: actionContextMeta, onNavigate, onToast, onSelectItem, onHoverPrefetch, locale = "en", strings, categories = [], storagePrefix = "command-palette", actionsShortcutKey = "m", compactMode = false, history = true, quickAi, views, tabView, initialQuery = "", initialView = "search", isSuccess = false, onFormSuccess, }: CommandPaletteProps) { const mobile = useMobileLayout() === true; const view = usePaletteStore((s) => s.view); const isActionsPopoverOpen = usePaletteStore((s) => s.isActionsPopoverOpen); const setView = usePaletteStore((s) => s.setView); const setQuery = usePaletteStore((s) => s.setQuery); const reset = usePaletteStore((s) => s.reset); // Query captured when entering the tab view (via Tab) const [tabViewQuery, setTabViewQuery] = useState(""); // Configure the storage prefix once per change useEffect(() => { configureSearchStorage(storagePrefix); }, [storagePrefix]); // === Translate function === const t = useMemo(() => createTranslate(locale, strings), [locale, strings]); // === Registry === const registry = useMemo(() => { const reg = createActionRegistry(); reg.register(ItemActionsProvider); for (const provider of providers ?? []) { reg.register(provider); } return reg; }, [providers]); // === Resolved config (context value) === const config = useMemo( () => ({ t, locale, categories, categoryOrder: buildCategoryOrder(categories), registry, shortcuts: { actionsKey: actionsShortcutKey.toLowerCase() }, }), [t, locale, categories, registry, actionsShortcutKey] ); // === Query history === const queryHistory = useQueryHistory(storagePrefix); // === Exit animation === // Closing keeps the last frame mounted for EXIT_MS so it can animate out; // the store is reset once it is gone, so the content does not flash back // to an empty search while it fades. const [closing, setClosing] = useState(false); const [wasOpen, setWasOpen] = useState(isOpen); if (isOpen !== wasOpen) { setWasOpen(isOpen); setClosing(!isOpen); } useEffect(() => { if (!closing) return; const timer = window.setTimeout(() => { setClosing(false); reset(); }, mobile ? MOBILE_EXIT_MS : EXIT_MS); return () => window.clearTimeout(timer); }, [closing, mobile, reset]); // While it fades out, the view is still mounted with its document keydown // handlers: a capture listener swallows keys so Enter cannot run a result // the user just dismissed. useEffect(() => { if (!closing) return; const swallow = (event: KeyboardEvent) => event.stopImmediatePropagation(); document.addEventListener("keydown", swallow, true); return () => document.removeEventListener("keydown", swallow, true); }, [closing]); // === Action execution context === const handleClose = useCallback(() => { onClose(); }, [onClose]); const execContext = useMemo( () => ({ locale, translate: t, closeMenu: handleClose, navigate: onNavigate, showToast: onToast, meta: actionContextMeta ?? {}, }), [locale, t, handleClose, onNavigate, onToast, actionContextMeta] ); // Reset state when opening useEffect(() => { if (isOpen) { reset(); setQuery(initialQuery); if (initialView !== "search") { setView(initialView); } } }, [isOpen, initialQuery, initialView, reset, setQuery, setView]); // Click outside closes const handleOverlayClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) { handleClose(); } }, [handleClose] ); // Escape closes (from the search view; other views handle their own Escape). // Skipped while the actions popover is open — it closes itself and // stopPropagation cannot shield same-node document listeners. useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && view === "search" && !isActionsPopoverOpen) { handleClose(); } }; return listenToNewKeydowns(document, handleKeyDown); }, [isOpen, view, isActionsPopoverOpen, handleClose]); // Tab in the search field enters the tab view with the current query const handleEnterTabView = useCallback( (query: string) => { if (!tabView) return; setTabViewQuery(query); setQuery(""); setView(tabView.view); }, [tabView, setQuery, setView] ); // Record usage on selection for relevance boosting, then delegate const handleSelectWithUsage = useMemo(() => { if (!onSelectItem) return undefined; return (item: PaletteItem) => { recordUsage(item.id); onSelectItem(item); }; }, [onSelectItem]); // Don't render if not open, past the exit animation if (!isOpen && !closing) return null; // Render current view const renderView = () => { if (view === "form") { return ( setView("search")} /> ); } if (view !== "search") { const renderer = views?.[view]; if (renderer) { return renderer({ initialQuery: tabViewQuery, backToSearch: () => { setTabViewQuery(""); setView("search"); }, closePalette: handleClose, }); } // Unknown view: fall back to search } return ( ); }; if (mobile) return { if (!next) handleClose(); }}> {t("search.placeholder")} {isSuccess ?
: renderView()}
; return ( {/* `desktop-palette-overlay` is a GLOBAL class, not a module: it is app/globals.css which reads it, to dig the sliding region of the window above this veil (MIN-308). Without it, "click next to to close" didn't close anything in the top 60 px — macOS swallowed the click and the window started sliding. */}
{/* Above a modal dialog (the issue panel, a creation form), the palette must be its top layer (MIN-844). Its focus scope pauses that dialog's trap, which would otherwise pull the focus back from the search field; it is not trapped itself, as the actions popover lives in a portal. The search bar focuses itself, and closing leaves the focus to whatever the chosen row opened, as before. As the top dismissable layer, it keeps Escape from also closing the dialog: the palette's own keys still handle it. */} event.preventDefault()} onUnmountAutoFocus={(event) => event.preventDefault()} >
{/* Success indicator overlay */} {isSuccess && (
)} {/* Hide content during success animation */} {!isSuccess && renderView()}
); }