Status: done Branch: task/lethe-web-ui-search Worktree: /Users/blikh/data/home/lethe/.worktrees/lethe-web-ui-search Mode: interactive
Replace the /search route stub with a real search page that queries GET /api/v1/search (#3), displays turn-level results in a dense table with FTS <mark> highlighting, paginates with a Load More button, and integrates the save-search action via the existing saved-searches API (#6).
In:
web/src/api/adapters.ts — new SearchRow, SearchResult TS types, and adaptSearchRow adapter (mirroring the existing adapter pattern)web/src/features/search/useSearch.ts — TanStack Query hook calling GET /api/v1/search, with cursor-paginated appendweb/src/features/search/SearchTable.tsx — dense grid table: columns tool, host, cwd, snippet (with <mark> highlights), rank; rows link to /session/:tool/:host/:session_id#turn-:turn_idweb/src/features/search/SearchFilters.tsx — tool/host filter chips synced to URL search params (matching Home's FilterChips pattern)web/src/features/search/SaveSearchForm.tsx — inline name+query form, pre-fills current query, submits via useCreateSavedSearchweb/src/routes/search.tsx — replaced stub with real page wiring the hook, filters, table, and save buttonweb/src/styles/search.css — styles for the search results pageweb/src/features/search/highlightSnippet.ts — helper that splits on \x02 / \x03 marker runes into React <mark> chunksweb/src/features/search/useSearch.test.ts — hook tests (cursor append, empty query, error states)Out:
since/until date range filters — deferred (API supports them, UI can add later)useCreateSavedSearch from features/settings/include_tool_outputs toggle — deferred (API supports it, add as a chip later)Feature module under web/src/features/search/, following the hook-then-component pattern used by home, projects, session, and settings/savedsearches.
Result table: Grid-column dense table (like SessionsTable). Columns: tool (with ToolDot), host, cwd, snippet (truncated, <mark>-highlighted), rank. Each row is a clickable link to the session transcript anchored at the specific turn.
FTS highlighting: The API's snippet field contains \x02 (STX) and \x03 (ETX) marker runes from SQLite FTS5. A highlightSnippet() helper splits on these markers and returns a React fragment with <mark> elements around matched spans. Plain text, not dangerouslySetInnerHTML — no XSS risk.
Pagination: useSearch manages an accumulated results array. Load More appends the next page via cursor. No prefetch; button at bottom calls fetchNextPage.
Save: SubBar contains a [save search] button. Clicking it reveals an inline form with the current query pre-filled and a name input. Uses the existing useCreateSavedSearch mutation; on success, shows the saved name briefly then collapses the form.
Filter state: q (the main search box), tool, and host are TanStack Router URL search params. The search box lives in the SubBar; filter chips live in a bar below. Changing any param re-triggers the query (no extra submit button).
Empty states: No query → EmptyState "enter a search query". Query with no results → EmptyState "no matches". Error → the existing card-wrapped error pattern used in SavedSearchesSection.
Link to sessions: Result rows construct URLs as /session/:tool/:host/:sessionId#turn-:turnId, using the composite key and turn_id from the search result.
Greenfield frontend — no existing consumers of the search page stub (it shows "coming in a later task"). The palette navigates to /search?q=... — this exact route and URL param must keep working. The search route's validateSearch already parses q and SearchParams — preserve that contract.
The useSearch hook (cursor append logic, empty-query gate, fetch construction) and the highlightSnippet helper are deterministic, reusable logic. Component rendering is verified by npm run typecheck && npm run build (no component snapshot tests in this codebase).
/search?q=<term> (from palette or direct URL) must trigger a search immediatelyuseCreateSavedSearch mutation; no duplicate API client codetokens.css, AuthGate wrapping, apiFetch for API callsPOST /api/v1/saved-searches) accepts arbitrary query strings without validation beyond non-emptyApproach: Two-phase frontend feature module, building from data layer → UI. Each layer is independently testable (hook tests, then component integration verified by build+typecheck).
1.1 web/src/api/adapters.ts:231+ (modify)
SearchRow interface — fields: tool, host, sessionId, cwd, turnId, role, timestamp, rank, matchSource, snippet (camelCase mirror of Go Row struct)SearchResult interface — results: SearchRow[], limit: number, nextCursor?: stringadaptSearchRow(d: SearchRowDTO): SearchRow — renames snake_case DTO fields, leaves snippet untouched (marker runes preserved)1.2 web/src/features/search/highlightSnippet.ts (create)
highlightSnippet(snippet: string): (string | React.JSX.Element)[] — splits on \x02/\x03 marker runes; alternates plain text and <mark> JSX. No dangerouslySetInnerHTML.1.3 web/src/features/search/useSearch.ts (create)
SearchFilters type — { q: string; tool?: string; host?: string }useSearch(filters: SearchFilters, enabled: boolean): UseInfiniteQueryResult<SearchResult> — useInfiniteQuery calling GET /api/v1/search; query key = ['search', filters]; getNextPageParam reads nextCursor → next cursor param; staleTime: 0 (search results are ephemeral){ results: SearchRow[], fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error }1.4 web/src/features/search/useSearch.test.ts (create)
getNextPageParam returns next cursor; returns empty results when q is empty string; propagates fetch errorsCommit: feat(search): add search data layer — adapter, highlight helper, useSearch hook
2.1 web/src/features/search/SearchTable.tsx (create)
rows: SearchRow[], hasMore: boolean, loadingMore: boolean, onLoadMore: () => voidSessionsTable pattern): columns CWD, snippet (with <mark> highlights from highlightSnippet), rank. Clickable row → navigate({ to: '/session/$tool/$host/$id', params: { tool, host, id: sessionId }, hash: \turn-${turnId}` })`rows.length === 0 and no loading → EmptyState "no matches"hasMore is true; shows "loading…" when loadingMore2.2 web/src/features/search/SearchFilters.tsx (create)
value: SearchFilters, onChange: (f: SearchFilters) => voidonKeyDown Enter triggers onChange with trimmed value; value reflected in SubBar as Tagclick tag → popover with radio options)2.3 web/src/features/search/SaveSearchForm.tsx (create)
query: stringopen: boolean, name: string (initialized to query when opened)[save search] toggles visibility. Form with pre-filled query (readonly mono span) + name input + Save/Cancel buttonsuseCreateSavedSearch from ../settings/useSavedSearches; on success shows checkmark briefly then closes2.4 web/src/styles/search.css (create)
1fr 1fr 2fr 60px (host, cwd, snippet, rank)tokens.css)2.5 web/src/routes/search.tsx:1-32 (modify — replace stub)
SearchParams extends to { q?: string; tool?: string; host?: string }validateSearch parses all three params from URLuseSearch, SearchFilters, SearchTable, SaveSearchFormAuthGateexport const Route = createFileRoute('/search') binding (palette navigates here)Commit: feat(search): add search UI — table, filters, save form, route, styles
useSearch.test.ts — URL construction from filters, cursor append, empty-q guard, error propagationhighlightSnippet — tested within useSearch.test.ts or separate unit if extractednpm run typecheck && npm run build covers component integrationuseSavedSearches.test.ts (mock apiFetch via vitest)SearchFilters type { q: string; tool?: string; host?: string } — defined in PH1 useSearch.ts, consumed by PH2 SearchFilters.tsx, search.tsxadaptSearchRow(d: SearchRowDTO): SearchRow — defined in PH1 adapters.ts, consumed by PH2 useSearch.tsuseCreateSavedSearch mutation — defined in features/settings/useSavedSearches.ts, consumed by PH2 SaveSearchForm.tsxSavedSearchesSection pattern). No data corruption risk.\x02/\x03) appear in non-matching snippet (API bug) → highlightSnippet handles gracefully: text with no markers renders as single string chunk, no <mark> elements produced.highlightSnippet core logic (PH1):
const MARKER_RUNES = /\x02|\x03/g
function highlightSnippet(snippet: string): (string | React.JSX.Element)[] {
const parts = snippet.split(MARKER_RUNES)
const out: (string | React.JSX.Element)[] = []
let inMatch = false
for (const p of parts) {
if (p === '') continue
if (inMatch) out.push(React.createElement('mark', { key: out.length }, p))
else out.push(p)
inMatch = !inMatch
}
return out
}
useSearch cursor append (PH1):
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
select: (data) => ({
pages: data.pages,
results: data.pages.flatMap(p => p.results.map(adaptSearchRow)),
}),
Result: passed
Positive:
adaptSearchRow maps snake_case DTO to camelCase SearchRow (6 useSearch tests pass)highlightSnippet splits on marker runes → text/mark alternation (typecheck clean, build succeeds)useSearch constructs correct URL from filters (useSearch test suite: 6/6)useSearch appends cursor results via flatMap (tested)SearchTable renders with grid columns, clickable rows link to /session/:tool/:host/:session_id#turn-:turn_idSearchFilters with search input + tool/host chip popoversSaveSearchForm toggles inline form, imports useCreateSavedSearch?q=, ?tool=, ?host= search params (validateSearch extended)Negative:
GET /api/v1/search (no query) → 400, "q must not be empty" (API validates; hook test covers empty q short-circuit)highlightSnippet handles marker-less text (split returns single plain text chunk)Invariants / assumptions:
validateSearch parses q param; palette navigates to /search?q=... (unchanged binding)dangerouslySetInnerHTML in features/search/ (grep: only in comment)useInfiniteQuery with flatMap append, not replaceuseCreateSavedSearch import (SaveSearchForm → ../settings/useSavedSearches)getNextPageParam returning next cursorInterfaces:
SearchFilters consumed by SearchFilters.tsx and search.tsxadaptSearchRow consumed by useSearch.tsuseCreateSavedSearch imported by SaveSearchForm.tsxSmoke: go run ./cmd/lethe/ + curl "GET /api/v1/search?q=test" → 200 {"results":[],"limit":50}; empty query → 400 "q must not be empty"
Outcome: Real search page implemented — adapter, hook, table, filters, save-search form, and route replace the stub. Branch task/lethe-web-ui-search, HEAD 96e95ab.
Invariants:
validateSearch parses q from URL; palette navigates to /search?q=... unchangeddangerouslySetInnerHTML in search feature code; highlightSnippet uses React.createElement onlyuseInfiniteQuery with flatMap append, Load More button calls fetchNextPageuseCreateSavedSearch import in SaveSearchForm from ../settings/useSavedSearchesgetNextPageParam reading next_cursor from API responsetruncate handles overflow{"results":[]})