"use client"; import * as React from "react"; import { usePathname, useRouter } from "next/navigation"; import { Command } from "cmdk"; import { useTheme } from "next-themes"; import { 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"; import { useApp, AUTO_MODEL_KEY } from "./store"; import { useApi } from "@/lib/client/api"; import type { Project, SearchGroup } from "@/lib/client/types"; import { ProviderIcon } from "@/components/brand/provider-icon"; import { ResponsiveDialog } from "@/components/ui/sheet"; import { Kbd, Spinner } from "@/components/ui/misc"; import { toast } from "@/components/ui/toast"; import { useIsMobile } from "@/lib/client/hooks"; import { providerName } from "@/lib/client/providers"; import { cn } from "@/lib/utils"; import { SearchSheet } from "@/components/search/search-sheet"; import { useSearch, useRecentSearches, SEARCH_EXAMPLES } from "@/components/search/use-search"; import { ConversationHitContent, MessageHitContent, ModelHitContent, PromptHitContent, ProjectHitContent, PresetHitContent, GROUP_LABELS, GROUP_ORDER, useSearchNavigation } from "@/components/search/hits"; import { FilterChips } from "@/components/search/filter-chips"; import { ShareSheetHost, openShareSheet } from "@/components/share/share-sheet"; import { EXPORT_OPTIONS, exportConversation } from "@/components/share/export"; import { EXPORT_ICONS } from "@/components/share/export-menu"; /** * Universal command palette (⌘K) — keyboard-first on desktop, bottom sheet with large rows on phones. * * Modes: root commands → sub-lists (Switch model, Open project, Export, Theme). Typing ≥ 2 characters in the * root shows matching commands AND live search results; a leading `/` forces search mode. * Backspace on an empty input goes back to the root; Esc closes. * * Window events emitted for other workstreams (documented in docs/upgrade-notes/G-search-share.md): * polyllm:open-attach → composer opens its attachment sheet / file picker * polyllm:switch-model → { detail: { modelKey } } chat view applies the model to the current conversation * This component also mounts (phone search) and once for the app. */ type Mode = "root" | "search" | "models" | "projects" | "export" | "theme"; interface Cmd { id: string; label: string; group: "Actions" | "Navigate" | "Conversation" | "View" | "Help"; icon: React.ReactNode; keywords?: string; shortcut?: string; hint?: string; /** Opens a sub-list instead of running. */ submode?: Mode; run?: () => void; } export const CHAT_ROUTE_RE = /^\/app\/chat\/([^/?#]+)/; export function CommandPalette() { return ( <> ); } function PaletteDialog() { const { paletteOpen, setPaletteOpen, searchOpen, setSearchOpen, setSidebarOpen, models, connectedProviders, favorites, labels, selectedModelKey, setSelectedModelKey, activeProjectId, setActiveProjectId } = useApp(); const router = useRouter(); const pathname = usePathname(); const isMobile = useIsMobile(); const { theme, setTheme } = useTheme(); const [mode, setMode] = React.useState("root"); const [q, setQ] = React.useState(""); const [shortcutsOpen, setShortcutsOpen] = React.useState(false); const inputRef = React.useRef(null); const recent = useRecentSearches(); const activeChatId = pathname.match(CHAT_ROUTE_RE)?.[1] ?? null; // Reset when closed. React.useEffect(() => { if (!paletteOpen) { // eslint-disable-next-line react-hooks/set-state-in-effect setMode("root"); setQ(""); } }, [paletteOpen]); // Desktop: `store.searchOpen` (sidebar search button, bottom nav on tablets) opens the palette in search mode. React.useEffect(() => { if (searchOpen && !isMobile) { // eslint-disable-next-line react-hooks/set-state-in-effect setMode("search"); setSearchOpen(false); setPaletteOpen(true); } }, [searchOpen, isMobile, setSearchOpen, setPaletteOpen]); const close = React.useCallback(() => setPaletteOpen(false), [setPaletteOpen]); const go = React.useCallback( (href: string) => { close(); router.push(href); }, [close, router], ); /* ---- search --------------------------------------------------------------------------------- */ const slash = q.startsWith("/"); const searchQuery = mode === "search" ? q : slash ? q.slice(1) : q.trim().length >= 2 ? q : ""; const search = useSearch(searchQuery, { enabled: paletteOpen && searchQuery.length > 0, limit: isMobile ? 8 : 6 }); const nav = useSearchNavigation({ onNavigate: () => { recent.add(searchQuery); close(); }, }); /* ---- commands ------------------------------------------------------------------------------- */ const commands = React.useMemo(() => { const list: Cmd[] = [ { id: "new-chat", label: "New chat", group: "Actions", icon: , shortcut: "⌘N", keywords: "create conversation start", run: () => go("/app/chat") }, { id: "temp-chat", label: "New temporary chat", group: "Actions", icon: , keywords: "incognito private ephemeral not stored", hint: "Not stored in history", run: () => go("/app/chat?temporary=1") }, { id: "search", label: "Search conversations", group: "Actions", icon: , shortcut: "/", keywords: "find messages history", submode: "search" }, { id: "switch-model", label: "Switch model", group: "Actions", icon: , keywords: "change model auto router pick", submode: "models" }, { id: "upload", label: "Upload file", group: "Actions", icon: , keywords: "attach image document pdf camera", run: () => uploadFile() }, { id: "open-project", label: "Open project", group: "Navigate", icon: , keywords: "workspace projects", submode: "projects" }, { id: "nav-chat", label: "Go to Chat", group: "Navigate", icon: , run: () => go("/app/chat") }, { id: "nav-arena", label: "Open Arena", group: "Navigate", icon: , keywords: "compare battle models side by side", run: () => go("/app/arena") }, { id: "nav-models", label: "Browse models", group: "Navigate", icon: , keywords: "catalog registry", run: () => go("/app/models") }, { id: "nav-usage", label: "Usage & costs", group: "Navigate", icon: , keywords: "analytics tokens spend dashboard", run: () => go("/app/usage") }, { id: "nav-providers", label: "Providers & API keys", group: "Navigate", icon: , keywords: "keys connect openai anthropic settings", run: () => go("/app/settings/providers") }, { id: "nav-projects", label: "Projects", group: "Navigate", icon: , run: () => go("/app/projects") }, { id: "nav-library", label: "Library", group: "Navigate", icon: , keywords: "files context documents", run: () => go("/app/library") }, { id: "nav-prompts", label: "Prompts", group: "Navigate", icon: , keywords: "prompt library templates presets", run: () => go("/app/prompts") }, { id: "nav-presets", label: "Model presets", group: "Navigate", icon: , run: () => go("/app/presets") }, { id: "nav-settings", label: "Settings", group: "Navigate", icon: , keywords: "account appearance data security", run: () => go("/app/settings/account") }, { id: "theme", label: "Toggle theme", group: "View", icon: , keywords: "dark light system appearance mode", submode: "theme" }, { id: "sidebar", label: isMobile ? "Open sidebar" : "Toggle sidebar", group: "View", icon: , shortcut: isMobile ? undefined : "⌘B", keywords: "collapse expand drawer conversations", run: () => toggleSidebar() }, { id: "shortcuts", label: "Keyboard shortcuts", group: "Help", icon: , shortcut: "?", keywords: "help keys hotkeys", run: () => { close(); setShortcutsOpen(true); } }, ]; if (activeChatId) { list.splice( 5, 0, { id: "copy-url", label: "Copy conversation URL", group: "Conversation", icon: , keywords: "link address clipboard", run: () => copyUrl() }, { id: "share", label: "Share conversation", group: "Conversation", icon: , keywords: "public link publish", run: () => { close(); openShareSheet({ conversationId: activeChatId }); } }, { id: "export", label: "Export conversation", group: "Conversation", icon: , keywords: "download markdown json txt pdf print html", submode: "export" }, ); } return list; function copyUrl() { const url = `${window.location.origin}/app/chat/${activeChatId}`; navigator.clipboard .writeText(url) .then(() => toast.success("Conversation URL copied", url)) .catch(() => toast.error("Could not copy", url)); close(); } function uploadFile() { close(); if (/^\/app\/chat/.test(pathname)) { window.dispatchEvent(new CustomEvent("polyllm:open-attach")); } else { router.push("/app/chat"); setTimeout(() => window.dispatchEvent(new CustomEvent("polyllm:open-attach")), 450); } } function toggleSidebar() { close(); if (isMobile) setSidebarOpen(true); // Desktop: the shell owns the collapsed state and listens for ⌘B on window. // TODO(integration: shell) expose `toggleSidebarCollapsed` in the store instead of a synthetic key event. else window.dispatchEvent(new KeyboardEvent("keydown", { key: "b", metaKey: true, bubbles: true })); } }, [activeChatId, close, go, isMobile, pathname, router, setSidebarOpen]); const filteredCommands = React.useMemo(() => { if (mode !== "root" || slash) return []; const tokens = q.toLowerCase().split(/\s+/).filter(Boolean); if (!tokens.length) return commands; return commands.filter((c) => { const hay = `${c.label} ${c.keywords ?? ""} ${c.group}`.toLowerCase(); return tokens.every((t) => hay.includes(t)); }); }, [commands, mode, q, slash]); const showSearch = mode === "search" || slash || (mode === "root" && q.trim().length >= 2); /* ---- sub-lists ------------------------------------------------------------------------------ */ const usableModels = React.useMemo(() => { const list = models.filter((m) => connectedProviders.has(m.provider) && m.status !== "deprecated"); const needle = q.trim().toLowerCase(); const filtered = needle ? list.filter((m) => `${m.displayName} ${m.key} ${labels[m.key] ?? ""} ${providerName(m.provider)}`.toLowerCase().includes(needle)) : list; return [...filtered].sort((a, b) => Number(favorites.has(b.key)) - Number(favorites.has(a.key)) || a.displayName.localeCompare(b.displayName)).slice(0, 40); }, [models, connectedProviders, favorites, labels, q]); const projectsQ = useApi<{ projects: Project[] }>(paletteOpen && mode === "projects" ? "/api/projects" : null, { shouldRetryOnError: false }); const projectList = React.useMemo(() => { const needle = q.trim().toLowerCase(); return (projectsQ.data?.projects ?? []).filter((p) => !p.archived && (!needle || p.name.toLowerCase().includes(needle))); }, [projectsQ.data, q]); const enter = (m: Mode) => { setMode(m); setQ(""); inputRef.current?.focus(); }; const back = () => enter("root"); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Backspace" && q === "" && mode !== "root") { e.preventDefault(); back(); } }; const placeholder: Record = { root: "Type a command or search… (/ to search)", search: 'Search chats, messages, models… model:claude after:7d "phrase"', models: "Filter models…", projects: "Filter projects…", export: "Choose a format", theme: "Choose a theme", }; const modeLabel: Partial> = { search: "Search", models: "Switch model", projects: "Open project", export: "Export", theme: "Theme" }; 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"); 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"; return ( <>
{mode !== "root" ? ( ) : ( )} {mode !== "root" ? {modeLabel[mode]} : null} {search.loading && showSearch ? : null} {isMobile ? ( ) : ( esc )}
{(mode === "search" || slash) ? (
{ setQ(mode === "search" ? next : `/${next}`); inputRef.current?.focus(); }} compact />
) : null} {/* ---- root commands ---- */} {mode === "root" && !slash ? ( <> {filteredCommands.length === 0 && !showSearch ? No matching command. : null} {(["Conversation", "Actions", "Navigate", "View", "Help"] as Cmd["group"][]).map((g) => { const items = filteredCommands.filter((c) => c.group === g); if (!items.length) return null; return ( {items.map((c) => ( (c.submode ? enter(c.submode) : c.run?.())} className={itemCls}> {c.icon} {c.label} {c.hint ? {c.hint} : null} {c.shortcut && !isMobile ? {c.shortcut} : null} {c.submode ? : null} ))} ); })} ) : null} {/* ---- search results (search mode, slash, or hybrid root) ---- */} {showSearch ? ( setQ(mode === "search" ? v : `/${v}`)} hybrid={mode === "root" && !slash} /> ) : null} {/* ---- switch model ---- */} {mode === "models" ? ( <> { 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}> AUTO — Smart Router Picks the best connected model for each prompt {selectedModelKey === AUTO_MODEL_KEY ? : null} {usableModels.length === 0 ?

{models.length ? "No model matches." : "Connect a provider first (Settings → Providers)."}

: null} {usableModels.map((m) => ( { 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}> {labels[m.key] ?? m.displayName} {providerName(m.provider)} {labels[m.key] ? ` · ${m.displayName}` : ""} {favorites.has(m.key) ? " · ★" : ""} {selectedModelKey === m.key ? : null} ))}
) : null} {/* ---- open project ---- */} {mode === "projects" ? ( {projectsQ.isLoading ?

Loading projects…

: null} {projectsQ.error ?

Projects are not available yet.

: null} {!projectsQ.isLoading && !projectsQ.error && projectList.length === 0 ? ( go("/app/projects?new=1")} className={itemCls}> Create your first project ) : null} {projectList.map((p) => ( { setActiveProjectId(p.id); go(`/app/projects/${p.id}`); }} className={itemCls}> {p.icon ?? } {p.name} {p.description ? {p.description} : null} {activeProjectId === p.id ? : null} ))}
) : null} {/* ---- export ---- */} {mode === "export" && activeChatId ? ( {EXPORT_OPTIONS.map((o) => ( { close(); void exportConversation(activeChatId, o.format).catch(() => {}); }} className={itemCls}> {EXPORT_ICONS[o.format]} {o.label} {o.description} {o.hint} ))} ) : null} {/* ---- theme ---- */} {mode === "theme" ? ( {( [ { v: "light", label: "Light", icon: }, { v: "dark", label: "Dark", icon: }, { v: "system", label: "System", icon: }, ] as { v: string; label: string; icon: React.ReactNode }[] ).map((t) => ( { setTheme(t.v); close(); }} className={itemCls}> {t.icon} {t.label} {theme === t.v ? : null} ))} ) : null}
{!isMobile ? (
↑↓ navigate ↵ select {mode !== "root" ? ( ⌫ back ) : ( / search )} esc close
) : null}
); } /* ------------------------------------------------------------------------------------------------ * Search results inside cmdk (↑↓↵ navigation) * ---------------------------------------------------------------------------------------------- */ function SearchResults({ searchQuery, search, nav, itemCls, isMobile, recent, onPickRecent, hybrid }: { searchQuery: string; search: ReturnType; nav: ReturnType; itemCls: string; isMobile: boolean; recent: ReturnType; onPickRecent: (v: string) => void; hybrid: boolean }) { const data = search.data; if (!search.searchable) { if (hybrid) return null; return ( <> {recent.list.length ? ( {recent.list.map((r) => ( onPickRecent(r)} className={itemCls}> {r} ))} ) : null} {SEARCH_EXAMPLES.map((e) => ( onPickRecent(e.q)} className={itemCls}> {e.label} ))}

Free text + model: provider: project: folder: after: before: role: is: · quote phrases with "…"

); } if (search.loading && !data) { return

Searching…

; } if (search.error) return

Search is unavailable right now.

; if (!data) return null; if (search.isEmpty) { return (

No results for “{searchQuery.trim()}”

Try fewer words or remove a filter.

); } const terms = data.query.terms; const dense = !isMobile; const groups = GROUP_ORDER.filter((g: SearchGroup) => (data[g] as unknown[]).length > 0); return ( <> {groups.map((g) => ( {g === "conversations" && data.conversations.map((c) => ( nav.openConversation(c)} className={itemCls}> ))} {g === "messages" && data.messages.map((m) => ( nav.openMessage(m)} className={cn(itemCls, "items-start")}> ))} {g === "models" && data.models.map((m) => ( nav.openModel(m)} className={itemCls}> ))} {g === "prompts" && data.prompts.map((p) => ( nav.openPrompt(p)} className={itemCls}> ))} {g === "projects" && data.projects.map((p) => ( nav.openProject(p)} className={itemCls}> ))} {g === "presets" && data.presets.map((p) => ( nav.openPreset(p)} className={itemCls}> ))} ))} {search.nextCursor ? ( void search.loadMore()} className={cn(itemCls, "justify-center text-accent data-[selected=true]:text-accent")}> {search.loadingMore ? : null} Load more results ) : null}

{search.total} result{search.total === 1 ? "" : "s"} · {data.tookMs} ms

); } /* ------------------------------------------------------------------------------------------------ * Keyboard shortcuts help * ---------------------------------------------------------------------------------------------- */ const SHORTCUTS: { keys: string[]; label: string; scope: string }[] = [ { keys: ["⌘", "K"], label: "Command palette / search", scope: "Global" }, { keys: ["⌘", "N"], label: "New chat", scope: "Global" }, { keys: ["⌘", "B"], label: "Toggle sidebar", scope: "Global" }, { keys: ["⌘", "/"], label: "Focus the composer", scope: "Chat" }, { keys: ["?"], label: "Open this help (outside inputs)", scope: "Global" }, { keys: ["/"], label: "Search mode inside the palette", scope: "Palette" }, { keys: ["↑", "↓"], label: "Move selection", scope: "Palette" }, { keys: ["↵"], label: "Send message / select item", scope: "Chat · Palette" }, { keys: ["⇧", "↵"], label: "New line in the composer", scope: "Chat" }, { keys: ["Esc"], label: "Stop generation / close sheets and dialogs", scope: "Chat · Global" }, { keys: ["⌫"], label: "Back to commands (empty input)", scope: "Palette" }, ]; export function ShortcutsSheet({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { const isMobile = useIsMobile(); return (
    {SHORTCUTS.map((s) => (
  • {s.label} {s.scope} {s.keys.map((k) => ( {k} ))}
  • ))}
); }