/** * Search view — the global cross-session message search (`POST /api/v1/search`). * Full width like the other non-chat views. Results are pageable (Load more); * clicking a hit hands it to the app shell (`onOpenResult`), which switches * to the chat view at the hit's session / agent / turn / step. */ import { useState } from 'react'; import { useConnection } from '../connection'; import { fetchSearchPage, type SearchHit, type SearchIndexState } from '../search/api'; import { ActionButton, Badge, ErrorLine, relTime } from '../ui'; type RoleFilter = 'all' | 'user' | 'assistant' | 'title'; type SortOrder = 'score' | 'time_desc' | 'time_asc'; type SearchMode = 'terms' | 'literal'; const PAGE_SIZE = 20; interface ExecutedSearch { readonly query: string; readonly role?: 'user' | 'assistant' | 'title' | undefined; readonly sort: SortOrder; readonly mode: SearchMode; readonly items: readonly SearchHit[]; readonly hasMore: boolean; readonly pageToken?: string | undefined; readonly incomplete?: 'candidate_cap' | undefined; readonly source?: 'live' | 'index' | undefined; readonly indexState: SearchIndexState; } export function SearchView({ onOpenResult }: { onOpenResult: (hit: SearchHit) => void }) { const { baseUrl, config } = useConnection(); const [input, setInput] = useState(''); const [role, setRole] = useState('all'); const [sort, setSort] = useState('score'); const [exact, setExact] = useState(false); const [result, setResult] = useState(null); const [searching, setSearching] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const runSearch = async () => { const query = input.trim(); if (query === '' || searching) return; setSearching(true); setError(null); try { const token = config.token.trim(); const mode: SearchMode = exact ? 'literal' : 'terms'; const page = await fetchSearchPage({ baseUrl, token: token === '' ? undefined : token, query, role: role === 'all' ? undefined : role, sort, mode, pageSize: PAGE_SIZE, }); setResult({ query, role: role === 'all' ? undefined : role, sort, mode, items: page.items, hasMore: page.hasMore, pageToken: page.pageToken, incomplete: page.incomplete, source: page.source, indexState: page.indexState, }); } catch (error) { setError(error); } finally { setSearching(false); } }; const loadMore = async () => { if (result === null || !result.hasMore || loadingMore) return; setLoadingMore(true); setError(null); try { const token = config.token.trim(); const page = await fetchSearchPage({ baseUrl, token: token === '' ? undefined : token, query: result.query, role: result.role, sort: result.sort, mode: result.mode, pageSize: PAGE_SIZE, pageToken: result.pageToken, }); setResult({ ...result, items: [...result.items, ...page.items], hasMore: page.hasMore, pageToken: page.pageToken, incomplete: page.incomplete, source: page.source, indexState: page.indexState, }); } catch (error) { setError(error); } finally { setLoadingMore(false); } }; return (
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void runSearch(); } }} /> void runSearch()} disabled={searching || input.trim() === ''}> {searching ? 'Searching…' : 'Search'} {result !== null ? ( {result.indexState.state === 'building' ? `index building (${result.indexState.indexedSessions}/${result.indexState.totalSessions} sessions) — results may be incomplete` : `${result.indexState.documents} documents indexed (${result.indexState.state})`} ) : null} {result?.source !== undefined ? ( source: {result.source} ) : null}
{error !== null ? (
) : null} {result === null ? (
Search user prompts, assistant replies and session titles across every session.
) : result.items.length === 0 && !searching ? (
No hits for “{result.query}”.
) : (
{result.incomplete === 'candidate_cap' ? (
too many matches — the candidate set was truncated, results may be incomplete
) : null} {result.items.map((hit, i) => ( ))} {result.hasMore ? (
void loadMore()} disabled={loadingMore}> {loadingMore ? 'Loading…' : 'Load more'}
) : null}
)}
); } function HitCard({ hit, onOpen }: { hit: SearchHit; onOpen: (hit: SearchHit) => void }) { return ( ); }