A web/src/features/search/SaveSearchForm.tsx => web/src/features/search/SaveSearchForm.tsx +80 -0
@@ 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 (
+ <div className="save-search-wrap">
+ <button className="save-search-btn" type="button" onClick={handleOpen}>
+ save search
+ </button>
+ </div>
+ )
+ }
+
+ return (
+ <div className="save-search-wrap">
+ {saved ? (
+ <span className="save-search-check">✓</span>
+ ) : (
+ <form className="save-search-form-inline" onSubmit={handleSave}>
+ <span className="save-search-query-display">{query}</span>
+ <input
+ className="save-search-name-input"
+ value={name}
+ onChange={e => setName(e.target.value)}
+ placeholder="name"
+ autoComplete="off"
+ autoFocus
+ />
+ <button
+ className="save-search-btn"
+ type="submit"
+ disabled={createMutation.isPending || !name.trim()}
+ >
+ {createMutation.isPending ? 'saving…' : 'save'}
+ </button>
+ <button className="save-search-btn" type="button" onClick={handleCancel}>
+ cancel
+ </button>
+ </form>
+ )}
+ </div>
+ )
+}
A web/src/features/search/SearchFilters.tsx => web/src/features/search/SearchFilters.tsx +158 -0
@@ 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<HTMLDivElement>(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 (
+ <div ref={ref} className="popover" style={{ top: anchor.top + 4, left: anchor.left }}>
+ <div className="ph">{dim}</div>
+ {options.map(o => (
+ <div
+ key={o}
+ className="pi"
+ onClick={() => { onPick(o); onClose() }}
+ >
+ <span style={{
+ width: 10,
+ height: 10,
+ borderRadius: 99,
+ border: '1px solid var(--ink-3)',
+ background: o === current ? 'var(--accent)' : 'transparent',
+ display: 'inline-block',
+ flexShrink: 0,
+ }} />
+ <span className="mono">{o}</span>
+ </div>
+ ))}
+ </div>
+ )
+}
+
+// ── 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<HTMLSpanElement>(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 (
+ <>
+ <span ref={ref} className={classes.join(' ')} onClick={handleClick}>
+ {dim}: {value}
+ </span>
+ {open && anchor != null && (
+ <Popover
+ dim={dim}
+ current={value}
+ options={options}
+ anchor={anchor}
+ onPick={onPick}
+ onClose={() => setOpen(false)}
+ />
+ )}
+ </>
+ )
+}
+
+// ── SearchFilters ──────────────────────────────────────────────────────────────
+
+export function SearchFilters({ value, onChange }: SearchFiltersProps): React.JSX.Element {
+ const inputRef = useRef<HTMLInputElement>(null)
+
+ function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
+ 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 (
+ <>
+ <input
+ ref={inputRef}
+ className="search-input"
+ defaultValue={value.q}
+ onKeyDown={handleKeyDown}
+ placeholder="search across turns…"
+ autoComplete="off"
+ />
+ <FilterChip
+ dim="tool"
+ value={tool || 'any'}
+ options={['any', ...TOOL_OPTIONS]}
+ isActive={!!tool}
+ onPick={v => onChange({ ...value, tool: v === 'any' ? undefined : v })}
+ />
+ <FilterChip
+ dim="host"
+ value={host || 'any'}
+ options={['any', ...HOST_OPTIONS]}
+ isActive={!!host}
+ onPick={v => onChange({ ...value, host: v === 'any' ? undefined : v })}
+ />
+ </>
+ )
+}
A web/src/features/search/SearchTable.tsx => web/src/features/search/SearchTable.tsx +68 -0
@@ 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 (
+ <div className="body body-pad">
+ <EmptyState glyph="∅" copy="no matches" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="search-table body">
+ <div className="search-thead search-cols" style={{ gridTemplateColumns: COLS }}>
+ <span>host</span>
+ <span>cwd</span>
+ <span>snippet</span>
+ <span className="right">rank</span>
+ </div>
+ {rows.map((r, i) => (
+ <div
+ key={`${r.sessionId}-${r.turnId}-${i}`}
+ className="search-row search-cols"
+ style={{ gridTemplateColumns: COLS }}
+ onClick={() => {
+ void navigate({
+ to: '/session/$tool/$host/$id',
+ params: { tool: r.tool, host: r.host, id: r.sessionId },
+ hash: `turn-${r.turnId}`,
+ })
+ }}
+ >
+ <span className="mono truncate">{r.host}</span>
+ <span className="mono muted truncate">{r.cwd}</span>
+ <span className="snippet truncate">{highlightSnippet(r.snippet)}</span>
+ <span className="right mono muted">{r.rank}</span>
+ </div>
+ ))}
+ {hasMore && (
+ <div className="load-more-wrap">
+ <button
+ className="load-more"
+ type="button"
+ disabled={loadingMore}
+ onClick={onLoadMore}
+ >
+ {loadingMore ? 'loading…' : 'load more'}
+ </button>
+ </div>
+ )}
+ </div>
+ )
+}
M web/src/routes/search.tsx => web/src/routes/search.tsx +82 -17
@@ 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<string, unknown>): SearchParams => ({
- q: typeof search['q'] === 'string' ? search['q'] : undefined,
- }),
+ validateSearch: (search: Record<string, unknown>): 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()
- return (
- <>
- <SubBar>
- <Tag kind="neutral">search</Tag>
- {q != null && q !== '' && (
- <Tag kind="neutral">"{q}"</Tag>
- )}
- </SubBar>
- <div className="body body-pad">
- <EmptyState glyph="∅" copy="coming in a later task" />
+ 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 (
+ <>
+ <SubBar>
+ <SearchFiltersCmp value={filters} onChange={handleFilterChange} />
+ </SubBar>
+ <div className="body body-pad">
+ <Sub>searching…</Sub>
+ </div>
+ </>
+ )
+ }
+
+ if (error != null && !(error instanceof AuthError)) {
+ const detail = error instanceof APIError ? error.message : String(error)
+ return (
+ <div className="body body-pad" style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}>
+ <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
+ <div className="uppercase-mono" style={{ marginBottom: 8 }}>error</div>
+ <div className="muted">{detail}</div>
+ </div>
</div>
- </>
+ )
+ }
+
+ return (
+ <AuthGate>
+ <>
+ <SubBar right={search.q != null ? <SaveSearchForm query={search.q} /> : undefined}>
+ {search.q != null && <Tag kind="neutral">“{search.q}”</Tag>}
+ <SearchFiltersCmp value={filters} onChange={handleFilterChange} />
+ </SubBar>
+ <SearchTable
+ rows={results}
+ hasMore={hasNextPage}
+ loadingMore={isFetchingNextPage}
+ onLoadMore={fetchNextPage}
+ />
+ </>
+ </AuthGate>
)
}
A web/src/styles/search.css => web/src/styles/search.css +196 -0
@@ 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);
+}