TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { ChevronDown, Clock, Search, SearchX, X } from "lucide-react";4import { useApp } from "@/components/app/store";5import { BottomSheet } from "@/components/ui/sheet";6import { Button } from "@/components/ui/button";7import { Skeleton, Spinner } from "@/components/ui/misc";8import { useIsMobile } from "@/lib/client/hooks";9import type { SearchGroup, SearchResponse } from "@/lib/client/types";10import { cn } from "@/lib/utils";11import { FilterChips } from "./filter-chips";12import { useSearch, useRecentSearches, SEARCH_EXAMPLES, SEARCH_MIN_CHARS } from "./use-search";13import { ConversationHitContent, MessageHitContent, ModelHitContent, PromptHitContent, ProjectHitContent, PresetHitContent, GROUP_LABELS, GROUP_ORDER, useSearchNavigation } from "./hits";1415/**16 * Phone full-screen search (opened by `store.searchOpen`; the sidebar and bottom nav call `setSearchOpen(true)`).17 * On ≥ md the command palette handles search, so this renders nothing — `CommandPalette` mounts it once.18 */19export function SearchSheet() {20 const { searchOpen, setSearchOpen } = useApp();21 const isMobile = useIsMobile();22 if (!isMobile) return null;23 return <SearchSheetInner open={searchOpen} onOpenChange={setSearchOpen} />;24}2526function SearchSheetInner({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {27 const [q, setQ] = React.useState("");28 const inputRef = React.useRef<HTMLInputElement>(null);29 const search = useSearch(q, { enabled: open, limit: 10 });30 const recent = useRecentSearches();31 const close = React.useCallback(() => {32 onOpenChange(false);33 setQ("");34 }, [onOpenChange]);35 const nav = useSearchNavigation({36 onNavigate: () => {37 recent.add(q);38 close();39 },40 });4142 // Focus the input once the sheet has animated in (BottomSheet blocks auto-focus to avoid jumpy layouts).43 React.useEffect(() => {44 if (!open) return;45 const t = setTimeout(() => inputRef.current?.focus(), 120);46 return () => clearTimeout(t);47 }, [open]);4849 const header = (50 <div className="space-y-2 px-3 pb-2">51 <div className="flex items-center gap-2">52 <div className="relative flex-1">53 <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" aria-hidden />54 <input55 ref={inputRef}56 value={q}57 onChange={(e) => setQ(e.target.value)}58 onKeyDown={(e) => {59 if (e.key === "Enter") {60 recent.add(q);61 (e.target as HTMLInputElement).blur();62 }63 if (e.key === "Escape") close();64 }}65 type="search"66 inputMode="search"67 enterKeyHint="search"68 autoCapitalize="off"69 autoCorrect="off"70 spellCheck={false}71 placeholder="Search chats, messages, models…"72 aria-label="Search"73 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"74 />75 {q ? (76 <button type="button" onClick={() => { setQ(""); inputRef.current?.focus(); }} className="tap absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-bg-muted p-1 text-fg-muted" aria-label="Clear search">77 <X className="size-3.5" />78 </button>79 ) : null}80 </div>81 <button type="button" onClick={close} className="tap shrink-0 px-1 text-[15px] font-medium text-accent">82 Cancel83 </button>84 </div>85 <FilterChips query={q} onChange={(next) => { setQ(next); inputRef.current?.focus(); }} compact />86 </div>87 );8889 return (90 <BottomSheet open={open} onOpenChange={(o) => !o && close()} snap="full" expandable={false} title="Search" hideTitle flush header={header} bodyClassName="px-2 pb-[max(16px,var(--sab))]">91 {!search.searchable ? (92 <IdleState recent={recent.list} onPick={(v) => { setQ(v); inputRef.current?.focus(); }} onRemove={recent.remove} onClear={recent.clear} q={q} />93 ) : search.loading && !search.data ? (94 <div className="space-y-2 px-1 pt-2">95 {Array.from({ length: 6 }).map((_, i) => (96 <Skeleton key={i} className="h-14" />97 ))}98 </div>99 ) : search.error ? (100 <p className="px-3 py-10 text-center text-[14px] text-fg-muted">Search is unavailable right now. Try again in a moment.</p>101 ) : search.isEmpty ? (102 <div className="flex flex-col items-center px-6 py-14 text-center">103 <SearchX className="size-6 text-fg-subtle" />104 <p className="mt-3 text-[15px] font-medium">No results for “{search.parsed.text || search.query}”</p>105 <p className="mt-1 text-[13px] text-fg-muted">Try fewer words, or remove a filter.</p>106 </div>107 ) : search.data ? (108 <Results data={search.data} terms={search.data.query.terms} nav={nav} loading={search.loading} loadingMore={search.loadingMore} nextCursor={search.nextCursor} onLoadMore={search.loadMore} />109 ) : null}110 </BottomSheet>111 );112}113114function IdleState({ recent, onPick, onRemove, onClear, q }: { recent: string[]; onPick: (v: string) => void; onRemove: (v: string) => void; onClear: () => void; q: string }) {115 return (116 <div className="space-y-6 px-1 pt-2">117 {q.trim().length > 0 && q.trim().length < SEARCH_MIN_CHARS ? <p className="px-2 text-[13px] text-fg-subtle">Keep typing — at least {SEARCH_MIN_CHARS} characters.</p> : null}118 {recent.length ? (119 <section>120 <div className="flex items-center justify-between px-2 pb-1">121 <h3 className="text-[11px] font-medium uppercase tracking-wide text-fg-subtle">Recent</h3>122 <button type="button" onClick={onClear} className="tap text-[12px] text-fg-subtle hover:text-fg">123 Clear124 </button>125 </div>126 <ul>127 {recent.map((r) => (128 <li key={r} className="flex items-center">129 <button type="button" onClick={() => onPick(r)} className="flex min-h-[46px] min-w-0 flex-1 items-center gap-3 rounded-lg px-2 text-left text-[15px] text-fg active:bg-bg-muted">130 <Clock className="size-4 shrink-0 text-fg-subtle" />131 <span className="truncate">{r}</span>132 </button>133 <button type="button" onClick={() => onRemove(r)} className="tap p-2 text-fg-subtle" aria-label={`Remove “${r}” from recent searches`}>134 <X className="size-4" />135 </button>136 </li>137 ))}138 </ul>139 </section>140 ) : null}141 <section>142 <h3 className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-subtle">Search syntax</h3>143 <div className="flex flex-wrap gap-1.5 px-2">144 {SEARCH_EXAMPLES.map((e) => (145 <button key={e.label} type="button" onClick={() => onPick(e.q)} className="rounded-full border border-border bg-bg-elevated px-3 py-1.5 font-mono text-[12px] text-fg-muted active:bg-bg-muted">146 {e.label}147 </button>148 ))}149 </div>150 <p className="mt-3 px-2 text-[12.5px] leading-5 text-fg-subtle">151 Combine free text with <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: <code className="font-mono">"exact words"</code>.152 </p>153 </section>154 </div>155 );156}157158type Nav = ReturnType<typeof useSearchNavigation>;159160function Results({ data, terms, nav, loading, loadingMore, nextCursor, onLoadMore }: { data: SearchResponse; terms: string[]; nav: Nav; loading: boolean; loadingMore: boolean; nextCursor: string | null; onLoadMore: () => Promise<void> }) {161 const row = "flex min-h-[56px] w-full items-center gap-3 rounded-xl px-2 py-2 text-left active:bg-bg-muted";162 const groups = GROUP_ORDER.filter((g) => (data[g] as unknown[]).length > 0);163 return (164 <div className={cn("space-y-5 pt-1 transition-opacity", loading && "opacity-60")}>165 <p className="px-2 text-[11.5px] tabular-nums text-fg-subtle">166 {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} ms167 </p>168 {groups.map((g: SearchGroup) => (169 <section key={g} aria-label={GROUP_LABELS[g]}>170 <h3 className="px-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle">171 {GROUP_LABELS[g]} <span className="font-normal text-fg-subtle/70">{(data[g] as unknown[]).length}</span>172 </h3>173 <ul className="space-y-px">174 {g === "conversations" &&175 data.conversations.map((c) => (176 <li key={c.id}>177 <button type="button" className={row} onClick={() => nav.openConversation(c)}>178 <ConversationHitContent hit={c} terms={terms} />179 </button>180 </li>181 ))}182 {g === "messages" &&183 data.messages.map((m) => (184 <li key={m.id}>185 <button type="button" className={cn(row, "items-start")} onClick={() => nav.openMessage(m)}>186 <MessageHitContent hit={m} terms={terms} />187 </button>188 </li>189 ))}190 {g === "models" &&191 data.models.map((m) => (192 <li key={m.key}>193 <button type="button" className={row} onClick={() => nav.openModel(m)}>194 <ModelHitContent hit={m} />195 </button>196 </li>197 ))}198 {g === "prompts" &&199 data.prompts.map((p) => (200 <li key={`${p.kind}-${p.id}`}>201 <button type="button" className={row} onClick={() => nav.openPrompt(p)}>202 <PromptHitContent hit={p} terms={terms} />203 </button>204 </li>205 ))}206 {g === "projects" &&207 data.projects.map((p) => (208 <li key={p.id}>209 <button type="button" className={row} onClick={() => nav.openProject(p)}>210 <ProjectHitContent hit={p} terms={terms} />211 </button>212 </li>213 ))}214 {g === "presets" &&215 data.presets.map((p) => (216 <li key={p.id}>217 <button type="button" className={row} onClick={() => nav.openPreset(p)}>218 <PresetHitContent hit={p} terms={terms} />219 </button>220 </li>221 ))}222 </ul>223 </section>224 ))}225 {nextCursor ? (226 <div className="px-2 pb-2">227 <Button variant="outline" size="lg" className="w-full" onClick={() => void onLoadMore()} loading={loadingMore}>228 {loadingMore ? null : <ChevronDown />} Load more229 </Button>230 </div>231 ) : null}232 {loading ? (233 <div className="flex justify-center py-2">234 <Spinner />235 </div>236 ) : null}237 </div>238 );239}240