diff --git a/tests/timeline-filter.test.ts b/tests/timeline-filter.test.ts new file mode 100644 index 0000000..8898525 --- /dev/null +++ b/tests/timeline-filter.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type { Call } from "../ui/src/api.js"; +import { callLabel, filterTimelineCalls } from "../ui/src/timeline-filter.js"; + +const call = (overrides: Partial): Call => ({ + id: overrides.id ?? "1", + method: overrides.method ?? "tools/call", + request: overrides.request ?? {}, + isError: overrides.isError ?? false, + startTs: overrides.startTs ?? 1, + requestTokens: overrides.requestTokens ?? 0, + responseTokens: overrides.responseTokens ?? 0, + ...overrides, +}); + +describe("filterTimelineCalls", () => { + const calls = [ + call({ id: "1", toolName: "github_search", method: "tools/call" }), + call({ id: "2", method: "resources/read", isError: true }), + call({ id: "3", toolName: "filesystem_write", method: "tools/call", isError: true }), + ]; + + it("matches tool or method names case-insensitively", () => { + expect(filterTimelineCalls(calls, "GITHUB", false).map((entry) => entry.id)).toEqual(["1"]); + expect(filterTimelineCalls(calls, "resources", false).map((entry) => entry.id)).toEqual(["2"]); + expect(filterTimelineCalls(calls, "tools/call", false).map((entry) => entry.id)).toEqual([ + "1", + "3", + ]); + expect( + filterTimelineCalls( + [call({ id: "4", toolName: "", method: "resources/list" })], + "resources", + false, + ).map((entry) => entry.id), + ).toEqual(["4"]); + }); + + it("can show only errored calls", () => { + expect(filterTimelineCalls(calls, "", true).map((entry) => entry.id)).toEqual(["2", "3"]); + }); + + it("combines search and errors-only filters", () => { + expect(filterTimelineCalls(calls, "filesystem", true).map((entry) => entry.id)).toEqual(["3"]); + }); +}); + +describe("callLabel", () => { + it("falls back to method for empty tool names", () => { + expect(callLabel(call({ toolName: "", method: "tools/call" }))).toBe("tools/call"); + }); +}); diff --git a/ui/src/api.ts b/ui/src/api.ts index d7e0fd3..952a287 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -37,11 +37,13 @@ export interface SessionDetail { totalTokens: number; } +const readJson = (response: Response): Promise => response.json() as Promise; + export const fetchSessions = (): Promise => - fetch("/api/sessions").then((r) => r.json()); + fetch("/api/sessions").then(readJson); export const fetchSession = (file: string): Promise => - fetch(`/api/session?file=${encodeURIComponent(file)}`).then((r) => r.json()); + fetch(`/api/session?file=${encodeURIComponent(file)}`).then(readJson); export interface ReplayResponse { original: unknown; @@ -57,7 +59,7 @@ export const postReplay = ( method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ file, id, startTs }), - }).then((r) => r.json()); + }).then(readJson); export function subscribeLive(onChange: (file: string) => void): () => void { const source = new EventSource("/api/live"); diff --git a/ui/src/app.tsx b/ui/src/app.tsx index d289ac3..5a89b2a 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -9,6 +9,7 @@ import { type SessionInfo, subscribeLive, } from "./api.js"; +import { callLabel, filterTimelineCalls } from "./timeline-filter.js"; const time = (ts: number) => new Date(ts).toLocaleTimeString(); const day = (ts: number) => new Date(ts).toLocaleDateString(); @@ -18,6 +19,8 @@ export function App() { const [selected, setSelected] = useState(null); const [detail, setDetail] = useState(null); const [call, setCall] = useState(null); + const [timelineQuery, setTimelineQuery] = useState(""); + const [errorsOnly, setErrorsOnly] = useState(false); useEffect(() => { fetchSessions().then((list) => { @@ -42,6 +45,10 @@ export function App() { }, [selected]); const errorCount = useMemo(() => detail?.calls.filter((c) => c.isError).length ?? 0, [detail]); + const filteredCalls = useMemo( + () => (detail ? filterTimelineCalls(detail.calls, timelineQuery, errorsOnly) : []), + [detail, errorsOnly, timelineQuery], + ); return (
@@ -115,6 +122,23 @@ export function App() {

Timeline

+
+ setTimelineQuery(event.currentTarget.value)} + /> + +
@@ -125,7 +149,7 @@ export function App() { - {detail.calls.map((c) => ( + {filteredCalls.map((c) => ( ))} + {!filteredCalls.length && ( + + + + )}
- {c.toolName ?? c.method} + {callLabel(c)} {time(c.startTs)} {c.latencyMs === undefined ? "…" : `${c.latencyMs}ms`} {(c.requestTokens + c.responseTokens).toLocaleString()}
+ no matching calls +
@@ -182,7 +213,7 @@ function CallDetail({ call, file, onClose }: { call: Call; file: string; onClose -

{call.toolName ?? call.method}

+

{callLabel(call)}

{call.isError ? "❌ error" : call.response ? "✓ ok" : "⏳ no response captured"} {call.latencyMs !== undefined && ` · ${call.latencyMs}ms`} diff --git a/ui/src/style.css b/ui/src/style.css index bbaa2e2..07672ef 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -215,6 +215,46 @@ tr.selected td { background: var(--warn); } +.timeline-controls { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 8px; +} + +.timeline-controls input[type="search"] { + min-width: 240px; + max-width: 360px; + flex: 1; + background: var(--bg-panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 7px 10px; + font: inherit; +} + +.timeline-controls input[type="search"]:focus { + outline: 1px solid var(--accent-dim); + border-color: var(--accent-dim); +} + +.timeline-controls label { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-dim); + font-size: 12px; + white-space: nowrap; +} + +.empty-row { + color: var(--text-dim); + font-family: var(--sans); + text-align: center; + padding: 18px 10px; +} + .detail { position: fixed; right: 0; diff --git a/ui/src/timeline-filter.ts b/ui/src/timeline-filter.ts new file mode 100644 index 0000000..515f4a8 --- /dev/null +++ b/ui/src/timeline-filter.ts @@ -0,0 +1,24 @@ +import type { Call } from "./api.js"; + +const hasText = (value: string | undefined): value is string => Boolean(value?.trim()); + +export function callLabel(call: Call): string { + return hasText(call.toolName) ? call.toolName : call.method; +} + +function callSearchText(call: Call): string { + return [call.toolName, call.method].filter(hasText).join(" ").toLowerCase(); +} + +export function filterTimelineCalls(calls: Call[], query: string, errorsOnly: boolean): Call[] { + const normalizedQuery = query.trim().toLowerCase(); + return calls.filter((call) => { + if (errorsOnly && !call.isError) { + return false; + } + if (!normalizedQuery) { + return true; + } + return callSearchText(call).includes(normalizedQuery); + }); +}