diff --git a/src/ui/observability/format.test.ts b/src/ui/observability/format.test.ts new file mode 100644 index 00000000..a361e756 --- /dev/null +++ b/src/ui/observability/format.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatReportedCost, + formatStarted, + formatStepCount, +} from "./format.js"; + +describe("formatReportedCost", () => { + test("is a dash when no span reports a cost", () => { + expect(formatReportedCost([{}, { usage: {} }])).toBe("—"); + }); + + test("shows zero when a span reports zero", () => { + expect(formatReportedCost([{ usage: { costUsd: 0 } }])).toBe("$0"); + }); + + test("sums the costs that were reported", () => { + expect( + formatReportedCost([ + { usage: { costUsd: 1.5 } }, + { usage: {} }, + { usage: { costUsd: 2.25 } }, + ]), + ).toBe("$3.75"); + }); +}); + +describe("formatStepCount", () => { + test("uses the run total when the page is shorter", () => { + expect(formatStepCount({ stepCount: 12, spans: [{}, {}] })).toBe("12"); + }); + + test("counts the loaded spans when no total was passed", () => { + expect(formatStepCount({ spans: [{}, {}, {}] })).toBe("3"); + }); +}); + +describe("formatStarted", () => { + test("returns an unparseable value unchanged", () => { + expect(formatStarted("not-a-time")).toBe("not-a-time"); + }); + + test("formats a timestamp as a local date and time", () => { + const shown = formatStarted("2026-10-24T15:04:00.000Z"); + expect(shown.includes("T")).toBe(false); + expect(shown.includes("2026-10-24")).toBe(false); + expect(/\bat\b/i.test(shown)).toBe(false); + expect(shown.includes(" · ")).toBe(true); + }); + + test("leaves a clock time unchanged", () => { + expect(formatStarted("09:14:02")).toBe("09:14:02"); + }); +}); diff --git a/src/ui/observability/format.ts b/src/ui/observability/format.ts index 62d6a29f..5692622c 100644 --- a/src/ui/observability/format.ts +++ b/src/ui/observability/format.ts @@ -32,6 +32,44 @@ export function formatCost(usd: number): string { return `$${usd.toFixed(2)}`; } +export function formatReportedCost( + spans: readonly { usage?: { costUsd?: number } }[], +): string { + let total = 0; + let known = false; + for (const span of spans) { + const cost = span.usage?.costUsd; + if (cost === undefined) continue; + known = true; + total += cost; + } + if (!known) return "—"; + return formatCost(total); +} + +export function formatStepCount(trace: { + stepCount?: number; + spans: readonly unknown[]; +}): string { + if (trace.stepCount !== undefined) return String(trace.stepCount); + return String(trace.spans.length); +} + +export function formatStarted(value: string): string { + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + const date = new Date(parsed); + const day = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + }).format(date); + const time = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }).format(date); + return `${day} · ${time}`; +} + export function spanTokens(span: { usage?: { inputTokens?: number; outputTokens?: number }; }): number { diff --git a/src/ui/observability/model.ts b/src/ui/observability/model.ts index ff7202aa..00e76114 100644 --- a/src/ui/observability/model.ts +++ b/src/ui/observability/model.ts @@ -230,6 +230,8 @@ export type Trace = { readonly startedAt: string; readonly durationMs: number; readonly spans: readonly Span[]; + /** Steps in the whole run. Omit to count `spans`. */ + readonly stepCount?: number; readonly placement?: Placement; readonly principal?: PrincipalRef; readonly tenant?: TenantRef; diff --git a/src/ui/observability/trace-header.tsx b/src/ui/observability/trace-header.tsx index f448d0a9..3276cffb 100644 --- a/src/ui/observability/trace-header.tsx +++ b/src/ui/observability/trace-header.tsx @@ -1,7 +1,9 @@ import { Badge } from "../badge.js"; import { - formatCost, formatDuration, + formatReportedCost, + formatStarted, + formatStepCount, formatTokens, spanTokens, } from "./format.js"; @@ -26,10 +28,6 @@ export const STATUS_TONE = { export function TraceHeader({ trace }: { readonly trace: Trace }) { const tokens = trace.spans.reduce((sum, span) => sum + spanTokens(span), 0); - const cost = trace.spans.reduce( - (sum, span) => sum + (span.usage?.costUsd ?? 0), - 0, - ); return (
@@ -46,14 +44,14 @@ export function TraceHeader({ trace }: { readonly trace: Trace }) {

- {trace.actor} · {trace.startedAt} + {trace.actor} · {formatStarted(trace.startedAt)}

- - + +
diff --git a/stories/observability/views/run.stories.tsx b/stories/observability/views/run.stories.tsx index f6fee056..530aa72d 100644 --- a/stories/observability/views/run.stories.tsx +++ b/stories/observability/views/run.stories.tsx @@ -34,6 +34,20 @@ export const NameOnly = () => ( ); NameOnly.storyName = "Name only"; +export const ParseableStart = () => { + const run = { + ...review, + startedAt: "2026-10-24T15:04:00.000Z", + spans: [span("turn", review), span("sub", review)], + }; + return ( +
+ +
+ ); +}; +ParseableStart.storyName = "Parseable start"; + export const WithSteps = () => (