/** * In-chat search bar — searches the messages of the CURRENT session only * (`container: { session_id }` on `POST /api/v1/search`; the whole session, * not just the active agent) and renders the first page as a dropdown under * the input. Clicking a hit hands it to the app shell (`onOpenHit`), which * switches the agent when the hit belongs to another one and jumps the * transcript to the hit's turn / step. Esc or an outside click closes the * panel. */ import { useEffect, useRef, useState } from 'react'; import { useConnection } from '../connection'; import { fetchSearchPage, type SearchHit } from '../search/api'; import { Badge, ErrorLine, relTime } from '../ui'; const PAGE_SIZE = 20; export function ChatSearchBar({ sessionId, onOpenHit, }: { sessionId: string; onOpenHit?: ((hit: SearchHit) => void) | undefined; }) { const { baseUrl, config } = useConnection(); const [input, setInput] = useState(''); const [open, setOpen] = useState(false); const [hits, setHits] = useState(null); const [searching, setSearching] = useState(false); const [error, setError] = useState(null); const rootRef = useRef(null); // A session switch drops the previous session's query and results. useEffect(() => { setInput(''); setHits(null); setError(null); setOpen(false); }, [sessionId]); // Clicking outside the bar closes the result panel. useEffect(() => { if (!open) return; const onDown = (event: MouseEvent) => { if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', onDown); return () => { document.removeEventListener('mousedown', onDown); }; }, [open]); const search = async () => { const query = input.trim(); if (query === '' || searching) return; setSearching(true); setError(null); setOpen(true); try { const token = config.token.trim(); const page = await fetchSearchPage({ baseUrl, token: token === '' ? undefined : token, query, // Substring semantics (Ctrl+F-like): terms mode is whole-token matching, // which surprises in an in-session search box. mode: 'literal', container: { sessionId }, pageSize: PAGE_SIZE, }); setHits(page.items); } catch (error) { setHits(null); setError(error); } finally { setSearching(false); } }; return (
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void search(); } else if (e.key === 'Escape') { setOpen(false); } }} onFocus={() => { if (hits !== null) setOpen(true); }} /> {open ? (
{searching ? (
Searching…
) : error !== null ? ( ) : hits === null ? null : hits.length === 0 ? (
No hits in this session.
) : (
{hits.map((hit, i) => ( ))}
)}
) : null}
); }