TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { api, useApi } from "@/lib/client/api";4import { useDebounced, useLocalStorage } from "@/lib/client/hooks";5import type { SearchResponse } from "@/lib/client/types";6import { parseQuery, type ParsedQuery } from "@/lib/search/query";78export const SEARCH_MIN_CHARS = 2;910export function isSearchableQuery(parsed: ParsedQuery): boolean {11 return parsed.text.replace(/"/g, "").trim().length >= SEARCH_MIN_CHARS || parsed.hasFilters;12}1314export interface UseSearchOptions {15 enabled?: boolean;16 limit?: number;17 debounceMs?: number;18}1920export interface UseSearchResult {21 /** Debounced, trimmed query that produced `data`. */22 query: string;23 parsed: ParsedQuery;24 searchable: boolean;25 data: SearchResponse | null;26 /** True while the first page is loading or the debounce is pending for a searchable query. */27 loading: boolean;28 loadingMore: boolean;29 error: unknown;30 nextCursor: string | null;31 loadMore: () => Promise<void>;32 /** Searched and nothing came back. */33 isEmpty: boolean;34 total: number;35}3637export function countHits(d: SearchResponse | null | undefined): number {38 if (!d) return 0;39 return d.conversations.length + d.messages.length + d.models.length + d.prompts.length + d.projects.length + d.presets.length;40}4142function mergePages(first: SearchResponse, pages: SearchResponse[]): SearchResponse {43 if (!pages.length) return first;44 return {45 ...first,46 conversations: [...first.conversations, ...pages.flatMap((p) => p.conversations)],47 messages: [...first.messages, ...pages.flatMap((p) => p.messages)],48 nextCursor: pages[pages.length - 1].nextCursor,49 };50}5152/**53 * Debounced search against GET /api/search with cursor pagination for conversations/messages.54 * SWR dedupes identical queries (palette + sheet) and keeps the previous page while typing.55 */56export function useSearch(rawQuery: string, { enabled = true, limit = 12, debounceMs = 180 }: UseSearchOptions = {}): UseSearchResult {57 const trimmed = rawQuery.trim();58 const debounced = useDebounced(trimmed, debounceMs);59 const parsed = React.useMemo(() => parseQuery(debounced), [debounced]);60 const searchable = isSearchableQuery(parsed);61 const key = enabled && searchable ? `/api/search?q=${encodeURIComponent(debounced)}&limit=${limit}` : null;62 const { data, isLoading, error } = useApi<SearchResponse>(key, { keepPreviousData: true, dedupingInterval: 4000 });6364 const [extra, setExtra] = React.useState<{ forKey: string | null; pages: SearchResponse[] }>({ forKey: null, pages: [] });65 const [loadingMore, setLoadingMore] = React.useState(false);66 const merged = React.useMemo(() => {67 if (!data) return null;68 return mergePages(data, extra.forKey === key ? extra.pages : []);69 }, [data, extra, key]);70 const nextCursor = merged?.nextCursor ?? null;7172 const loadMore = React.useCallback(async () => {73 if (!key || !nextCursor || loadingMore) return;74 setLoadingMore(true);75 try {76 const res = await api<SearchResponse>(`${key}&cursor=${encodeURIComponent(nextCursor)}&groups=conversations,messages`);77 setExtra((prev) => ({ forKey: key, pages: [...(prev.forKey === key ? prev.pages : []), res] }));78 } finally {79 setLoadingMore(false);80 }81 }, [key, nextCursor, loadingMore]);8283 const pending = Boolean(key) && (isLoading || debounced !== trimmed);84 const total = countHits(merged);85 return {86 query: debounced,87 parsed,88 searchable,89 data: key ? merged : null,90 loading: pending,91 loadingMore,92 error,93 nextCursor,94 loadMore,95 isEmpty: Boolean(key) && !pending && Boolean(merged) && total === 0,96 total,97 };98}99100/* ------------------------------------------------------------------------------------------------101 * Recent searches (localStorage)102 * ---------------------------------------------------------------------------------------------- */103const RECENT_KEY = "polyllm:recent-searches";104const RECENT_MAX = 8;105106export function useRecentSearches() {107 const [list, setList] = useLocalStorage<string[]>(RECENT_KEY, []);108 const add = React.useCallback(109 (q: string) => {110 const v = q.trim();111 if (v.length < SEARCH_MIN_CHARS) return;112 setList((prev) => [v, ...prev.filter((x) => x.toLowerCase() !== v.toLowerCase())].slice(0, RECENT_MAX));113 },114 [setList],115 );116 const remove = React.useCallback((q: string) => setList((prev) => prev.filter((x) => x !== q)), [setList]);117 const clear = React.useCallback(() => setList([]), [setList]);118 return { list, add, remove, clear };119}120121/** Example queries shown in empty states. */122export const SEARCH_EXAMPLES: { q: string; label: string }[] = [123 { q: "model:claude ", label: "model:claude" },124 { q: "after:7d role:assistant ", label: "after:7d role:assistant" },125 { q: "is:pinned ", label: "is:pinned" },126 { q: "before:2026-01-01 ", label: "before:YYYY-MM-DD" },127 { q: "provider:openai ", label: "provider:openai" },128 { q: "is:shared ", label: "is:shared" },129];130