Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 179 additions & 46 deletions src/web/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,60 @@
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<SearchFilters>(EMPTY_FILTERS);
const [state, setState] = useState<SearchState>(
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;
}
let cancelled = false;
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 });
Expand All @@ -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";
};

Expand All @@ -42,7 +76,7 @@ export function Search({ query }: { query: string }) {
<form
onSubmit={(e) => {
e.preventDefault();
submit(input);
submit();
}}
className="flex gap-2"
>
Expand All @@ -63,46 +97,145 @@ export function Search({ query }: { query: string }) {

{state.tag === "idle" && (
<p className="text-sm text-slate-400">
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.
</p>
)}
{state.tag === "loading" && <p className="text-slate-400 text-sm">searching…</p>}
{state.tag === "error" && <p className="text-rose-400 text-sm">{state.message}</p>}
{state.tag === "ready" && <SearchResults query={query} matches={state.matches} />}
{state.tag === "ready" && (
<SearchResults
query={activeQuery}
matches={state.matches}
filters={filters}
setFilters={setFilters}
/>
)}
</div>
);
}

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 <p className="text-sm text-slate-400">No matches for "{query}".</p>;
return (
<div className="space-y-1">
<p className="text-sm text-slate-300">No matches for "{query}".</p>
<p className="text-xs text-slate-500">
Tip: searches are case-insensitive substring matches over turn content and event payloads.
Try broader terms or check that the phrase appears verbatim.
</p>
</div>
);
}

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 (
<ul className="space-y-2">
{matches.map((m, i) => (
<li
key={`${m.runId}:${m.matchKind}:${m.turnNumber ?? m.eventType ?? i}`}
className="rounded border border-slate-800 bg-slate-900/60 p-3"
>
<a
href={`#/runs/${m.runId}`}
className="flex flex-wrap items-baseline gap-2 text-sm rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500"
>
<span className="font-mono text-cyan-400">{m.issueIdentifier}</span>
{m.issueTitle && (
<span className="text-slate-300 truncate" title={m.issueTitle}>
{m.issueTitle}
</span>
)}
<StatusBadge status={m.status} />
<span className="text-xs text-slate-500">
{m.matchKind === "turn" ? `turn #${m.turnNumber}` : `event ${m.eventType}`}
</span>
</a>
<Highlighted text={m.snippet} query={query} />
</li>
))}
</ul>
<div className="space-y-3">
<div className="text-sm text-slate-300">
<span className="font-medium">{summary.total}</span>{" "}
{summary.total === 1 ? "match" : "matches"} across{" "}
<span className="font-medium">{summary.runs}</span> {summary.runs === 1 ? "run" : "runs"} ·{" "}
{summary.turns} {summary.turns === 1 ? "turn" : "turns"} · {summary.events}{" "}
{summary.events === 1 ? "event" : "events"}
{filteredOut > 0 && <span className="text-slate-500"> · showing {filtered.length}</span>}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-slate-500">kind:</span>
<Chip active={filters.kinds.has("turn")} onClick={() => toggleKind("turn")}>
turns ({summary.turns})
</Chip>
<Chip active={filters.kinds.has("event")} onClick={() => toggleKind("event")}>
events ({summary.events})
</Chip>
{statuses.length > 1 && (
<>
<span className="text-slate-500 ml-2">status:</span>
{statuses.map((s) => (
<Chip key={s} active={filters.statuses.has(s)} onClick={() => toggleStatus(s)}>
{s}
</Chip>
))}
</>
)}
</div>
{filtered.length === 0 ? (
<p className="text-sm text-slate-400">
No matches under the current filters. Toggle a chip to widen.
</p>
) : (
<ul className="space-y-2">
{filtered.map((m, i) => (
<li
key={`${m.runId}:${m.matchKind}:${m.turnNumber ?? m.eventType ?? i}`}
className="rounded border border-slate-800 bg-slate-900/60 p-3"
>
<a
href={`#/runs/${m.runId}`}
className="flex flex-wrap items-baseline gap-2 text-sm rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500"
>
<span className="font-mono text-cyan-400">{m.issueIdentifier}</span>
{m.issueTitle && (
<span className="text-slate-300 truncate" title={m.issueTitle}>
{m.issueTitle}
</span>
)}
<StatusBadge status={m.status} />
<span className="text-xs text-slate-500">
{m.matchKind === "turn" ? `turn #${m.turnNumber}` : `event ${m.eventType}`}
</span>
</a>
<Highlighted text={m.snippet} query={query} />
</li>
))}
</ul>
)}
</div>
);
}

function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`rounded-full border px-2 py-0.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500 ${
active
? "border-cyan-500 bg-cyan-500/10 text-cyan-200"
: "border-slate-700 bg-slate-900 text-slate-400 hover:text-slate-200"
}`}
>
{children}
</button>
);
}

Expand Down
119 changes: 119 additions & 0 deletions src/web/searchUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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"]);
});
});
Loading
Loading