());
+ 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 = () => (
+
+
+
+);