~bigbes/lethe

ref: 6806748f7302141c5259dd82474f3655ccc231c8 lethe/docs/tasks/lethe-web-ui-search.md -rw-r--r-- 15.5 KiB
6806748f — Eugene Blikh chore: track task file and dist update for search UI 23 days ago

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):

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)),
}),

#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