SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
31.6 KB · 588 lines tsx
Raw Blame History
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 &quot;…&quot;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