"use client"; import * as React from "react"; import { ChevronDown, Clock, Search, SearchX, X } from "lucide-react"; import { useApp } from "@/components/app/store"; import { BottomSheet } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { Skeleton, Spinner } from "@/components/ui/misc"; import { useIsMobile } from "@/lib/client/hooks"; import type { SearchGroup, SearchResponse } from "@/lib/client/types"; import { cn } from "@/lib/utils"; import { FilterChips } from "./filter-chips"; import { useSearch, useRecentSearches, SEARCH_EXAMPLES, SEARCH_MIN_CHARS } from "./use-search"; import { ConversationHitContent, MessageHitContent, ModelHitContent, PromptHitContent, ProjectHitContent, PresetHitContent, GROUP_LABELS, GROUP_ORDER, useSearchNavigation } from "./hits"; /** * Phone full-screen search (opened by `store.searchOpen`; the sidebar and bottom nav call `setSearchOpen(true)`). * On ≥ md the command palette handles search, so this renders nothing — `CommandPalette` mounts it once. */ export function SearchSheet() { const { searchOpen, setSearchOpen } = useApp(); const isMobile = useIsMobile(); if (!isMobile) return null; return ; } function SearchSheetInner({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { const [q, setQ] = React.useState(""); const inputRef = React.useRef(null); const search = useSearch(q, { enabled: open, limit: 10 }); const recent = useRecentSearches(); const close = React.useCallback(() => { onOpenChange(false); setQ(""); }, [onOpenChange]); const nav = useSearchNavigation({ onNavigate: () => { recent.add(q); close(); }, }); // Focus the input once the sheet has animated in (BottomSheet blocks auto-focus to avoid jumpy layouts). React.useEffect(() => { if (!open) return; const t = setTimeout(() => inputRef.current?.focus(), 120); return () => clearTimeout(t); }, [open]); const header = (
setQ(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { recent.add(q); (e.target as HTMLInputElement).blur(); } if (e.key === "Escape") close(); }} type="search" inputMode="search" enterKeyHint="search" autoCapitalize="off" autoCorrect="off" spellCheck={false} placeholder="Search chats, messages, models…" aria-label="Search" className="h-11 w-full rounded-xl border border-border bg-bg-subtle pl-9 pr-9 text-[16px] text-fg outline-none placeholder:text-fg-subtle focus:border-accent focus:bg-bg-elevated [&::-webkit-search-cancel-button]:hidden" /> {q ? ( ) : null}
{ setQ(next); inputRef.current?.focus(); }} compact />
); return ( !o && close()} snap="full" expandable={false} title="Search" hideTitle flush header={header} bodyClassName="px-2 pb-[max(16px,var(--sab))]"> {!search.searchable ? ( { setQ(v); inputRef.current?.focus(); }} onRemove={recent.remove} onClear={recent.clear} q={q} /> ) : search.loading && !search.data ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : search.error ? (

Search is unavailable right now. Try again in a moment.

) : search.isEmpty ? (

No results for “{search.parsed.text || search.query}”

Try fewer words, or remove a filter.

) : search.data ? ( ) : null}
); } function IdleState({ recent, onPick, onRemove, onClear, q }: { recent: string[]; onPick: (v: string) => void; onRemove: (v: string) => void; onClear: () => void; q: string }) { return (
{q.trim().length > 0 && q.trim().length < SEARCH_MIN_CHARS ?

Keep typing — at least {SEARCH_MIN_CHARS} characters.

: null} {recent.length ? (

Recent

    {recent.map((r) => (
  • ))}
) : null}

Search syntax

{SEARCH_EXAMPLES.map((e) => ( ))}

Combine free text with model: provider: project: folder: after: before: role: is:. Quote phrases: "exact words".

); } type Nav = ReturnType; function Results({ data, terms, nav, loading, loadingMore, nextCursor, onLoadMore }: { data: SearchResponse; terms: string[]; nav: Nav; loading: boolean; loadingMore: boolean; nextCursor: string | null; onLoadMore: () => Promise }) { const row = "flex min-h-[56px] w-full items-center gap-3 rounded-xl px-2 py-2 text-left active:bg-bg-muted"; const groups = GROUP_ORDER.filter((g) => (data[g] as unknown[]).length > 0); return (

{groups.reduce((n, g) => n + (data[g] as unknown[]).length, 0)} result{groups.length === 1 && (data[groups[0]] as unknown[]).length === 1 ? "" : "s"} · {data.tookMs} ms

{groups.map((g: SearchGroup) => (

{GROUP_LABELS[g]} {(data[g] as unknown[]).length}

    {g === "conversations" && data.conversations.map((c) => (
  • ))} {g === "messages" && data.messages.map((m) => (
  • ))} {g === "models" && data.models.map((m) => (
  • ))} {g === "prompts" && data.prompts.map((p) => (
  • ))} {g === "projects" && data.projects.map((p) => (
  • ))} {g === "presets" && data.presets.map((p) => (
  • ))}
))} {nextCursor ? (
) : null} {loading ? (
) : null}
); }