Skip to content
Merged
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
96 changes: 96 additions & 0 deletions src/blocks/observability/trace-reading.test.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
278 changes: 278 additions & 0 deletions src/blocks/observability/trace-reading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Run trace={trace} className="min-h-0 flex-1" defaultSelectedId="">
{(state) => (
<article className="min-h-0 flex-1 overflow-auto">
<header className="flex flex-col gap-2 border-b border-border px-4 py-4">
<h1 className="text-lg font-semibold text-foreground">
{trace.name}
</h1>
<p className="font-mono text-xs text-muted-foreground">
{metaLine(trace)}
</p>
</header>
{question === undefined ? null : (
<ReadingBlock label="Question">{question}</ReadingBlock>
)}
{reading.answer === undefined ? null : (
<ReadingBlock label="Answer">{reading.answer}</ReadingBlock>
)}
<ReadingSteps
placed={state.placed}
selected={state.selected}
select={state.select}
/>
</article>
)}
</Run>
);
}

/** 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 (
<section className="border-b border-border px-4 py-4">
<h2 className="text-xs font-medium text-muted-foreground">{label}</h2>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-foreground">
{children}
</p>
</section>
);
}

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<string, HTMLButtonElement>());
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 (
<div onKeyDown={onKey}>
{placed.map((row, index) => (
<StepRow
key={row.span.id}
span={row.span}
depth={row.depth}
open={selected?.id === row.span.id}
tabIndex={stepTab(row.span.id, selected?.id, index)}
onSelect={() => select(row.span.id, row.span.startMs)}
buttonRef={(node) => storeButton(buttons.current, row.span.id, node)}
/>
))}
</div>
);
}

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 (
<div
className={cn(
"border-b border-border",
open && "bg-muted/70 shadow-[inset_2px_0_0_var(--foreground)]",
)}
>
<button
type="button"
id={span.id}
ref={buttonRef}
tabIndex={tabIndex}
aria-expanded={open}
className="flex w-full min-w-0 items-center gap-3 py-2 text-left"
style={{ paddingLeft: 16 + depth * 14, paddingRight: 16 }}
onClick={onSelect}
>
<span className="w-28 shrink-0 truncate text-xs text-muted-foreground">
{rowMarker(span)}
</span>
<span className="min-w-0 flex-1 truncate text-sm">
{stepTitle(span)}
</span>
{span.status === "ok" ? null : (
<Badge tone={STATUS_TONE[span.status]} className="w-20 shrink-0">
{STATUS_LABEL[span.status]}
</Badge>
)}
<span className="w-16 shrink-0 text-right font-mono text-xs text-muted-foreground">
{formatDuration(span.durationMs)}
</span>
</button>
{open ? <StepBody span={span} depth={depth} /> : null}
</div>
);
}

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 (
<div
className="flex flex-col gap-4 pt-2 pb-4"
style={{ paddingLeft: 16 + depth * 14, paddingRight: 16 }}
>
<Thinking span={span} />
<Input span={span} />
<Output span={span} />
</div>
);
}

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<string, HTMLButtonElement>,
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;
}
7 changes: 6 additions & 1 deletion src/ui/observability/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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) }));
}

Expand Down
Loading
Loading