diff --git a/src/blocks/observability/trace-reading.test.ts b/src/blocks/observability/trace-reading.test.ts new file mode 100644 index 00000000..37d7c6e5 --- /dev/null +++ b/src/blocks/observability/trace-reading.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; + +import type { Span } from "../../ui/observability/model.js"; +import { placeSpans } from "../../ui/observability/tree.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(); + }); + + test("reads turns that share a start in step order", () => { + const [left, right] = namesThatTieBreakApart(); + const spans = [ + turn(left, 0, `Ask from ${left}`, `Reply from ${left}`), + turn(right, 0, `Ask from ${right}`, `Reply from ${right}`), + ]; + const turns = placeSpans(spans).filter((row) => row.span.kind === "turn"); + const first = turns[0]; + const last = turns[turns.length - 1]; + if (first === undefined || last === undefined) { + throw new Error("expected two turns"); + } + const reading = turnReading(spans); + expect(reading.question).toBe(first.span.input); + expect(reading.answer).toBe(last.span.output); + }); +}); + +/** Two names `<` and `localeCompare` order differently. */ +function namesThatTieBreakApart(): readonly [string, string] { + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + for (const left of letters) { + for (const right of letters) { + if (left === right) continue; + const codeUnit = left < right ? -1 : 1; + if (codeUnit !== Math.sign(left.localeCompare(right))) { + return [left, right]; + } + } + } + throw new Error("expected names whose orders disagree"); +} + +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..795bdb22 --- /dev/null +++ b/src/blocks/observability/trace-reading.tsx @@ -0,0 +1,278 @@ +"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, + STATUS_TONE, +} from "../../ui/observability/trace-header.js"; +import { Run } from "../../ui/observability/trace-view.js"; +import { compareSpans, 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} + )} + +
+ )} +
+ ); +} + +/** The first model prompt, and the last model reply, in step order. */ +export function turnReading(spans: readonly Span[]): { + question: string | undefined; + answer: string | undefined; +} { + const ordered = [...spans].sort(compareSpans); + 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 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; +}) { + return ( +
+ + {open ? : null} +
+ ); +} + +function StepBody({ + span, + depth, +}: { + readonly span: Span; + readonly depth: number; +}) { + if ( + span.input === undefined && + span.output === undefined && + span.thinking === undefined && + span.redacted !== true + ) { + return null; + } + return ( +
+ + + +
+ ); +} + +function focusedStep( + target: EventTarget | null, + placed: readonly PlacedSpan[], +): string | undefined { + if (!(target instanceof Element)) return undefined; + const button = target.closest("button"); + if (button === null) return undefined; + const found = placed.some((row) => row.span.id === button.id); + if (!found) return undefined; + return button.id; +} + +function neighbor( + placed: readonly PlacedSpan[], + fromId: string, + delta: number, +): PlacedSpan | undefined { + const current = placed.findIndex((row) => row.span.id === fromId); + if (current < 0) return undefined; + return placed[current + delta]; +} + +function stepTab( + id: string, + selectedId: string | undefined, + index: number, +): number { + if (selectedId === undefined) return index === 0 ? 0 : -1; + return id === selectedId ? 0 : -1; +} + +function storeButton( + buttons: Map, + id: string, + node: HTMLButtonElement | null, +) { + if (node === null) buttons.delete(id); + else buttons.set(id, node); +} + +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; +} diff --git a/src/ui/observability/tree.ts b/src/ui/observability/tree.ts index 5eacb46c..66885ea2 100644 --- a/src/ui/observability/tree.ts +++ b/src/ui/observability/tree.ts @@ -5,6 +5,11 @@ export type PlacedSpan = { readonly depth: number; }; +/** Step order: earlier start first, then `localeCompare` on the name. */ +export function compareSpans(left: Span, right: Span): number { + return left.startMs - right.startMs || left.name.localeCompare(right.name); +} + export function placeSpans(spans: readonly Span[]): readonly PlacedSpan[] { const byId = new Map(spans.map((span) => [span.id, span])); const depth = (span: Span, guard = 0): number => { @@ -14,7 +19,7 @@ export function placeSpans(spans: readonly Span[]): readonly PlacedSpan[] { return 1 + depth(parent, guard + 1); }; return [...spans] - .sort((a, b) => a.startMs - b.startMs || a.name.localeCompare(b.name)) + .sort(compareSpans) .map((span) => ({ span, depth: depth(span) })); } diff --git a/stories/observability/views/reading.stories.tsx b/stories/observability/views/reading.stories.tsx new file mode 100644 index 00000000..d0ced224 --- /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 = () => ( +
+ +
+);