diff --git a/src/web/Search.tsx b/src/web/Search.tsx index 2b9d8a5..699fbfd 100644 --- a/src/web/Search.tsx +++ b/src/web/Search.tsx @@ -1,19 +1,52 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { searchRuns, type ApiSearchMatch } from "./api.js"; -import { StatusBadge } from "./Dashboard.js"; +import { StatusBadge } from "./shared.js"; +import { + EMPTY_FILTERS, + type MatchKind, + type SearchFilters, + availableStatuses, + filterMatches, + summarizeMatches, + toggleSetMember, +} from "./searchUtils.js"; -export function Search({ query }: { query: string }) { - const [input, setInput] = useState(query); - const [state, setState] = useState< - | { tag: "idle" } - | { tag: "loading" } - | { tag: "ready"; matches: ApiSearchMatch[] } - | { tag: "error"; message: string } - >(query ? { tag: "loading" } : { tag: "idle" }); +const DEBOUNCE_MS = 250; + +type SearchState = + | { tag: "idle" } + | { tag: "loading" } + | { tag: "ready"; matches: ApiSearchMatch[] } + | { tag: "error"; message: string }; + +export function Search({ query: initialQuery }: { query: string }) { + const [input, setInput] = useState(initialQuery); + const [activeQuery, setActiveQuery] = useState(initialQuery); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [state, setState] = useState( + initialQuery ? { tag: "loading" } : { tag: "idle" }, + ); useEffect(() => { - setInput(query); - if (!query) { + setInput(initialQuery); + setActiveQuery(initialQuery); + }, [initialQuery]); + + useEffect(() => { + const trimmed = input.trim(); + if (trimmed === activeQuery) return; + const t = setTimeout(() => { + setActiveQuery(trimmed); + const newHash = trimmed ? `#/search?q=${encodeURIComponent(trimmed)}` : "#/search"; + if (window.location.hash !== newHash) { + window.history.replaceState(null, "", newHash); + } + }, DEBOUNCE_MS); + return () => clearTimeout(t); + }, [input, activeQuery]); + + useEffect(() => { + if (!activeQuery) { setState({ tag: "idle" }); return; } @@ -21,7 +54,7 @@ export function Search({ query }: { query: string }) { setState({ tag: "loading" }); (async () => { try { - const res = await searchRuns(query); + const res = await searchRuns(activeQuery); if (!cancelled) setState({ tag: "ready", matches: res.matches }); } catch (err) { if (!cancelled) setState({ tag: "error", message: (err as Error).message }); @@ -30,10 +63,11 @@ export function Search({ query }: { query: string }) { return () => { cancelled = true; }; - }, [query]); + }, [activeQuery]); - const submit = (q: string) => { - const trimmed = q.trim(); + const submit = () => { + const trimmed = input.trim(); + setActiveQuery(trimmed); window.location.hash = trimmed ? `#/search?q=${encodeURIComponent(trimmed)}` : "#/search"; }; @@ -42,7 +76,7 @@ export function Search({ query }: { query: string }) {
{ e.preventDefault(); - submit(input); + submit(); }} className="flex gap-2" > @@ -63,46 +97,145 @@ export function Search({ query }: { query: string }) { {state.tag === "idle" && (

- Type a phrase that might appear in a turn's content or an event payload. + Type a phrase that might appear in a turn's content or an event payload. Results update as + you type — searches are case-insensitive substring matches.

)} {state.tag === "loading" &&

searching…

} {state.tag === "error" &&

{state.message}

} - {state.tag === "ready" && } + {state.tag === "ready" && ( + + )} ); } -function SearchResults({ query, matches }: { query: string; matches: ApiSearchMatch[] }) { +function SearchResults({ + query, + matches, + filters, + setFilters, +}: { + query: string; + matches: ApiSearchMatch[]; + filters: SearchFilters; + setFilters: (f: SearchFilters) => void; +}) { + const summary = useMemo(() => summarizeMatches(matches), [matches]); + const statuses = useMemo(() => availableStatuses(matches), [matches]); + const filtered = useMemo(() => filterMatches(matches, filters), [matches, filters]); + if (matches.length === 0) { - return

No matches for "{query}".

; + return ( +
+

No matches for "{query}".

+

+ Tip: searches are case-insensitive substring matches over turn content and event payloads. + Try broader terms or check that the phrase appears verbatim. +

+
+ ); } + + const toggleKind = (kind: MatchKind) => + setFilters({ ...filters, kinds: toggleSetMember(filters.kinds, kind) }); + + const toggleStatus = (status: string) => + setFilters({ ...filters, statuses: toggleSetMember(filters.statuses, status) }); + + const filteredOut = matches.length - filtered.length; + return ( - +
+
+ {summary.total}{" "} + {summary.total === 1 ? "match" : "matches"} across{" "} + {summary.runs} {summary.runs === 1 ? "run" : "runs"} ·{" "} + {summary.turns} {summary.turns === 1 ? "turn" : "turns"} · {summary.events}{" "} + {summary.events === 1 ? "event" : "events"} + {filteredOut > 0 && · showing {filtered.length}} +
+
+ kind: + toggleKind("turn")}> + turns ({summary.turns}) + + toggleKind("event")}> + events ({summary.events}) + + {statuses.length > 1 && ( + <> + status: + {statuses.map((s) => ( + toggleStatus(s)}> + {s} + + ))} + + )} +
+ {filtered.length === 0 ? ( +

+ No matches under the current filters. Toggle a chip to widen. +

+ ) : ( + + )} +
+ ); +} + +function Chip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + ); } diff --git a/src/web/searchUtils.test.ts b/src/web/searchUtils.test.ts new file mode 100644 index 0000000..65df41f --- /dev/null +++ b/src/web/searchUtils.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { ApiSearchMatch } from "./api.js"; +import { + EMPTY_FILTERS, + availableStatuses, + filterMatches, + summarizeMatches, + toggleSetMember, +} from "./searchUtils.js"; + +function match(overrides: Partial = {}): ApiSearchMatch { + return { + runId: "run-1", + issueIdentifier: "BEN-1", + issueTitle: "demo", + status: "completed", + matchKind: "turn", + turnNumber: 1, + eventType: null, + snippet: "hello", + ...overrides, + }; +} + +describe("summarizeMatches", () => { + it("returns zeroes for an empty list", () => { + expect(summarizeMatches([])).toEqual({ total: 0, runs: 0, turns: 0, events: 0 }); + }); + + it("counts unique runs and per-kind matches", () => { + const matches = [ + match({ runId: "a", matchKind: "turn", turnNumber: 1 }), + match({ runId: "a", matchKind: "turn", turnNumber: 2 }), + match({ runId: "a", matchKind: "event", turnNumber: null, eventType: "error" }), + match({ runId: "b", matchKind: "event", turnNumber: null, eventType: "error" }), + ]; + expect(summarizeMatches(matches)).toEqual({ total: 4, runs: 2, turns: 2, events: 2 }); + }); +}); + +describe("availableStatuses", () => { + it("dedupes and sorts statuses", () => { + const matches = [ + match({ status: "running" }), + match({ status: "completed" }), + match({ status: "running" }), + match({ status: "failed" }), + ]; + expect(availableStatuses(matches)).toEqual(["completed", "failed", "running"]); + }); + + it("returns [] when there are no matches", () => { + expect(availableStatuses([])).toEqual([]); + }); +}); + +describe("filterMatches", () => { + const matches = [ + match({ runId: "a", matchKind: "turn", status: "completed" }), + match({ runId: "b", matchKind: "event", status: "running", eventType: "tool" }), + match({ runId: "c", matchKind: "turn", status: "failed" }), + ]; + + it("returns a copy when no filters are active", () => { + const out = filterMatches(matches, EMPTY_FILTERS); + expect(out).toEqual(matches); + expect(out).not.toBe(matches); + }); + + it("narrows by match kind", () => { + const out = filterMatches(matches, { + kinds: new Set(["turn"]), + statuses: new Set(), + }); + expect(out.map((m) => m.runId)).toEqual(["a", "c"]); + }); + + it("narrows by status", () => { + const out = filterMatches(matches, { + kinds: new Set(), + statuses: new Set(["running", "failed"]), + }); + expect(out.map((m) => m.runId)).toEqual(["b", "c"]); + }); + + it("combines kind and status filters with AND semantics", () => { + const out = filterMatches(matches, { + kinds: new Set(["turn"]), + statuses: new Set(["failed"]), + }); + expect(out.map((m) => m.runId)).toEqual(["c"]); + }); + + it("returns an empty list when filters exclude everything", () => { + const out = filterMatches(matches, { + kinds: new Set(["event"]), + statuses: new Set(["failed"]), + }); + expect(out).toEqual([]); + }); +}); + +describe("toggleSetMember", () => { + it("adds a missing value", () => { + const next = toggleSetMember(new Set(["a"]), "b"); + expect([...next].sort()).toEqual(["a", "b"]); + }); + + it("removes an existing value", () => { + const next = toggleSetMember(new Set(["a", "b"]), "a"); + expect([...next]).toEqual(["b"]); + }); + + it("does not mutate the input", () => { + const input = new Set(["a"]); + toggleSetMember(input, "b"); + expect([...input]).toEqual(["a"]); + }); +}); diff --git a/src/web/searchUtils.ts b/src/web/searchUtils.ts new file mode 100644 index 0000000..8573857 --- /dev/null +++ b/src/web/searchUtils.ts @@ -0,0 +1,59 @@ +import type { ApiSearchMatch } from "./api.js"; + +export type MatchKind = "turn" | "event"; + +export interface SearchFilters { + kinds: ReadonlySet; + statuses: ReadonlySet; +} + +export interface SearchSummary { + total: number; + runs: number; + turns: number; + events: number; +} + +export const EMPTY_FILTERS: SearchFilters = { + kinds: new Set(), + statuses: new Set(), +}; + +export function summarizeMatches(matches: ReadonlyArray): SearchSummary { + const runs = new Set(); + let turns = 0; + let events = 0; + for (const m of matches) { + runs.add(m.runId); + if (m.matchKind === "turn") turns++; + else if (m.matchKind === "event") events++; + } + return { total: matches.length, runs: runs.size, turns, events }; +} + +export function availableStatuses(matches: ReadonlyArray): string[] { + const set = new Set(); + for (const m of matches) set.add(m.status); + return [...set].sort(); +} + +export function filterMatches( + matches: ReadonlyArray, + filters: SearchFilters, +): ApiSearchMatch[] { + const allKinds = filters.kinds.size === 0; + const allStatuses = filters.statuses.size === 0; + if (allKinds && allStatuses) return [...matches]; + return matches.filter((m) => { + if (!allKinds && !filters.kinds.has(m.matchKind)) return false; + if (!allStatuses && !filters.statuses.has(m.status)) return false; + return true; + }); +} + +export function toggleSetMember(set: ReadonlySet, value: T): Set { + const next = new Set(set); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; +}