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%
11.3 KB · 240 lines tsx
Raw Blame History
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">&quot;exact words&quot;</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