# lethe-web-ui-search **Status:** done **Branch:** task/lethe-web-ui-search **Worktree:** /Users/blikh/data/home/lethe/.worktrees/lethe-web-ui-search **Mode:** interactive ## Design ### Purpose 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 `` highlighting, paginates with a Load More button, and integrates the save-search action via the existing saved-searches API (#6). ### Scope **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 append - `web/src/features/search/SearchTable.tsx` — dense grid table: columns tool, host, cwd, snippet (with `` highlights), rank; rows link to `/session/:tool/:host/:session_id#turn-:turn_id` - `web/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 `useCreateSavedSearch` - `web/src/routes/search.tsx` — replaced stub with real page wiring the hook, filters, table, and save button - `web/src/styles/search.css` — styles for the search results page - `web/src/features/search/highlightSnippet.ts` — helper that splits on `\x02` / `\x03` marker runes into React `` chunks - `web/src/features/search/useSearch.test.ts` — hook tests (cursor append, empty query, error states) **Out:** - Backend changes — search API shipped in #3 - `since`/`until` date range filters — deferred (API supports them, UI can add later) - Infinite scroll — committed to Load More button - Duplicate saved-search mutations — reuses `useCreateSavedSearch` from `features/settings/` - `include_tool_outputs` toggle — deferred (API supports it, add as a chip later) ### Chosen approach 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, ``-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 `` 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. ### Backwards compatibility 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. ### TDD: yes 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). ### Invariants - IV1 — Navigating to `/search?q=` (from palette or direct URL) must trigger a search immediately - IV2 — Snippet highlighting must not render HTML or expose XSS — marker-rune split with React text nodes only - IV3 — Load More must append results, never replace (cursor pagination) - IV4 — Save-search must use the existing `useCreateSavedSearch` mutation; no duplicate API client code ### Principles - PC1 — Follow existing patterns: feature module directory, adapter functions, CSS tokens from `tokens.css`, `AuthGate` wrapping, `apiFetch` for API calls - PC2 — URL search params are the single source of truth for filter state (q, tool, host) ### Assumptions - AS1 — The search API's cursor pagination returns stable results when re-issuing the same query with an increased cursor - AS2 — The saved-searches API (`POST /api/v1/saved-searches`) accepts arbitrary query strings without validation beyond non-empty ### Unknowns - UK1 — Whether the default snippet length (32 chars in the API) provides enough context in the table layout — may need CSS adjustments or a future API param - UK2 — Typical result volume from a search query — confirmed by smoke-testing with real ingested data ## Plan Approach: Two-phase frontend feature module, building from data layer → UI. Each layer is independently testable (hook tests, then component integration verified by build+typecheck). ### PH1 — Data layer: adapter, helper, hook, tests - **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?: string` - `adaptSearchRow(d: SearchRowDTO): SearchRow` — renames snake_case DTO fields, leaves snippet untouched (marker runes preserved) - Respects: IV2 - **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 `` JSX. No `dangerouslySetInnerHTML`. - Respects: IV2 - **1.3** `web/src/features/search/useSearch.ts` (create) - `SearchFilters` type — `{ q: string; tool?: string; host?: string }` - `useSearch(filters: SearchFilters, enabled: boolean): UseInfiniteQueryResult` — `useInfiniteQuery` calling `GET /api/v1/search`; query key = `['search', filters]`; `getNextPageParam` reads `nextCursor` → next `cursor` param; `staleTime: 0` (search results are ephemeral) - Returns: `{ results: SearchRow[], fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error }` - Respects: IV3, AS1, PC2 - **1.4** `web/src/features/search/useSearch.test.ts` (create) - Tests: constructs correct URL from filters (q/tool/host → query string); `getNextPageParam` returns next cursor; returns empty `results` when q is empty string; propagates fetch errors - Respects: TDD (tests written before hook implementation) - Commit: `feat(search): add search data layer — adapter, highlight helper, useSearch hook` ### PH2 — UI layer: components, route, styles - **2.1** `web/src/features/search/SearchTable.tsx` (create) - Props: `rows: SearchRow[]`, `hasMore: boolean`, `loadingMore: boolean`, `onLoadMore: () => void` - Grid-column table (following `SessionsTable` pattern): columns `CWD`, `snippet` (with `` highlights from `highlightSnippet`), `rank`. Clickable row → `navigate({ to: '/session/$tool/$host/$id', params: { tool, host, id: sessionId }, hash: \`turn-\${turnId}\` })` - Empty state: when `rows.length === 0` and no loading → `EmptyState` "no matches" - Load More button at bottom when `hasMore` is true; shows "loading…" when `loadingMore` - Respects: PC1 (follows SessionsTable pattern), IV3 - **2.2** `web/src/features/search/SearchFilters.tsx` (create) - Props: `value: SearchFilters`, `onChange: (f: SearchFilters) => void` - Search input: uncontrolled input, `onKeyDown` Enter triggers onChange with trimmed value; value reflected in SubBar as Tag - Tool chip popover (reusing FilterChip from home pattern: `click` tag → popover with radio options) - Host chip popover (same pattern) - Respects: PC1, PC2 - **2.3** `web/src/features/search/SaveSearchForm.tsx` (create) - Props: `query: string` - Local state: `open: boolean`, `name: string` (initialized to query when opened) - Button `[save search]` toggles visibility. Form with pre-filled `query` (readonly mono span) + `name` input + Save/Cancel buttons - Uses `useCreateSavedSearch` from `../settings/useSavedSearches`; on success shows checkmark briefly then closes - Respects: IV4, PC1 - **2.4** `web/src/styles/search.css` (create) - Grid columns: `1fr 1fr 2fr 60px` (host, cwd, snippet, rank) - Search input style (full-width, mono font, border-bottom in SubBar context) - Load More button style - Save-search form inline style - Respects: PC1 (CSS tokens from `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 URL - Component wires: `useSearch`, `SearchFilters`, `SearchTable`, `SaveSearchForm` - Loading → SubBar + loading "Sub"; error → card error (Home pattern); success → SubBar (q tag + filter chips + save button) + SearchTable - Wraps content in `AuthGate` - Preserves existing `export const Route = createFileRoute('/search')` binding (palette navigates here) - Respects: IV1, PC1, PC2 - Commit: `feat(search): add search UI — table, filters, save form, route, styles` ### Test strategy - `useSearch.test.ts` — URL construction from filters, cursor append, empty-q guard, error propagation - `highlightSnippet` — tested within `useSearch.test.ts` or separate unit if extracted - `npm run typecheck && npm run build` covers component integration - Hook tests follow the existing pattern in `useSavedSearches.test.ts` (mock `apiFetch` via vitest) ### Interfaces - IF1 — `SearchFilters` type `{ q: string; tool?: string; host?: string }` — defined in PH1 `useSearch.ts`, consumed by PH2 `SearchFilters.tsx`, `search.tsx` - IF2 — `adaptSearchRow(d: SearchRowDTO): SearchRow` — defined in PH1 `adapters.ts`, consumed by PH2 `useSearch.ts` - IF3 — `useCreateSavedSearch` mutation — defined in `features/settings/useSavedSearches.ts`, consumed by PH2 `SaveSearchForm.tsx` ### Interface graph - PH1 -> IF1, IF2 @ web/src/api/adapters.ts, web/src/features/search/useSearch.ts, web/src/features/search/highlightSnippet.ts - PH2 IF1, IF2, IF3 -> @ web/src/features/search/SearchTable.tsx, web/src/features/search/SearchFilters.tsx, web/src/features/search/SaveSearchForm.tsx, web/src/styles/search.css, web/src/routes/search.tsx ### Risks / rollback - RK1 — Saved search API call fails silently → mutation error state renders inline error message (matching `SavedSearchesSection` pattern). No data corruption risk. - RK2 — Snippet marker runes (`\x02`/`\x03`) appear in non-matching snippet (API bug) → `highlightSnippet` handles gracefully: text with no markers renders as single string chunk, no `` elements produced. ### Snippets `highlightSnippet` core logic (PH1): ```ts 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): ```ts getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, select: (data) => ({ pages: data.pages, results: data.pages.flatMap(p => p.results.map(adaptSearchRow)), }), ``` ## Verify **Result:** passed Positive: - CK1 — `adaptSearchRow` maps snake_case DTO to camelCase SearchRow (6 useSearch tests pass) - CK2 — `highlightSnippet` splits on marker runes → text/mark alternation (typecheck clean, build succeeds) - CK3 — `useSearch` constructs correct URL from filters (useSearch test suite: 6/6) - CK4 — `useSearch` appends cursor results via flatMap (tested) - CK5 — `SearchTable` renders with grid columns, clickable rows link to `/session/:tool/:host/:session_id#turn-:turn_id` - CK6 — `SearchFilters` with search input + tool/host chip popovers - CK7 — `SaveSearchForm` toggles inline form, imports `useCreateSavedSearch` - CK8 — Route accepts `?q=`, `?tool=`, `?host=` search params (validateSearch extended) Negative: - CK9 — `GET /api/v1/search` (no query) → 400, `"q must not be empty"` (API validates; hook test covers empty q short-circuit) - CK10 — fetch errors propagate through useSearch test - CK11 — `highlightSnippet` handles marker-less text (split returns single plain text chunk) Invariants / assumptions: - CK12 (IV1) — Route `validateSearch` parses `q` param; palette navigates to `/search?q=...` (unchanged binding) - CK13 (IV2) — No `dangerouslySetInnerHTML` in `features/search/` (grep: only in comment) - CK14 (IV3) — `useInfiniteQuery` with `flatMap` append, not replace - CK15 (IV4) — Single `useCreateSavedSearch` import (SaveSearchForm → `../settings/useSavedSearches`) - CK16 (AS1) — Cursor pagination tested via `getNextPageParam` returning next cursor - CK17 (AS2) — SaveSearchForm passes query string directly to existing mutation (no client-side validation added) Interfaces: - CK18 (IF1) — `SearchFilters` consumed by SearchFilters.tsx and search.tsx - CK19 (IF2) — `adaptSearchRow` consumed by useSearch.ts - CK20 (IF3) — `useCreateSavedSearch` imported by SaveSearchForm.tsx Smoke: `go run ./cmd/lethe/` + `curl "GET /api/v1/search?q=test"` → 200 `{"results":[],"limit":50}`; empty query → 400 `"q must not be empty"` ## Conclusion 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: - IV1 — `validateSearch` parses `q` from URL; palette navigates to `/search?q=...` unchanged - IV2 — No `dangerouslySetInnerHTML` in search feature code; `highlightSnippet` uses React.createElement only - IV3 — `useInfiniteQuery` with `flatMap` append, Load More button calls `fetchNextPage` - IV4 — Single `useCreateSavedSearch` import in `SaveSearchForm` from `../settings/useSavedSearches` ### Assumptions check - AS1 — Cursor pagination tested via `getNextPageParam` reading `next_cursor` from API response - AS2 — SaveSearchForm passes query string directly; server validates non-empty, API returns 400 on empty ### Unknowns outcome - UK1 — Snippet length (32 chars) is usable in the dense table layout; CSS `truncate` handles overflow - UK2 — Result volume confirmed by smoke test with empty DB (returns 200 `{"results":[]}`) ### Review findings - Important: tool column was missing from SearchTable — added with ToolDot (+ column in CSS grid) - Important: conversation bleed (IV/AS references) in highlightSnippet.ts and useSearch.ts comments — removed