From 73aed812c1c8f95b5d2553a68eed62b00ad026a9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 15:35:28 -0700 Subject: [PATCH 1/4] feat: read a trace as the question, the answer, and its steps The playback view leads with a scrubber and an empty inspector. A reader should see the prompt and the last reply before the steps. --- .../observability/trace-reading.test.ts | 63 +++++ src/blocks/observability/trace-reading.tsx | 219 ++++++++++++++++++ .../observability/views/reading.stories.tsx | 19 ++ 3 files changed, 301 insertions(+) create mode 100644 src/blocks/observability/trace-reading.test.ts create mode 100644 src/blocks/observability/trace-reading.tsx create mode 100644 stories/observability/views/reading.stories.tsx diff --git a/src/blocks/observability/trace-reading.test.ts b/src/blocks/observability/trace-reading.test.ts new file mode 100644 index 00000000..6e7d3197 --- /dev/null +++ b/src/blocks/observability/trace-reading.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; + +import type { Span } from "../../ui/observability/model.js"; +import { turnReading } from "./trace-reading.js"; + +describe("turnReading", () => { + test("keeps the first prompt and the last reply", () => { + const reading = turnReading([ + turn("late", 40, "Second ask", "The parser drops the comma."), + turn("early", 10, "Review the diff.", "I will look."), + tool("grep", 20, "12 matches."), + ]); + expect(reading.question).toBe("Review the diff."); + expect(reading.answer).toBe("The parser drops the comma."); + }); + + test("skips a turn that has no text", () => { + const reading = turnReading([ + turn("empty", 0, " ", ""), + turn("real", 5, "Review the diff.", "Done."), + ]); + expect(reading.question).toBe("Review the diff."); + expect(reading.answer).toBe("Done."); + }); + + test("a tool reply is not the answer", () => { + const reading = turnReading([tool("shell", 0, "1 failed.")]); + expect(reading.question).toBeUndefined(); + expect(reading.answer).toBeUndefined(); + }); +}); + +function turn( + id: string, + startMs: number, + input: string, + output: string, +): Span { + return { + id, + parentId: null, + kind: "turn", + name: id, + status: "ok", + startMs, + durationMs: 1, + input, + output, + }; +} + +function tool(id: string, startMs: number, output: string): Span { + return { + id, + parentId: null, + kind: "tool", + name: id, + status: "ok", + startMs, + durationMs: 1, + output, + }; +} diff --git a/src/blocks/observability/trace-reading.tsx b/src/blocks/observability/trace-reading.tsx new file mode 100644 index 00000000..eea449b8 --- /dev/null +++ b/src/blocks/observability/trace-reading.tsx @@ -0,0 +1,219 @@ +import { cn } from "../../lib/utils.js"; +import { Input, Output, Thinking } from "../../ui/observability/elements.js"; +import { formatDuration } from "../../ui/observability/format.js"; +import type { Span, Trace } from "../../ui/observability/model.js"; +import { STATUS_LABEL } from "../../ui/observability/trace-header.js"; +import { Run } from "../../ui/observability/trace-view.js"; +import type { PlacedSpan } from "../../ui/observability/tree.js"; +import { rowMarker, stepTitle } from "../../ui/observability/wording.js"; + +/** + * A trace as something to read. The question and the model's last reply come + * first. The steps follow, and choosing one shows what it was given and what + * it returned. Playback stays on `AuditRun`. + */ +export function TraceReading({ trace }: { readonly trace: Trace }) { + const reading = turnReading(trace.spans); + const question = sameText(reading.question, trace.name) + ? undefined + : reading.question; + return ( + + {(state) => ( +
+
+

+ {trace.name} +

+

{metaLine(trace)}

+
+ {question === undefined ? null : ( + {question} + )} + {reading.answer === undefined ? null : ( + {reading.answer} + )} +
+ onStepKey(event, state.placed, state.selected, state.select) + } + > + {state.placed.map((row) => ( + state.select(row.span.id, row.span.startMs)} + /> + ))} +
+
+ )} +
+ ); +} + +/** The first model prompt, and the last model reply, in start order. */ +export function turnReading(spans: readonly Span[]): { + question: string | undefined; + answer: string | undefined; +} { + const ordered = [...spans].sort(byStart); + let question: string | undefined; + let answer: string | undefined; + for (const span of ordered) { + if (span.kind !== "turn") continue; + const input = textOf(span.input); + if (question === undefined && input !== undefined) question = input; + const output = textOf(span.output); + if (output !== undefined) answer = output; + } + return { question, answer }; +} + +function ReadingBlock({ + label, + children, +}: { + readonly label: string; + readonly children: string; +}) { + return ( +
+

{label}

+

+ {children} +

+
+ ); +} + +function StepRow({ + span, + depth, + open, + onSelect, +}: { + readonly span: Span; + readonly depth: number; + readonly open: boolean; + readonly onSelect: () => void; +}) { + const mark = span.status === "ok" ? "" : STATUS_LABEL[span.status]; + return ( +
+ + {open ? : null} +
+ ); +} + +function StepBody({ + span, + depth, +}: { + readonly span: Span; + readonly depth: number; +}) { + return ( +
+ + + +
+ ); +} + +function onStepKey( + event: { key: string; preventDefault: () => void }, + placed: readonly PlacedSpan[], + selected: Span | undefined, + select: (id: string, startMs: number) => void, +) { + const delta = keyDelta(event.key); + if (delta === 0) return; + event.preventDefault(); + const current = + selected === undefined + ? -1 + : placed.findIndex((row) => row.span.id === selected.id); + const next = placed[fromIndex(current, delta, placed.length) + delta]; + if (next === undefined) return; + select(next.span.id, next.span.startMs); +} + +function fromIndex(current: number, delta: number, length: number): number { + if (current >= 0) return current; + if (delta > 0) return -1; + return length; +} + +function keyDelta(key: string): number { + if (key === "ArrowDown") return 1; + if (key === "ArrowUp") return -1; + return 0; +} + +function metaLine(trace: Trace): string { + const parts: string[] = []; + if (trace.actor.length > 0) parts.push(trace.actor); + parts.push(STATUS_LABEL[trace.status]); + parts.push(formatDuration(trace.durationMs)); + return parts.join(" ยท "); +} + +function sameText(left: string | undefined, right: string): boolean { + if (left === undefined) return false; + return left === right.trim(); +} + +function textOf(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const text = value.trim(); + if (text.length === 0) return undefined; + return text; +} + +function byStart(left: Span, right: Span): number { + if (left.startMs !== right.startMs) return left.startMs - right.startMs; + if (left.name < right.name) return -1; + if (left.name > right.name) return 1; + return 0; +} diff --git a/stories/observability/views/reading.stories.tsx b/stories/observability/views/reading.stories.tsx new file mode 100644 index 00000000..c3c09eb7 --- /dev/null +++ b/stories/observability/views/reading.stories.tsx @@ -0,0 +1,19 @@ +import { TraceReading } from "../../../src/blocks/observability/trace-reading.js"; +import { trace } from "../fixtures.js"; + +export default { title: "Observability / Views / Reading" }; + +const review = trace("wf-review"); +const editor = trace("ag-editor"); + +export const Review = () => ( +
+ +
+); + +export const Editor = () => ( +
+ +
+); From 765145bfbd5d5b910c1abd4e8bb4a5a383829e35 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 25 Oct 2026 15:51:25 -0700 Subject: [PATCH 2/4] fix: keep trace reading keys on the focused step Arrow keys followed the selected row while focus stayed on another button, so Enter reopened the previous step. Running and waiting used the failure color. The detail stays under the row, so the list is not a listbox. --- src/blocks/observability/trace-reading.tsx | 150 ++++++++++++++------- 1 file changed, 103 insertions(+), 47 deletions(-) diff --git a/src/blocks/observability/trace-reading.tsx b/src/blocks/observability/trace-reading.tsx index eea449b8..910b61a5 100644 --- a/src/blocks/observability/trace-reading.tsx +++ b/src/blocks/observability/trace-reading.tsx @@ -1,8 +1,16 @@ +"use client"; + +import { useRef } from "react"; + import { cn } from "../../lib/utils.js"; +import { Badge } from "../../ui/badge.js"; import { Input, Output, Thinking } from "../../ui/observability/elements.js"; import { formatDuration } from "../../ui/observability/format.js"; import type { Span, Trace } from "../../ui/observability/model.js"; -import { STATUS_LABEL } from "../../ui/observability/trace-header.js"; +import { + STATUS_LABEL, + STATUS_TONE, +} from "../../ui/observability/trace-header.js"; import { Run } from "../../ui/observability/trace-view.js"; import type { PlacedSpan } from "../../ui/observability/tree.js"; import { rowMarker, stepTitle } from "../../ui/observability/wording.js"; @@ -33,28 +41,11 @@ export function TraceReading({ trace }: { readonly trace: Trace }) { {reading.answer === undefined ? null : ( {reading.answer} )} -
- onStepKey(event, state.placed, state.selected, state.select) - } - > - {state.placed.map((row) => ( - state.select(row.span.id, row.span.startMs)} - /> - ))} -
+ )} @@ -96,18 +87,64 @@ function ReadingBlock({ ); } +function ReadingSteps({ + placed, + selected, + select, +}: { + readonly placed: readonly PlacedSpan[]; + readonly selected: Span | undefined; + readonly select: (id: string, startMs: number) => void; +}) { + const buttons = useRef(new Map()); + function onKey(event: { + key: string; + preventDefault: () => void; + target: EventTarget | null; + }) { + const delta = keyDelta(event.key); + if (delta === 0) return; + const from = focusedStep(event.target, placed); + if (from === undefined) return; + const next = neighbor(placed, from, delta); + if (next === undefined) return; + event.preventDefault(); + select(next.span.id, next.span.startMs); + const node = buttons.current.get(next.span.id); + if (node !== undefined) node.focus(); + } + return ( +
+ {placed.map((row, index) => ( + select(row.span.id, row.span.startMs)} + buttonRef={(node) => storeButton(buttons.current, row.span.id, node)} + /> + ))} +
+ ); +} + function StepRow({ span, depth, open, + tabIndex, onSelect, + buttonRef, }: { readonly span: Span; readonly depth: number; readonly open: boolean; + readonly tabIndex: number; readonly onSelect: () => void; + readonly buttonRef: (node: HTMLButtonElement | null) => void; }) { - const mark = span.status === "ok" ? "" : STATUS_LABEL[span.status]; return (