TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { usePathname, useRouter } from "next/navigation";4import { Command } from "cmdk";5import { useTheme } from "next-themes";6import { ArrowLeft, BarChart3, Boxes, Check, ChevronRight, Clock, Download, FolderKanban, Ghost, Keyboard, Library, Link2, MessageSquare, Monitor, Moon, PanelLeft, Paperclip, Plus, Search, SearchX, Settings, Share2, ShieldCheck, Sparkles, Sun, SunMoon, Swords, WandSparkles, Wand2, X } from "lucide-react";7import { useApp, AUTO_MODEL_KEY } from "./store";8import { useApi } from "@/lib/client/api";9import type { Project, SearchGroup } from "@/lib/client/types";10import { ProviderIcon } from "@/components/brand/provider-icon";11import { ResponsiveDialog } from "@/components/ui/sheet";12import { Kbd, Spinner } from "@/components/ui/misc";13import { toast } from "@/components/ui/toast";14import { useIsMobile } from "@/lib/client/hooks";15import { providerName } from "@/lib/client/providers";16import { cn } from "@/lib/utils";17import { SearchSheet } from "@/components/search/search-sheet";18import { useSearch, useRecentSearches, SEARCH_EXAMPLES } from "@/components/search/use-search";19import { ConversationHitContent, MessageHitContent, ModelHitContent, PromptHitContent, ProjectHitContent, PresetHitContent, GROUP_LABELS, GROUP_ORDER, useSearchNavigation } from "@/components/search/hits";20import { FilterChips } from "@/components/search/filter-chips";21import { ShareSheetHost, openShareSheet } from "@/components/share/share-sheet";22import { EXPORT_OPTIONS, exportConversation } from "@/components/share/export";23import { EXPORT_ICONS } from "@/components/share/export-menu";2425/**26 * Universal command palette (⌘K) — keyboard-first on desktop, bottom sheet with large rows on phones.27 *28 * Modes: root commands → sub-lists (Switch model, Open project, Export, Theme). Typing ≥ 2 characters in the29 * root shows matching commands AND live search results; a leading `/` forces search mode.30 * Backspace on an empty input goes back to the root; Esc closes.31 *32 * Window events emitted for other workstreams (documented in docs/upgrade-notes/G-search-share.md):33 * polyllm:open-attach → composer opens its attachment sheet / file picker34 * polyllm:switch-model → { detail: { modelKey } } chat view applies the model to the current conversation35 * This component also mounts <SearchSheet /> (phone search) and <ShareSheetHost /> once for the app.36 */37type Mode = "root" | "search" | "models" | "projects" | "export" | "theme";3839interface Cmd {40 id: string;41 label: string;42 group: "Actions" | "Navigate" | "Conversation" | "View" | "Help";43 icon: React.ReactNode;44 keywords?: string;45 shortcut?: string;46 hint?: string;47 /** Opens a sub-list instead of running. */48 submode?: Mode;49 run?: () => void;50}5152export const CHAT_ROUTE_RE = /^\/app\/chat\/([^/?#]+)/;5354export function CommandPalette() {55 return (56 <>57 <PaletteDialog />58 <SearchSheet />59 <ShareSheetHost />60 </>61 );62}6364function PaletteDialog() {65 const { paletteOpen, setPaletteOpen, searchOpen, setSearchOpen, setSidebarOpen, models, connectedProviders, favorites, labels, selectedModelKey, setSelectedModelKey, activeProjectId, setActiveProjectId } = useApp();66 const router = useRouter();67 const pathname = usePathname();68 const isMobile = useIsMobile();69 const { theme, setTheme } = useTheme();70 const [mode, setMode] = React.useState<Mode>("root");71 const [q, setQ] = React.useState("");72 const [shortcutsOpen, setShortcutsOpen] = React.useState(false);73 const inputRef = React.useRef<HTMLInputElement>(null);74 const recent = useRecentSearches();75 const activeChatId = pathname.match(CHAT_ROUTE_RE)?.[1] ?? null;7677 // Reset when closed.78 React.useEffect(() => {79 if (!paletteOpen) {80 // eslint-disable-next-line react-hooks/set-state-in-effect81 setMode("root");82 setQ("");83 }84 }, [paletteOpen]);8586 // Desktop: `store.searchOpen` (sidebar search button, bottom nav on tablets) opens the palette in search mode.87 React.useEffect(() => {88 if (searchOpen && !isMobile) {89 // eslint-disable-next-line react-hooks/set-state-in-effect90 setMode("search");91 setSearchOpen(false);92 setPaletteOpen(true);93 }94 }, [searchOpen, isMobile, setSearchOpen, setPaletteOpen]);9596 const close = React.useCallback(() => setPaletteOpen(false), [setPaletteOpen]);97 const go = React.useCallback(98 (href: string) => {99 close();100 router.push(href);101 },102 [close, router],103 );104105 /* ---- search --------------------------------------------------------------------------------- */106 const slash = q.startsWith("/");107 const searchQuery = mode === "search" ? q : slash ? q.slice(1) : q.trim().length >= 2 ? q : "";108 const search = useSearch(searchQuery, { enabled: paletteOpen && searchQuery.length > 0, limit: isMobile ? 8 : 6 });109 const nav = useSearchNavigation({110 onNavigate: () => {111 recent.add(searchQuery);112 close();113 },114 });115116 /* ---- commands ------------------------------------------------------------------------------- */117 const commands = React.useMemo<Cmd[]>(() => {118 const list: Cmd[] = [119 { id: "new-chat", label: "New chat", group: "Actions", icon: <Plus />, shortcut: "⌘N", keywords: "create conversation start", run: () => go("/app/chat") },120 { id: "temp-chat", label: "New temporary chat", group: "Actions", icon: <Ghost />, keywords: "incognito private ephemeral not stored", hint: "Not stored in history", run: () => go("/app/chat?temporary=1") },121 { id: "search", label: "Search conversations", group: "Actions", icon: <Search />, shortcut: "/", keywords: "find messages history", submode: "search" },122 { id: "switch-model", label: "Switch model", group: "Actions", icon: <Boxes />, keywords: "change model auto router pick", submode: "models" },123 { id: "upload", label: "Upload file", group: "Actions", icon: <Paperclip />, keywords: "attach image document pdf camera", run: () => uploadFile() },124 { id: "open-project", label: "Open project", group: "Navigate", icon: <FolderKanban />, keywords: "workspace projects", submode: "projects" },125 { id: "nav-chat", label: "Go to Chat", group: "Navigate", icon: <MessageSquare />, run: () => go("/app/chat") },126 { id: "nav-arena", label: "Open Arena", group: "Navigate", icon: <Swords />, keywords: "compare battle models side by side", run: () => go("/app/arena") },127 { id: "nav-models", label: "Browse models", group: "Navigate", icon: <Boxes />, keywords: "catalog registry", run: () => go("/app/models") },128 { id: "nav-usage", label: "Usage & costs", group: "Navigate", icon: <BarChart3 />, keywords: "analytics tokens spend dashboard", run: () => go("/app/usage") },129 { id: "nav-providers", label: "Providers & API keys", group: "Navigate", icon: <ShieldCheck />, keywords: "keys connect openai anthropic settings", run: () => go("/app/settings/providers") },130 { id: "nav-projects", label: "Projects", group: "Navigate", icon: <FolderKanban />, run: () => go("/app/projects") },131 { id: "nav-library", label: "Library", group: "Navigate", icon: <Library />, keywords: "files context documents", run: () => go("/app/library") },132 { id: "nav-prompts", label: "Prompts", group: "Navigate", icon: <WandSparkles />, keywords: "prompt library templates presets", run: () => go("/app/prompts") },133 { id: "nav-presets", label: "Model presets", group: "Navigate", icon: <Sparkles />, run: () => go("/app/presets") },134 { id: "nav-settings", label: "Settings", group: "Navigate", icon: <Settings />, keywords: "account appearance data security", run: () => go("/app/settings/account") },135 { id: "theme", label: "Toggle theme", group: "View", icon: <SunMoon />, keywords: "dark light system appearance mode", submode: "theme" },136 { id: "sidebar", label: isMobile ? "Open sidebar" : "Toggle sidebar", group: "View", icon: <PanelLeft />, shortcut: isMobile ? undefined : "⌘B", keywords: "collapse expand drawer conversations", run: () => toggleSidebar() },137 { id: "shortcuts", label: "Keyboard shortcuts", group: "Help", icon: <Keyboard />, shortcut: "?", keywords: "help keys hotkeys", run: () => { close(); setShortcutsOpen(true); } },138 ];139 if (activeChatId) {140 list.splice(141 5,142 0,143 { id: "copy-url", label: "Copy conversation URL", group: "Conversation", icon: <Link2 />, keywords: "link address clipboard", run: () => copyUrl() },144 { id: "share", label: "Share conversation", group: "Conversation", icon: <Share2 />, keywords: "public link publish", run: () => { close(); openShareSheet({ conversationId: activeChatId }); } },145 { id: "export", label: "Export conversation", group: "Conversation", icon: <Download />, keywords: "download markdown json txt pdf print html", submode: "export" },146 );147 }148 return list;149150 function copyUrl() {151 const url = `${window.location.origin}/app/chat/${activeChatId}`;152 navigator.clipboard153 .writeText(url)154 .then(() => toast.success("Conversation URL copied", url))155 .catch(() => toast.error("Could not copy", url));156 close();157 }158 function uploadFile() {159 close();160 if (/^\/app\/chat/.test(pathname)) {161 window.dispatchEvent(new CustomEvent("polyllm:open-attach"));162 } else {163 router.push("/app/chat");164 setTimeout(() => window.dispatchEvent(new CustomEvent("polyllm:open-attach")), 450);165 }166 }167 function toggleSidebar() {168 close();169 if (isMobile) setSidebarOpen(true);170 // Desktop: the shell owns the collapsed state and listens for ⌘B on window.171 // TODO(integration: shell) expose `toggleSidebarCollapsed` in the store instead of a synthetic key event.172 else window.dispatchEvent(new KeyboardEvent("keydown", { key: "b", metaKey: true, bubbles: true }));173 }174 }, [activeChatId, close, go, isMobile, pathname, router, setSidebarOpen]);175176 const filteredCommands = React.useMemo(() => {177 if (mode !== "root" || slash) return [];178 const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);179 if (!tokens.length) return commands;180 return commands.filter((c) => {181 const hay = `${c.label} ${c.keywords ?? ""} ${c.group}`.toLowerCase();182 return tokens.every((t) => hay.includes(t));183 });184 }, [commands, mode, q, slash]);185186 const showSearch = mode === "search" || slash || (mode === "root" && q.trim().length >= 2);187188 /* ---- sub-lists ------------------------------------------------------------------------------ */189 const usableModels = React.useMemo(() => {190 const list = models.filter((m) => connectedProviders.has(m.provider) && m.status !== "deprecated");191 const needle = q.trim().toLowerCase();192 const filtered = needle ? list.filter((m) => `${m.displayName} ${m.key} ${labels[m.key] ?? ""} ${providerName(m.provider)}`.toLowerCase().includes(needle)) : list;193 return [...filtered].sort((a, b) => Number(favorites.has(b.key)) - Number(favorites.has(a.key)) || a.displayName.localeCompare(b.displayName)).slice(0, 40);194 }, [models, connectedProviders, favorites, labels, q]);195196 const projectsQ = useApi<{ projects: Project[] }>(paletteOpen && mode === "projects" ? "/api/projects" : null, { shouldRetryOnError: false });197 const projectList = React.useMemo(() => {198 const needle = q.trim().toLowerCase();199 return (projectsQ.data?.projects ?? []).filter((p) => !p.archived && (!needle || p.name.toLowerCase().includes(needle)));200 }, [projectsQ.data, q]);201202 const enter = (m: Mode) => {203 setMode(m);204 setQ("");205 inputRef.current?.focus();206 };207 const back = () => enter("root");208209 const onKeyDown = (e: React.KeyboardEvent) => {210 if (e.key === "Backspace" && q === "" && mode !== "root") {211 e.preventDefault();212 back();213 }214 };215216 const placeholder: Record<Mode, string> = {217 root: "Type a command or search… (/ to search)",218 search: 'Search chats, messages, models… model:claude after:7d "phrase"',219 models: "Filter models…",220 projects: "Filter projects…",221 export: "Choose a format",222 theme: "Choose a theme",223 };224225 const modeLabel: Partial<Record<Mode, string>> = { search: "Search", models: "Switch model", projects: "Open project", export: "Export", theme: "Theme" };226227 const itemCls = cn("flex cursor-default select-none items-center gap-3 rounded-lg px-2.5 text-fg-muted transition-colors data-[selected=true]:bg-bg-muted data-[selected=true]:text-fg [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-fg-subtle", isMobile ? "min-h-[52px] py-2 text-[15px]" : "min-h-[38px] py-1.5 text-sm");228 const groupCls = "[&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-2.5 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:text-fg-subtle";229230 return (231 <>232 <ResponsiveDialog233 open={paletteOpen}234 onOpenChange={setPaletteOpen}235 title="Command palette"236 hideTitle237 showClose={false}238 size="lg"239 snap="full"240 expandable={false}241 flush242 desktopFlush243 className={cn("overflow-hidden", !isMobile && "md:top-[14%] md:translate-y-0 md:max-h-[min(72vh,760px)]")}244 bodyClassName="flex min-h-0 flex-col"245 >246 <Command label="Command palette" shouldFilter={false} loop onKeyDown={onKeyDown} className="flex min-h-0 flex-1 flex-col">247 <div className={cn("flex items-center gap-2 border-b border-border px-3", isMobile ? "h-14" : "h-12")}>248 {mode !== "root" ? (249 <button type="button" onClick={back} className="tap -ml-1 rounded-md p-1 text-fg-muted hover:bg-bg-muted hover:text-fg" aria-label="Back to commands">250 <ArrowLeft className="size-4" />251 </button>252 ) : (253 <Search className="size-4 shrink-0 text-fg-subtle" aria-hidden />254 )}255 {mode !== "root" ? <span className="shrink-0 rounded-md bg-bg-muted px-1.5 py-0.5 text-[11px] font-medium text-fg-muted">{modeLabel[mode]}</span> : null}256 <Command.Input ref={inputRef} autoFocus value={q} onValueChange={setQ} placeholder={placeholder[mode]} className={cn("h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-fg-subtle", isMobile ? "text-[16px]" : "text-[15px]")} />257 {search.loading && showSearch ? <Spinner className="size-3.5" /> : null}258 {isMobile ? (259 <button type="button" onClick={close} className="tap rounded-md p-1 text-fg-muted" aria-label="Close">260 <X className="size-4" />261 </button>262 ) : (263 <Kbd>esc</Kbd>264 )}265 </div>266267 {(mode === "search" || slash) ? (268 <div className="border-b border-border px-3 py-2">269 <FilterChips query={searchQuery} onChange={(next) => { setQ(mode === "search" ? next : `/${next}`); inputRef.current?.focus(); }} compact />270 </div>271 ) : null}272273 <Command.List className={cn("min-h-0 flex-1 overflow-y-auto p-2 scrollbar-thin contain-scroll", groupCls, isMobile ? "pb-[max(12px,var(--sab))]" : "max-h-[60vh]")}>274 {/* ---- root commands ---- */}275 {mode === "root" && !slash ? (276 <>277 {filteredCommands.length === 0 && !showSearch ? <Command.Empty className="px-3 py-8 text-center text-sm text-fg-muted">No matching command.</Command.Empty> : null}278 {(["Conversation", "Actions", "Navigate", "View", "Help"] as Cmd["group"][]).map((g) => {279 const items = filteredCommands.filter((c) => c.group === g);280 if (!items.length) return null;281 return (282 <Command.Group key={g} heading={g}>283 {items.map((c) => (284 <Command.Item key={c.id} value={`cmd:${c.id}`} onSelect={() => (c.submode ? enter(c.submode) : c.run?.())} className={itemCls}>285 {c.icon}286 <span className="min-w-0 flex-1 truncate">{c.label}</span>287 {c.hint ? <span className="truncate text-[11px] text-fg-subtle">{c.hint}</span> : null}288 {c.shortcut && !isMobile ? <Kbd>{c.shortcut}</Kbd> : null}289 {c.submode ? <ChevronRight className="size-3.5 text-fg-subtle" /> : null}290 </Command.Item>291 ))}292 </Command.Group>293 );294 })}295 </>296 ) : null}297298 {/* ---- search results (search mode, slash, or hybrid root) ---- */}299 {showSearch ? (300 <SearchResults301 searchQuery={searchQuery}302 search={search}303 nav={nav}304 itemCls={itemCls}305 isMobile={isMobile}306 recent={recent}307 onPickRecent={(v) => setQ(mode === "search" ? v : `/${v}`)}308 hybrid={mode === "root" && !slash}309 />310 ) : null}311312 {/* ---- switch model ---- */}313 {mode === "models" ? (314 <>315 <Command.Group heading="Smart router">316 <Command.Item value="model:auto" onSelect={() => { setSelectedModelKey(AUTO_MODEL_KEY); window.dispatchEvent(new CustomEvent("polyllm:switch-model", { detail: { modelKey: AUTO_MODEL_KEY } })); close(); if (!activeChatId && !/^\/app\/chat/.test(pathname)) router.push("/app/chat"); }} className={itemCls}>317 <Wand2 />318 <span className="min-w-0 flex-1">319 <span className="block truncate text-fg">AUTO — Smart Router</span>320 <span className="block truncate text-[11.5px] text-fg-subtle">Picks the best connected model for each prompt</span>321 </span>322 {selectedModelKey === AUTO_MODEL_KEY ? <Check className="size-4 text-accent" /> : null}323 </Command.Item>324 </Command.Group>325 <Command.Group heading={`Connected models${usableModels.length ? ` · ${usableModels.length}` : ""}`}>326 {usableModels.length === 0 ? <p className="px-3 py-6 text-center text-sm text-fg-muted">{models.length ? "No model matches." : "Connect a provider first (Settings → Providers)."}</p> : null}327 {usableModels.map((m) => (328 <Command.Item key={m.key} value={`model:${m.key}`} onSelect={() => { setSelectedModelKey(m.key); window.dispatchEvent(new CustomEvent("polyllm:switch-model", { detail: { modelKey: m.key } })); close(); if (!/^\/app\/chat/.test(pathname)) router.push("/app/chat"); }} className={itemCls}>329 <ProviderIcon provider={m.provider} size={16} />330 <span className="min-w-0 flex-1">331 <span className="block truncate text-fg">{labels[m.key] ?? m.displayName}</span>332 <span className="block truncate text-[11.5px] text-fg-subtle">333 {providerName(m.provider)}334 {labels[m.key] ? ` · ${m.displayName}` : ""}335 {favorites.has(m.key) ? " · ★" : ""}336 </span>337 </span>338 {selectedModelKey === m.key ? <Check className="size-4 text-accent" /> : null}339 </Command.Item>340 ))}341 </Command.Group>342 </>343 ) : null}344345 {/* ---- open project ---- */}346 {mode === "projects" ? (347 <Command.Group heading="Projects">348 {projectsQ.isLoading ? <p className="px-3 py-6 text-center text-sm text-fg-muted">Loading projects…</p> : null}349 {projectsQ.error ? <p className="px-3 py-6 text-center text-sm text-fg-muted">Projects are not available yet.</p> : null}350 {!projectsQ.isLoading && !projectsQ.error && projectList.length === 0 ? (351 <Command.Item value="project:new" onSelect={() => go("/app/projects?new=1")} className={itemCls}>352 <Plus />353 <span className="flex-1">Create your first project</span>354 </Command.Item>355 ) : null}356 {projectList.map((p) => (357 <Command.Item key={p.id} value={`project:${p.id}`} onSelect={() => { setActiveProjectId(p.id); go(`/app/projects/${p.id}`); }} className={itemCls}>358 <span className="flex size-4 items-center justify-center text-[13px]" style={p.color ? { color: p.color } : undefined} aria-hidden>359 {p.icon ?? <FolderKanban className="size-4" />}360 </span>361 <span className="min-w-0 flex-1">362 <span className="block truncate text-fg">{p.name}</span>363 {p.description ? <span className="block truncate text-[11.5px] text-fg-subtle">{p.description}</span> : null}364 </span>365 {activeProjectId === p.id ? <Check className="size-4 text-accent" /> : null}366 </Command.Item>367 ))}368 </Command.Group>369 ) : null}370371 {/* ---- export ---- */}372 {mode === "export" && activeChatId ? (373 <Command.Group heading="Export this conversation">374 {EXPORT_OPTIONS.map((o) => (375 <Command.Item key={o.format} value={`export:${o.format}`} onSelect={() => { close(); void exportConversation(activeChatId, o.format).catch(() => {}); }} className={itemCls}>376 {EXPORT_ICONS[o.format]}377 <span className="min-w-0 flex-1">378 <span className="block truncate text-fg">{o.label}</span>379 <span className="block truncate text-[11.5px] text-fg-subtle">{o.description}</span>380 </span>381 <span className="font-mono text-[11px] text-fg-subtle">{o.hint}</span>382 </Command.Item>383 ))}384 </Command.Group>385 ) : null}386387 {/* ---- theme ---- */}388 {mode === "theme" ? (389 <Command.Group heading="Appearance">390 {(391 [392 { v: "light", label: "Light", icon: <Sun /> },393 { v: "dark", label: "Dark", icon: <Moon /> },394 { v: "system", label: "System", icon: <Monitor /> },395 ] as { v: string; label: string; icon: React.ReactNode }[]396 ).map((t) => (397 <Command.Item key={t.v} value={`theme:${t.v}`} onSelect={() => { setTheme(t.v); close(); }} className={itemCls}>398 {t.icon}399 <span className="flex-1">{t.label}</span>400 {theme === t.v ? <Check className="size-4 text-accent" /> : null}401 </Command.Item>402 ))}403 </Command.Group>404 ) : null}405 </Command.List>406407 {!isMobile ? (408 <div className="flex items-center gap-3 border-t border-border px-3 py-1.5 text-[11px] text-fg-subtle">409 <span className="inline-flex items-center gap-1">410 <Kbd>↑↓</Kbd> navigate411 </span>412 <span className="inline-flex items-center gap-1">413 <Kbd>↵</Kbd> select414 </span>415 {mode !== "root" ? (416 <span className="inline-flex items-center gap-1">417 <Kbd>⌫</Kbd> back418 </span>419 ) : (420 <span className="inline-flex items-center gap-1">421 <Kbd>/</Kbd> search422 </span>423 )}424 <span className="ml-auto inline-flex items-center gap-1">425 <Kbd>esc</Kbd> close426 </span>427 </div>428 ) : null}429 </Command>430 </ResponsiveDialog>431432 <ShortcutsSheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />433 </>434 );435}436437/* ------------------------------------------------------------------------------------------------438 * Search results inside cmdk (↑↓↵ navigation)439 * ---------------------------------------------------------------------------------------------- */440function SearchResults({ searchQuery, search, nav, itemCls, isMobile, recent, onPickRecent, hybrid }: { searchQuery: string; search: ReturnType<typeof useSearch>; nav: ReturnType<typeof useSearchNavigation>; itemCls: string; isMobile: boolean; recent: ReturnType<typeof useRecentSearches>; onPickRecent: (v: string) => void; hybrid: boolean }) {441 const data = search.data;442 if (!search.searchable) {443 if (hybrid) return null;444 return (445 <>446 {recent.list.length ? (447 <Command.Group heading="Recent searches">448 {recent.list.map((r) => (449 <Command.Item key={r} value={`recent:${r}`} onSelect={() => onPickRecent(r)} className={itemCls}>450 <Clock />451 <span className="min-w-0 flex-1 truncate">{r}</span>452 <button type="button" onClick={(e) => { e.stopPropagation(); recent.remove(r); }} className="tap rounded p-1 text-fg-subtle hover:text-fg" aria-label={`Remove “${r}”`}>453 <X className="size-3.5" />454 </button>455 </Command.Item>456 ))}457 </Command.Group>458 ) : null}459 <Command.Group heading="Search syntax">460 {SEARCH_EXAMPLES.map((e) => (461 <Command.Item key={e.label} value={`example:${e.label}`} onSelect={() => onPickRecent(e.q)} className={itemCls}>462 <Search />463 <span className="flex-1 font-mono text-[12.5px]">{e.label}</span>464 </Command.Item>465 ))}466 <p className="px-2.5 py-2 text-[11.5px] leading-5 text-fg-subtle">467 Free text + <code className="font-mono">model:</code> <code className="font-mono">provider:</code> <code className="font-mono">project:</code> <code className="font-mono">folder:</code> <code className="font-mono">after:</code> <code className="font-mono">before:</code> <code className="font-mono">role:</code> <code className="font-mono">is:</code> · quote phrases with "…"468 </p>469 </Command.Group>470 </>471 );472 }473 if (search.loading && !data) {474 return <p className="flex items-center justify-center gap-2 px-3 py-8 text-sm text-fg-muted"><Spinner /> Searching…</p>;475 }476 if (search.error) return <p className="px-3 py-8 text-center text-sm text-fg-muted">Search is unavailable right now.</p>;477 if (!data) return null;478 if (search.isEmpty) {479 return (480 <div className="flex flex-col items-center px-3 py-8 text-center">481 <SearchX className="size-5 text-fg-subtle" />482 <p className="mt-2 text-sm font-medium">No results for “{searchQuery.trim()}”</p>483 <p className="mt-0.5 text-[12.5px] text-fg-muted">Try fewer words or remove a filter.</p>484 </div>485 );486 }487 const terms = data.query.terms;488 const dense = !isMobile;489 const groups = GROUP_ORDER.filter((g: SearchGroup) => (data[g] as unknown[]).length > 0);490 return (491 <>492 {groups.map((g) => (493 <Command.Group key={g} heading={`${GROUP_LABELS[g]} · ${(data[g] as unknown[]).length}`}>494 {g === "conversations" &&495 data.conversations.map((c) => (496 <Command.Item key={c.id} value={`conv:${c.id}`} onSelect={() => nav.openConversation(c)} className={itemCls}>497 <ConversationHitContent hit={c} terms={terms} dense={dense} />498 </Command.Item>499 ))}500 {g === "messages" &&501 data.messages.map((m) => (502 <Command.Item key={m.id} value={`msg:${m.id}`} onSelect={() => nav.openMessage(m)} className={cn(itemCls, "items-start")}>503 <MessageHitContent hit={m} terms={terms} dense={dense} />504 </Command.Item>505 ))}506 {g === "models" &&507 data.models.map((m) => (508 <Command.Item key={m.key} value={`smodel:${m.key}`} onSelect={() => nav.openModel(m)} className={itemCls}>509 <ModelHitContent hit={m} dense={dense} />510 </Command.Item>511 ))}512 {g === "prompts" &&513 data.prompts.map((p) => (514 <Command.Item key={`${p.kind}-${p.id}`} value={`prompt:${p.kind}:${p.id}`} onSelect={() => nav.openPrompt(p)} className={itemCls}>515 <PromptHitContent hit={p} terms={terms} dense={dense} />516 </Command.Item>517 ))}518 {g === "projects" &&519 data.projects.map((p) => (520 <Command.Item key={p.id} value={`sproject:${p.id}`} onSelect={() => nav.openProject(p)} className={itemCls}>521 <ProjectHitContent hit={p} terms={terms} dense={dense} />522 </Command.Item>523 ))}524 {g === "presets" &&525 data.presets.map((p) => (526 <Command.Item key={p.id} value={`preset:${p.id}`} onSelect={() => nav.openPreset(p)} className={itemCls}>527 <PresetHitContent hit={p} terms={terms} dense={dense} />528 </Command.Item>529 ))}530 </Command.Group>531 ))}532 {search.nextCursor ? (533 <Command.Group>534 <Command.Item value="search:more" onSelect={() => void search.loadMore()} className={cn(itemCls, "justify-center text-accent data-[selected=true]:text-accent")}>535 {search.loadingMore ? <Spinner className="size-3.5" /> : null}536 <span>Load more results</span>537 </Command.Item>538 </Command.Group>539 ) : null}540 <p className="px-2.5 pt-2 text-right text-[10.5px] tabular-nums text-fg-subtle">541 {search.total} result{search.total === 1 ? "" : "s"} · {data.tookMs} ms542 </p>543 </>544 );545}546547/* ------------------------------------------------------------------------------------------------548 * Keyboard shortcuts help549 * ---------------------------------------------------------------------------------------------- */550const SHORTCUTS: { keys: string[]; label: string; scope: string }[] = [551 { keys: ["⌘", "K"], label: "Command palette / search", scope: "Global" },552 { keys: ["⌘", "N"], label: "New chat", scope: "Global" },553 { keys: ["⌘", "B"], label: "Toggle sidebar", scope: "Global" },554 { keys: ["⌘", "/"], label: "Focus the composer", scope: "Chat" },555 { keys: ["?"], label: "Open this help (outside inputs)", scope: "Global" },556 { keys: ["/"], label: "Search mode inside the palette", scope: "Palette" },557 { keys: ["↑", "↓"], label: "Move selection", scope: "Palette" },558 { keys: ["↵"], label: "Send message / select item", scope: "Chat · Palette" },559 { keys: ["⇧", "↵"], label: "New line in the composer", scope: "Chat" },560 { keys: ["Esc"], label: "Stop generation / close sheets and dialogs", scope: "Chat · Global" },561 { keys: ["⌫"], label: "Back to commands (empty input)", scope: "Palette" },562];563564export function ShortcutsSheet({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {565 const isMobile = useIsMobile();566 return (567 <ResponsiveDialog open={open} onOpenChange={onOpenChange} title="Keyboard shortcuts" description={isMobile ? "Available when a keyboard is connected." : "Press ? anywhere to open this list."} size="sm">568 <ul className="divide-y divide-hairline">569 {SHORTCUTS.map((s) => (570 <li key={s.label} className="flex min-h-[44px] items-center gap-3 py-2">571 <span className="min-w-0 flex-1">572 <span className="block text-[14px] text-fg">{s.label}</span>573 <span className="block text-[11.5px] text-fg-subtle">{s.scope}</span>574 </span>575 <span className="flex shrink-0 items-center gap-1">576 {s.keys.map((k) => (577 <Kbd key={k} className="h-6 min-w-[1.6rem] text-[11px]">578 {k}579 </Kbd>580 ))}581 </span>582 </li>583 ))}584 </ul>585 </ResponsiveDialog>586 );587}588