M web/src/api/adapters.ts => web/src/api/adapters.ts +51 -0
@@ 204,6 204,57 @@ export function adaptStats(d: StatsDTO): Stats {
}
}
+// ── Search ────────────────────────────────────────────────────────────────────
+
+export interface SearchRowDTO {
+ owner: string
+ tool: string
+ host: string
+ session_id: string
+ working_dir?: string
+ turn_id: string
+ seq: number
+ role: string
+ timestamp: number
+ rank: number
+ match_source: string
+ snippet: string
+}
+
+export interface SearchRow {
+ tool: string
+ host: string
+ sessionId: string
+ cwd: string
+ turnId: string
+ role: string
+ timestamp: number
+ rank: number
+ matchSource: string
+ snippet: string
+}
+
+export interface SearchResult {
+ results: SearchRow[]
+ limit: number
+ nextCursor?: string
+}
+
+export function adaptSearchRow(d: SearchRowDTO): SearchRow {
+ return {
+ tool: d.tool,
+ host: d.host,
+ sessionId: d.session_id,
+ cwd: d.working_dir ?? '',
+ turnId: d.turn_id,
+ role: d.role,
+ timestamp: d.timestamp,
+ rank: d.rank,
+ matchSource: d.match_source,
+ snippet: d.snippet,
+ }
+}
+
// ── Saved searches ──────────────────────────────────────────────────────────
export interface SavedSearchDTO {
A web/src/features/search/highlightSnippet.ts => web/src/features/search/highlightSnippet.ts +23 -0
@@ 0,0 1,23 @@
+import React from 'react'
+
+const MARKER_RUNES = /\x02|\x03/g
+
+/**
+ * Splits a snippet string on \x02/\x03 marker runes and interleaves plain text
+ * with `<mark>` elements for matched segments.
+ *
+ * Respects IV2 — no dangerouslySetInnerHTML, marker-rune split with React text
+ * nodes only, safe against XSS.
+ */
+export 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
+}
A web/src/features/search/useSearch.test.ts => web/src/features/search/useSearch.test.ts +166 -0
@@ 0,0 1,166 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook, act, waitFor } from '@testing-library/react'
+import React from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useSearch } from './useSearch'
+import type { SearchRowDTO } from '../../api/adapters'
+import type { SearchFilters } from './useSearch'
+
+// ── Mock apiFetch ─────────────────────────────────────────────────────────────
+
+const mockApiFetch = vi.fn()
+
+vi.mock('../../api/client', () => ({
+ apiFetch: (...args: unknown[]) => mockApiFetch(...args),
+ apiFetchVoid: vi.fn().mockResolvedValue(undefined),
+ AuthError: class AuthError extends Error {
+ name = 'AuthError'
+ constructor(message: string) { super(message) }
+ },
+ APIError: class APIError extends Error {
+ name = 'APIError'
+ status: number
+ code: string
+ constructor(message: string, status: number, code: string) {
+ super(message)
+ this.status = status
+ this.code = code
+ }
+ },
+}))
+
+// ── Test wrapper ──────────────────────────────────────────────────────────────
+
+function makeWrapper(queryClient: QueryClient) {
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return React.createElement(QueryClientProvider, { client: queryClient }, children)
+ }
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function makeRowDTO(overrides?: Partial<SearchRowDTO>): SearchRowDTO {
+ return {
+ owner: '',
+ tool: 'opencode',
+ host: 'laptop',
+ session_id: 'ses-1',
+ working_dir: '/home/user',
+ turn_id: 'turn-1',
+ seq: 1,
+ role: 'user',
+ timestamp: 1000000,
+ rank: 0.5,
+ match_source: 'message',
+ snippet: 'hello \x02world\x03',
+ ...overrides,
+ }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('useSearch', () => {
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+ mockApiFetch.mockReset()
+ })
+
+ it('constructs correct URL from filters (q, tool, host → query string)', async () => {
+ mockApiFetch.mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20 })
+
+ const filters: SearchFilters = { q: 'hello', tool: 'opencode', host: 'laptop' }
+ const wrapper = makeWrapper(queryClient)
+ renderHook(() => useSearch(filters, true), { wrapper })
+
+ await waitFor(() => {
+ expect(mockApiFetch).toHaveBeenCalledTimes(1)
+ })
+
+ const url = mockApiFetch.mock.calls[0][0] as string
+ expect(url).toContain('/api/v1/search')
+ expect(url).toContain('q=hello')
+ expect(url).toContain('tool=opencode')
+ expect(url).toContain('host=laptop')
+ })
+
+ it('getNextPageParam returns next cursor from last page', async () => {
+ mockApiFetch
+ .mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20, next_cursor: 'cursor-2' })
+ .mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20 })
+
+ const filters: SearchFilters = { q: 'hello' }
+ const wrapper = makeWrapper(queryClient)
+ const { result } = renderHook(() => useSearch(filters, true), { wrapper })
+
+ // Wait for first page
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
+
+ // Fetch next page
+ await act(() => result.current.fetchNextPage())
+
+ await waitFor(() => {
+ expect(mockApiFetch).toHaveBeenCalledTimes(2)
+ })
+
+ // Second call should have cursor param
+ const secondUrl = mockApiFetch.mock.calls[1][0] as string
+ expect(secondUrl).toContain('cursor=cursor-2')
+ })
+
+ it('returns flat results array from all pages', async () => {
+ mockApiFetch
+ .mockResolvedValueOnce({ results: [makeRowDTO({ snippet: 'a' })], limit: 20, next_cursor: 'cursor-2' })
+ .mockResolvedValueOnce({ results: [makeRowDTO({ snippet: 'b' })], limit: 20 })
+
+ const filters: SearchFilters = { q: 'hello' }
+ const wrapper = makeWrapper(queryClient)
+ const { result } = renderHook(() => useSearch(filters, true), { wrapper })
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
+
+ expect(result.current.results).toHaveLength(1)
+
+ await act(() => result.current.fetchNextPage())
+ await waitFor(() => {
+ expect(result.current.results).toHaveLength(2)
+ })
+ })
+
+ it('returns empty results when q is empty string', async () => {
+ const filters: SearchFilters = { q: '' }
+ const wrapper = makeWrapper(queryClient)
+ const { result } = renderHook(() => useSearch(filters, true), { wrapper })
+
+ expect(result.current.results).toEqual([])
+ expect(mockApiFetch).not.toHaveBeenCalled()
+ })
+
+ it('does not fetch when disabled', async () => {
+ const filters: SearchFilters = { q: 'hello' }
+ const wrapper = makeWrapper(queryClient)
+ renderHook(() => useSearch(filters, false), { wrapper })
+
+ // Give any potential fetch a moment
+ await new Promise(r => setTimeout(r, 50))
+ expect(mockApiFetch).not.toHaveBeenCalled()
+ })
+
+ it('propagates fetch errors', async () => {
+ mockApiFetch.mockRejectedValueOnce(new Error('network error'))
+
+ const filters: SearchFilters = { q: 'hello' }
+ const wrapper = makeWrapper(queryClient)
+ const { result } = renderHook(() => useSearch(filters, true), { wrapper })
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
+ expect(result.current.error).toBeDefined()
+ expect(result.current.error).toBeInstanceOf(Error)
+ })
+})
A web/src/features/search/useSearch.ts => web/src/features/search/useSearch.ts +74 -0
@@ 0,0 1,74 @@
+import { useInfiniteQuery } from '@tanstack/react-query'
+import { apiFetch } from '../../api/client'
+import { adaptSearchRow } from '../../api/adapters'
+import type { SearchRow, SearchRowDTO } from '../../api/adapters'
+
+interface SearchResponseDTO {
+ results: SearchRowDTO[]
+ limit: number
+ next_cursor?: string
+}
+
+export interface SearchResult {
+ results: SearchRow[]
+ limit: number
+ nextCursor?: string
+}
+
+export interface SearchFilters {
+ q: string
+ tool?: string
+ host?: string
+}
+
+interface UseSearchReturn {
+ results: SearchRow[]
+ fetchNextPage: () => void
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ isLoading: boolean
+ error: Error | null
+}
+
+/**
+ * Hook for cursor-paginated full-text search against the search API.
+ *
+ * - Query key includes filters so changing any filter triggers a fresh fetch.
+ * - Empty `q` short-circuits to an empty result set without issuing a request.
+ * - Cursor pagination appends results (never replaces) when fetchNextPage is called.
+ *
+ * Respects: IV3 (cursor pagination), AS1 (cursor pagination is stable).
+ */
+export function useSearch(filters: SearchFilters, enabled: boolean): UseSearchReturn {
+ const query = useInfiniteQuery<SearchResponseDTO, Error, SearchResult>({
+ queryKey: ['search', filters],
+ queryFn: async ({ pageParam }) => {
+ const params = new URLSearchParams()
+ params.set('q', filters.q)
+ if (filters.tool) params.set('tool', filters.tool)
+ if (filters.host) params.set('host', filters.host)
+ if (pageParam) params.set('cursor', pageParam as string)
+ return apiFetch<SearchResponseDTO>(`/api/v1/search?${params.toString()}`)
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
+ enabled: enabled && filters.q.length > 0,
+ staleTime: 0,
+ select: (data) => ({
+ pages: data.pages,
+ pageParams: data.pageParams,
+ results: data.pages.flatMap(p => p.results.map(adaptSearchRow)),
+ limit: data.pages[data.pages.length - 1]?.limit ?? 0,
+ nextCursor: data.pages[data.pages.length - 1]?.next_cursor,
+ }),
+ })
+
+ return {
+ results: query.data?.results ?? [],
+ fetchNextPage: query.fetchNextPage,
+ hasNextPage: query.hasNextPage ?? false,
+ isFetchingNextPage: query.isFetchingNextPage,
+ isLoading: query.isLoading,
+ error: query.error,
+ }
+}