~bigbes/lethe

6806748f7302141c5259dd82474f3655ccc231c8 — Eugene Blikh 23 days ago 54353ce
chore: track task file and dist update for search UI
2 files changed, 261 insertions(+), 2 deletions(-)

A docs/tasks/lethe-web-ui-search.md
M internal/server/web/dist/index.html
A docs/tasks/lethe-web-ui-search.md => docs/tasks/lethe-web-ui-search.md +259 -0
@@ 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 `<mark>` 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 `<mark>` 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 `<mark>` 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, `<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.

### 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=<term>` (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 `<mark>` 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<SearchResult>` — `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 `<mark>` 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 `<mark>` 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

M internal/server/web/dist/index.html => internal/server/web/dist/index.html +2 -2
@@ 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"
    />
    <script type="module" crossorigin src="/assets/index-Dc6BAeWw.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-BUKcSLZA.css">
    <script type="module" crossorigin src="/assets/index-BLnRqqxn.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-Dc2JzkyG.css">
  </head>
  <body class="density-compact">
    <div id="root"></div>