From 8aeda698dc352ac3d1e93b667f56ad5dbd68d8b9 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 4 May 2026 10:20:03 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20add=20search=20UI=20layer=20=E2=80=94?= =?UTF-8?q?=20SearchTable,=20SearchFilters,=20SaveSearchForm,=20route,=20a?= =?UTF-8?q?nd=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/features/search/SaveSearchForm.tsx | 80 +++++++++ web/src/features/search/SearchFilters.tsx | 158 +++++++++++++++++ web/src/features/search/SearchTable.tsx | 68 +++++++ web/src/routes/search.tsx | 99 +++++++++-- web/src/styles/search.css | 196 +++++++++++++++++++++ 5 files changed, 584 insertions(+), 17 deletions(-) create mode 100644 web/src/features/search/SaveSearchForm.tsx create mode 100644 web/src/features/search/SearchFilters.tsx create mode 100644 web/src/features/search/SearchTable.tsx create mode 100644 web/src/styles/search.css 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 ? ( + + ) : ( +
+ {query} + setName(e.target.value)} + placeholder="name" + autoComplete="off" + autoFocus + /> + + +
+ )} +
+ ) +} 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() - return ( - <> - - search - {q != null && q !== '' && ( - "{q}" - )} - -
- + 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 ( +
+
+
error
+
{detail}
+
- + ) + } + + return ( + + <> + : 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); +}