diff --git a/web/src/features/search/SaveSearchForm.tsx b/web/src/features/search/SaveSearchForm.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f8d7aa0f1fbfc8e5545f7bd759a0c09a2206bc33
--- /dev/null
+++ b/web/src/features/search/SaveSearchForm.tsx
@@ -0,0 +1,80 @@
+import React, { useState } from 'react'
+import { useCreateSavedSearch } from '../settings/useSavedSearches'
+
+interface SaveSearchFormProps {
+ query: string
+}
+
+export function SaveSearchForm({ query }: SaveSearchFormProps): React.JSX.Element {
+ const [open, setOpen] = useState(false)
+ const [name, setName] = useState(query)
+ const [saved, setSaved] = useState(false)
+ const createMutation = useCreateSavedSearch()
+
+ function handleOpen() {
+ setName(query)
+ setSaved(false)
+ setOpen(true)
+ }
+
+ function handleCancel() {
+ setOpen(false)
+ }
+
+ function handleSave(e: React.FormEvent) {
+ e.preventDefault()
+ if (!name.trim()) return
+ createMutation.mutate(
+ { name: name.trim(), query },
+ {
+ onSuccess: () => {
+ setSaved(true)
+ setTimeout(() => {
+ setOpen(false)
+ setSaved(false)
+ }, 1200)
+ },
+ },
+ )
+ }
+
+ if (!open) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {saved ? (
+ ✓
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/web/src/features/search/SearchFilters.tsx b/web/src/features/search/SearchFilters.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2f4ce6d4ab54df4aedc383bb353e602a4eb921c7
--- /dev/null
+++ b/web/src/features/search/SearchFilters.tsx
@@ -0,0 +1,158 @@
+import React, { useRef, useState, useEffect } from 'react'
+import type { SearchFilters as SearchFiltersType } from './useSearch'
+
+interface SearchFiltersProps {
+ value: SearchFiltersType
+ onChange: (next: SearchFiltersType) => void
+}
+
+const TOOL_OPTIONS = ['claude-code', 'opencode', 'crush', 'pi', 'kimi']
+const HOST_OPTIONS: string[] = []
+
+// ── Popover ─────────────────────────────────────────────────────────────────────
+
+interface PopoverProps {
+ dim: string
+ current: string
+ options: string[]
+ anchor: { top: number; left: number }
+ onPick: (v: string) => void
+ onClose: () => void
+}
+
+function Popover({ dim, current, options, anchor, onPick, onClose }: PopoverProps): React.JSX.Element {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const onDoc = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ onClose()
+ }
+ }
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose()
+ }
+ const t = setTimeout(() => {
+ document.addEventListener('mousedown', onDoc)
+ document.addEventListener('keydown', onKey)
+ }, 0)
+ return () => {
+ clearTimeout(t)
+ document.removeEventListener('mousedown', onDoc)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [onClose])
+
+ return (
+
+
{dim}
+ {options.map(o => (
+
{ onPick(o); onClose() }}
+ >
+
+ {o}
+
+ ))}
+
+ )
+}
+
+// ── FilterChip ─────────────────────────────────────────────────────────────────
+
+interface ChipProps {
+ dim: string
+ value: string
+ options: string[]
+ isActive: boolean
+ onPick: (v: string) => void
+}
+
+function FilterChip({ dim, value, options, isActive, onPick }: ChipProps): React.JSX.Element {
+ const [open, setOpen] = useState(false)
+ const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null)
+ const ref = useRef(null)
+
+ function handleClick() {
+ if (!ref.current) return
+ const r = ref.current.getBoundingClientRect()
+ const appEl = ref.current.closest('.app')
+ const parent = appEl ? appEl.getBoundingClientRect() : { top: 0, left: 0 }
+ setAnchor({ top: r.bottom - parent.top, left: r.left - parent.left })
+ setOpen(true)
+ }
+
+ const classes = ['tag', 'click']
+ if (isActive) classes.push('accent')
+
+ return (
+ <>
+
+ {dim}: {value}
+
+ {open && anchor != null && (
+ setOpen(false)}
+ />
+ )}
+ >
+ )
+}
+
+// ── SearchFilters ──────────────────────────────────────────────────────────────
+
+export function SearchFilters({ value, onChange }: SearchFiltersProps): React.JSX.Element {
+ const inputRef = useRef(null)
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === 'Enter') {
+ const trimmed = (e.target as HTMLInputElement).value.trim()
+ onChange({ ...value, q: trimmed })
+ }
+ }
+
+ const tool = value.tool ?? ''
+ const host = value.host ?? ''
+
+ return (
+ <>
+
+ onChange({ ...value, tool: v === 'any' ? undefined : v })}
+ />
+ onChange({ ...value, host: v === 'any' ? undefined : v })}
+ />
+ >
+ )
+}
diff --git a/web/src/features/search/SearchTable.tsx b/web/src/features/search/SearchTable.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b9fcc6777fc811b6f40cf4542d8b7098b6bb9d99
--- /dev/null
+++ b/web/src/features/search/SearchTable.tsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import { useNavigate } from '@tanstack/react-router'
+import { EmptyState } from '../../primitives'
+import type { SearchRow } from '../../api/adapters'
+import { highlightSnippet } from './highlightSnippet'
+
+interface SearchTableProps {
+ rows: SearchRow[]
+ hasMore: boolean
+ loadingMore: boolean
+ onLoadMore: () => void
+}
+
+const COLS = '1fr 1fr 2fr 60px'
+
+export function SearchTable({ rows, hasMore, loadingMore, onLoadMore }: SearchTableProps): React.JSX.Element {
+ const navigate = useNavigate()
+
+ if (rows.length === 0) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ host
+ cwd
+ snippet
+ rank
+
+ {rows.map((r, i) => (
+
{
+ void navigate({
+ to: '/session/$tool/$host/$id',
+ params: { tool: r.tool, host: r.host, id: r.sessionId },
+ hash: `turn-${r.turnId}`,
+ })
+ }}
+ >
+ {r.host}
+ {r.cwd}
+ {highlightSnippet(r.snippet)}
+ {r.rank}
+
+ ))}
+ {hasMore && (
+
+
+
+ )}
+
+ )
+}
diff --git a/web/src/routes/search.tsx b/web/src/routes/search.tsx
index c06a0bb0e9d50a159ad76cfbf54df4ab445ab266..071c1e30d28ea6dc751d5b63a8b2b43993523465 100644
--- a/web/src/routes/search.tsx
+++ b/web/src/routes/search.tsx
@@ -1,32 +1,97 @@
-import { createFileRoute } from '@tanstack/react-router'
import React from 'react'
-import { EmptyState, Tag } from '../primitives'
+import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { SubBar } from '../shell/SubBar'
+import { Sub, Tag } from '../primitives'
+import { AuthGate } from '../shell/AuthGate'
+import { AuthError, APIError } from '../api/client'
+import { useSearch } from '../features/search/useSearch'
+import type { SearchFilters } from '../features/search/useSearch'
+import { SearchFilters as SearchFiltersCmp } from '../features/search/SearchFilters'
+import { SearchTable } from '../features/search/SearchTable'
+import { SaveSearchForm } from '../features/search/SaveSearchForm'
+import '../styles/search.css'
type SearchParams = {
q?: string
+ tool?: string
+ host?: string
}
export const Route = createFileRoute('/search')({
- validateSearch: (search: Record): SearchParams => ({
- q: typeof search['q'] === 'string' ? search['q'] : undefined,
- }),
+ validateSearch: (search: Record): SearchParams => {
+ const q = typeof search['q'] === 'string' && search['q'] !== '' ? search['q'] : undefined
+ const tool = typeof search['tool'] === 'string' && search['tool'] !== '' ? search['tool'] : undefined
+ const host = typeof search['host'] === 'string' && search['host'] !== '' ? search['host'] : undefined
+ return { q, tool, host }
+ },
component: SearchRoute,
})
function SearchRoute(): React.JSX.Element {
- const { q } = Route.useSearch()
+ const navigate = useNavigate()
+ const search = Route.useSearch()
+
+ const filters: SearchFilters = {
+ q: search.q ?? '',
+ tool: search.tool,
+ host: search.host,
+ }
+
+ const { results, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useSearch(
+ filters,
+ search.q != null,
+ )
+
+ function handleFilterChange(next: SearchFilters) {
+ void navigate({
+ to: '/search',
+ search: {
+ q: next.q || undefined,
+ tool: next.tool || undefined,
+ host: next.host || undefined,
+ },
+ })
+ }
+
+ if (isLoading) {
+ return (
+ <>
+
+
+
+
+ searching…
+
+ >
+ )
+ }
+
+ if (error != null && !(error instanceof AuthError)) {
+ const detail = error instanceof APIError ? error.message : String(error)
+ return (
+
+ )
+ }
+
return (
- <>
-
- search
- {q != null && q !== '' && (
- "{q}"
- )}
-
-
-
-
- >
+
+ <>
+ : undefined}>
+ {search.q != null && “{search.q}”}
+
+
+
+ >
+
)
}
diff --git a/web/src/styles/search.css b/web/src/styles/search.css
new file mode 100644
index 0000000000000000000000000000000000000000..354f95fb4bf348ee077d9f0f1d122c8bd82b4005
--- /dev/null
+++ b/web/src/styles/search.css
@@ -0,0 +1,196 @@
+/* Search route styles */
+
+/* ─── Table ─── */
+.search-table {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
+}
+
+.search-thead {
+ display: grid;
+ padding: 5px 14px;
+ background: var(--paper-3);
+ font-size: 10px;
+ color: var(--ink-3);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-family: var(--mono);
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid var(--rule-2);
+ flex: none;
+}
+
+.search-row {
+ display: grid;
+ padding: var(--row-pad) 14px;
+ border-bottom: 1px solid var(--rule-2);
+ align-items: center;
+ gap: 10px;
+ font-size: 11.5px;
+ cursor: pointer;
+ color: var(--ink);
+}
+
+.search-row:hover {
+ background: var(--paper-2);
+}
+
+/* Column grid: HOST · CWD · SNIPPET · RANK */
+.search-cols {
+ grid-template-columns: 1fr 1fr 2fr 60px;
+}
+
+/* ─── Snippet with highlights ─── */
+.snippet mark {
+ background: var(--accent-soft);
+ color: var(--ink);
+ border-radius: 2px;
+ padding: 0 1px;
+}
+
+/* ─── Search input in SubBar ─── */
+.search-input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ border-bottom: 1px solid var(--rule);
+ background: transparent;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink);
+ outline: none;
+ padding: 2px 4px;
+}
+
+.search-input::placeholder {
+ color: var(--ink-4);
+}
+
+/* ─── Load More button ─── */
+.load-more-wrap {
+ display: flex;
+ justify-content: center;
+ padding: 12px 14px;
+}
+
+.load-more {
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink-3);
+ background: var(--paper-3);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ padding: 4px 18px;
+ cursor: pointer;
+}
+
+.load-more:hover {
+ background: var(--paper-2);
+ color: var(--ink-2);
+}
+
+.load-more:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+/* ─── Save-search form ─── */
+.save-search-wrap {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.save-search-btn {
+ font-family: var(--mono);
+ font-size: 10px;
+ padding: 2px 8px;
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ background: var(--paper);
+ color: var(--ink-2);
+ cursor: pointer;
+}
+
+.save-search-btn:hover {
+ background: var(--paper-2);
+}
+
+.save-search-btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.save-search-form-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.save-search-query-display {
+ font-family: var(--mono);
+ font-size: 10px;
+ color: var(--ink-3);
+ background: var(--tag-bg);
+ padding: 1px 6px;
+ border-radius: 3px;
+}
+
+.save-search-name-input {
+ font-family: var(--mono);
+ font-size: 10px;
+ padding: 2px 6px;
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ background: var(--paper);
+ color: var(--ink);
+ outline: none;
+ width: 140px;
+}
+
+.save-search-name-input:focus {
+ border-color: var(--accent);
+}
+
+.save-search-check {
+ font-size: 13px;
+ color: var(--ok);
+ line-height: 1;
+}
+
+/* ─── Popover (reused FilterChip pattern) ─── */
+.popover {
+ position: absolute;
+ background: var(--paper-4);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ box-shadow: var(--shadow-pop);
+ padding: 4px 0;
+ min-width: 140px;
+ z-index: 40;
+}
+
+.popover .ph {
+ font-family: var(--mono);
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--ink-4);
+ padding: 4px 10px 2px;
+}
+
+.popover .pi {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ font-size: 11px;
+ cursor: pointer;
+ color: var(--ink);
+}
+
+.popover .pi:hover {
+ background: var(--paper-2);
+}