~bigbes/lethe

8aeda698dc352ac3d1e93b667f56ad5dbd68d8b9 — Eugene Blikh 23 days ago f0f651b
feat: add search UI layer — SearchTable, SearchFilters, SaveSearchForm, route, and styles
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">&ldquo;{search.q}&rdquo;</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);
}