"use client"; import * as React from "react"; import { api, useApi } from "@/lib/client/api"; import { useDebounced, useLocalStorage } from "@/lib/client/hooks"; import type { SearchResponse } from "@/lib/client/types"; import { parseQuery, type ParsedQuery } from "@/lib/search/query"; export const SEARCH_MIN_CHARS = 2; export function isSearchableQuery(parsed: ParsedQuery): boolean { return parsed.text.replace(/"/g, "").trim().length >= SEARCH_MIN_CHARS || parsed.hasFilters; } export interface UseSearchOptions { enabled?: boolean; limit?: number; debounceMs?: number; } export interface UseSearchResult { /** Debounced, trimmed query that produced `data`. */ query: string; parsed: ParsedQuery; searchable: boolean; data: SearchResponse | null; /** True while the first page is loading or the debounce is pending for a searchable query. */ loading: boolean; loadingMore: boolean; error: unknown; nextCursor: string | null; loadMore: () => Promise; /** Searched and nothing came back. */ isEmpty: boolean; total: number; } export function countHits(d: SearchResponse | null | undefined): number { if (!d) return 0; return d.conversations.length + d.messages.length + d.models.length + d.prompts.length + d.projects.length + d.presets.length; } function mergePages(first: SearchResponse, pages: SearchResponse[]): SearchResponse { if (!pages.length) return first; return { ...first, conversations: [...first.conversations, ...pages.flatMap((p) => p.conversations)], messages: [...first.messages, ...pages.flatMap((p) => p.messages)], nextCursor: pages[pages.length - 1].nextCursor, }; } /** * Debounced search against GET /api/search with cursor pagination for conversations/messages. * SWR dedupes identical queries (palette + sheet) and keeps the previous page while typing. */ export function useSearch(rawQuery: string, { enabled = true, limit = 12, debounceMs = 180 }: UseSearchOptions = {}): UseSearchResult { const trimmed = rawQuery.trim(); const debounced = useDebounced(trimmed, debounceMs); const parsed = React.useMemo(() => parseQuery(debounced), [debounced]); const searchable = isSearchableQuery(parsed); const key = enabled && searchable ? `/api/search?q=${encodeURIComponent(debounced)}&limit=${limit}` : null; const { data, isLoading, error } = useApi(key, { keepPreviousData: true, dedupingInterval: 4000 }); const [extra, setExtra] = React.useState<{ forKey: string | null; pages: SearchResponse[] }>({ forKey: null, pages: [] }); const [loadingMore, setLoadingMore] = React.useState(false); const merged = React.useMemo(() => { if (!data) return null; return mergePages(data, extra.forKey === key ? extra.pages : []); }, [data, extra, key]); const nextCursor = merged?.nextCursor ?? null; const loadMore = React.useCallback(async () => { if (!key || !nextCursor || loadingMore) return; setLoadingMore(true); try { const res = await api(`${key}&cursor=${encodeURIComponent(nextCursor)}&groups=conversations,messages`); setExtra((prev) => ({ forKey: key, pages: [...(prev.forKey === key ? prev.pages : []), res] })); } finally { setLoadingMore(false); } }, [key, nextCursor, loadingMore]); const pending = Boolean(key) && (isLoading || debounced !== trimmed); const total = countHits(merged); return { query: debounced, parsed, searchable, data: key ? merged : null, loading: pending, loadingMore, error, nextCursor, loadMore, isEmpty: Boolean(key) && !pending && Boolean(merged) && total === 0, total, }; } /* ------------------------------------------------------------------------------------------------ * Recent searches (localStorage) * ---------------------------------------------------------------------------------------------- */ const RECENT_KEY = "polyllm:recent-searches"; const RECENT_MAX = 8; export function useRecentSearches() { const [list, setList] = useLocalStorage(RECENT_KEY, []); const add = React.useCallback( (q: string) => { const v = q.trim(); if (v.length < SEARCH_MIN_CHARS) return; setList((prev) => [v, ...prev.filter((x) => x.toLowerCase() !== v.toLowerCase())].slice(0, RECENT_MAX)); }, [setList], ); const remove = React.useCallback((q: string) => setList((prev) => prev.filter((x) => x !== q)), [setList]); const clear = React.useCallback(() => setList([]), [setList]); return { list, add, remove, clear }; } /** Example queries shown in empty states. */ export const SEARCH_EXAMPLES: { q: string; label: string }[] = [ { q: "model:claude ", label: "model:claude" }, { q: "after:7d role:assistant ", label: "after:7d role:assistant" }, { q: "is:pinned ", label: "is:pinned" }, { q: "before:2026-01-01 ", label: "before:YYYY-MM-DD" }, { q: "provider:openai ", label: "provider:openai" }, { q: "is:shared ", label: "is:shared" }, ];