From 6806748f7302141c5259dd82474f3655ccc231c8 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 4 May 2026 10:33:46 +0300 Subject: [PATCH] chore: track task file and dist update for search UI --- docs/tasks/lethe-web-ui-search.md | 259 ++++++++++++++++++++++++++++ internal/server/web/dist/index.html | 4 +- 2 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 docs/tasks/lethe-web-ui-search.md diff --git a/docs/tasks/lethe-web-ui-search.md b/docs/tasks/lethe-web-ui-search.md new file mode 100644 index 0000000000000000000000000000000000000000..3c622941b69a4c55af4ef4a88782f60fd99cc645 --- /dev/null +++ b/docs/tasks/lethe-web-ui-search.md @@ -0,0 +1,259 @@ +# 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 diff --git a/internal/server/web/dist/index.html b/internal/server/web/dist/index.html index 1694b18e861d94dfd27f07d5e951e626bc7d14cc..3a0adf3c27f25360f3946c9da930a6eba7de0e3a 100644 --- a/internal/server/web/dist/index.html +++ b/internal/server/web/dist/index.html @@ -13,8 +13,8 @@ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" /> - - + +