From d571e177a2d8a90187a19900dab8375a098923e9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 00:37:55 -0700 Subject: [PATCH 01/14] feat: add observability trace viewers Storybook can step through an agent or workflow run, including the instructions, tool list, reasoning, compaction, and director nudges. --- src/blocks/observability/app.tsx | 373 ++++++ src/ui/observability/area-chart.tsx | 215 ++++ src/ui/observability/format.ts | 43 + src/ui/observability/metrics.ts | 343 ++++++ src/ui/observability/model.ts | 269 +++++ src/ui/observability/observations.tsx | 173 +++ src/ui/observability/playback.ts | 89 ++ src/ui/observability/session-table.tsx | 111 ++ src/ui/observability/slots.tsx | 535 +++++++++ src/ui/observability/timeline.tsx | 172 +++ src/ui/observability/trace-view.tsx | 1035 +++++++++++++++++ src/ui/observability/tree.ts | 26 + src/ui/observability/wording.ts | 267 +++++ stories/observability/fixtures.ts | 824 +++++++++++++ .../observability/observability.stories.tsx | 78 ++ 15 files changed, 4553 insertions(+) create mode 100644 src/blocks/observability/app.tsx create mode 100644 src/ui/observability/area-chart.tsx create mode 100644 src/ui/observability/format.ts create mode 100644 src/ui/observability/metrics.ts create mode 100644 src/ui/observability/model.ts create mode 100644 src/ui/observability/observations.tsx create mode 100644 src/ui/observability/playback.ts create mode 100644 src/ui/observability/session-table.tsx create mode 100644 src/ui/observability/slots.tsx create mode 100644 src/ui/observability/timeline.tsx create mode 100644 src/ui/observability/trace-view.tsx create mode 100644 src/ui/observability/tree.ts create mode 100644 src/ui/observability/wording.ts create mode 100644 stories/observability/fixtures.ts create mode 100644 stories/observability/observability.stories.tsx diff --git a/src/blocks/observability/app.tsx b/src/blocks/observability/app.tsx new file mode 100644 index 00000000..5dc1b608 --- /dev/null +++ b/src/blocks/observability/app.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { Button } from "../../ui/button.js"; +import { EmptyState } from "../../ui/empty-state.js"; +import { FilterBar } from "../../ui/filter-bar.js"; +import { Input } from "../../ui/input.js"; +import { formatCost, formatDuration, formatTokens } from "../../ui/observability/format.js"; +import { AreaChart } from "../../ui/observability/area-chart.js"; +import type { + SeriesPoint, + SessionSummary, + Trace, + TraceKind, +} from "../../ui/observability/model.js"; +import { observationRows, portfolio, rollup, usageOf } from "../../ui/observability/metrics.js"; +import { Observations } from "../../ui/observability/observations.js"; +import { UsageMix } from "../../ui/observability/slots.js"; +import { SessionTable } from "../../ui/observability/session-table.js"; +import { AgentTrace, WorkflowTrace } from "../../ui/observability/trace-view.js"; +import type { Wording } from "../../ui/observability/wording.js"; +import { StatGrid, StatGridItem } from "../../ui/stat-grid.js"; +import { cn } from "../../lib/utils.js"; + +export type ObservabilityAppProps = { + readonly sessions: readonly SessionSummary[]; + readonly traces: Readonly>; + readonly series: readonly SeriesPoint[]; + readonly failures?: readonly SeriesPoint[]; +}; + +type Screen = "overview" | "sessions" | "observations" | "trace"; + +export function ObservabilityApp({ + sessions, + traces, + series, + failures, +}: ObservabilityAppProps) { + const [screen, setScreen] = useState("overview"); + const [kind, setKind] = useState(null); + const [query, setQuery] = useState(""); + const [openId, setOpenId] = useState(null); + const [focusSpan, setFocusSpan] = useState(undefined); + const [adding, setAdding] = useState(false); + const [token, setToken] = useState(null); + const [wording, setWording] = useState("plain"); + + const visible = useMemo( + () => + sessions.filter((session) => { + if (kind !== null && session.kind !== kind) return false; + if (query.length === 0) return true; + const haystack = `${session.name} ${session.actor} ${session.model}`.toLowerCase(); + return haystack.includes(query.toLowerCase()); + }), + [sessions, kind, query], + ); + + const trace = openId === null ? undefined : traces[openId]; + + return ( +
+ +
+ {screen === "overview" ? ( + setScreen("sessions")} + /> + ) : null} + {screen === "sessions" ? ( +
+
+

Sessions

+ setQuery(event.target.value)} + className="max-w-xs" + /> +
+ setKind(value)} + /> + {visible.length === 0 ? ( + + ) : ( + { + setOpenId(id); + setFocusSpan(undefined); + setScreen("trace"); + }} + /> + )} +
+ ) : null} + {screen === "observations" ? ( + { + setOpenId(traceId); + setFocusSpan(spanId); + setScreen("trace"); + }} + /> + ) : null} + {screen === "trace" ? ( + trace === undefined ? ( + + ) : ( +
+
+ +
+ {trace.kind === "workflow" ? ( + + ) : ( + + )} +
+ ) + ) : null} +
+ {adding ? ( + setAdding(false)} + onMint={(name: TraceKind) => { + setToken(`obs_${name}_${Math.random().toString(36).slice(2, 10)}`); + }} + /> + ) : null} +
+ ); +} + +function Overview({ + series, + failures, + sessions, + traces, + onOpen, +}: { + series: readonly SeriesPoint[]; + failures?: readonly SeriesPoint[]; + sessions: readonly SessionSummary[]; + traces: readonly Trace[]; + onOpen: () => void; +}) { + const failed = sessions.filter((session) => session.status === "error").length; + const tokens = sessions.reduce((sum, session) => sum + session.tokens, 0); + const cost = traces.reduce((sum, trace) => sum + rollup(trace).costUsd, 0); + const usage = traces.reduce( + (sum, trace) => { + const part = usageOf(trace); + return { + input: sum.input + part.input, + output: sum.output + part.output, + cacheRead: sum.cacheRead + part.cacheRead, + cacheWrite: sum.cacheWrite + part.cacheWrite, + thinking: sum.thinking + part.thinking, + }; + }, + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + ); + const shares = portfolio(traces); + const steps = observationRows(traces).length; + return ( +
+
+

This week

+ +
+ + point.value)} sparklineLabel="Sessions across the week" /> + + + + +
+ + +
+
+
+

Cost by model

+
    + {shares.map((share) => ( +
  • + {share.provider} / {share.model} + {formatCost(share.costUsd)} · {formatTokens(share.tokens)} +
  • + ))} +
+
+
+

Latency by run

+
    + {sessions.map((session) => ( +
  • + {session.name} + {formatDuration(session.durationMs)} · {formatCost(session.costUsd)} +
  • + ))} +
+
+
+
+ ); +} + +function NavButton({ + current, + onClick, + children, +}: { + current: boolean; + onClick: () => void; + children: string; +}) { + return ( + + ); +} + +function AddAgent({ + token, + onClose, + onMint, +}: { + token: string | null; + onClose: () => void; + onMint: (kind: TraceKind) => void; +}) { + const [name, setName] = useState("editor"); + return ( +
+
+

+ Add an agent +

+

+ A token is shown once. The machine that runs the agent keeps it. +

+ + {token === null ? null : ( +

{token}

+ )} +
+ + +
+
+
+ ); +} diff --git a/src/ui/observability/area-chart.tsx b/src/ui/observability/area-chart.tsx new file mode 100644 index 00000000..e42f09b1 --- /dev/null +++ b/src/ui/observability/area-chart.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useState } from "react"; + +import { areaPath, linePath, niceTicks } from "../../lib/chart-geometry.js"; +import { seriesColor, seriesDash } from "../../lib/chart-palette.js"; +import { ChartFrame } from "../chart-frame.js"; +import type { SeriesPoint } from "./model.js"; + +export type AreaChartProps = { + readonly title: string; + readonly description?: string; + readonly data: readonly SeriesPoint[]; + /** A second series, same order and labels as `data`. Drawn dashed. */ + readonly compare?: readonly SeriesPoint[]; + readonly valueLabel?: string; + readonly compareLabel?: string; + readonly format?: (value: number) => string; + readonly className?: string; +}; + +const WIDTH = 560; +const HEIGHT = 180; +const PAD_LEFT = 36; +const PAD_RIGHT = 12; +const PAD_TOP = 12; +const PAD_BOTTOM = 22; + +/** + * One or two series over time, with a pointer crosshair. + * + * The fill is the line colour at low opacity, so it does not invent another + * series. Orange is not used: it is the action accent, and a passive series + * painted orange reads as a button. The table under the frame has the numbers. + */ +export function AreaChart({ + title, + description, + data, + compare, + valueLabel = "Value", + compareLabel = "Compare", + format = (value) => String(value), + className, +}: AreaChartProps) { + const [hover, setHover] = useState(null); + const compareValues = + compare !== undefined && compare.length === data.length ? compare : undefined; + const max = Math.max( + 0, + ...data.map((point) => point.value), + ...(compareValues ?? []).map((point) => point.value), + ); + const ticks = niceTicks(max); + const axisMax = ticks[ticks.length - 1] ?? 1; + const innerW = WIDTH - PAD_LEFT - PAD_RIGHT; + const innerH = HEIGHT - PAD_TOP - PAD_BOTTOM; + const steps = Math.max(data.length - 1, 1); + const plot = (value: number, index: number) => ({ + x: PAD_LEFT + (index / steps) * innerW, + y: PAD_TOP + innerH - (axisMax <= 0 ? 0 : value / axisMax) * innerH, + }); + const primary = data.map((point, index) => plot(point.value, index)); + const secondary = compareValues?.map((point, index) => plot(point.value, index)); + const baseline = PAD_TOP + innerH; + const color = seriesColor(0); + const compareColor = seriesColor(1); + const dash = seriesDash(1); + const active = hover === null ? undefined : data[hover]; + const activeCompare = hover === null ? undefined : compareValues?.[hover]; + + function hoverAt(clientX: number, width: number) { + if (data.length === 0 || width <= 0) return; + const x = (clientX / width) * WIDTH; + let best = 0; + let dist = Number.POSITIVE_INFINITY; + primary.forEach((point, index) => { + const delta = Math.abs(point.x - x); + if (delta < dist) { + dist = delta; + best = index; + } + }); + setHover(best); + } + + return ( + { + const row = [point.label, format(point.value)]; + const other = compareValues?.[index]; + if (other !== undefined) row.push(format(other.value)); + return row; + }), + }} + > +
setHover(null)} + onMouseMove={(event) => { + const rect = event.currentTarget.getBoundingClientRect(); + hoverAt(event.clientX - rect.left, rect.width); + }} + > + + {ticks.map((tick) => { + const y = plot(tick, 0).y; + return ( + + + + {format(tick)} + + + ); + })} + {primary.length > 1 ? ( + + ) : null} + {primary.length > 1 ? ( + + ) : null} + {secondary !== undefined && secondary.length > 1 ? ( + + ) : null} + {data.map((point, index) => ( + + {point.label} + + ))} + {hover !== null && primary[hover] !== undefined ? ( + + + + {secondary?.[hover] !== undefined ? ( + + ) : null} + + ) : null} + + {active === undefined ? null : ( +

+ {active.label} · {valueLabel} {format(active.value)} + {activeCompare === undefined + ? "" + : ` · ${compareLabel} ${format(activeCompare.value)}`} +

+ )} +
+
+ ); +} diff --git a/src/ui/observability/format.ts b/src/ui/observability/format.ts new file mode 100644 index 00000000..a46539f1 --- /dev/null +++ b/src/ui/observability/format.ts @@ -0,0 +1,43 @@ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`; + const minutes = Math.floor(ms / 60_000); + const seconds = Math.round((ms % 60_000) / 1000); + if (seconds === 0) return `${minutes}m`; + return `${minutes}m ${seconds}s`; +} + +export function formatClock(ms: number): string { + const sign = ms < 0 ? "−" : "+"; + return `${sign}${formatDuration(Math.abs(ms))}`; +} + +export function formatTokens(value: number): string { + if (value < 1000) return String(value); + if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`; + return `${(value / 1_000_000).toFixed(2)}M`; +} + +export function formatSpeed(tokensPerSecond: number | null): string { + if (tokensPerSecond === null || !Number.isFinite(tokensPerSecond)) return "—"; + if (tokensPerSecond < 10) return `${tokensPerSecond.toFixed(1)} tok/s`; + return `${Math.round(tokensPerSecond)} tok/s`; +} + +export function formatCost(usd: number): string { + if (usd === 0) return "$0"; + if (usd < 0.01) return `$${usd.toFixed(4)}`; + return `$${usd.toFixed(2)}`; +} + +export function spanTokens(span: { + usage?: { inputTokens?: number; outputTokens?: number }; +}): number { + return (span.usage?.inputTokens ?? 0) + (span.usage?.outputTokens ?? 0); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/ui/observability/metrics.ts b/src/ui/observability/metrics.ts new file mode 100644 index 00000000..4566d44c --- /dev/null +++ b/src/ui/observability/metrics.ts @@ -0,0 +1,343 @@ +import { spanTokens } from "./format.js"; +import type { Span, Trace } from "./model.js"; + +export type StepMeasure = { + readonly id: string; + readonly key: string; + readonly name: string; + readonly kind: Span["kind"]; + readonly provider: string; + readonly model: string; + readonly tokens: number; + readonly cachedTokens: number; + readonly costUsd: number; + readonly durationMs: number; + readonly tokensPerSecond: number | null; + readonly status: Span["status"]; + readonly startMs: number; +}; + +export type TraceRollup = { + readonly tokens: number; + readonly cachedTokens: number; + readonly costUsd: number; + readonly durationMs: number; + readonly tokensPerSecond: number | null; + readonly denied: number; + readonly blocked: number; + readonly externalHops: number; + readonly badSignatures: number; + readonly waitingMs: number; + readonly provisionMs: number; +}; + +export type ModelShare = { + readonly provider: string; + readonly model: string; + readonly tokens: number; + readonly costUsd: number; + readonly quirks: readonly string[]; +}; + +export type Finding = { + readonly id: string; + readonly tone: "danger" | "warning"; + readonly title: string; + readonly detail: string; +}; + +export type MeasureSort = "time" | "cost" | "duration"; + +export type UsageBreakdown = { + readonly input: number; + readonly output: number; + readonly cacheRead: number; + readonly cacheWrite: number; + readonly thinking: number; +}; + +export type ShapeNode = { + readonly key: string; + readonly name: string; + readonly count: number; + readonly durationMs: number; + readonly tokens: number; + readonly costUsd: number; +}; + +export type ObservationRow = { + readonly traceId: string; + readonly traceName: string; + readonly spanId: string; + readonly type: Span["kind"]; + readonly name: string; + readonly model: string; + readonly level: "Default" | "Warning" | "Error" | "Debug"; + readonly latencyMs: number; + readonly tokens: number; + readonly costUsd: number; +}; + +const speed = (tokens: number, durationMs: number): number | null => { + if (tokens <= 0 || durationMs <= 0) return null; + return tokens / (durationMs / 1000); +}; + +/** Stable key for a step. A repeated name gets `.2`, `.3`, and so on. */ +export function stepKeys(spans: readonly Span[]): ReadonlyMap { + const seen = new Map(); + const keys = new Map(); + for (const span of spans) { + const base = `${span.kind}.${span.name}` + .toLowerCase() + .replace(/[^a-z0-9.]+/g, "_") + .replace(/_+/g, "_"); + const count = (seen.get(base) ?? 0) + 1; + seen.set(base, count); + keys.set(span.id, count === 1 ? base : `${base}.${count}`); + } + return keys; +} + +export function measures(trace: Trace, sort: MeasureSort = "time"): readonly StepMeasure[] { + const keys = stepKeys(trace.spans); + const rows = trace.spans.map((span) => { + const tokens = spanTokens(span); + const cachedTokens = span.usage?.cachedTokens ?? 0; + const durationMs = span.durationMs; + return { + id: span.id, + key: keys.get(span.id) ?? span.id, + name: span.name, + kind: span.kind, + provider: span.usage?.provider ?? "—", + model: span.usage?.model ?? "—", + tokens, + cachedTokens, + costUsd: span.usage?.costUsd ?? 0, + durationMs, + tokensPerSecond: speed(tokens, durationMs), + status: span.status, + startMs: span.startMs, + }; + }); + if (sort === "cost") { + return rows + .filter((row) => row.costUsd > 0 || row.tokens > 0) + .sort((a, b) => b.costUsd - a.costUsd || a.startMs - b.startMs); + } + if (sort === "duration") { + return rows + .filter((row) => row.durationMs >= 1000 || row.tokens > 0) + .sort((a, b) => b.durationMs - a.durationMs || a.startMs - b.startMs); + } + return rows; +} + +export function rollup(trace: Trace): TraceRollup { + let tokens = 0; + let cachedTokens = 0; + let costUsd = 0; + let denied = 0; + let blocked = 0; + let externalHops = 0; + let badSignatures = 0; + let waitingMs = 0; + let provisionMs = 0; + for (const span of trace.spans) { + tokens += spanTokens(span); + cachedTokens += span.usage?.cachedTokens ?? 0; + costUsd += span.usage?.costUsd ?? 0; + if (span.status === "denied") denied += 1; + if (span.grant?.blocked === true) blocked += 1; + externalHops += span.network?.filter((hop) => hop.external).length ?? 0; + if (span.signature === "invalid" || span.signature === "missing") badSignatures += 1; + if (span.status === "waiting") waitingMs += span.durationMs; + if (span.kind === "provision") provisionMs += span.durationMs; + } + return { + tokens, + cachedTokens, + costUsd, + durationMs: trace.durationMs, + tokensPerSecond: speed(tokens, trace.durationMs), + denied, + blocked, + externalHops, + badSignatures, + waitingMs, + provisionMs, + }; +} + +export function modelShares(trace: Trace): readonly ModelShare[] { + const byKey = new Map(); + for (const span of trace.spans) { + const provider = span.usage?.provider; + const model = span.usage?.model; + if (provider === undefined || model === undefined) continue; + const key = `${provider}/${model}`; + const current = byKey.get(key) ?? { + provider, + model, + tokens: 0, + costUsd: 0, + quirks: quirksFor(trace, provider, model), + }; + byKey.set(key, { + ...current, + tokens: current.tokens + spanTokens(span), + costUsd: current.costUsd + (span.usage?.costUsd ?? 0), + }); + } + return [...byKey.values()].sort((a, b) => b.costUsd - a.costUsd); +} + +function quirksFor(trace: Trace, provider: string, model: string): readonly string[] { + const listed = + trace.providers?.find((item) => item.provider === provider && item.model === model) + ?.quirks ?? []; + const redacted = trace.spans.some( + (span) => span.redacted === true && modelOf(trace, span) === model, + ); + return redacted ? [...listed, "This run redacted a thinking block"] : listed; +} + +export function usageOf(trace: Trace): UsageBreakdown { + const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }; + for (const span of trace.spans) { + totals.input += span.usage?.inputTokens ?? 0; + totals.output += span.usage?.outputTokens ?? 0; + totals.cacheRead += span.usage?.cachedTokens ?? 0; + totals.cacheWrite += span.usage?.cacheWriteTokens ?? 0; + totals.thinking += span.usage?.thinkingTokens ?? 0; + } + return totals; +} + +export function usageOfSpan(span: Span): UsageBreakdown { + return { + input: span.usage?.inputTokens ?? 0, + output: span.usage?.outputTokens ?? 0, + cacheRead: span.usage?.cachedTokens ?? 0, + cacheWrite: span.usage?.cacheWriteTokens ?? 0, + thinking: span.usage?.thinkingTokens ?? 0, + }; +} + +/** Same step name collapsed, with a count. Loops show up as one node. */ +export function shapeOf(trace: Trace): readonly ShapeNode[] { + const keys = stepKeys(trace.spans); + const grouped = new Map(); + for (const span of trace.spans) { + const full = keys.get(span.id) ?? span.id; + const key = full.replace(/\.\d+$/, ""); + const current = grouped.get(key) ?? { + key, + name: span.name, + count: 0, + durationMs: 0, + tokens: 0, + costUsd: 0, + }; + grouped.set(key, { + ...current, + count: current.count + 1, + durationMs: current.durationMs + span.durationMs, + tokens: current.tokens + spanTokens(span), + costUsd: current.costUsd + (span.usage?.costUsd ?? 0), + }); + } + return [...grouped.values()].sort((a, b) => b.durationMs - a.durationMs); +} + +export function observationRows(traces: readonly Trace[]): readonly ObservationRow[] { + const rows: ObservationRow[] = []; + for (const trace of traces) { + for (const span of trace.spans) { + rows.push({ + traceId: trace.id, + traceName: trace.name, + spanId: span.id, + type: span.kind, + name: span.name, + model: span.usage?.model ?? "—", + level: levelOf(span.status), + latencyMs: span.durationMs, + tokens: spanTokens(span), + costUsd: span.usage?.costUsd ?? 0, + }); + } + } + return rows.sort((a, b) => b.costUsd - a.costUsd || b.latencyMs - a.latencyMs); +} + +function levelOf(status: Span["status"]): ObservationRow["level"] { + if (status === "error" || status === "denied") return "Error"; + if (status === "waiting") return "Warning"; + if (status === "running") return "Debug"; + return "Default"; +} + +export function portfolio(traces: readonly Trace[]): readonly ModelShare[] { + const byKey = new Map(); + for (const trace of traces) { + for (const share of modelShares(trace)) { + const key = `${share.provider}/${share.model}`; + const current = byKey.get(key) ?? { ...share, tokens: 0, costUsd: 0 }; + byKey.set(key, { + ...current, + tokens: current.tokens + share.tokens, + costUsd: current.costUsd + share.costUsd, + quirks: current.quirks.length > share.quirks.length ? current.quirks : share.quirks, + }); + } + } + return [...byKey.values()].sort((a, b) => b.costUsd - a.costUsd); +} + +function modelOf(trace: Trace, span: Span): string | undefined { + if (span.usage?.model !== undefined) return span.usage.model; + if (span.parentId === null) return undefined; + const parent = trace.spans.find((item) => item.id === span.parentId); + return parent === undefined ? undefined : modelOf(trace, parent); +} + +export function findings(trace: Trace): readonly Finding[] { + const rows: Finding[] = []; + for (const span of trace.spans) { + if (span.status === "denied" || span.grant?.effect === "deny") { + rows.push({ + id: `${span.id}:deny`, + tone: "danger", + title: span.name, + detail: span.grant?.blockReason ?? "Denied", + }); + } else if (span.grant?.blocked === true) { + rows.push({ + id: `${span.id}:block`, + tone: "warning", + title: span.name, + detail: span.grant.blockReason ?? "Blocked pending approval", + }); + } + for (const hop of span.network ?? []) { + if (!hop.external) continue; + rows.push({ + id: `${span.id}:${hop.url}`, + tone: "warning", + title: `${hop.method} ${hop.url}`, + detail: hop.status === null ? "External, no status" : `External · ${hop.status}`, + }); + } + if (span.signature === "invalid" || span.signature === "missing") { + rows.push({ + id: `${span.id}:sig`, + tone: "danger", + title: span.name, + detail: `Signature ${span.signature}`, + }); + } + } + return rows; +} diff --git a/src/ui/observability/model.ts b/src/ui/observability/model.ts new file mode 100644 index 00000000..cbc30339 --- /dev/null +++ b/src/ui/observability/model.ts @@ -0,0 +1,269 @@ +/** A recorded session. An agent run and a workflow run share this header. */ +export type TraceKind = "agent" | "workflow"; + +export type SpanKind = + | "turn" + | "reasoning" + | "tool" + | "permission" + | "subagent" + | "compact" + | "error" + | "event" + | "step" + | "gate" + | "mail" + | "provision" + | "fork" + | "reactor" + | "network" + | "attachment" + | "citation" + | "transform" + | "nudge"; + +export type SpanStatus = "running" | "ok" | "error" | "waiting" | "denied"; + +export type GrantEffect = "allow" | "deny" | "ask"; + +export type GrantOrigin = "system" | "role" | "creator" | "invoker"; + +/** One grant that matched a tool call or a gate. Mirrors a resolved grant. */ +export type GrantRef = { + readonly id: string; + readonly resource: string; + readonly action: string; + readonly effect: GrantEffect; + readonly origin: GrantOrigin; + readonly specificity?: number; +}; + +/** + * The authorization outcome at one moment. `effect: "none"` is a call where + * nothing matched and the runtime failed closed. + */ +export type GrantUse = { + readonly effect: GrantEffect | "none"; + readonly resolvedBy: GrantRef | null; + readonly matching: readonly GrantRef[]; + readonly blocked: boolean; + readonly blockReason?: string; +}; + +export type SignatureStatus = "valid" | "invalid" | "unknown" | "missing"; + +export type MediaItem = { + readonly name: string; + readonly mediaType: string; + readonly bytes: number; + readonly note?: string; +}; + +/** One outbound or inbound hop. `external` means it left the host network. */ +export type NetworkHop = { + readonly method: string; + readonly url: string; + readonly status: number | null; + readonly bytes: number; + readonly external: boolean; +}; + +export type Usage = { + readonly provider?: string; + readonly model?: string; + readonly inputTokens?: number; + readonly outputTokens?: number; + /** Tokens read from the provider cache. A slice of input, not an extra pile. */ + readonly cachedTokens?: number; + readonly cacheWriteTokens?: number; + readonly thinkingTokens?: number; + readonly costUsd?: number; +}; + +/** What a model does that changes the bill, the latency, or the audit. */ +export type ProviderInfo = { + readonly provider: string; + readonly model: string; + readonly region?: string; + readonly quirks: readonly string[]; +}; + +export type PrincipalRef = { + readonly id: string; + readonly name: string; + readonly kind: string; + readonly keyId: string; +}; + +export type TenantRef = { + readonly id: string; + readonly name: string; +}; + +export type Attribute = { + readonly label: string; + readonly value: string; +}; + +/** The instructions and tools the harness handed the model. */ +export type AgentTool = { + readonly name: string; + readonly description: string; +}; + +export type AgentBrief = { + readonly systemPrompt: string; + readonly tools: readonly AgentTool[]; +}; + +/** One turn the compactor kept or dropped. */ +export type CompactionTurn = { + readonly role: string; + readonly title: string; + readonly tokens: number; + readonly kept: boolean; + readonly note?: string; +}; + +/** A director `compact` action and the manifest record it wrote. */ +export type Compaction = { + readonly compactor: string; + readonly version: string; + readonly reason: string; + readonly detail: string; + readonly turnsBefore: number; + readonly turnsAfter: number; + readonly tokensBefore: number; + readonly tokensAfter: number; + readonly turns: readonly CompactionTurn[]; +}; + +/** One context-strategy run. History and the prompt are different files. */ +export type ContextChange = { + readonly strategy: string; + readonly version: string; + readonly trigger: string; + readonly reason: string; + readonly summary: string; +}; + +export type NudgeAction = + | "infer" + | "execute_tools" + | "compact" + | "wait" + | "checkpoint" + | "reply" + | "suspend" + | "done"; + +/** One action the director handed the reactor. */ +export type DirectorNudge = { + readonly action: NudgeAction; + readonly because: string; +}; + +/** Where a run was placed. Present once the hub has allocated a sidecar. */ +export type Placement = { + readonly sidecarId: string; + readonly host: string; + readonly region: string; + readonly size: string; + readonly packageName: string; + readonly packageVersion: string; + readonly generation: number; + readonly status: string; +}; + +/** One timed step inside a trace. `startMs` is relative to the trace start. */ +export type Span = { + readonly id: string; + readonly parentId: string | null; + readonly kind: SpanKind; + readonly name: string; + readonly status: SpanStatus; + readonly startMs: number; + readonly durationMs: number; + readonly input?: string; + readonly output?: string; + /** Chain of thought for a reasoning span, or a turn that streamed thinking. */ + readonly thinking?: string; + readonly redacted?: boolean; + readonly grant?: GrantUse; + readonly media?: readonly MediaItem[]; + readonly network?: readonly NetworkHop[]; + readonly signature?: SignatureStatus; + readonly usage?: Usage; + readonly attributes?: readonly Attribute[]; + /** Host payload, secrets already scrubbed. Shown when the raw layer is on. */ + readonly payload?: string; + readonly compaction?: Compaction; + readonly contextChange?: ContextChange; + readonly nudge?: DirectorNudge; +}; + +export type SessionSummary = { + readonly id: string; + readonly kind: TraceKind; + readonly name: string; + readonly actor: string; + readonly model: string; + readonly startedAt: string; + readonly durationMs: number; + readonly tokens: number; + readonly costUsd: number; + readonly steps: number; + readonly status: SpanStatus; +}; + +export type Trace = { + readonly id: string; + readonly kind: TraceKind; + readonly name: string; + readonly actor: string; + readonly status: SpanStatus; + readonly startedAt: string; + readonly durationMs: number; + readonly spans: readonly Span[]; + readonly placement?: Placement; + readonly principal?: PrincipalRef; + readonly tenant?: TenantRef; + readonly providers?: readonly ProviderInfo[]; + readonly brief?: AgentBrief; +}; + +export type Audience = "operator" | "cfo" | "coo" | "ciso"; + +/** How the steps are drawn. The audience picks a default. The caller can override it. */ +export type TraceRepresentation = "timeline" | "ledger" | "cost" | "shape"; + +export const AUDIENCE_REPRESENTATION: Record = { + operator: "timeline", + cfo: "ledger", + coo: "ledger", + ciso: "timeline", +}; + +export type SeriesPoint = { + readonly label: string; + readonly value: number; +}; + +/** What the viewer is willing to show. Every layer is a caller-owned toggle. */ +export type TraceLayers = { + readonly reasoning: boolean; + readonly grants: boolean; + readonly io: boolean; + readonly media: boolean; + readonly network: boolean; + readonly raw: boolean; +}; + +export const DEFAULT_LAYERS: TraceLayers = { + reasoning: true, + grants: true, + io: true, + media: true, + network: true, + raw: false, +}; diff --git a/src/ui/observability/observations.tsx b/src/ui/observability/observations.tsx new file mode 100644 index 00000000..1c36bb82 --- /dev/null +++ b/src/ui/observability/observations.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { cn } from "../../lib/utils.js"; +import { Badge } from "../badge.js"; +import { formatCost, formatDuration, formatTokens } from "./format.js"; +import { observationRows, type ObservationRow } from "./metrics.js"; +import type { Trace } from "./model.js"; +import { + DEFAULT_FOCUS, + FOCUS_KEYS, + FOCUS_LABEL, + focusKeepsKind, + kindLabel, + stepTitle, + type TraceFocus, + type Wording, +} from "./wording.js"; + +export type ObservationsProps = { + readonly traces: readonly Trace[]; + readonly onOpen: (traceId: string, spanId: string) => void; + readonly wording?: Wording; +}; + +const LEVEL_TONE = { + Default: "neutral", + Warning: "warning", + Error: "danger", + Debug: "info", +} as const; + +export function Observations({ traces, onOpen, wording = "plain" }: ObservationsProps) { + const rows = useMemo(() => observationRows(traces), [traces]); + const [focus, setFocus] = useState(DEFAULT_FOCUS); + const [level, setLevel] = useState<"all" | "Error" | "Warning">("all"); + const shown = rows.filter((row) => { + if (!focusKeepsKind(row.type, focus)) return false; + if (level !== "all" && row.level !== level) return false; + return true; + }); + + return ( +
+
+
+

Observations

+

+ Every step, across runs. Turn off the groups you do not need, then open the trace. +

+
+

{shown.length} shown

+
+
+
+ {FOCUS_KEYS.map((key) => { + const on = focus[key]; + return ( + + ); + })} +
+ +
+
+ + + + + + + + + + + + + + + {shown.map((row) => ( + + + + + + + + + + + ))} + +
TraceTypeNameModelLevelLatencyTokensCost
+ + {kindLabel(row.type, wording)}{rowTitle(traces, row, wording)}{row.model} + {row.level} + {formatDuration(row.latencyMs)} + {row.tokens === 0 ? "—" : formatTokens(row.tokens)} + + {row.costUsd === 0 ? "—" : formatCost(row.costUsd)} +
+
+
+ ); +} + +function rowTitle(traces: readonly Trace[], row: ObservationRow, wording: Wording): string { + const trace = traces.find((item) => item.id === row.traceId); + const span = trace?.spans.find((item) => item.id === row.spanId); + return span === undefined ? row.name : stepTitle(span, wording); +} + +function Filter({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: readonly (readonly [T, string])[]; + onChange: (value: T) => void; +}) { + return ( + + ); +} diff --git a/src/ui/observability/playback.ts b/src/ui/observability/playback.ts new file mode 100644 index 00000000..2be25abb --- /dev/null +++ b/src/ui/observability/playback.ts @@ -0,0 +1,89 @@ +import type { Span, TraceLayers } from "./model.js"; +import { placeSpans } from "./tree.js"; +import { focusKeeps, type TraceFocus } from "./wording.js"; + +/** How long a full play-through takes on screen, whatever the trace length. */ +export const SCAN_MS = 16_000; + +export function spanEnd(span: Span): number { + return span.startMs + Math.max(span.durationMs, 1); +} + +export function contains(span: Span, timeMs: number): boolean { + return timeMs >= span.startMs && timeMs < spanEnd(span); +} + +export function visibleSpans( + spans: readonly Span[], + layers: TraceLayers, + focus?: TraceFocus, +): readonly Span[] { + return spans.filter((span) => { + if (focus !== undefined && !focusKeeps(span, focus)) return false; + if (span.kind === "reasoning" && !layers.reasoning) return false; + if (span.kind === "network" && !layers.network) return false; + if (span.kind === "attachment" && !layers.media) return false; + return true; + }); +} + +/** Deepest span that covers `timeMs`. */ +export function deepestAt( + spans: readonly Span[], + timeMs: number, +): Span | undefined { + let best: { readonly span: Span; readonly depth: number } | undefined; + for (const row of placeSpans(spans)) { + if (!contains(row.span, timeMs)) continue; + if (best === undefined || row.depth >= best.depth) best = row; + } + return best?.span; +} + +/** The grant that applies at `timeMs`: the deepest active span that has one. */ +export function grantAt( + spans: readonly Span[], + timeMs: number, +): Span | undefined { + let best: { readonly span: Span; readonly depth: number } | undefined; + for (const row of placeSpans(spans)) { + if (row.span.grant === undefined || !contains(row.span, timeMs)) continue; + if (best === undefined || row.depth >= best.depth) best = row; + } + return best?.span; +} + +/** + * How much of `text` has been produced by `timeMs` inside `span`. + * Cuts on a space so the playhead does not stop mid-word. + */ +export function revealedText(text: string, span: Span, timeMs: number): string { + if (timeMs <= span.startMs) return ""; + if (timeMs >= spanEnd(span)) return text; + const ratio = (timeMs - span.startMs) / Math.max(span.durationMs, 1); + const cut = Math.max(1, Math.floor(text.length * ratio)); + const space = text.lastIndexOf(" ", cut); + return text.slice(0, space > 0 ? space : cut); +} + +export function nextBoundary( + spans: readonly Span[], + timeMs: number, +): number | undefined { + const starts = spans + .map((span) => span.startMs) + .filter((start) => start > timeMs + 1) + .sort((a, b) => a - b); + return starts[0]; +} + +export function previousBoundary( + spans: readonly Span[], + timeMs: number, +): number | undefined { + const starts = spans + .map((span) => span.startMs) + .filter((start) => start < timeMs - 1) + .sort((a, b) => a - b); + return starts[starts.length - 1]; +} diff --git a/src/ui/observability/session-table.tsx b/src/ui/observability/session-table.tsx new file mode 100644 index 00000000..f3d90a75 --- /dev/null +++ b/src/ui/observability/session-table.tsx @@ -0,0 +1,111 @@ +import { cn } from "../../lib/utils.js"; +import { Badge } from "../badge.js"; +import { formatCost, formatDuration, formatTokens } from "./format.js"; +import type { SessionSummary, SpanStatus, TraceKind } from "./model.js"; + +export type SessionTableProps = { + readonly sessions: readonly SessionSummary[]; + readonly selectedId?: string; + readonly onSelect: (id: string) => void; + readonly className?: string; +}; + +const STATUS_TONE = { + running: "info", + ok: "success", + error: "danger", + waiting: "warning", + denied: "danger", +} as const; + +const STATUS_LABEL: Record = { + running: "Running", + ok: "Done", + error: "Failed", + waiting: "Waiting", + denied: "Denied", +}; + +const KIND_LABEL: Record = { + agent: "Agent", + workflow: "Workflow", +}; + +export function SessionTable({ + sessions, + selectedId, + onSelect, + className, +}: SessionTableProps) { + return ( +
+ + + + + + + + + + + + + + + + {sessions.map((session) => { + const selected = session.id === selectedId; + return ( + + + + + + + + + + + + ); + })} + +
SessionWhoModelWhenDurationTokensCostStepsStatus
+ + {session.actor} + {session.model} + + {session.startedAt} + + {formatDuration(session.durationMs)} + + {formatTokens(session.tokens)} + + {formatCost(session.costUsd)} + + {session.steps} + + + {STATUS_LABEL[session.status]} + +
+
+ ); +} diff --git a/src/ui/observability/slots.tsx b/src/ui/observability/slots.tsx new file mode 100644 index 00000000..23f7edbe --- /dev/null +++ b/src/ui/observability/slots.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { cn } from "../../lib/utils.js"; +import { StatGrid, StatGridItem } from "../stat-grid.js"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../table.js"; +import { Badge } from "../badge.js"; +import { + formatCost, + formatDuration, + formatSpeed, + formatTokens, +} from "./format.js"; +import { + findings, + measures, + modelShares, + rollup, + shapeOf, + stepKeys, + usageOf, + type Finding, + type MeasureSort, + type UsageBreakdown, +} from "./metrics.js"; +import type { Audience, Trace } from "./model.js"; +import { phrase, stepTitle, type Wording } from "./wording.js"; + +export function AudienceBand({ + audience, + trace, + peers = [], + onOpen, + wording, +}: { + readonly audience: Audience; + readonly trace: Trace; + readonly peers?: readonly Trace[]; + readonly onOpen?: (id: string) => void; + readonly wording: Wording; +}) { + if (audience === "operator") return null; + const totals = rollup(trace); + return ( +
+ {audience === "cfo" ? : null} + {audience === "coo" ? : null} + {audience === "ciso" ? : null} +
+ ); +} + +function FinanceBand({ + trace, + totals, + peers, + onOpen, +}: { + trace: Trace; + totals: ReturnType; + peers: readonly Trace[]; + onOpen?: ((id: string) => void) | undefined; +}) { + const perThousand = totals.tokens === 0 ? 0 : totals.costUsd / (totals.tokens / 1000); + const shares = modelShares(trace); + return ( + <> + + + + + + +
+ ({ + key: `${share.provider}/${share.model}`, + label: `${share.provider} / ${share.model}`, + value: share.costUsd, + detail: formatTokens(share.tokens), + }))} + /> +
+ + +
+
+ {peers.length < 2 ? null : } + + ); +} + +function OperationsBand({ + trace, + totals, + wording, +}: { + trace: Trace; + totals: ReturnType; + wording: Wording; +}) { + return ( + <> + + + + 0} + /> + + + + + ); +} + +function SecurityBand({ + trace, + totals, + wording, +}: { + trace: Trace; + totals: ReturnType; + wording: Wording; +}) { + const items = findings(trace); + return ( + <> +
+ + + 0} /> + 0} /> + + 0} + /> + +
+
+

+ {phrase(wording, "What left the grant", "What was blocked or left the network")} +

+ {items.length === 0 ? ( +

+ {phrase( + wording, + "No denies, blocks, or external calls.", + "Nothing was blocked, and nothing left the network.", + )} +

+ ) : ( +
    + {items.map((item) => ( +
  • + + {item.tone === "danger" + ? phrase(wording, "Deny", "Blocked") + : phrase(wording, "Watch", "Check")} + + {findingTitle(trace, item, wording)} + {item.detail} +
  • + ))} +
+ )} +
+ + ); +} + +function Identity({ trace, wording }: { trace: Trace; wording: Wording }) { + const principal = trace.principal; + const tenant = trace.tenant; + return ( +
+

Who ran it

+ {principal === undefined ? ( +

+ {phrase(wording, "No principal on this run.", "No person recorded for this run.")} +

+ ) : ( +
+
{phrase(wording, "Principal", "Person")}
+
{principal.name}
+
Id
+
{principal.id}
+
Kind
+
{principal.kind}
+
Key
+
{principal.keyId}
+
{phrase(wording, "Tenant", "Workspace")}
+
{tenant === undefined ? "—" : `${tenant.name} · ${tenant.id}`}
+
+ )} +
+ ); +} + +function findingTitle(trace: Trace, item: Finding, wording: Wording): string { + const span = trace.spans.find((candidate) => item.id.startsWith(`${candidate.id}:`)); + if (span === undefined) return item.title; + if ( + item.id === `${span.id}:deny` || + item.id === `${span.id}:block` || + item.id === `${span.id}:sig` + ) { + return stepTitle(span, wording); + } + return item.title; +} + +function ProviderNotes({ shares }: { shares: ReturnType }) { + if (shares.length === 0) return null; + return ( +
+

Model notes

+
    + {shares.map((share) => ( +
  • +

    + {share.provider} / {share.model} +

    + {share.quirks.length === 0 ? ( +

    No recorded quirks.

    + ) : ( +

    {share.quirks.join(" · ")}

    + )} +
  • + ))} +
+
+ ); +} + +export function Compare({ + peers, + currentId, + onOpen, +}: { + readonly peers: readonly Trace[]; + readonly currentId: string; + readonly onOpen?: ((id: string) => void) | undefined; +}) { + return ( +
+

Compare runs

+ + + + Run + Cost + Tokens + Duration + Speed + Denied + + + + {peers.map((peer) => { + const totals = rollup(peer); + const current = peer.id === currentId; + return ( + + + {onOpen === undefined ? ( + peer.name + ) : ( + + )} + + {formatCost(totals.costUsd)} + {formatTokens(totals.tokens)} + {formatDuration(totals.durationMs)} + {formatSpeed(totals.tokensPerSecond)} + {totals.denied} + + ); + })} + +
+
+ ); +} + +export function Ledger({ + trace, + sort, + selectedId, + onSelect, + wording, +}: { + readonly trace: Trace; + readonly sort: MeasureSort; + readonly selectedId?: string; + readonly onSelect?: (id: string) => void; + readonly wording: Wording; +}) { + const rows = measures(trace, sort); + return ( + + + + {wording === "precise" ? Key : null} + Step + Provider + Tokens + Cost + Time + Speed + + + + {rows.map((row) => { + const span = trace.spans.find((item) => item.id === row.id); + const title = span === undefined ? row.name : stepTitle(span, wording); + return ( + + {wording === "precise" ? {row.key} : null} + + {onSelect === undefined ? ( + title + ) : ( + + )} + + + {row.provider === "—" ? "—" : `${row.provider}/${row.model}`} + + + {row.tokens === 0 ? "—" : formatTokens(row.tokens)} + + {row.costUsd === 0 ? "—" : formatCost(row.costUsd)} + {formatDuration(row.durationMs)} + {formatSpeed(row.tokensPerSecond)} + + ); + })} + +
+ ); +} + +export function CostPicture({ trace, wording }: { readonly trace: Trace; readonly wording: Wording }) { + const priced = measures(trace, "cost").filter((row) => row.costUsd > 0); + return ( + { + const span = trace.spans.find((item) => item.id === row.id); + return { + key: row.id, + label: span === undefined ? row.name : stepTitle(span, wording), + value: row.costUsd, + detail: formatTokens(row.tokens), + }; + })} + /> + ); +} + +const USAGE_PARTS = [ + ["input", "Input"], + ["output", "Output"], + ["cacheRead", "Cache read"], + ["cacheWrite", "Cache write"], + ["thinking", "Thinking"], +] as const; + +const USAGE_BAR = { + input: "bg-chart-1", + output: "bg-chart-2", + cacheRead: "bg-chart-4", + cacheWrite: "bg-chart-3", + thinking: "bg-foreground/55", +} as const; + +export function UsageMix({ usage }: { readonly usage: UsageBreakdown }) { + const max = Math.max( + 1, + usage.input, + usage.output, + usage.cacheRead, + usage.cacheWrite, + usage.thinking, + ); + return ( +
+

Usage

+

+ Cache read is a slice of input, not an extra charge on top of it. +

+
    + {USAGE_PARTS.map(([key, label]) => { + const value = usage[key]; + return ( +
  • + {label} + + + + {value === 0 ? "—" : formatTokens(value)} +
  • + ); + })} +
+
+ ); +} + +export function Shape({ trace, wording }: { readonly trace: Trace; readonly wording: Wording }) { + const nodes = shapeOf(trace); + const keys = stepKeys(trace.spans); + return ( +
+
+

Shape

+

+ Steps that share a name collapse into one node. The count is how many times it ran. +

+
+ + + + Node + Count + Time + Tokens + Cost + + + + {nodes.map((node) => ( + + + {titlesFor(trace, keys, node.key, wording)} + {wording === "precise" ? ( + {node.key} + ) : null} + + {node.count} + {formatDuration(node.durationMs)} + {node.tokens === 0 ? "—" : formatTokens(node.tokens)} + {node.costUsd === 0 ? "—" : formatCost(node.costUsd)} + + ))} + +
+
+ ); +} + +function titlesFor( + trace: Trace, + keys: ReadonlyMap, + nodeKey: string, + wording: Wording, +): string { + const titles: string[] = []; + for (const span of trace.spans) { + const full = keys.get(span.id) ?? span.id; + if (full.replace(/\.\d+$/, "") !== nodeKey) continue; + const title = stepTitle(span, wording); + if (!titles.includes(title)) titles.push(title); + } + return titles.join(" · "); +} + +function CostBars({ + title, + description, + rows, +}: { + title: string; + description: string; + rows: readonly { key: string; label: string; value: number; detail: string }[]; +}) { + const max = Math.max(0.0001, ...rows.map((row) => row.value)); + return ( +
+
+

{title}

+

{description}

+
+
    + {rows.map((row) => ( +
  • +
    +

    {row.label}

    +
    +
    +
    +
    +

    + {formatCost(row.value)} + {row.detail} +

    +
  • + ))} +
+
+ ); +} diff --git a/src/ui/observability/timeline.tsx b/src/ui/observability/timeline.tsx new file mode 100644 index 00000000..bcd04bd1 --- /dev/null +++ b/src/ui/observability/timeline.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { usePrefersReducedMotion } from "../../hooks/use-prefers-reduced-motion.js"; +import { cn } from "../../lib/utils.js"; +import { Button } from "../button.js"; +import { formatClock } from "./format.js"; +import type { Span, SpanKind } from "./model.js"; +import { nextBoundary, previousBoundary, SCAN_MS } from "./playback.js"; +import { stepTitle, type Wording } from "./wording.js"; + +export type TraceTimelineProps = { + readonly spans: readonly Span[]; + readonly endMs: number; + readonly timeMs: number; + readonly playing: boolean; + readonly wording: Wording; + readonly onTimeChange: (timeMs: number) => void; + readonly onPlayingChange: (playing: boolean) => void; + readonly className?: string; +}; + +const KIND_MARK: Record = { + turn: "bg-foreground/70", + reasoning: "bg-foreground/40", + tool: "bg-foreground/80", + permission: "bg-destructive", + subagent: "bg-foreground/70", + compact: "bg-chart-3", + transform: "bg-chart-4", + nudge: "bg-foreground/60", + error: "bg-destructive", + event: "bg-foreground/40", + step: "bg-foreground/50", + gate: "bg-primary", + mail: "bg-chart-2", + provision: "bg-chart-4", + fork: "bg-chart-1", + reactor: "bg-chart-3", + network: "bg-chart-1", + attachment: "bg-chart-2", + citation: "bg-foreground/40", +}; + +export function TraceTimeline({ + spans, + endMs, + timeMs, + playing, + wording, + onTimeChange, + onPlayingChange, + className, +}: TraceTimelineProps) { + const reduce = usePrefersReducedMotion(); + const timeRef = useRef(timeMs); + const spansRef = useRef(spans); + timeRef.current = timeMs; + spansRef.current = spans; + const end = Math.max(endMs, 1); + + useEffect(() => { + if (!playing) return; + if (reduce) { + const id = window.setInterval(() => { + const next = nextBoundary(spansRef.current, timeRef.current); + if (next === undefined) { + onTimeChange(end); + onPlayingChange(false); + return; + } + onTimeChange(next); + }, 700); + return () => window.clearInterval(id); + } + + let frame = 0; + let last = performance.now(); + const tick = (now: number) => { + const dt = now - last; + last = now; + const next = timeRef.current + (dt / SCAN_MS) * end; + if (next >= end) { + onTimeChange(end); + onPlayingChange(false); + return; + } + onTimeChange(next); + frame = window.requestAnimationFrame(tick); + }; + frame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frame); + }, [playing, reduce, end, onTimeChange, onPlayingChange]); + + function step(direction: 1 | -1) { + onPlayingChange(false); + const boundary = + direction === 1 + ? nextBoundary(spans, timeMs) + : previousBoundary(spans, timeMs); + onTimeChange(boundary ?? (direction === 1 ? end : 0)); + } + + return ( +
+
+ + + + + {formatClock(timeMs)} + / {formatClock(end)} + +
+ + +
+ ); +} diff --git a/src/ui/observability/trace-view.tsx b/src/ui/observability/trace-view.tsx new file mode 100644 index 00000000..54f10f64 --- /dev/null +++ b/src/ui/observability/trace-view.tsx @@ -0,0 +1,1035 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { useControllableState } from "../../hooks/use-controllable-state.js"; +import { cn } from "../../lib/utils.js"; +import { Badge } from "../badge.js"; +import { InspectorHeader, InspectorKv, InspectorShell } from "../inspector-shell.js"; +import { + formatBytes, + formatClock, + formatCost, + formatDuration, + formatSpeed, + formatTokens, + spanTokens, +} from "./format.js"; +import { + AUDIENCE_REPRESENTATION, + DEFAULT_LAYERS, + type AgentBrief, + type Audience, + type Compaction, + type ContextChange, + type DirectorNudge, + type GrantUse, + type MediaItem, + type NetworkHop, + type SignatureStatus, + type Span, + type SpanStatus, + type Trace, + type TraceLayers, + type TraceRepresentation, +} from "./model.js"; +import { usageOfSpan, type UsageBreakdown } from "./metrics.js"; +import { AudienceBand, CostPicture, Ledger, Shape, UsageMix } from "./slots.js"; +import { + deepestAt, + grantAt, + revealedText, + spanEnd, + visibleSpans, +} from "./playback.js"; +import { TraceTimeline } from "./timeline.js"; +import { placeSpans, traceEnd } from "./tree.js"; +import { + DEFAULT_FOCUS, + effectLabel, + FOCUS_KEYS, + FOCUS_LABEL, + compactionLine, + kindLabel, + originLabel, + phrase, + placementStatus, + rowMarker, + stepTitle, + type TraceFocus, + type Wording, +} from "./wording.js"; + +export type TraceViewProps = { + readonly trace: Trace; + readonly selectedId?: string; + readonly defaultSelectedId?: string; + readonly onSelect?: (id: string) => void; + readonly timeMs?: number; + readonly defaultTimeMs?: number; + readonly onTimeChange?: (timeMs: number) => void; + readonly playing?: boolean; + readonly defaultPlaying?: boolean; + readonly onPlayingChange?: (playing: boolean) => void; + readonly layers?: TraceLayers; + readonly onLayersChange?: (layers: TraceLayers) => void; + readonly audience?: Audience; + readonly defaultAudience?: Audience; + readonly onAudienceChange?: (audience: Audience) => void; + readonly representation?: TraceRepresentation; + readonly onRepresentationChange?: (representation: TraceRepresentation) => void; + readonly peers?: readonly Trace[]; + readonly onOpen?: (id: string) => void; + readonly wording?: Wording; + readonly onWordingChange?: (wording: Wording) => void; + readonly className?: string; +}; + +const STATUS_LABEL: Record = { + running: "Running", + ok: "Done", + error: "Failed", + waiting: "Waiting", + denied: "Denied", +}; + +const STATUS_TONE = { + running: "info", + ok: "success", + error: "danger", + waiting: "warning", + denied: "danger", +} as const; + +const LAYERS = ["reasoning", "grants", "io", "media", "network", "raw"] as const; + +function layerLabel(key: (typeof LAYERS)[number], wording: Wording): string { + if (wording === "precise") { + if (key === "reasoning") return "Thought"; + if (key === "grants") return "Grants"; + if (key === "io") return "Input / output"; + if (key === "media") return "Media"; + if (key === "network") return "Network"; + return "Raw"; + } + if (key === "reasoning") return "Thinking"; + if (key === "grants") return "Rules"; + if (key === "io") return "In and out"; + if (key === "media") return "Files"; + if (key === "network") return "Internet"; + return "Raw record"; +} + +export function AgentTrace(props: TraceViewProps) { + return ; +} + +export function WorkflowTrace(props: TraceViewProps) { + return ; +} + +export function TraceView({ + trace, + selectedId: selectedProp, + defaultSelectedId, + onSelect, + timeMs: timeProp, + defaultTimeMs = 0, + onTimeChange, + playing: playingProp, + defaultPlaying = false, + onPlayingChange, + layers: layersProp, + onLayersChange, + audience: audienceProp, + defaultAudience = "operator", + onAudienceChange, + representation: representationProp, + onRepresentationChange, + peers, + onOpen, + wording: wordingProp, + onWordingChange, + className, +}: TraceViewProps) { + const placedAll = placeSpans(trace.spans); + const [selectedId, setSelectedId] = useControllableState({ + value: selectedProp, + defaultValue: defaultSelectedId ?? placedAll[0]?.span.id ?? "", + onChange: onSelect, + name: "selectedId", + }); + const [timeMs, setTimeMs] = useControllableState({ + value: timeProp, + defaultValue: defaultTimeMs, + onChange: onTimeChange, + name: "timeMs", + }); + const [playing, setPlaying] = useControllableState({ + value: playingProp, + defaultValue: defaultPlaying, + onChange: onPlayingChange, + name: "playing", + }); + const [layers, setLayers] = useControllableState({ + value: layersProp, + defaultValue: DEFAULT_LAYERS, + onChange: onLayersChange, + name: "layers", + }); + const [audience, setAudience] = useControllableState({ + value: audienceProp, + defaultValue: defaultAudience, + onChange: onAudienceChange, + name: "audience", + }); + const [wording, setWording] = useControllableState({ + value: wordingProp, + defaultValue: "plain" as Wording, + onChange: onWordingChange, + name: "wording", + }); + const [focus, setFocus] = useControllableState({ + value: undefined, + defaultValue: DEFAULT_FOCUS, + name: "focus", + }); + const [representation, setRepresentation] = useControllableState({ + value: representationProp, + defaultValue: AUDIENCE_REPRESENTATION[defaultAudience], + onChange: onRepresentationChange, + name: "representation", + }); + + const shown = visibleSpans(trace.spans, layers, focus); + const placed = placeSpans(shown); + const selected = trace.spans.find((span) => span.id === selectedId); + const end = Math.max(trace.durationMs, traceEnd(trace.spans), 1); + const liveGrant = layers.grants ? grantAt(trace.spans, timeMs) : undefined; + const index = placed.findIndex((row) => row.span.id === selectedId); + const rowRefs = useRef(new Map()); + + useEffect(() => { + if (!playing) return; + const active = deepestAt(visibleSpans(trace.spans, layers, focus), timeMs); + if (active !== undefined && active.id !== selectedId) setSelectedId(active.id); + }, [playing, trace.spans, layers, focus, timeMs, selectedId, setSelectedId]); + + useEffect(() => { + rowRefs.current.get(selectedId)?.scrollIntoView({ block: "nearest" }); + }, [selectedId]); + + function seek(id: string, at: number) { + setPlaying(false); + setSelectedId(id); + setTimeMs(at); + } + + function move(delta: number) { + const next = placed[index + delta]; + if (next !== undefined) seek(next.span.id, next.span.startMs); + } + + 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 ( +
+
+
+
+
+

{trace.name}

+ {trace.kind === "agent" ? "Agent" : "Workflow"} + {STATUS_LABEL[trace.status]} +
+

+ {trace.actor} · {trace.startedAt} +

+
+ {audience === "operator" ? ( +
+ + + + +
+ ) : null} +
+ {trace.placement === undefined ? null : ( +
+ + + + + + + +
+ )} +
+ {trace.brief === undefined ? null : } + +
+ { + setAudience(next); + if (representationProp === undefined) setRepresentation(AUDIENCE_REPRESENTATION[next]); + }} + /> + + +
+
+ {FOCUS_KEYS.map((key) => { + const on = focus[key]; + return ( + + ); + })} +
+ + + {representation === "timeline" ? ( +
+ +
+ {LAYERS.map((key) => { + const on = layers[key]; + return ( + + ); + })} +
+ +
+ ) : null} + +
+ {representation === "timeline" ? ( +
{ + if (event.key === "ArrowDown") { + event.preventDefault(); + move(1); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + move(-1); + } + }} + > +
+ Step + + 0 + {formatDuration(end)} + +
+ {placed.map(({ span, depth }) => { + const selectedRow = span.id === selectedId; + const future = span.startMs > timeMs; + const played = playedRatio(span, timeMs); + return ( +
+ + + ); + })} +
+ ) : ( +
+ {representation === "cost" ? : null} + {representation === "shape" ? : null} + {representation === "shape" ? null : ( + { + const span = trace.spans.find((item) => item.id === id); + if (span !== undefined) seek(span.id, span.startMs); + }} + /> + )} +
+ )} + + {kindLabel(selected.kind, wording)} + + {STATUS_LABEL[selected.status]} + + + } + title={stepTitle(selected, wording)} + description={`${formatClock(selected.startMs)} · ${formatDuration(selected.durationMs)}`} + /> + ) + } + {...(selected === undefined ? { empty:

Select a step.

} : {})} + > + {selected === undefined ? null : ( + + )} +
+
+
+ ); +} + +function playedRatio(span: Span, timeMs: number): number { + if (timeMs <= span.startMs) return 0; + if (timeMs >= spanEnd(span)) return 1; + return (timeMs - span.startMs) / Math.max(span.durationMs, 1); +} + +function barBox(span: Span, end: number): { left: string; width: string } { + const width = (Math.max(span.durationMs, 1) / end) * 100; + return { + left: `${(span.startMs / end) * 100}%`, + width: `max(4px, ${width}%)`, + }; +} + +function barClass(span: Span): string { + if (span.status === "error" || span.status === "denied") return "bg-destructive"; + if (span.status === "waiting" || span.status === "running") return "bg-primary"; + if (span.kind === "network") return "bg-chart-1"; + if (span.kind === "mail" || span.kind === "attachment") return "bg-chart-2"; + if (span.kind === "provision" || span.kind === "reactor") return "bg-chart-4"; + if (span.kind === "reasoning") return "bg-foreground/40"; + if (span.kind === "compact") return "bg-chart-3"; + if (span.kind === "transform") return "bg-chart-4"; + if (span.kind === "nudge") return "bg-foreground/60"; + return "bg-foreground/75"; +} + +function usageTotal(usage: UsageBreakdown): number { + return usage.input + usage.output + usage.cacheRead + usage.cacheWrite + usage.thinking; +} + +function ledgerSort( + audience: Audience, + representation: TraceRepresentation, +): "time" | "cost" | "duration" { + if (audience === "cfo" || representation === "cost") return "cost"; + if (audience === "coo") return "duration"; + return "time"; +} + +function ChipRow({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: readonly (readonly [T, string])[]; + onChange: (value: T) => void; +}) { + return ( +
+ {label} + {options.map(([id, text]) => ( + + ))} +
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function PlacementItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function LiveGrant({ span, wording }: { span: Span | undefined; wording: Wording }) { + if (span?.grant === undefined) { + return ( +

+ {phrase(wording, "No grant is resolved at the playhead.", "Nothing is asking for permission here.")} +

+ ); + } + const grant = span.grant; + const winner = grant.resolvedBy; + const stopped = grant.blocked ? phrase(wording, " · blocked", ", stopped") : ""; + const by = + winner === null + ? "" + : wording === "plain" + ? ` by ${originLabel(winner.origin, wording)}` + : ` · ${winner.origin} ${winner.resource} ${winner.action} · ${winner.id}`; + return ( +

+ {phrase(wording, "Grant now", "Permission now")} · + {stepTitle(span, wording)} · {effectLabel(grant.effect, wording)} + {stopped} + {by} +

+ ); +} + +function SpanDetail({ + traceSpans, + span, + timeMs, + layers, + playing, + wording, +}: { + traceSpans: readonly Span[]; + span: Span; + timeMs: number; + layers: TraceLayers; + playing: boolean; + wording: Wording; +}) { + const thoughts = thoughtSpans(traceSpans, span); + return ( +
+ {usageTotal(usageOfSpan(span)) > 0 ? : null} + 0 ? spanTokens(span) / (span.durationMs / 1000) : null, + ), + mono: true, + }, + ]), + ...(span.usage === undefined + ? [] + : [ + { + label: "Tokens", + value: formatTokens(spanTokens(span)), + mono: true, + }, + ]), + ...(span.signature === undefined + ? [] + : [ + { + label: "Signature", + value: , + }, + ]), + ...(span.attributes ?? []).map((attribute) => ({ + label: attribute.label, + value: attribute.value, + mono: true, + })), + ]} + /> + {span.nudge === undefined ? null : } + {span.compaction === undefined ? null : ( + + )} + {span.contextChange === undefined ? null : ( + + )} + {layers.grants && span.grant !== undefined ? ( + + ) : null} + {layers.reasoning + ? thoughts.map((thought) => ( + + )) + : null} + {layers.io && span.input !== undefined ? ( + + ) : null} + {layers.io && span.output !== undefined ? ( + + ) : null} + {layers.media && span.media !== undefined && span.media.length > 0 ? ( + + ) : null} + {layers.network && span.network !== undefined && span.network.length > 0 ? ( + + ) : null} + {layers.raw ? : null} +
+ ); +} + +function thoughtSpans(spans: readonly Span[], span: Span): readonly Span[] { + if (span.thinking !== undefined || span.redacted === true) return [span]; + return spans.filter( + (item) => + item.parentId === span.id && + item.kind === "reasoning" && + (item.thinking !== undefined || item.redacted === true), + ); +} + +function AgentBriefPanel({ brief, wording }: { brief: AgentBrief; wording: Wording }) { + return ( +
+
+

{phrase(wording, "System prompt", "Instructions")}

+
+          {brief.systemPrompt}
+        
+
+
+

{phrase(wording, "Tools", "What it can use")}

+
    + {brief.tools.map((tool) => ( +
  • + {tool.name} + {tool.description} +
  • + ))} +
+
+
+ ); +} + +function NudgeBlock({ nudge, wording }: { nudge: DirectorNudge; wording: Wording }) { + return ( +
+

{phrase(wording, "Director action", "Nudge")}

+

{nudge.because}

+ {wording === "precise" ? ( +

{nudge.action}

+ ) : null} +
+ ); +} + +function CompactionBlock({ + compaction, + wording, +}: { + compaction: Compaction; + wording: Wording; +}) { + return ( +
+
+

{phrase(wording, "Compaction", "What was shortened")}

+

+ {compaction.turnsBefore} turns down to {compaction.turnsAfter} + {` · ${compactionLine(compaction)}`} +

+

{compaction.detail}

+ {wording === "precise" ? ( +

+ {compaction.compactor} v{compaction.version} · {compaction.reason} +

+ ) : null} +
+ + + + + + + + + + {compaction.turns.map((turn) => ( + + + + + + ))} + +
{phrase(wording, "Turn", "Piece")}Tokens{phrase(wording, "Kept", "Stayed")}
+ {turn.title} + {turn.note === undefined ? null : ( + {turn.note} + )} + {formatTokens(turn.tokens)}{turn.kept ? phrase(wording, "Kept", "Stayed") : phrase(wording, "Dropped", "Cut")}
+
+ ); +} + +function ContextBlock({ change, wording }: { change: ContextChange; wording: Wording }) { + return ( +
+

{phrase(wording, "Context transform", "Prompt change")}

+

{change.summary}

+ {wording === "precise" ? ( +

+ {change.strategy} v{change.version} · {change.trigger} · {change.reason} +

+ ) : null} +
+ ); +} + +function ThoughtBlock({ + span, + timeMs, + playing, + wording, +}: { + span: Span; + timeMs: number; + playing: boolean; + wording: Wording; +}) { + const started = timeMs > span.startMs; + const done = timeMs >= spanEnd(span); + const streaming = playing && started && !done; + return ( +
+

{phrase(wording, "Chain of thought", "Thinking")}

+ {span.redacted === true || span.thinking === undefined ? ( +

Redacted by the model.

+ ) : playing && !started ? ( +

+ Starts at {formatClock(span.startMs)}. +

+ ) : ( +

+ {streaming ? revealedText(span.thinking, span, timeMs) : span.thinking} + {streaming ? ( + + ) : null} +

+ )} +
+ ); +} + +function GrantBlock({ grant, wording }: { grant: GrantUse; wording: Wording }) { + return ( +
+
+

+ {phrase(wording, "Grant at this step", "Permission at this step")} +

+ + {grant.blocked ? {phrase(wording, "Blocked", "Stopped")} : null} +
+ {grant.blockReason === undefined ? null : ( +

{grant.blockReason}

+ )} + {grant.resolvedBy === null ? ( +

+ {phrase(wording, "Nothing matched. Failed closed.", "No rule matched, so this was refused.")} +

+ ) : ( + + )} + {grant.matching.length === 0 ? null : ( +
    + {grant.matching.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +function GrantLine({ + label, + grant, + wording, +}: { + label: string; + grant: NonNullable; + wording: Wording; +}) { + const exact = + grant.specificity === undefined || wording === "plain" ? "" : ` · specificity ${grant.specificity}`; + return ( +

+ {label} + {wording === "plain" ? ( + <> + {effectLabel(grant.effect, wording)} by {originLabel(grant.origin, wording)} + + ) : ( + <> + {grant.id} · {grant.effect} · {grant.origin} +
+ {grant.resource} {grant.action} + {exact} + + )} +

+ ); +} + +function EffectBadge({ effect, wording }: { effect: GrantUse["effect"]; wording: Wording }) { + const tone = + effect === "allow" ? "success" : effect === "ask" ? "warning" : effect === "none" ? "neutral" : "danger"; + return {effectLabel(effect, wording)}; +} + +function SignatureBadge({ status, wording }: { status: SignatureStatus; wording: Wording }) { + const tone = + status === "valid" ? "success" : status === "invalid" ? "danger" : "warning"; + const label = + wording === "precise" + ? status + : status === "valid" + ? "Checked" + : status === "invalid" + ? "Bad" + : status === "missing" + ? "Missing" + : "Unknown"; + return {label}; +} + +function TextBlock({ label, text }: { label: string; text: string }) { + return ( +
+

{label}

+
+        {text}
+      
+
+ ); +} + +function MediaBlock({ items }: { items: readonly MediaItem[] }) { + return ( +
+

Attached

+
    + {items.map((item) => ( +
  • + {item.mediaType.startsWith("image/") ? ( +
    + {item.mediaType} +
    + ) : null} +

    {item.name}

    +

    + {item.mediaType} · {formatBytes(item.bytes)} +

    + {item.note === undefined ? null : ( +

    {item.note}

    + )} +
  • + ))} +
+
+ ); +} + +function NetworkBlock({ hops }: { hops: readonly NetworkHop[] }) { + return ( +
+

Network

+
    + {hops.map((hop) => ( +
  • +

    + {hop.method} {hop.url} +

    +

    + {hop.status === null ? "no status" : hop.status} + {formatBytes(hop.bytes)} + {hop.external ? External : Internal} +

    +
  • + ))} +
+
+ ); +} + +function rawSpan(span: Span): string { + return JSON.stringify(span, null, 2); +} diff --git a/src/ui/observability/tree.ts b/src/ui/observability/tree.ts new file mode 100644 index 00000000..5eacb46c --- /dev/null +++ b/src/ui/observability/tree.ts @@ -0,0 +1,26 @@ +import type { Span } from "./model.js"; + +export type PlacedSpan = { + readonly span: Span; + readonly depth: number; +}; + +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 => { + if (span.parentId === null || guard > 16) return 0; + const parent = byId.get(span.parentId); + if (parent === undefined) return 0; + return 1 + depth(parent, guard + 1); + }; + return [...spans] + .sort((a, b) => a.startMs - b.startMs || a.name.localeCompare(b.name)) + .map((span) => ({ span, depth: depth(span) })); +} + +export function traceEnd(spans: readonly Span[]): number { + return spans.reduce( + (end, span) => Math.max(end, span.startMs + span.durationMs), + 0, + ); +} diff --git a/src/ui/observability/wording.ts b/src/ui/observability/wording.ts new file mode 100644 index 00000000..050ce201 --- /dev/null +++ b/src/ui/observability/wording.ts @@ -0,0 +1,267 @@ +import { formatTokens } from "./format.js"; +import type { Compaction, GrantEffect, GrantOrigin, Span, SpanKind } from "./model.js"; + +/** Plain hides the platform words. Precise keeps them. */ +export type Wording = "plain" | "precise"; + +export const FOCUS_KEYS = [ + "thinking", + "actions", + "approvals", + "messages", + "setup", + "internet", + "files", + "compaction", + "context", + "nudges", +] as const; + +export type FocusKey = (typeof FOCUS_KEYS)[number]; + +export type TraceFocus = Record; + +export const DEFAULT_FOCUS: TraceFocus = { + thinking: true, + actions: true, + approvals: true, + messages: true, + setup: true, + internet: true, + files: true, + compaction: true, + context: true, + nudges: true, +}; + +const FOCUS_KINDS: Record = { + thinking: ["reasoning", "citation"], + compaction: ["compact"], + context: ["transform"], + nudges: ["nudge"], + actions: ["tool"], + approvals: ["gate", "permission"], + messages: ["mail"], + setup: ["provision", "reactor", "fork", "event"], + internet: ["network"], + files: ["attachment"], +}; + +const KIND_PRECISE: Record = { + turn: "Turn", + reasoning: "Thought", + tool: "Tool", + permission: "Permission", + subagent: "Agent", + compact: "Compact", + error: "Error", + event: "Event", + step: "Step", + gate: "Gate", + mail: "Mail", + provision: "Provision", + fork: "Fork", + reactor: "Reactor", + network: "Network", + attachment: "Media", + citation: "Citation", + transform: "Transform", + nudge: "Director", +}; + +const KIND_PLAIN: Record = { + turn: "Reply", + reasoning: "Thinking", + tool: "Action", + permission: "Ask", + subagent: "Helper", + compact: "Shorten", + error: "Failed", + event: "Note", + step: "Step", + gate: "Approval", + mail: "Message", + provision: "Setup", + fork: "Split", + reactor: "Runtime", + network: "Internet", + attachment: "File", + citation: "Source", + transform: "Context", + nudge: "Nudge", +}; + +const NAME_PLAIN: Record = { + "Reactor started": "The run started", + "Allocate a sidecar": "Starting the machine", + "Approval requested": "Asked for approval", + "Inbound trigger": "A message started this run", + "Send the verdict": "Sent the result", + "Fork a test run": "Split off a test run", + "Context compacted": "Shortened the notes", + "Hidden reasoning": "Thinking was hidden", + "Session started": "Session started", +}; + +export const FOCUS_LABEL: Record> = { + plain: { + thinking: "Thinking", + actions: "Actions", + approvals: "Approvals", + messages: "Messages", + setup: "Setup", + internet: "Internet", + files: "Files", + compaction: "Shortening", + context: "Context", + nudges: "Nudges", + }, + precise: { + thinking: "Reasoning", + actions: "Tools", + approvals: "Gates", + messages: "Mail", + setup: "Provision", + internet: "Network", + files: "Media", + compaction: "Compaction", + context: "Transforms", + nudges: "Director", + }, +}; + +export function phrase(wording: Wording, precise: string, plain: string): string { + return wording === "plain" ? plain : precise; +} + +export function kindLabel(kind: SpanKind, wording: Wording): string { + return wording === "plain" ? KIND_PLAIN[kind] : KIND_PRECISE[kind]; +} + +export function focusKeyOf(kind: SpanKind): FocusKey | null { + for (const key of FOCUS_KEYS) { + if (FOCUS_KINDS[key].includes(kind)) return key; + } + return null; +} + +export function focusKeepsKind(kind: SpanKind, focus: TraceFocus): boolean { + const key = focusKeyOf(kind); + if (key === null) return true; + return focus[key]; +} + +export function focusKeeps(span: Span, focus: TraceFocus): boolean { + return focusKeepsKind(span.kind, focus); +} + +export function effectLabel(effect: GrantEffect | "none", wording: Wording): string { + if (wording === "precise") return effect; + if (effect === "allow") return "Allowed"; + if (effect === "deny") return "Denied"; + if (effect === "ask") return "Asked"; + return "None"; +} + +export function originLabel(origin: GrantOrigin, wording: Wording): string { + if (wording === "precise") return origin; + if (origin === "system") return "a built-in rule"; + if (origin === "role") return "their role"; + if (origin === "creator") return "the person who set it up"; + return "the person who started the run"; +} + +export function placementStatus(status: string, wording: Wording): string { + if (wording === "precise") return status; + if (status === "pending") return "waiting"; + if (status === "provisioning") return "starting"; + if (status === "allocated") return "ready"; + if (status === "replacing") return "swapping"; + if (status === "releasing") return "shutting down"; + if (status === "destroy_failed") return "could not shut down"; + if (status === "released") return "finished"; + if (status === "failed") return "failed"; + return status; +} + +/** Small label beside the row. Precise keeps the tool name when the title was rewritten. */ +export function rowMarker(span: Span, wording: Wording): string { + const title = stepTitle(span, wording); + if (title === span.name || wording === "plain") return kindLabel(span.kind, wording); + return span.name; +} + +/** The row title: what the step did, not only the tool that did it. */ +export function stepTitle(span: Span, wording: Wording): string { + const did = whatItDid(span); + if (did !== undefined) return did; + if (span.compaction !== undefined) { + return `${span.compaction.turnsBefore} turns down to ${span.compaction.turnsAfter}, ${compactionLine(span.compaction)}`; + } + if (span.nudge !== undefined) return span.nudge.because; + if (span.contextChange !== undefined) return span.contextChange.summary; + if (span.kind === "reasoning" && span.thinking !== undefined) { + const line = span.thinking.split("\n")[0] ?? span.thinking; + return line.length > 88 ? `${line.slice(0, 85)}…` : line; + } + if (wording === "plain") return NAME_PLAIN[span.name] ?? span.name; + return span.name; +} + +export function compactionLine(compaction: Compaction): string { + return `${formatTokens(compaction.tokensBefore)} down to ${formatTokens(compaction.tokensAfter)}`; +} + +function whatItDid(span: Span): string | undefined { + const hop = span.network?.[0]; + if (hop !== undefined) return `${hop.method} ${shortUrl(hop.url)}`; + const record = recordOf(span.input); + if (record === undefined) return undefined; + const command = text(record, "command") ?? text(record, "cmd"); + if (command !== undefined) return command; + const pattern = text(record, "pattern"); + const path = text(record, "path") ?? text(record, "file"); + if (pattern !== undefined && !span.name.includes(pattern)) { + return path === undefined ? `Find ${pattern}` : `Find ${pattern} in ${path}`; + } + if (path !== undefined && !span.name.includes(path)) return `${verb(span.name)} ${path}`; + const url = text(record, "url"); + if (url !== undefined) return shortUrl(url); + return undefined; +} + +function verb(name: string): string { + if (name === "write_file" || name === "write") return "Write"; + if (name === "read_file" || name === "read") return "Read"; + if (name.includes("_")) return name.replaceAll("_", " "); + return name; +} + +function shortUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.host}${parsed.pathname}`; + } catch { + return url; + } +} + +function recordOf(input: string | undefined): Record | undefined { + if (input === undefined || !input.startsWith("{")) return undefined; + try { + const value: unknown = JSON.parse(input); + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + const record: Record = {}; + for (const [key, item] of Object.entries(value)) record[key] = item; + return record; + } + } catch { + return undefined; + } + return undefined; +} + +function text(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/stories/observability/fixtures.ts b/stories/observability/fixtures.ts new file mode 100644 index 00000000..e6a33329 --- /dev/null +++ b/stories/observability/fixtures.ts @@ -0,0 +1,824 @@ +import type { + GrantRef, + GrantUse, + Placement, + PrincipalRef, + ProviderInfo, + SeriesPoint, + SessionSummary, + TenantRef, + Trace, +} from "../../src/ui/observability/model.js"; + +export const SERIES: readonly SeriesPoint[] = [ + { label: "Mon", value: 42 }, + { label: "Tue", value: 51 }, + { label: "Wed", value: 38 }, + { label: "Thu", value: 67 }, + { label: "Fri", value: 74 }, + { label: "Sat", value: 22 }, + { label: "Sun", value: 19 }, +]; + +export const FAILURES: readonly SeriesPoint[] = [ + { label: "Mon", value: 2 }, + { label: "Tue", value: 4 }, + { label: "Wed", value: 1 }, + { label: "Thu", value: 6 }, + { label: "Fri", value: 3 }, + { label: "Sat", value: 1 }, + { label: "Sun", value: 0 }, +]; + +export const SESSIONS: readonly SessionSummary[] = [ + { + id: "wf-review", + kind: "workflow", + name: "Review a pull request", + actor: "repo.review", + model: "several", + startedAt: "09:14:02", + durationMs: 186_000, + tokens: 13440, + costUsd: 0.11, + steps: 17, + status: "ok", + }, + { + id: "ag-editor", + kind: "agent", + name: "Edit the release notes", + actor: "editor", + model: "frontier", + startedAt: "09:21:40", + durationMs: 54_200, + tokens: 2900, + costUsd: 0.03, + steps: 12, + status: "error", + }, + { + id: "ag-shell", + kind: "agent", + name: "Investigate a failing check", + actor: "shell", + model: "frontier", + startedAt: "09:28:11", + durationMs: 31_000, + tokens: 1620, + costUsd: 0.02, + steps: 5, + status: "waiting", + }, +]; + +const payload = (value: unknown) => JSON.stringify(value, null, 2); + +const readGrant: GrantRef = { + id: "gr_read", + resource: "tool:fs", + action: "read", + effect: "allow", + origin: "role", + specificity: 18, +}; + +const shellAsk: GrantRef = { + id: "gr_shell_ask", + resource: "tool:shell", + action: "exec", + effect: "ask", + origin: "creator", + specificity: 14, +}; + +const shellDeny: GrantRef = { + id: "gr_shell_deny", + resource: "tool:shell", + action: "exec", + effect: "deny", + origin: "system", + specificity: 22, +}; + +const allowRead: GrantUse = { + effect: "allow", + blocked: false, + resolvedBy: readGrant, + matching: [readGrant], +}; + +const askShell: GrantUse = { + effect: "ask", + blocked: true, + blockReason: "The creator grant requires approval before a shell command runs.", + resolvedBy: shellAsk, + matching: [shellAsk, shellDeny], +}; + +const denyWrite: GrantUse = { + effect: "deny", + blocked: true, + blockReason: "The path is outside the workspace. No allow grant matched.", + resolvedBy: null, + matching: [ + { + id: "gr_write_workspace", + resource: "tool:fs", + action: "write", + effect: "allow", + origin: "role", + specificity: 11, + }, + ], +}; + +const placed: Placement = { + sidecarId: "sc_19", + host: "worker-19.region-a", + region: "region-a", + size: "2 vCPU · 4 GB", + packageName: "runtime", + packageVersion: "0.5.0", + generation: 4, + status: "allocated", +}; + +const provisioning: Placement = { + ...placed, + sidecarId: "sc_22", + host: "worker-22.region-a", + status: "provisioning", + generation: 1, +}; + +const tenant: TenantRef = { id: "tnt_north", name: "North" }; + +const frontierInfo: ProviderInfo = { + provider: "frontier", + model: "frontier", + region: "region-a", + quirks: [ + "Thinking can be redacted", + "Tool arguments arrive as fragments", + "Cached input is billed separately", + ], +}; + +const compactInfo: ProviderInfo = { + provider: "compact", + model: "compact", + region: "region-a", + quirks: ["No reasoning stream", "A refusal arrives as one message"], +}; + +const reviewPrincipal: PrincipalRef = { + id: "prn_review", + name: "repo.review", + kind: "workflow", + keyId: "key_review", +}; + +const editorPrincipal: PrincipalRef = { + id: "prn_editor", + name: "editor", + kind: "agent", + keyId: "key_editor", +}; + +const shellPrincipal: PrincipalRef = { + id: "prn_shell", + name: "shell", + kind: "agent", + keyId: "key_shell", +}; + +const THOUGHT = + "The assertion expects four tokens and the parser returns three. The third token is the trailing comma, which the old splitter dropped. I should read the test before changing the splitter, because the comment above the assertion says the comma is significant. If the shell grant is ask, I stop at the gate instead of running the suite myself."; + +export const TRACES: Record = { + "wf-review": { + id: "wf-review", + kind: "workflow", + name: "Review a pull request", + actor: "repo.review", + status: "ok", + startedAt: "09:14:02", + durationMs: 186_000, + placement: placed, + principal: reviewPrincipal, + tenant, + providers: [frontierInfo, compactInfo], + spans: [ + { + id: "mail-in", + parentId: null, + kind: "mail", + name: "Inbound trigger", + status: "ok", + startMs: 0, + durationMs: 1_800, + signature: "valid", + input: "From reviews@example.com\nSubject: please review 418", + output: "Accepted. Signature valid. One attachment.", + media: [ + { + name: "diff.patch", + mediaType: "text/x-diff", + bytes: 12_840, + note: "The patch that started the run.", + }, + ], + payload: payload({ + type: "message.queued", + signatureStatus: "valid", + mailbox: "repo.review", + }), + }, + { + id: "provision", + parentId: null, + kind: "provision", + name: "Allocate a sidecar", + status: "ok", + startMs: 1_800, + durationMs: 5_200, + output: "Allocated worker-19. Pending became allocated.", + attributes: [ + { label: "Host", value: placed.host }, + { label: "Size", value: placed.size }, + { label: "Package", value: "runtime@0.5.0" }, + { label: "Status", value: "pending → allocated" }, + ], + payload: payload({ + allocation: "allocated", + sidecarId: placed.sidecarId, + generation: placed.generation, + }), + }, + { + id: "reactor", + parentId: null, + kind: "reactor", + name: "Reactor started", + status: "ok", + startMs: 7_000, + durationMs: 1_200, + output: "Run is live on generation 4.", + }, + { + id: "step-read", + parentId: null, + kind: "step", + name: "Read the diff", + status: "ok", + startMs: 8_200, + durationMs: 21_800, + output: "14 files, +420 −88.", + }, + { + id: "tool-read", + parentId: "step-read", + kind: "tool", + name: "read_file", + status: "ok", + startMs: 9_000, + durationMs: 800, + grant: allowRead, + input: payload({ path: "src/parse.ts" }), + output: "export function parse(input: string) { … }", + }, + { + id: "net", + parentId: "step-read", + kind: "network", + name: "Fetch the package", + status: "ok", + startMs: 11_000, + durationMs: 3_000, + network: [ + { + method: "GET", + url: "https://registry.example/runtime-0.5.0.tgz", + status: 200, + bytes: 48_220, + external: true, + }, + ], + output: "Tarball matches runtime@0.5.0.", + }, + { + id: "step-review", + parentId: null, + kind: "step", + name: "Ask the reviewer", + status: "ok", + startMs: 30_000, + durationMs: 98_000, + }, + { + id: "turn", + parentId: "step-review", + kind: "turn", + name: "Review the change", + status: "ok", + startMs: 31_000, + durationMs: 59_000, + usage: { + provider: "frontier", + model: "frontier", + inputTokens: 8200, + outputTokens: 640, + cachedTokens: 6100, + cacheWriteTokens: 420, + thinkingTokens: 1800, + costUsd: 0.07, + }, + input: "Review the diff for correctness and missing tests.", + output: "The trailing comma is the missing token. I will not run the suite until the gate clears.", + }, + { + id: "thought", + parentId: "turn", + kind: "reasoning", + name: "Why the count is short", + status: "ok", + startMs: 32_000, + durationMs: 16_000, + thinking: THOUGHT, + }, + { + id: "tool-grep", + parentId: "turn", + kind: "tool", + name: "grep", + status: "ok", + startMs: 50_000, + durationMs: 1_200, + grant: allowRead, + input: payload({ pattern: "parse\\(", path: "src" }), + output: "12 matches.", + }, + { + id: "cite", + parentId: "turn", + kind: "citation", + name: "src/parse.ts:40", + status: "ok", + startMs: 52_000, + durationMs: 400, + output: "The assertion expects four tokens. The splitter drops a trailing comma.", + }, + { + id: "tool-shell", + parentId: "turn", + kind: "tool", + name: "shell", + status: "error", + startMs: 56_000, + durationMs: 12_000, + grant: { + effect: "allow", + blocked: false, + resolvedBy: { + id: "gr_shell_test", + resource: "tool:shell", + action: "exec", + effect: "allow", + origin: "invoker", + specificity: 16, + }, + matching: [shellAsk], + }, + input: payload({ command: "bun test src/parse.test.ts" }), + output: "1 failed. expected 4, received 3.", + }, + { + id: "fork", + parentId: "step-review", + kind: "fork", + name: "Fork a test run", + status: "ok", + startMs: 90_000, + durationMs: 36_000, + attributes: [{ label: "Mode", value: "child" }], + output: "Child finished. The same assertion failed.", + }, + { + id: "sub", + parentId: "fork", + kind: "subagent", + name: "Test the parser", + status: "ok", + startMs: 92_000, + durationMs: 32_000, + usage: { + provider: "compact", + model: "compact", + inputTokens: 4100, + outputTokens: 500, + costUsd: 0.04, + }, + }, + { + id: "gate", + parentId: null, + kind: "gate", + name: "Approve the comment", + status: "ok", + startMs: 128_000, + durationMs: 32_000, + input: "Post a review comment on the failing assertion.", + output: "Approved by the author.", + }, + { + id: "ask", + parentId: "gate", + kind: "reactor", + name: "Approval requested", + status: "ok", + startMs: 128_200, + durationMs: 20_000, + grant: askShell, + output: "Blocked until the author answered.", + }, + { + id: "mail-out", + parentId: null, + kind: "mail", + name: "Send the verdict", + status: "ok", + startMs: 160_000, + durationMs: 26_000, + signature: "valid", + output: "Verdict mailed to the run.", + media: [ + { + name: "review.md", + mediaType: "text/markdown", + bytes: 2_240, + }, + ], + payload: payload({ + type: "message.run.ended", + correlated: "mail-in", + }), + }, + ], + }, + "ag-editor": { + id: "ag-editor", + kind: "agent", + name: "Edit the release notes", + actor: "editor", + status: "error", + startedAt: "09:21:40", + durationMs: 54_200, + placement: placed, + principal: editorPrincipal, + tenant, + providers: [frontierInfo], + brief: { + systemPrompt: + "You edit release notes. Stay inside the workspace. When a write is refused, say so and stop. Do not invent a version number.", + tools: [ + { name: "read_file", description: "Read one file by its path." }, + { name: "write_file", description: "Replace one file. Refused outside the workspace." }, + { name: "grep", description: "Find a pattern under a path." }, + ], + }, + spans: [ + { + id: "e-prov", + parentId: null, + kind: "provision", + name: "Allocate a sidecar", + status: "ok", + startMs: 0, + durationMs: 4_000, + attributes: [ + { label: "Host", value: placed.host }, + { label: "Size", value: placed.size }, + { label: "Package", value: "runtime@0.5.0" }, + ], + }, + { + id: "e1", + parentId: null, + kind: "event", + name: "Session started", + status: "ok", + startMs: 4_000, + durationMs: 40, + payload: payload({ cwd: "/work/notes", host: placed.host }), + }, + { + id: "e-nudge-infer", + parentId: null, + kind: "nudge", + name: "infer", + status: "ok", + startMs: 4_100, + durationMs: 80, + nudge: { + action: "infer", + because: "A new message arrived, so the model was asked to answer.", + }, + }, + { + id: "e2", + parentId: null, + kind: "turn", + name: "Draft the notes", + status: "ok", + startMs: 4_200, + durationMs: 17_800, + usage: { + provider: "frontier", + model: "frontier", + inputTokens: 2100, + outputTokens: 800, + cachedTokens: 900, + cacheWriteTokens: 120, + thinkingTokens: 640, + costUsd: 0.03, + }, + input: "Write the notes for the parser fix.", + output: "I will open the existing notes and the diff.", + }, + { + id: "e3", + parentId: "e2", + kind: "reasoning", + name: "Decide what to mention", + status: "ok", + startMs: 4_500, + durationMs: 7_500, + thinking: + "Lead with the bug, then the test that caught it. The notes file may sit outside the workspace. If the write grant does not cover that path, stop and say so instead of retrying.", + }, + { + id: "e4", + parentId: "e2", + kind: "permission", + name: "Write notes.md", + status: "denied", + startMs: 13_000, + durationMs: 1_200, + grant: denyWrite, + input: payload({ path: "notes.md" }), + output: "Denied. The file is outside the workspace.", + }, + { + id: "e-media", + parentId: "e2", + kind: "attachment", + name: "Screenshot of the notes", + status: "ok", + startMs: 16_000, + durationMs: 600, + media: [ + { + name: "notes.png", + mediaType: "image/png", + bytes: 86_400, + note: "The page the agent was editing.", + }, + ], + }, + { + id: "e-nudge-compact", + parentId: null, + kind: "nudge", + name: "compact", + status: "ok", + startMs: 21_800, + durationMs: 120, + nudge: { + action: "compact", + because: "The notes were too long, so the director asked for a shorter history.", + }, + }, + { + id: "e5", + parentId: null, + kind: "compact", + name: "summarize", + status: "ok", + startMs: 22_000, + durationMs: 2_000, + compaction: { + compactor: "summarize", + version: "1", + reason: "input-budget", + detail: "The prompt crossed the input budget, so older turns were folded.", + turnsBefore: 6, + turnsAfter: 2, + tokensBefore: 4_200, + tokensAfter: 1_100, + turns: [ + { role: "user", title: "Write the notes for the parser fix.", tokens: 180, kept: true }, + { + role: "assistant", + title: "I will open the existing notes and the diff.", + tokens: 240, + kept: true, + }, + { + role: "tool", + title: "Write notes.md", + tokens: 90, + kept: false, + note: "Refused. Replaced with one line.", + }, + { + role: "tool", + title: "File listing of the workspace", + tokens: 2_600, + kept: false, + note: "Dropped. The listing was stale.", + }, + { + role: "assistant", + title: "Looking through the files.", + tokens: 700, + kept: false, + note: "Folded into the kept reply.", + }, + { + role: "user", + title: "Screenshot of the notes", + tokens: 390, + kept: false, + note: "The picture stays attached. The caption was cut.", + }, + ], + }, + }, + { + id: "e-xform", + parentId: null, + kind: "transform", + name: "clear-old-results", + status: "ok", + startMs: 24_200, + durationMs: 400, + contextChange: { + strategy: "clear-old-results", + version: "1", + trigger: "pre-inference", + reason: "stale-tool-result", + summary: "The prompt dropped the old file listing. The saved history still has it.", + }, + }, + { + id: "e-net", + parentId: null, + kind: "network", + name: "Look up the changelog style", + status: "ok", + startMs: 25_000, + durationMs: 3_000, + network: [ + { + method: "GET", + url: "https://docs.example/changelog", + status: 200, + bytes: 18_200, + external: true, + }, + ], + }, + { + id: "e6", + parentId: null, + kind: "tool", + name: "write_file", + status: "error", + startMs: 30_000, + durationMs: 24_200, + grant: { + effect: "allow", + blocked: false, + resolvedBy: { + id: "gr_write_workspace", + resource: "tool:fs", + action: "write", + effect: "allow", + origin: "role", + specificity: 11, + }, + matching: [ + { + id: "gr_write_workspace", + resource: "tool:fs", + action: "write", + effect: "allow", + origin: "role", + specificity: 11, + }, + ], + }, + input: payload({ path: "CHANGELOG.md" }), + output: "Disk full.", + payload: payload({ + hook: "PostToolUseFailure", + tool: "write_file", + error: "ENOSPC", + }), + }, + ], + }, + "ag-shell": { + id: "ag-shell", + kind: "agent", + name: "Investigate a failing check", + actor: "shell", + status: "waiting", + startedAt: "09:28:11", + durationMs: 31_000, + placement: provisioning, + principal: shellPrincipal, + tenant, + providers: [frontierInfo], + brief: { + systemPrompt: + "You investigate a failing check. Run the test only after a person approves the command. Report the assertion that failed.", + tools: [ + { name: "shell", description: "Run one command. A shell command waits for approval." }, + ], + }, + spans: [ + { + id: "h-prov", + parentId: null, + kind: "provision", + name: "Allocate a sidecar", + status: "running", + startMs: 0, + durationMs: 6_000, + output: "Still provisioning. No package has started.", + attributes: [ + { label: "Host", value: provisioning.host }, + { label: "Size", value: provisioning.size }, + { label: "Package", value: "runtime@0.5.0" }, + { label: "Status", value: "provisioning" }, + ], + }, + { + id: "h1", + parentId: null, + kind: "turn", + name: "Find the failure", + status: "ok", + startMs: 6_000, + durationMs: 12_000, + usage: { + provider: "frontier", + model: "frontier", + inputTokens: 1400, + outputTokens: 220, + thinkingTokens: 900, + costUsd: 0.02, + }, + input: "The check named parse failed. Find why.", + output: "The assertion expects 4 tokens and the parser returns 3.", + }, + { + id: "h-redacted", + parentId: "h1", + kind: "reasoning", + name: "Hidden reasoning", + status: "ok", + startMs: 7_000, + durationMs: 3_000, + redacted: true, + }, + { + id: "h-nudge", + parentId: "h1", + kind: "nudge", + name: "suspend", + status: "ok", + startMs: 17_800, + durationMs: 80, + nudge: { + action: "suspend", + because: "The shell command needs a person, so the run is waiting.", + }, + }, + { + id: "h2", + parentId: "h1", + kind: "tool", + name: "shell", + status: "waiting", + startMs: 18_000, + durationMs: 13_000, + grant: askShell, + input: payload({ command: "bun test src/parse.test.ts" }), + output: "Waiting on approval.", + }, + ], + }, +}; diff --git a/stories/observability/observability.stories.tsx b/stories/observability/observability.stories.tsx new file mode 100644 index 00000000..dd12bb23 --- /dev/null +++ b/stories/observability/observability.stories.tsx @@ -0,0 +1,78 @@ +import { ObservabilityApp } from "../../src/blocks/observability/app.js"; +import { AreaChart } from "../../src/ui/observability/area-chart.js"; +import { SessionTable } from "../../src/ui/observability/session-table.js"; +import { AgentTrace, WorkflowTrace } from "../../src/ui/observability/trace-view.js"; +import { FAILURES, SERIES, SESSIONS, TRACES } from "./fixtures.js"; + +export default { title: "Observability / Spike" }; + +export const App = () => ( + +); + +export const Area = () => ( +
+ +
+); + +export const Sessions = () => ( +
+ {}} /> +
+); + +function trace(id: string) { + const value = TRACES[id]; + if (value === undefined) throw new Error(`Missing fixture ${id}`); + return value; +} + +export const Agent = () => ( +
+ +
+); + +export const Workflow = () => ( +
+ +
+); + +export const Finance = () => ( +
+ +
+); + +export const Operations = () => ( +
+ +
+); + +export const Security = () => ( +
+ +
+); From 07e6de51b6be837c07b4dd98b741bab799c30dac Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 00:39:38 -0700 Subject: [PATCH 02/14] chore: drop restated comments on the trace types --- src/ui/observability/area-chart.tsx | 8 +------- src/ui/observability/model.ts | 6 ------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/ui/observability/area-chart.tsx b/src/ui/observability/area-chart.tsx index e42f09b1..72e4d4d4 100644 --- a/src/ui/observability/area-chart.tsx +++ b/src/ui/observability/area-chart.tsx @@ -26,13 +26,7 @@ const PAD_RIGHT = 12; const PAD_TOP = 12; const PAD_BOTTOM = 22; -/** - * One or two series over time, with a pointer crosshair. - * - * The fill is the line colour at low opacity, so it does not invent another - * series. Orange is not used: it is the action accent, and a passive series - * painted orange reads as a button. The table under the frame has the numbers. - */ +/** Two series over time. The fill is the line colour. Orange stays off the series. */ export function AreaChart({ title, description, diff --git a/src/ui/observability/model.ts b/src/ui/observability/model.ts index cbc30339..8806b2be 100644 --- a/src/ui/observability/model.ts +++ b/src/ui/observability/model.ts @@ -28,7 +28,6 @@ export type GrantEffect = "allow" | "deny" | "ask"; export type GrantOrigin = "system" | "role" | "creator" | "invoker"; -/** One grant that matched a tool call or a gate. Mirrors a resolved grant. */ export type GrantRef = { readonly id: string; readonly resource: string; @@ -80,7 +79,6 @@ export type Usage = { readonly costUsd?: number; }; -/** What a model does that changes the bill, the latency, or the audit. */ export type ProviderInfo = { readonly provider: string; readonly model: string; @@ -105,7 +103,6 @@ export type Attribute = { readonly value: string; }; -/** The instructions and tools the harness handed the model. */ export type AgentTool = { readonly name: string; readonly description: string; @@ -116,7 +113,6 @@ export type AgentBrief = { readonly tools: readonly AgentTool[]; }; -/** One turn the compactor kept or dropped. */ export type CompactionTurn = { readonly role: string; readonly title: string; @@ -125,7 +121,6 @@ export type CompactionTurn = { readonly note?: string; }; -/** A director `compact` action and the manifest record it wrote. */ export type Compaction = { readonly compactor: string; readonly version: string; @@ -157,7 +152,6 @@ export type NudgeAction = | "suspend" | "done"; -/** One action the director handed the reactor. */ export type DirectorNudge = { readonly action: NudgeAction; readonly because: string; From 27ad6d5919e764f18f83ee4db863ab1c4d238128 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 00:39:59 -0700 Subject: [PATCH 03/14] feat: quiet the trace controls and keep orange on play --- src/ui/observability/observations.tsx | 6 +-- src/ui/observability/timeline.tsx | 2 +- src/ui/observability/trace-view.tsx | 53 ++++++++++++++------------- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/ui/observability/observations.tsx b/src/ui/observability/observations.tsx index 1c36bb82..3eeb8b98 100644 --- a/src/ui/observability/observations.tsx +++ b/src/ui/observability/observations.tsx @@ -63,10 +63,10 @@ export function Observations({ traces, onOpen, wording = "plain" }: Observations aria-pressed={on} onClick={() => setFocus({ ...focus, [key]: !on })} className={cn( - "rounded-md border px-2 py-1 text-xs", + "rounded-full px-2 py-1 text-xs", on - ? "border-border bg-muted font-medium text-foreground" - : "border-transparent text-muted-foreground hover:bg-muted/70", + ? "font-medium text-foreground" + : "text-muted-foreground line-through decoration-border hover:text-foreground hover:no-underline", )} > {FOCUS_LABEL[wording][key]} diff --git a/src/ui/observability/timeline.tsx b/src/ui/observability/timeline.tsx index bcd04bd1..1d0690d4 100644 --- a/src/ui/observability/timeline.tsx +++ b/src/ui/observability/timeline.tsx @@ -33,7 +33,7 @@ const KIND_MARK: Record = { error: "bg-destructive", event: "bg-foreground/40", step: "bg-foreground/50", - gate: "bg-primary", + gate: "bg-foreground", mail: "bg-chart-2", provision: "bg-chart-4", fork: "bg-chart-1", diff --git a/src/ui/observability/trace-view.tsx b/src/ui/observability/trace-view.tsx index 54f10f64..1210a380 100644 --- a/src/ui/observability/trace-view.tsx +++ b/src/ui/observability/trace-view.tsx @@ -279,7 +279,7 @@ export function TraceView({ {trace.brief === undefined ? null : } -
+
setFocus({ ...focus, [key]: !on })} className={cn( - "rounded-md border px-2 py-1 text-xs", - on - ? "border-border bg-muted font-medium text-foreground" - : "border-transparent text-muted-foreground hover:bg-muted/70", + "rounded-full px-2 py-1 text-xs", + on ? "font-medium text-foreground" : "text-muted-foreground line-through decoration-border hover:text-foreground hover:no-underline", )} > {FOCUS_LABEL[wording][key]} @@ -365,10 +363,8 @@ export function TraceView({ aria-pressed={on} onClick={() => setLayers({ ...layers, [key]: !on })} className={cn( - "rounded-md border px-2 py-1 text-xs", - on - ? "border-border bg-muted font-medium text-foreground" - : "border-transparent text-muted-foreground hover:bg-muted/70", + "rounded-full px-2 py-1 text-xs", + on ? "font-medium text-foreground" : "text-muted-foreground line-through decoration-border hover:text-foreground hover:no-underline", )} > {layerLabel(key, wording)} @@ -562,24 +558,29 @@ function ChipRow({ onChange: (value: T) => void; }) { return ( -
+
{label} - {options.map(([id, text]) => ( - - ))} + {options.map(([id, text]) => { + const on = value === id; + return ( + + ); + })}
); } From e264fe880ced5664e7e9c339539987e3a3ecf6cf Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 00:40:22 -0700 Subject: [PATCH 04/14] feat: give the trace a calmer brief, ribbon, and step list --- src/ui/observability/timeline.tsx | 6 +++--- src/ui/observability/trace-view.tsx | 30 ++++++++++++++--------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/ui/observability/timeline.tsx b/src/ui/observability/timeline.tsx index 1d0690d4..412cc833 100644 --- a/src/ui/observability/timeline.tsx +++ b/src/ui/observability/timeline.tsx @@ -146,13 +146,13 @@ export function TraceTimeline({ style={{ accentColor: "var(--foreground)" }} /> - {trace.placement === undefined ? null : ( -
+
@@ -395,7 +395,7 @@ export function TraceView({ } }} > -
+
Step 0 @@ -410,8 +410,8 @@ export function TraceView({
@@ -428,7 +428,7 @@ export function TraceView({ style={{ paddingLeft: 8 + depth * 14 }} onClick={() => seek(span.id, span.startMs)} > - + {rowMarker(span, wording)} {stepTitle(span, wording)} @@ -596,7 +596,7 @@ function Metric({ label, value }: { label: string; value: string }) { function PlacementItem({ label, value }: { label: string; value: string }) { return ( -
+
{label}
{value}
@@ -747,20 +747,18 @@ function thoughtSpans(spans: readonly Span[], span: Span): readonly Span[] { function AgentBriefPanel({ brief, wording }: { brief: AgentBrief; wording: Wording }) { return ( -
-
+
+

{phrase(wording, "System prompt", "Instructions")}

-
-          {brief.systemPrompt}
-        
+

{brief.systemPrompt}

-
+

{phrase(wording, "Tools", "What it can use")}

-
    +
      {brief.tools.map((tool) => ( -
    • - {tool.name} - {tool.description} +
    • + {tool.name} + {tool.description}
    • ))}
    From 9dd459a7ca2705de4265e5eca60c0e9682771a5e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 00:41:00 -0700 Subject: [PATCH 05/14] feat: settle the observe nav and the overview rows --- src/blocks/observability/app.tsx | 62 ++++++++++++++++---------------- src/ui/observability/slots.tsx | 2 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/blocks/observability/app.tsx b/src/blocks/observability/app.tsx index 5dc1b608..af29011d 100644 --- a/src/blocks/observability/app.tsx +++ b/src/blocks/observability/app.tsx @@ -73,33 +73,33 @@ export function ObservabilityApp({ setScreen("observations")}> Observations -
    - Words -
    - {( - [ - ["plain", "Plain"], - ["precise", "Precise"], - ] as const - ).map(([id, label]) => ( - - ))} +
    +
    + Words +
    + {( + [ + ["plain", "Plain"], + ["precise", "Precise"], + ] as const + ).map(([id, label]) => ( + + ))} +
    -
    -
    @@ -281,9 +281,9 @@ function Overview({

    Cost by model

    -
      +
        {shares.map((share) => ( -
      • +
      • {share.provider} / {share.model} {formatCost(share.costUsd)} · {formatTokens(share.tokens)}
      • @@ -292,11 +292,11 @@ function Overview({

    Latency by run

    -
      +
        {sessions.map((session) => ( -
      • +
      • {session.name} - {formatDuration(session.durationMs)} · {formatCost(session.costUsd)} + {formatDuration(session.durationMs)} · {formatCost(session.costUsd)}
      • ))}
      diff --git a/src/ui/observability/slots.tsx b/src/ui/observability/slots.tsx index 23f7edbe..56d75761 100644 --- a/src/ui/observability/slots.tsx +++ b/src/ui/observability/slots.tsx @@ -422,7 +422,7 @@ export function UsageMix({ usage }: { readonly usage: UsageBreakdown }) { return (
    • {label} - + Date: Fri, 23 Oct 2026 00:46:35 -0700 Subject: [PATCH 06/14] feat: paint trace marks from the chart palette Tailwind has no bg-chart utility here, so usage bars and the ribbon were falling back to the empty track. Marks now use the chart series colours, the waterfall keeps a tint at rest, and the ribbon is the scrubber. --- src/ui/observability/slots.tsx | 16 ++-- src/ui/observability/timeline.tsx | 135 +++++++++++++++++----------- src/ui/observability/trace-view.tsx | 37 +++++--- 3 files changed, 112 insertions(+), 76 deletions(-) diff --git a/src/ui/observability/slots.tsx b/src/ui/observability/slots.tsx index 56d75761..6efe2f32 100644 --- a/src/ui/observability/slots.tsx +++ b/src/ui/observability/slots.tsx @@ -1,6 +1,6 @@ "use client"; -import { cn } from "../../lib/utils.js"; +import { seriesColor } from "../../lib/chart-palette.js"; import { StatGrid, StatGridItem } from "../stat-grid.js"; import { Table, @@ -394,11 +394,11 @@ const USAGE_PARTS = [ ] as const; const USAGE_BAR = { - input: "bg-chart-1", - output: "bg-chart-2", - cacheRead: "bg-chart-4", - cacheWrite: "bg-chart-3", - thinking: "bg-foreground/55", + input: seriesColor(0), + output: seriesColor(1), + cacheRead: seriesColor(3), + cacheWrite: seriesColor(2), + thinking: "color-mix(in oklab, var(--foreground) 55%, transparent)", } as const; export function UsageMix({ usage }: { readonly usage: UsageBreakdown }) { @@ -424,8 +424,8 @@ export function UsageMix({ usage }: { readonly usage: UsageBreakdown }) { {label} {value === 0 ? "—" : formatTokens(value)} diff --git a/src/ui/observability/timeline.tsx b/src/ui/observability/timeline.tsx index 412cc833..89f92e28 100644 --- a/src/ui/observability/timeline.tsx +++ b/src/ui/observability/timeline.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react"; import { usePrefersReducedMotion } from "../../hooks/use-prefers-reduced-motion.js"; +import { seriesColor } from "../../lib/chart-palette.js"; import { cn } from "../../lib/utils.js"; import { Button } from "../button.js"; import { formatClock } from "./format.js"; @@ -21,26 +22,44 @@ export type TraceTimelineProps = { readonly className?: string; }; +function ink(amount: number): string { + return `color-mix(in oklab, var(--foreground) ${amount}%, transparent)`; +} + +function seekAt( + node: HTMLDivElement, + clientX: number, + end: number, + onTimeChange: (timeMs: number) => void, + onPlayingChange: (playing: boolean) => void, +): void { + const rect = node.getBoundingClientRect(); + if (rect.width <= 0) return; + const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + onPlayingChange(false); + onTimeChange(Math.round(ratio * end)); +} + const KIND_MARK: Record = { - turn: "bg-foreground/70", - reasoning: "bg-foreground/40", - tool: "bg-foreground/80", - permission: "bg-destructive", - subagent: "bg-foreground/70", - compact: "bg-chart-3", - transform: "bg-chart-4", - nudge: "bg-foreground/60", - error: "bg-destructive", - event: "bg-foreground/40", - step: "bg-foreground/50", - gate: "bg-foreground", - mail: "bg-chart-2", - provision: "bg-chart-4", - fork: "bg-chart-1", - reactor: "bg-chart-3", - network: "bg-chart-1", - attachment: "bg-chart-2", - citation: "bg-foreground/40", + turn: ink(70), + reasoning: ink(40), + tool: ink(80), + permission: "var(--destructive)", + subagent: ink(70), + compact: seriesColor(2), + transform: seriesColor(3), + nudge: ink(60), + error: "var(--destructive)", + event: ink(40), + step: ink(50), + gate: "var(--foreground)", + mail: seriesColor(1), + provision: seriesColor(3), + fork: seriesColor(0), + reactor: seriesColor(2), + network: seriesColor(0), + attachment: seriesColor(1), + citation: ink(40), }; export function TraceTimeline({ @@ -127,45 +146,53 @@ export function TraceTimeline({ / {formatClock(end)}
    -
@@ -161,11 +171,7 @@ export function ObservabilityApp({ { - setOpenId(traceId); - setFocusSpan(spanId); - setScreen("trace"); - }} + onOpen={(traceId, spanId) => openTrace(traceId, spanId)} /> ) : null} {screen === "trace" ? ( @@ -187,7 +193,7 @@ export function ObservabilityApp({ key={`${trace.id}:${focusSpan ?? ""}`} trace={trace} peers={Object.values(traces)} - onOpen={setOpenId} + onOpen={openTrace} wording={wording} onWordingChange={setWording} {...(focusSpan === undefined ? {} : { defaultSelectedId: focusSpan })} @@ -198,7 +204,7 @@ export function ObservabilityApp({ key={`${trace.id}:${focusSpan ?? ""}`} trace={trace} peers={Object.values(traces)} - onOpen={setOpenId} + onOpen={openTrace} wording={wording} onWordingChange={setWording} {...(focusSpan === undefined ? {} : { defaultSelectedId: focusSpan })} @@ -213,8 +219,13 @@ export function ObservabilityApp({ setAdding(false)} - onMint={(name: TraceKind) => { - setToken(`obs_${name}_${Math.random().toString(36).slice(2, 10)}`); + onMint={(name) => { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + setToken(`obs_${slug}_${Math.random().toString(36).slice(2, 10)}`); }} /> ) : null} @@ -338,36 +349,42 @@ function AddAgent({ }: { token: string | null; onClose: () => void; - onMint: (kind: TraceKind) => void; + onMint: (name: string) => void; }) { const [name, setName] = useState("editor"); + const label = name.trim(); return ( -
-
-

- Add an agent -

-

- A token is shown once. The machine that runs the agent keeps it. -

- - {token === null ? null : ( -

{token}

- )} -
+ { + if (!open) onClose(); + }} + > + + + Add an agent + + A token is shown once. The machine that runs the agent keeps it. + + + + + {token === null ? null : ( +

{token}

+ )} +
+ - -
-
-
+ + + + ); } diff --git a/src/ui/observability/format.ts b/src/ui/observability/format.ts index a46539f1..62d6a29f 100644 --- a/src/ui/observability/format.ts +++ b/src/ui/observability/format.ts @@ -1,8 +1,10 @@ export function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)} ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`; - const minutes = Math.floor(ms / 60_000); - const seconds = Math.round((ms % 60_000) / 1000); + if (ms < 10_000) return `${(ms / 1000).toFixed(1)} s`; + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes === 0) return `${seconds} s`; if (seconds === 0) return `${minutes}m`; return `${minutes}m ${seconds}s`; } @@ -14,7 +16,7 @@ export function formatClock(ms: number): string { export function formatTokens(value: number): string { if (value < 1000) return String(value); - if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`; + if (value < 999_500) return `${(value / 1000).toFixed(1)}k`; return `${(value / 1_000_000).toFixed(2)}M`; } diff --git a/src/ui/observability/metrics.ts b/src/ui/observability/metrics.ts index 4566d44c..bfaa207d 100644 --- a/src/ui/observability/metrics.ts +++ b/src/ui/observability/metrics.ts @@ -121,14 +121,20 @@ export function measures(trace: Trace, sort: MeasureSort = "time"): readonly Ste startMs: span.startMs, }; }); + const kept = (row: StepMeasure) => + row.costUsd > 0 || + row.tokens > 0 || + row.status === "error" || + row.status === "denied" || + row.status === "waiting"; if (sort === "cost") { return rows - .filter((row) => row.costUsd > 0 || row.tokens > 0) + .filter(kept) .sort((a, b) => b.costUsd - a.costUsd || a.startMs - b.startMs); } if (sort === "duration") { return rows - .filter((row) => row.durationMs >= 1000 || row.tokens > 0) + .filter((row) => row.durationMs >= 1000 || kept(row)) .sort((a, b) => b.durationMs - a.durationMs || a.startMs - b.startMs); } return rows; @@ -227,11 +233,9 @@ export function usageOfSpan(span: Span): UsageBreakdown { /** Same step name collapsed, with a count. Loops show up as one node. */ export function shapeOf(trace: Trace): readonly ShapeNode[] { - const keys = stepKeys(trace.spans); const grouped = new Map(); for (const span of trace.spans) { - const full = keys.get(span.id) ?? span.id; - const key = full.replace(/\.\d+$/, ""); + const key = `${span.kind}:${span.name}`; const current = grouped.get(key) ?? { key, name: span.name, diff --git a/src/ui/observability/playback.ts b/src/ui/observability/playback.ts index 2be25abb..a77e190d 100644 --- a/src/ui/observability/playback.ts +++ b/src/ui/observability/playback.ts @@ -72,7 +72,7 @@ export function nextBoundary( ): number | undefined { const starts = spans .map((span) => span.startMs) - .filter((start) => start > timeMs + 1) + .filter((start) => start > timeMs) .sort((a, b) => a - b); return starts[0]; } @@ -83,7 +83,7 @@ export function previousBoundary( ): number | undefined { const starts = spans .map((span) => span.startMs) - .filter((start) => start < timeMs - 1) + .filter((start) => start < timeMs) .sort((a, b) => a - b); return starts[starts.length - 1]; } diff --git a/src/ui/observability/trace-view.tsx b/src/ui/observability/trace-view.tsx index 0caf0b32..cda6c490 100644 --- a/src/ui/observability/trace-view.tsx +++ b/src/ui/observability/trace-view.tsx @@ -197,7 +197,7 @@ export function TraceView({ }); const [representation, setRepresentation] = useControllableState({ value: representationProp, - defaultValue: AUDIENCE_REPRESENTATION[defaultAudience], + defaultValue: AUDIENCE_REPRESENTATION[audienceProp ?? defaultAudience], onChange: onRepresentationChange, name: "representation", }); @@ -206,7 +206,7 @@ export function TraceView({ const placed = placeSpans(shown); const selected = trace.spans.find((span) => span.id === selectedId); const end = Math.max(trace.durationMs, traceEnd(trace.spans), 1); - const liveGrant = layers.grants ? grantAt(trace.spans, timeMs) : undefined; + const liveGrant = layers.grants ? grantAt(shown, timeMs) : undefined; const index = placed.findIndex((row) => row.span.id === selectedId); const rowRefs = useRef(new Map()); @@ -531,7 +531,8 @@ function ink(amount: number): string { function barColor(span: Span): string { if (span.status === "error" || span.status === "denied") return "var(--destructive)"; - if (span.status === "waiting" || span.status === "running") return "var(--primary)"; + if (span.status === "waiting") return "var(--warn)"; + if (span.status === "running") return "var(--foreground)"; if (span.kind === "network") return seriesColor(0); if (span.kind === "mail" || span.kind === "attachment") return seriesColor(1); if (span.kind === "provision" || span.kind === "reactor") return seriesColor(3); From e65b8c6a4bbff38d504785e906118ca25ff80f31 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 08:01:22 -0700 Subject: [PATCH 08/14] feat: show the step type and label that were passed in The trace row uses the source type and an optional label. Audience and plain/precise switches are gone. Storybook splits that into a multi-run FinOps overview, a single-run FinOps overview, and the detailed workflow. --- src/blocks/observability/app.tsx | 40 +- src/ui/observability/metrics.ts | 4 +- src/ui/observability/model.ts | 25 +- src/ui/observability/observations.tsx | 54 +-- src/ui/observability/playback.ts | 3 - src/ui/observability/slots.tsx | 325 ++++--------- src/ui/observability/timeline.tsx | 6 +- src/ui/observability/trace-view.tsx | 454 +++--------------- src/ui/observability/wording.ts | 202 +------- stories/observability/fixtures.ts | 21 + .../observability/observability.stories.tsx | 38 +- 11 files changed, 230 insertions(+), 942 deletions(-) diff --git a/src/blocks/observability/app.tsx b/src/blocks/observability/app.tsx index 616ad695..768c0d56 100644 --- a/src/blocks/observability/app.tsx +++ b/src/blocks/observability/app.tsx @@ -27,9 +27,7 @@ import { Observations } from "../../ui/observability/observations.js"; import { UsageMix } from "../../ui/observability/slots.js"; import { SessionTable } from "../../ui/observability/session-table.js"; import { AgentTrace, WorkflowTrace } from "../../ui/observability/trace-view.js"; -import type { Wording } from "../../ui/observability/wording.js"; import { StatGrid, StatGridItem } from "../../ui/stat-grid.js"; -import { cn } from "../../lib/utils.js"; export type ObservabilityAppProps = { readonly sessions: readonly SessionSummary[]; @@ -53,7 +51,6 @@ export function ObservabilityApp({ const [focusSpan, setFocusSpan] = useState(undefined); const [adding, setAdding] = useState(false); const [token, setToken] = useState(null); - const [wording, setWording] = useState("plain"); const visible = useMemo( () => @@ -87,33 +84,7 @@ export function ObservabilityApp({ setScreen("observations")}> Observations -
-
- Words -
- {( - [ - ["plain", "Plain"], - ["precise", "Precise"], - ] as const - ).map(([id, label]) => ( - - ))} -
-
+
@@ -170,7 +141,6 @@ export function ObservabilityApp({ {screen === "observations" ? ( openTrace(traceId, spanId)} /> ) : null} @@ -192,10 +162,6 @@ export function ObservabilityApp({ @@ -203,10 +169,6 @@ export function ObservabilityApp({ diff --git a/src/ui/observability/metrics.ts b/src/ui/observability/metrics.ts index bfaa207d..18ed0b9d 100644 --- a/src/ui/observability/metrics.ts +++ b/src/ui/observability/metrics.ts @@ -69,7 +69,7 @@ export type ObservationRow = { readonly traceId: string; readonly traceName: string; readonly spanId: string; - readonly type: Span["kind"]; + readonly type: string; readonly name: string; readonly model: string; readonly level: "Default" | "Warning" | "Error" | "Debug"; @@ -263,7 +263,7 @@ export function observationRows(traces: readonly Trace[]): readonly ObservationR traceId: trace.id, traceName: trace.name, spanId: span.id, - type: span.kind, + type: span.type ?? span.kind, name: span.name, model: span.usage?.model ?? "—", level: levelOf(span.status), diff --git a/src/ui/observability/model.ts b/src/ui/observability/model.ts index 8806b2be..ff7202aa 100644 --- a/src/ui/observability/model.ts +++ b/src/ui/observability/model.ts @@ -169,11 +169,22 @@ export type Placement = { readonly status: string; }; -/** One timed step inside a trace. `startMs` is relative to the trace start. */ +/** + * One timed step. `startMs` is relative to the trace start. + * + * `kind` is the role this viewer knows how to draw. A caller maps interchange, + * a local session (`~/.corbits/code`, `~/.claude/projects`, `~/.codex`), or an + * SDK trace (Vercel AI SDK, Mastra) onto that role. `type` is the source's own + * step type and `label` is optional display copy. Both are shown as passed. + */ export type Span = { readonly id: string; readonly parentId: string | null; readonly kind: SpanKind; + /** Source step type, shown beside the row. Omit to show `kind`. */ + readonly type?: string; + /** Row title. Omit to show what the step did, then `name`. */ + readonly label?: string; readonly name: string; readonly status: SpanStatus; readonly startMs: number; @@ -226,18 +237,6 @@ export type Trace = { readonly brief?: AgentBrief; }; -export type Audience = "operator" | "cfo" | "coo" | "ciso"; - -/** How the steps are drawn. The audience picks a default. The caller can override it. */ -export type TraceRepresentation = "timeline" | "ledger" | "cost" | "shape"; - -export const AUDIENCE_REPRESENTATION: Record = { - operator: "timeline", - cfo: "ledger", - coo: "ledger", - ciso: "timeline", -}; - export type SeriesPoint = { readonly label: string; readonly value: number; diff --git a/src/ui/observability/observations.tsx b/src/ui/observability/observations.tsx index 3eeb8b98..96eeca1e 100644 --- a/src/ui/observability/observations.tsx +++ b/src/ui/observability/observations.tsx @@ -7,21 +7,11 @@ import { Badge } from "../badge.js"; import { formatCost, formatDuration, formatTokens } from "./format.js"; import { observationRows, type ObservationRow } from "./metrics.js"; import type { Trace } from "./model.js"; -import { - DEFAULT_FOCUS, - FOCUS_KEYS, - FOCUS_LABEL, - focusKeepsKind, - kindLabel, - stepTitle, - type TraceFocus, - type Wording, -} from "./wording.js"; +import { stepTitle } from "./wording.js"; export type ObservationsProps = { readonly traces: readonly Trace[]; readonly onOpen: (traceId: string, spanId: string) => void; - readonly wording?: Wording; }; const LEVEL_TONE = { @@ -31,49 +21,21 @@ const LEVEL_TONE = { Debug: "info", } as const; -export function Observations({ traces, onOpen, wording = "plain" }: ObservationsProps) { +export function Observations({ traces, onOpen }: ObservationsProps) { const rows = useMemo(() => observationRows(traces), [traces]); - const [focus, setFocus] = useState(DEFAULT_FOCUS); const [level, setLevel] = useState<"all" | "Error" | "Warning">("all"); - const shown = rows.filter((row) => { - if (!focusKeepsKind(row.type, focus)) return false; - if (level !== "all" && row.level !== level) return false; - return true; - }); + const shown = rows.filter((row) => level === "all" || row.level === level); return (

Observations

-

- Every step, across runs. Turn off the groups you do not need, then open the trace. -

+

Every step, across runs.

{shown.length} shown

-
- {FOCUS_KEYS.map((key) => { - const on = focus[key]; - return ( - - ); - })} -
- {kindLabel(row.type, wording)} - {rowTitle(traces, row, wording)} + {row.type} + {rowTitle(traces, row)} {row.model} {row.level} @@ -133,10 +95,10 @@ export function Observations({ traces, onOpen, wording = "plain" }: Observations ); } -function rowTitle(traces: readonly Trace[], row: ObservationRow, wording: Wording): string { +function rowTitle(traces: readonly Trace[], row: ObservationRow): string { const trace = traces.find((item) => item.id === row.traceId); const span = trace?.spans.find((item) => item.id === row.spanId); - return span === undefined ? row.name : stepTitle(span, wording); + return span === undefined ? row.name : stepTitle(span); } function Filter({ diff --git a/src/ui/observability/playback.ts b/src/ui/observability/playback.ts index a77e190d..2d86e9f8 100644 --- a/src/ui/observability/playback.ts +++ b/src/ui/observability/playback.ts @@ -1,6 +1,5 @@ import type { Span, TraceLayers } from "./model.js"; import { placeSpans } from "./tree.js"; -import { focusKeeps, type TraceFocus } from "./wording.js"; /** How long a full play-through takes on screen, whatever the trace length. */ export const SCAN_MS = 16_000; @@ -16,10 +15,8 @@ export function contains(span: Span, timeMs: number): boolean { export function visibleSpans( spans: readonly Span[], layers: TraceLayers, - focus?: TraceFocus, ): readonly Span[] { return spans.filter((span) => { - if (focus !== undefined && !focusKeeps(span, focus)) return false; if (span.kind === "reasoning" && !layers.reasoning) return false; if (span.kind === "network" && !layers.network) return false; if (span.kind === "attachment" && !layers.media) return false; diff --git a/src/ui/observability/slots.tsx b/src/ui/observability/slots.tsx index 6efe2f32..ea3e85da 100644 --- a/src/ui/observability/slots.tsx +++ b/src/ui/observability/slots.tsx @@ -10,7 +10,6 @@ import { TableHeader, TableRow, } from "../table.js"; -import { Badge } from "../badge.js"; import { formatCost, formatDuration, @@ -18,59 +17,104 @@ import { formatTokens, } from "./format.js"; import { - findings, measures, modelShares, + portfolio, rollup, shapeOf, stepKeys, usageOf, - type Finding, type MeasureSort, type UsageBreakdown, } from "./metrics.js"; -import type { Audience, Trace } from "./model.js"; -import { phrase, stepTitle, type Wording } from "./wording.js"; +import type { Trace } from "./model.js"; +import { stepTitle } from "./wording.js"; -export function AudienceBand({ - audience, - trace, - peers = [], - onOpen, - wording, -}: { - readonly audience: Audience; - readonly trace: Trace; - readonly peers?: readonly Trace[]; - readonly onOpen?: (id: string) => void; - readonly wording: Wording; -}) { - if (audience === "operator") return null; - const totals = rollup(trace); +export function WorkflowsFinOps({ traces }: { readonly traces: readonly Trace[] }) { + const totals = traces.map((trace) => rollup(trace)); + const cost = totals.reduce((sum, row) => sum + row.costUsd, 0); + const tokens = totals.reduce((sum, row) => sum + row.tokens, 0); + const failed = traces.filter((trace) => trace.status === "error" || trace.status === "denied").length; + const usage = traces.reduce( + (sum, trace) => { + const part = usageOf(trace); + return { + input: sum.input + part.input, + output: sum.output + part.output, + cacheRead: sum.cacheRead + part.cacheRead, + cacheWrite: sum.cacheWrite + part.cacheWrite, + thinking: sum.thinking + part.thinking, + }; + }, + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + ); + const shares = portfolio(traces); return ( -
- {audience === "cfo" ? : null} - {audience === "coo" ? : null} - {audience === "ciso" ? : null} -
+
+
+

Runs

+

{traces.length} runs

+
+ + + + + 0} /> + +
+ ({ + key: `${share.provider}/${share.model}`, + label: `${share.provider} / ${share.model}`, + value: share.costUsd, + detail: formatTokens(share.tokens), + }))} + /> + +
+ + + + Run + Cost + Tokens + Duration + Status + + + + {traces.map((trace) => { + const row = rollup(trace); + return ( + + {trace.name} + {formatCost(row.costUsd)} + {formatTokens(row.tokens)} + {formatDuration(row.durationMs)} + {trace.status} + + ); + })} + +
+
); } -function FinanceBand({ - trace, - totals, - peers, - onOpen, -}: { - trace: Trace; - totals: ReturnType; - peers: readonly Trace[]; - onOpen?: ((id: string) => void) | undefined; -}) { +export function WorkflowFinOps({ trace }: { readonly trace: Trace }) { + const totals = rollup(trace); const perThousand = totals.tokens === 0 ? 0 : totals.costUsd / (totals.tokens / 1000); const shares = modelShares(trace); return ( - <> +
+
+

{trace.name}

+

+ {trace.actor} · {trace.startedAt} +

+
-
+
-
+
- {peers.length < 2 ? null : } - - ); -} - -function OperationsBand({ - trace, - totals, - wording, -}: { - trace: Trace; - totals: ReturnType; - wording: Wording; -}) { - return ( - <> - - - - 0} - /> - - - - - ); -} - -function SecurityBand({ - trace, - totals, - wording, -}: { - trace: Trace; - totals: ReturnType; - wording: Wording; -}) { - const items = findings(trace); - return ( - <> -
- - - 0} /> - 0} /> - - 0} - /> - -
-
-

- {phrase(wording, "What left the grant", "What was blocked or left the network")} -

- {items.length === 0 ? ( -

- {phrase( - wording, - "No denies, blocks, or external calls.", - "Nothing was blocked, and nothing left the network.", - )} -

- ) : ( -
    - {items.map((item) => ( -
  • - - {item.tone === "danger" - ? phrase(wording, "Deny", "Blocked") - : phrase(wording, "Watch", "Check")} - - {findingTitle(trace, item, wording)} - {item.detail} -
  • - ))} -
- )} -
- - ); -} - -function Identity({ trace, wording }: { trace: Trace; wording: Wording }) { - const principal = trace.principal; - const tenant = trace.tenant; - return ( -
-

Who ran it

- {principal === undefined ? ( -

- {phrase(wording, "No principal on this run.", "No person recorded for this run.")} -

- ) : ( -
-
{phrase(wording, "Principal", "Person")}
-
{principal.name}
-
Id
-
{principal.id}
-
Kind
-
{principal.kind}
-
Key
-
{principal.keyId}
-
{phrase(wording, "Tenant", "Workspace")}
-
{tenant === undefined ? "—" : `${tenant.name} · ${tenant.id}`}
-
- )} +
); } -function findingTitle(trace: Trace, item: Finding, wording: Wording): string { - const span = trace.spans.find((candidate) => item.id.startsWith(`${candidate.id}:`)); - if (span === undefined) return item.title; - if ( - item.id === `${span.id}:deny` || - item.id === `${span.id}:block` || - item.id === `${span.id}:sig` - ) { - return stepTitle(span, wording); - } - return item.title; -} - function ProviderNotes({ shares }: { shares: ReturnType }) { if (shares.length === 0) return null; return ( @@ -249,82 +169,22 @@ function ProviderNotes({ shares }: { shares: ReturnType }) { ); } -export function Compare({ - peers, - currentId, - onOpen, -}: { - readonly peers: readonly Trace[]; - readonly currentId: string; - readonly onOpen?: ((id: string) => void) | undefined; -}) { - return ( -
-

Compare runs

- - - - Run - Cost - Tokens - Duration - Speed - Denied - - - - {peers.map((peer) => { - const totals = rollup(peer); - const current = peer.id === currentId; - return ( - - - {onOpen === undefined ? ( - peer.name - ) : ( - - )} - - {formatCost(totals.costUsd)} - {formatTokens(totals.tokens)} - {formatDuration(totals.durationMs)} - {formatSpeed(totals.tokensPerSecond)} - {totals.denied} - - ); - })} - -
-
- ); -} - export function Ledger({ trace, sort, selectedId, onSelect, - wording, }: { readonly trace: Trace; readonly sort: MeasureSort; readonly selectedId?: string; readonly onSelect?: (id: string) => void; - readonly wording: Wording; }) { const rows = measures(trace, sort); return ( - {wording === "precise" ? Key : null} Step Provider Tokens @@ -336,10 +196,9 @@ export function Ledger({ {rows.map((row) => { const span = trace.spans.find((item) => item.id === row.id); - const title = span === undefined ? row.name : stepTitle(span, wording); + const title = span === undefined ? row.name : stepTitle(span); return ( - {wording === "precise" ? {row.key} : null} {onSelect === undefined ? ( title @@ -366,7 +225,7 @@ export function Ledger({ ); } -export function CostPicture({ trace, wording }: { readonly trace: Trace; readonly wording: Wording }) { +export function CostPicture({ trace }: { readonly trace: Trace }) { const priced = measures(trace, "cost").filter((row) => row.costUsd > 0); return ( item.id === row.id); return { key: row.id, - label: span === undefined ? row.name : stepTitle(span, wording), + label: span === undefined ? row.name : stepTitle(span), value: row.costUsd, detail: formatTokens(row.tokens), }; @@ -437,7 +296,7 @@ export function UsageMix({ usage }: { readonly usage: UsageBreakdown }) { ); } -export function Shape({ trace, wording }: { readonly trace: Trace; readonly wording: Wording }) { +export function Shape({ trace }: { readonly trace: Trace }) { const nodes = shapeOf(trace); const keys = stepKeys(trace.spans); return ( @@ -462,10 +321,7 @@ export function Shape({ trace, wording }: { readonly trace: Trace; readonly word {nodes.map((node) => ( - {titlesFor(trace, keys, node.key, wording)} - {wording === "precise" ? ( - {node.key} - ) : null} + {titlesFor(trace, keys, node.key)} {node.count} {formatDuration(node.durationMs)} @@ -483,13 +339,12 @@ function titlesFor( trace: Trace, keys: ReadonlyMap, nodeKey: string, - wording: Wording, ): string { const titles: string[] = []; for (const span of trace.spans) { const full = keys.get(span.id) ?? span.id; if (full.replace(/\.\d+$/, "") !== nodeKey) continue; - const title = stepTitle(span, wording); + const title = stepTitle(span); if (!titles.includes(title)) titles.push(title); } return titles.join(" · "); diff --git a/src/ui/observability/timeline.tsx b/src/ui/observability/timeline.tsx index 89f92e28..fa94e302 100644 --- a/src/ui/observability/timeline.tsx +++ b/src/ui/observability/timeline.tsx @@ -9,14 +9,13 @@ import { Button } from "../button.js"; import { formatClock } from "./format.js"; import type { Span, SpanKind } from "./model.js"; import { nextBoundary, previousBoundary, SCAN_MS } from "./playback.js"; -import { stepTitle, type Wording } from "./wording.js"; +import { stepTitle } from "./wording.js"; export type TraceTimelineProps = { readonly spans: readonly Span[]; readonly endMs: number; readonly timeMs: number; readonly playing: boolean; - readonly wording: Wording; readonly onTimeChange: (timeMs: number) => void; readonly onPlayingChange: (playing: boolean) => void; readonly className?: string; @@ -67,7 +66,6 @@ export function TraceTimeline({ endMs, timeMs, playing, - wording, onTimeChange, onPlayingChange, className, @@ -162,7 +160,7 @@ export function TraceTimeline({ {spans.map((span) => ( timeMs && "opacity-35")} style={{ left: `${(span.startMs / end) * 100}%`, diff --git a/src/ui/observability/trace-view.tsx b/src/ui/observability/trace-view.tsx index cda6c490..a281f70a 100644 --- a/src/ui/observability/trace-view.tsx +++ b/src/ui/observability/trace-view.tsx @@ -17,10 +17,8 @@ import { spanTokens, } from "./format.js"; import { - AUDIENCE_REPRESENTATION, DEFAULT_LAYERS, type AgentBrief, - type Audience, type Compaction, type ContextChange, type DirectorNudge, @@ -32,10 +30,9 @@ import { type SpanStatus, type Trace, type TraceLayers, - type TraceRepresentation, } from "./model.js"; import { usageOfSpan, type UsageBreakdown } from "./metrics.js"; -import { AudienceBand, CostPicture, Ledger, Shape, UsageMix } from "./slots.js"; +import { UsageMix } from "./slots.js"; import { deepestAt, grantAt, @@ -45,21 +42,7 @@ import { } from "./playback.js"; import { TraceTimeline } from "./timeline.js"; import { placeSpans, traceEnd } from "./tree.js"; -import { - DEFAULT_FOCUS, - effectLabel, - FOCUS_KEYS, - FOCUS_LABEL, - compactionLine, - kindLabel, - originLabel, - phrase, - placementStatus, - rowMarker, - stepTitle, - type TraceFocus, - type Wording, -} from "./wording.js"; +import { compactionLine, rowMarker, stepTitle } from "./wording.js"; export type TraceViewProps = { readonly trace: Trace; @@ -73,16 +56,6 @@ export type TraceViewProps = { readonly defaultPlaying?: boolean; readonly onPlayingChange?: (playing: boolean) => void; readonly layers?: TraceLayers; - readonly onLayersChange?: (layers: TraceLayers) => void; - readonly audience?: Audience; - readonly defaultAudience?: Audience; - readonly onAudienceChange?: (audience: Audience) => void; - readonly representation?: TraceRepresentation; - readonly onRepresentationChange?: (representation: TraceRepresentation) => void; - readonly peers?: readonly Trace[]; - readonly onOpen?: (id: string) => void; - readonly wording?: Wording; - readonly onWordingChange?: (wording: Wording) => void; readonly className?: string; }; @@ -102,25 +75,6 @@ const STATUS_TONE = { denied: "danger", } as const; -const LAYERS = ["reasoning", "grants", "io", "media", "network", "raw"] as const; - -function layerLabel(key: (typeof LAYERS)[number], wording: Wording): string { - if (wording === "precise") { - if (key === "reasoning") return "Thought"; - if (key === "grants") return "Grants"; - if (key === "io") return "Input / output"; - if (key === "media") return "Media"; - if (key === "network") return "Network"; - return "Raw"; - } - if (key === "reasoning") return "Thinking"; - if (key === "grants") return "Rules"; - if (key === "io") return "In and out"; - if (key === "media") return "Files"; - if (key === "network") return "Internet"; - return "Raw record"; -} - export function AgentTrace(props: TraceViewProps) { return ; } @@ -141,16 +95,6 @@ export function TraceView({ defaultPlaying = false, onPlayingChange, layers: layersProp, - onLayersChange, - audience: audienceProp, - defaultAudience = "operator", - onAudienceChange, - representation: representationProp, - onRepresentationChange, - peers, - onOpen, - wording: wordingProp, - onWordingChange, className, }: TraceViewProps) { const placedAll = placeSpans(trace.spans); @@ -172,37 +116,8 @@ export function TraceView({ onChange: onPlayingChange, name: "playing", }); - const [layers, setLayers] = useControllableState({ - value: layersProp, - defaultValue: DEFAULT_LAYERS, - onChange: onLayersChange, - name: "layers", - }); - const [audience, setAudience] = useControllableState({ - value: audienceProp, - defaultValue: defaultAudience, - onChange: onAudienceChange, - name: "audience", - }); - const [wording, setWording] = useControllableState({ - value: wordingProp, - defaultValue: "plain" as Wording, - onChange: onWordingChange, - name: "wording", - }); - const [focus, setFocus] = useControllableState({ - value: undefined, - defaultValue: DEFAULT_FOCUS, - name: "focus", - }); - const [representation, setRepresentation] = useControllableState({ - value: representationProp, - defaultValue: AUDIENCE_REPRESENTATION[audienceProp ?? defaultAudience], - onChange: onRepresentationChange, - name: "representation", - }); - - const shown = visibleSpans(trace.spans, layers, focus); + const layers = layersProp ?? DEFAULT_LAYERS; + const shown = visibleSpans(trace.spans, layers); const placed = placeSpans(shown); const selected = trace.spans.find((span) => span.id === selectedId); const end = Math.max(trace.durationMs, traceEnd(trace.spans), 1); @@ -212,9 +127,9 @@ export function TraceView({ useEffect(() => { if (!playing) return; - const active = deepestAt(visibleSpans(trace.spans, layers, focus), timeMs); + const active = deepestAt(visibleSpans(trace.spans, layers), timeMs); if (active !== undefined && active.id !== selectedId) setSelectedId(active.id); - }, [playing, trace.spans, layers, focus, timeMs, selectedId, setSelectedId]); + }, [playing, trace.spans, layers, timeMs, selectedId, setSelectedId]); useEffect(() => { rowRefs.current.get(selectedId)?.scrollIntoView({ block: "nearest" }); @@ -251,134 +166,43 @@ export function TraceView({ {trace.actor} · {trace.startedAt}

- {audience === "operator" ? ( -
- - - - -
- ) : null} +
+ + + + +
{trace.placement === undefined ? null : (
- - + + - - + +
)} - {trace.brief === undefined ? null : } + {trace.brief === undefined ? null : } -
- { - setAudience(next); - if (representationProp === undefined) setRepresentation(AUDIENCE_REPRESENTATION[next]); - }} - /> - - -
-
- {FOCUS_KEYS.map((key) => { - const on = focus[key]; - return ( - - ); - })} -
- - - {representation === "timeline" ? (
-
- {LAYERS.map((key) => { - const on = layers[key]; - return ( - - ); - })} -
- +
- ) : null}
- {representation === "timeline" ? (
seek(span.id, span.startMs)} > - - {rowMarker(span, wording)} + + {rowMarker(span)} - {stepTitle(span, wording)} + {stepTitle(span)}
- + - + @@ -829,7 +559,7 @@ function CompactionBlock({ )} - + ))} @@ -838,16 +568,14 @@ function CompactionBlock({ ); } -function ContextBlock({ change, wording }: { change: ContextChange; wording: Wording }) { +function ContextBlock({ change }: { change: ContextChange }) { return (
-

{phrase(wording, "Context transform", "Prompt change")}

+

Context transform

{change.summary}

- {wording === "precise" ? ( -

- {change.strategy} v{change.version} · {change.trigger} · {change.reason} -

- ) : null} +

+ {change.strategy} v{change.version} · {change.trigger} · {change.reason} +

); } @@ -856,19 +584,17 @@ function ThoughtBlock({ span, timeMs, playing, - wording, }: { span: Span; timeMs: number; playing: boolean; - wording: Wording; }) { const started = timeMs > span.startMs; const done = timeMs >= spanEnd(span); const streaming = playing && started && !done; return (
-

{phrase(wording, "Chain of thought", "Thinking")}

+

Thinking

{span.redacted === true || span.thinking === undefined ? (

Redacted by the model.

) : playing && !started ? ( @@ -887,39 +613,25 @@ function ThoughtBlock({ ); } -function GrantBlock({ grant, wording }: { grant: GrantUse; wording: Wording }) { +function GrantBlock({ grant }: { grant: GrantUse }) { return (
-

- {phrase(wording, "Grant at this step", "Permission at this step")} -

- - {grant.blocked ? {phrase(wording, "Blocked", "Stopped")} : null} +

Grant

+ + {grant.blocked ? Blocked : null}
- {grant.blockReason === undefined ? null : ( -

{grant.blockReason}

- )} + {grant.blockReason === undefined ? null :

{grant.blockReason}

} {grant.resolvedBy === null ? ( -

- {phrase(wording, "Nothing matched. Failed closed.", "No rule matched, so this was refused.")} -

+

Nothing matched. Failed closed.

) : ( - + )} {grant.matching.length === 0 ? null : (
    {grant.matching.map((item) => (
  • - +
  • ))}
@@ -931,53 +643,31 @@ function GrantBlock({ grant, wording }: { grant: GrantUse; wording: Wording }) { function GrantLine({ label, grant, - wording, }: { label: string; grant: NonNullable; - wording: Wording; }) { - const exact = - grant.specificity === undefined || wording === "plain" ? "" : ` · specificity ${grant.specificity}`; + const exact = grant.specificity === undefined ? "" : ` · specificity ${grant.specificity}`; return (

{label} - {wording === "plain" ? ( - <> - {effectLabel(grant.effect, wording)} by {originLabel(grant.origin, wording)} - - ) : ( - <> - {grant.id} · {grant.effect} · {grant.origin} -
- {grant.resource} {grant.action} - {exact} - - )} + {grant.id} · {grant.effect} · {grant.origin} +
+ {grant.resource} {grant.action} + {exact}

); } -function EffectBadge({ effect, wording }: { effect: GrantUse["effect"]; wording: Wording }) { +function EffectBadge({ effect }: { effect: GrantUse["effect"] }) { const tone = effect === "allow" ? "success" : effect === "ask" ? "warning" : effect === "none" ? "neutral" : "danger"; - return {effectLabel(effect, wording)}; + return {effect}; } -function SignatureBadge({ status, wording }: { status: SignatureStatus; wording: Wording }) { - const tone = - status === "valid" ? "success" : status === "invalid" ? "danger" : "warning"; - const label = - wording === "precise" - ? status - : status === "valid" - ? "Checked" - : status === "invalid" - ? "Bad" - : status === "missing" - ? "Missing" - : "Unknown"; - return {label}; +function SignatureBadge({ status }: { status: SignatureStatus }) { + const tone = status === "valid" ? "success" : status === "invalid" ? "danger" : "warning"; + return {status}; } function TextBlock({ label, text }: { label: string; text: string }) { diff --git a/src/ui/observability/wording.ts b/src/ui/observability/wording.ts index 050ce201..f8b6c7dd 100644 --- a/src/ui/observability/wording.ts +++ b/src/ui/observability/wording.ts @@ -1,198 +1,17 @@ import { formatTokens } from "./format.js"; -import type { Compaction, GrantEffect, GrantOrigin, Span, SpanKind } from "./model.js"; +import type { Compaction, Span } from "./model.js"; -/** Plain hides the platform words. Precise keeps them. */ -export type Wording = "plain" | "precise"; - -export const FOCUS_KEYS = [ - "thinking", - "actions", - "approvals", - "messages", - "setup", - "internet", - "files", - "compaction", - "context", - "nudges", -] as const; - -export type FocusKey = (typeof FOCUS_KEYS)[number]; - -export type TraceFocus = Record; - -export const DEFAULT_FOCUS: TraceFocus = { - thinking: true, - actions: true, - approvals: true, - messages: true, - setup: true, - internet: true, - files: true, - compaction: true, - context: true, - nudges: true, -}; - -const FOCUS_KINDS: Record = { - thinking: ["reasoning", "citation"], - compaction: ["compact"], - context: ["transform"], - nudges: ["nudge"], - actions: ["tool"], - approvals: ["gate", "permission"], - messages: ["mail"], - setup: ["provision", "reactor", "fork", "event"], - internet: ["network"], - files: ["attachment"], -}; - -const KIND_PRECISE: Record = { - turn: "Turn", - reasoning: "Thought", - tool: "Tool", - permission: "Permission", - subagent: "Agent", - compact: "Compact", - error: "Error", - event: "Event", - step: "Step", - gate: "Gate", - mail: "Mail", - provision: "Provision", - fork: "Fork", - reactor: "Reactor", - network: "Network", - attachment: "Media", - citation: "Citation", - transform: "Transform", - nudge: "Director", -}; - -const KIND_PLAIN: Record = { - turn: "Reply", - reasoning: "Thinking", - tool: "Action", - permission: "Ask", - subagent: "Helper", - compact: "Shorten", - error: "Failed", - event: "Note", - step: "Step", - gate: "Approval", - mail: "Message", - provision: "Setup", - fork: "Split", - reactor: "Runtime", - network: "Internet", - attachment: "File", - citation: "Source", - transform: "Context", - nudge: "Nudge", -}; - -const NAME_PLAIN: Record = { - "Reactor started": "The run started", - "Allocate a sidecar": "Starting the machine", - "Approval requested": "Asked for approval", - "Inbound trigger": "A message started this run", - "Send the verdict": "Sent the result", - "Fork a test run": "Split off a test run", - "Context compacted": "Shortened the notes", - "Hidden reasoning": "Thinking was hidden", - "Session started": "Session started", -}; - -export const FOCUS_LABEL: Record> = { - plain: { - thinking: "Thinking", - actions: "Actions", - approvals: "Approvals", - messages: "Messages", - setup: "Setup", - internet: "Internet", - files: "Files", - compaction: "Shortening", - context: "Context", - nudges: "Nudges", - }, - precise: { - thinking: "Reasoning", - actions: "Tools", - approvals: "Gates", - messages: "Mail", - setup: "Provision", - internet: "Network", - files: "Media", - compaction: "Compaction", - context: "Transforms", - nudges: "Director", - }, -}; - -export function phrase(wording: Wording, precise: string, plain: string): string { - return wording === "plain" ? plain : precise; -} - -export function kindLabel(kind: SpanKind, wording: Wording): string { - return wording === "plain" ? KIND_PLAIN[kind] : KIND_PRECISE[kind]; -} - -export function focusKeyOf(kind: SpanKind): FocusKey | null { - for (const key of FOCUS_KEYS) { - if (FOCUS_KINDS[key].includes(kind)) return key; - } - return null; -} - -export function focusKeepsKind(kind: SpanKind, focus: TraceFocus): boolean { - const key = focusKeyOf(kind); - if (key === null) return true; - return focus[key]; -} - -export function focusKeeps(span: Span, focus: TraceFocus): boolean { - return focusKeepsKind(span.kind, focus); -} - -export function effectLabel(effect: GrantEffect | "none", wording: Wording): string { - if (wording === "precise") return effect; - if (effect === "allow") return "Allowed"; - if (effect === "deny") return "Denied"; - if (effect === "ask") return "Asked"; - return "None"; -} - -export function originLabel(origin: GrantOrigin, wording: Wording): string { - if (wording === "precise") return origin; - if (origin === "system") return "a built-in rule"; - if (origin === "role") return "their role"; - if (origin === "creator") return "the person who set it up"; - return "the person who started the run"; -} - -export function placementStatus(status: string, wording: Wording): string { - if (wording === "precise") return status; - if (status === "pending") return "waiting"; - if (status === "provisioning") return "starting"; - if (status === "allocated") return "ready"; - if (status === "replacing") return "swapping"; - if (status === "releasing") return "shutting down"; - if (status === "destroy_failed") return "could not shut down"; - if (status === "released") return "finished"; - if (status === "failed") return "failed"; - return status; -} - -/** Small label beside the row. Precise keeps the tool name when the title was rewritten. */ -export function rowMarker(span: Span, wording: Wording): string { - const title = stepTitle(span, wording); - if (title === span.name || wording === "plain") return kindLabel(span.kind, wording); - return span.name; +/** The type column. Callers pass `type`. Otherwise the row shows `kind`. */ +export function rowMarker(span: Span): string { + return span.type ?? span.kind; } -/** The row title: what the step did, not only the tool that did it. */ -export function stepTitle(span: Span, wording: Wording): string { +/** + * The row title. A caller-supplied `label` wins. Otherwise the title is what + * the payload did, then the step name. + */ +export function stepTitle(span: Span): string { + if (span.label !== undefined && span.label.length > 0) return span.label; const did = whatItDid(span); if (did !== undefined) return did; if (span.compaction !== undefined) { @@ -204,7 +23,6 @@ export function stepTitle(span: Span, wording: Wording): string { const line = span.thinking.split("\n")[0] ?? span.thinking; return line.length > 88 ? `${line.slice(0, 85)}…` : line; } - if (wording === "plain") return NAME_PLAIN[span.name] ?? span.name; return span.name; } diff --git a/stories/observability/fixtures.ts b/stories/observability/fixtures.ts index e6a33329..ab4f84ba 100644 --- a/stories/observability/fixtures.ts +++ b/stories/observability/fixtures.ts @@ -214,6 +214,8 @@ export const TRACES: Record = { id: "mail-in", parentId: null, kind: "mail", + type: "message.queued", + label: "A message started this run", name: "Inbound trigger", status: "ok", startMs: 0, @@ -239,6 +241,8 @@ export const TRACES: Record = { id: "provision", parentId: null, kind: "provision", + type: "provision", + label: "Starting the machine", name: "Allocate a sidecar", status: "ok", startMs: 1_800, @@ -260,6 +264,8 @@ export const TRACES: Record = { id: "reactor", parentId: null, kind: "reactor", + type: "reactor.start", + label: "The run started", name: "Reactor started", status: "ok", startMs: 7_000, @@ -280,6 +286,7 @@ export const TRACES: Record = { id: "tool-read", parentId: "step-read", kind: "tool", + type: "read_file", name: "read_file", status: "ok", startMs: 9_000, @@ -373,6 +380,7 @@ export const TRACES: Record = { id: "tool-shell", parentId: "turn", kind: "tool", + type: "shell", name: "shell", status: "error", startMs: 56_000, @@ -397,6 +405,8 @@ export const TRACES: Record = { id: "fork", parentId: "step-review", kind: "fork", + type: "fork", + label: "Split off a test run", name: "Fork a test run", status: "ok", startMs: 90_000, @@ -435,6 +445,8 @@ export const TRACES: Record = { id: "ask", parentId: "gate", kind: "reactor", + type: "reactor.wait", + label: "Asked for approval", name: "Approval requested", status: "ok", startMs: 128_200, @@ -446,6 +458,8 @@ export const TRACES: Record = { id: "mail-out", parentId: null, kind: "mail", + type: "message.send", + label: "Sent the result", name: "Send the verdict", status: "ok", startMs: 160_000, @@ -492,6 +506,8 @@ export const TRACES: Record = { id: "e-prov", parentId: null, kind: "provision", + type: "provision", + label: "Starting the machine", name: "Allocate a sidecar", status: "ok", startMs: 0, @@ -753,6 +769,8 @@ export const TRACES: Record = { id: "h-prov", parentId: null, kind: "provision", + type: "provision", + label: "Starting the machine", name: "Allocate a sidecar", status: "running", startMs: 0, @@ -788,6 +806,8 @@ export const TRACES: Record = { id: "h-redacted", parentId: "h1", kind: "reasoning", + type: "reasoning", + label: "Thinking was hidden", name: "Hidden reasoning", status: "ok", startMs: 7_000, @@ -811,6 +831,7 @@ export const TRACES: Record = { id: "h2", parentId: "h1", kind: "tool", + type: "shell", name: "shell", status: "waiting", startMs: 18_000, diff --git a/stories/observability/observability.stories.tsx b/stories/observability/observability.stories.tsx index dd12bb23..c55aa564 100644 --- a/stories/observability/observability.stories.tsx +++ b/stories/observability/observability.stories.tsx @@ -1,6 +1,7 @@ import { ObservabilityApp } from "../../src/blocks/observability/app.js"; import { AreaChart } from "../../src/ui/observability/area-chart.js"; import { SessionTable } from "../../src/ui/observability/session-table.js"; +import { WorkflowFinOps, WorkflowsFinOps } from "../../src/ui/observability/slots.js"; import { AgentTrace, WorkflowTrace } from "../../src/ui/observability/trace-view.js"; import { FAILURES, SERIES, SESSIONS, TRACES } from "./fixtures.js"; @@ -41,38 +42,23 @@ export const Agent = () => ( ); -export const Workflow = () => ( -
- +export const WorkflowsFinOpsOverview = () => ( +
+
); +WorkflowsFinOpsOverview.storyName = "Workflows FinOps Overview"; -export const Finance = () => ( -
- +export const WorkflowFinOpsOverview = () => ( +
+
); +WorkflowFinOpsOverview.storyName = "Workflow FinOps Overview"; -export const Operations = () => ( -
- -
-); - -export const Security = () => ( +export const Workflow = () => (
- +
); +Workflow.storyName = "Workflow"; From 51cba73c17c6cc72f7aaeb3455bf6f4cd8ce0089 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 08:04:54 -0700 Subject: [PATCH 09/14] chore: format the observability spike oxfmt --check was failing CI on the trace viewers. --- src/blocks/observability/app.tsx | 108 ++++++++--- src/ui/observability/area-chart.tsx | 22 ++- src/ui/observability/metrics.ts | 45 ++++- src/ui/observability/observations.tsx | 29 ++- src/ui/observability/slots.tsx | 155 +++++++++++---- src/ui/observability/timeline.tsx | 21 +- src/ui/observability/trace-view.tsx | 182 ++++++++++++++---- src/ui/observability/wording.ts | 16 +- stories/observability/fixtures.ts | 35 +++- .../observability/observability.stories.tsx | 23 ++- 10 files changed, 493 insertions(+), 143 deletions(-) diff --git a/src/blocks/observability/app.tsx b/src/blocks/observability/app.tsx index 768c0d56..a87aeac7 100644 --- a/src/blocks/observability/app.tsx +++ b/src/blocks/observability/app.tsx @@ -15,18 +15,30 @@ import { import { EmptyState } from "../../ui/empty-state.js"; import { FilterBar } from "../../ui/filter-bar.js"; import { Input } from "../../ui/input.js"; -import { formatCost, formatDuration, formatTokens } from "../../ui/observability/format.js"; +import { + formatCost, + formatDuration, + formatTokens, +} from "../../ui/observability/format.js"; import { AreaChart } from "../../ui/observability/area-chart.js"; import type { SeriesPoint, SessionSummary, Trace, } from "../../ui/observability/model.js"; -import { observationRows, portfolio, rollup, usageOf } from "../../ui/observability/metrics.js"; +import { + observationRows, + portfolio, + rollup, + usageOf, +} from "../../ui/observability/metrics.js"; import { Observations } from "../../ui/observability/observations.js"; import { UsageMix } from "../../ui/observability/slots.js"; import { SessionTable } from "../../ui/observability/session-table.js"; -import { AgentTrace, WorkflowTrace } from "../../ui/observability/trace-view.js"; +import { + AgentTrace, + WorkflowTrace, +} from "../../ui/observability/trace-view.js"; import { StatGrid, StatGridItem } from "../../ui/stat-grid.js"; export type ObservabilityAppProps = { @@ -57,7 +69,8 @@ export function ObservabilityApp({ sessions.filter((session) => { if (kind !== null && session.kind !== kind) return false; if (query.length === 0) return true; - const haystack = `${session.name} ${session.actor} ${session.model}`.toLowerCase(); + const haystack = + `${session.name} ${session.actor} ${session.model}`.toLowerCase(); return haystack.includes(query.toLowerCase()); }), [sessions, kind, query], @@ -75,13 +88,22 @@ export function ObservabilityApp({
{shown.map((row) => ( - + - + - + @@ -116,11 +127,15 @@ function Filter({
{phrase(wording, "Turn", "Piece")}Turn Tokens{phrase(wording, "Kept", "Stayed")}Kept
{formatTokens(turn.tokens)}{turn.kept ? phrase(wording, "Kept", "Stayed") : phrase(wording, "Dropped", "Cut")}{turn.kept ? "Kept" : "Dropped"}
{row.type} + {row.type} + {rowTitle(traces, row)} {row.model} {row.level} {formatDuration(row.latencyMs)} + {formatDuration(row.latencyMs)} + {row.tokens === 0 ? "—" : formatTokens(row.tokens)}
@@ -321,12 +376,20 @@ export function Shape({ trace }: { readonly trace: Trace }) { {nodes.map((node) => ( - {titlesFor(trace, keys, node.key)} + + {titlesFor(trace, keys, node.key)} + {node.count} - {formatDuration(node.durationMs)} - {node.tokens === 0 ? "—" : formatTokens(node.tokens)} - {node.costUsd === 0 ? "—" : formatCost(node.costUsd)} + + {formatDuration(node.durationMs)} + + + {node.tokens === 0 ? "—" : formatTokens(node.tokens)} + + + {node.costUsd === 0 ? "—" : formatCost(node.costUsd)} + ))} @@ -357,7 +420,12 @@ function CostBars({ }: { title: string; description: string; - rows: readonly { key: string; label: string; value: number; detail: string }[]; + rows: readonly { + key: string; + label: string; + value: number; + detail: string; + }[]; }) { const max = Math.max(0.0001, ...rows.map((row) => row.value)); return ( @@ -368,7 +436,10 @@ function CostBars({
    {rows.map((row) => ( -
  • +
  • {row.label}

    diff --git a/src/ui/observability/timeline.tsx b/src/ui/observability/timeline.tsx index fa94e302..9b15de07 100644 --- a/src/ui/observability/timeline.tsx +++ b/src/ui/observability/timeline.tsx @@ -150,18 +150,33 @@ export function TraceTimeline({ aria-hidden="true" onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); - seekAt(event.currentTarget, event.clientX, end, onTimeChange, onPlayingChange); + seekAt( + event.currentTarget, + event.clientX, + end, + onTimeChange, + onPlayingChange, + ); }} onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; - seekAt(event.currentTarget, event.clientX, end, onTimeChange, onPlayingChange); + seekAt( + event.currentTarget, + event.clientX, + end, + onTimeChange, + onPlayingChange, + ); }} > {spans.map((span) => ( timeMs && "opacity-35")} + className={cn( + "absolute top-1.5 h-3 rounded-full", + span.startMs > timeMs && "opacity-35", + )} style={{ left: `${(span.startMs / end) * 100}%`, width: `max(4px, ${(Math.max(span.durationMs, 1) / end) * 100}%)`, diff --git a/src/ui/observability/trace-view.tsx b/src/ui/observability/trace-view.tsx index a281f70a..041d048f 100644 --- a/src/ui/observability/trace-view.tsx +++ b/src/ui/observability/trace-view.tsx @@ -6,7 +6,11 @@ import { useControllableState } from "../../hooks/use-controllable-state.js"; import { seriesColor } from "../../lib/chart-palette.js"; import { cn } from "../../lib/utils.js"; import { Badge } from "../badge.js"; -import { InspectorHeader, InspectorKv, InspectorShell } from "../inspector-shell.js"; +import { + InspectorHeader, + InspectorKv, + InspectorShell, +} from "../inspector-shell.js"; import { formatBytes, formatClock, @@ -128,7 +132,8 @@ export function TraceView({ useEffect(() => { if (!playing) return; const active = deepestAt(visibleSpans(trace.spans, layers), timeMs); - if (active !== undefined && active.id !== selectedId) setSelectedId(active.id); + if (active !== undefined && active.id !== selectedId) + setSelectedId(active.id); }, [playing, trace.spans, layers, timeMs, selectedId, setSelectedId]); useEffect(() => { @@ -158,9 +163,15 @@ export function TraceView({
    -

    {trace.name}

    - {trace.kind === "agent" ? "Agent" : "Workflow"} - {STATUS_LABEL[trace.status]} +

    + {trace.name} +

    + + {trace.kind === "agent" ? "Agent" : "Workflow"} + + + {STATUS_LABEL[trace.status]} +

    {trace.actor} · {trace.startedAt} @@ -183,12 +194,17 @@ export function TraceView({ label="Package" value={`${trace.placement.packageName}@${trace.placement.packageVersion}`} /> - + )} - {trace.brief === undefined ? null : } + {trace.brief === undefined ? null : ( + + )}

    @@ -258,7 +275,11 @@ export function TraceView({ {stepTitle(span)} -
{compaction.turns.map((turn) => ( - + - + ))} @@ -603,7 +685,9 @@ function ThoughtBlock({

) : (

- {streaming ? revealedText(span.thinking, span, timeMs) : span.thinking} + {streaming + ? revealedText(span.thinking, span, timeMs) + : span.thinking} {streaming ? ( ) : null} @@ -621,9 +705,13 @@ function GrantBlock({ grant }: { grant: GrantUse }) { {grant.blocked ? Blocked : null} - {grant.blockReason === undefined ? null :

{grant.blockReason}

} + {grant.blockReason === undefined ? null : ( +

{grant.blockReason}

+ )} {grant.resolvedBy === null ? ( -

Nothing matched. Failed closed.

+

+ Nothing matched. Failed closed. +

) : ( )} @@ -647,7 +735,10 @@ function GrantLine({ label: string; grant: NonNullable; }) { - const exact = grant.specificity === undefined ? "" : ` · specificity ${grant.specificity}`; + const exact = + grant.specificity === undefined + ? "" + : ` · specificity ${grant.specificity}`; return (

{label} @@ -661,12 +752,23 @@ function GrantLine({ function EffectBadge({ effect }: { effect: GrantUse["effect"] }) { const tone = - effect === "allow" ? "success" : effect === "ask" ? "warning" : effect === "none" ? "neutral" : "danger"; + effect === "allow" + ? "success" + : effect === "ask" + ? "warning" + : effect === "none" + ? "neutral" + : "danger"; return {effect}; } function SignatureBadge({ status }: { status: SignatureStatus }) { - const tone = status === "valid" ? "success" : status === "invalid" ? "danger" : "warning"; + const tone = + status === "valid" + ? "success" + : status === "invalid" + ? "danger" + : "warning"; return {status}; } @@ -720,7 +822,11 @@ function NetworkBlock({ hops }: { hops: readonly NetworkHop[] }) {

{hop.status === null ? "no status" : hop.status} {formatBytes(hop.bytes)} - {hop.external ? External : Internal} + {hop.external ? ( + External + ) : ( + Internal + )}

))} diff --git a/src/ui/observability/wording.ts b/src/ui/observability/wording.ts index f8b6c7dd..c0db62cb 100644 --- a/src/ui/observability/wording.ts +++ b/src/ui/observability/wording.ts @@ -40,9 +40,12 @@ function whatItDid(span: Span): string | undefined { const pattern = text(record, "pattern"); const path = text(record, "path") ?? text(record, "file"); if (pattern !== undefined && !span.name.includes(pattern)) { - return path === undefined ? `Find ${pattern}` : `Find ${pattern} in ${path}`; + return path === undefined + ? `Find ${pattern}` + : `Find ${pattern} in ${path}`; } - if (path !== undefined && !span.name.includes(path)) return `${verb(span.name)} ${path}`; + if (path !== undefined && !span.name.includes(path)) + return `${verb(span.name)} ${path}`; const url = text(record, "url"); if (url !== undefined) return shortUrl(url); return undefined; @@ -64,7 +67,9 @@ function shortUrl(url: string): string { } } -function recordOf(input: string | undefined): Record | undefined { +function recordOf( + input: string | undefined, +): Record | undefined { if (input === undefined || !input.startsWith("{")) return undefined; try { const value: unknown = JSON.parse(input); @@ -79,7 +84,10 @@ function recordOf(input: string | undefined): Record | undefine return undefined; } -function text(record: Record, key: string): string | undefined { +function text( + record: Record, + key: string, +): string | undefined { const value = record[key]; return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/stories/observability/fixtures.ts b/stories/observability/fixtures.ts index ab4f84ba..0237e2c7 100644 --- a/stories/observability/fixtures.ts +++ b/stories/observability/fixtures.ts @@ -111,7 +111,8 @@ const allowRead: GrantUse = { const askShell: GrantUse = { effect: "ask", blocked: true, - blockReason: "The creator grant requires approval before a shell command runs.", + blockReason: + "The creator grant requires approval before a shell command runs.", resolvedBy: shellAsk, matching: [shellAsk, shellDeny], }; @@ -342,7 +343,8 @@ export const TRACES: Record = { costUsd: 0.07, }, input: "Review the diff for correctness and missing tests.", - output: "The trailing comma is the missing token. I will not run the suite until the gate clears.", + output: + "The trailing comma is the missing token. I will not run the suite until the gate clears.", }, { id: "thought", @@ -374,7 +376,8 @@ export const TRACES: Record = { status: "ok", startMs: 52_000, durationMs: 400, - output: "The assertion expects four tokens. The splitter drops a trailing comma.", + output: + "The assertion expects four tokens. The splitter drops a trailing comma.", }, { id: "tool-shell", @@ -497,7 +500,10 @@ export const TRACES: Record = { "You edit release notes. Stay inside the workspace. When a write is refused, say so and stop. Do not invent a version number.", tools: [ { name: "read_file", description: "Read one file by its path." }, - { name: "write_file", description: "Replace one file. Refused outside the workspace." }, + { + name: "write_file", + description: "Replace one file. Refused outside the workspace.", + }, { name: "grep", description: "Find a pattern under a path." }, ], }, @@ -612,7 +618,8 @@ export const TRACES: Record = { durationMs: 120, nudge: { action: "compact", - because: "The notes were too long, so the director asked for a shorter history.", + because: + "The notes were too long, so the director asked for a shorter history.", }, }, { @@ -627,13 +634,19 @@ export const TRACES: Record = { compactor: "summarize", version: "1", reason: "input-budget", - detail: "The prompt crossed the input budget, so older turns were folded.", + detail: + "The prompt crossed the input budget, so older turns were folded.", turnsBefore: 6, turnsAfter: 2, tokensBefore: 4_200, tokensAfter: 1_100, turns: [ - { role: "user", title: "Write the notes for the parser fix.", tokens: 180, kept: true }, + { + role: "user", + title: "Write the notes for the parser fix.", + tokens: 180, + kept: true, + }, { role: "assistant", title: "I will open the existing notes and the diff.", @@ -684,7 +697,8 @@ export const TRACES: Record = { version: "1", trigger: "pre-inference", reason: "stale-tool-result", - summary: "The prompt dropped the old file listing. The saved history still has it.", + summary: + "The prompt dropped the old file listing. The saved history still has it.", }, }, { @@ -761,7 +775,10 @@ export const TRACES: Record = { systemPrompt: "You investigate a failing check. Run the test only after a person approves the command. Report the assertion that failed.", tools: [ - { name: "shell", description: "Run one command. A shell command waits for approval." }, + { + name: "shell", + description: "Run one command. A shell command waits for approval.", + }, ], }, spans: [ diff --git a/stories/observability/observability.stories.tsx b/stories/observability/observability.stories.tsx index c55aa564..644f0111 100644 --- a/stories/observability/observability.stories.tsx +++ b/stories/observability/observability.stories.tsx @@ -1,14 +1,25 @@ import { ObservabilityApp } from "../../src/blocks/observability/app.js"; import { AreaChart } from "../../src/ui/observability/area-chart.js"; import { SessionTable } from "../../src/ui/observability/session-table.js"; -import { WorkflowFinOps, WorkflowsFinOps } from "../../src/ui/observability/slots.js"; -import { AgentTrace, WorkflowTrace } from "../../src/ui/observability/trace-view.js"; +import { + WorkflowFinOps, + WorkflowsFinOps, +} from "../../src/ui/observability/slots.js"; +import { + AgentTrace, + WorkflowTrace, +} from "../../src/ui/observability/trace-view.js"; import { FAILURES, SERIES, SESSIONS, TRACES } from "./fixtures.js"; export default { title: "Observability / Spike" }; export const App = () => ( - + ); export const Area = () => ( @@ -26,7 +37,11 @@ export const Area = () => ( export const Sessions = () => (
- {}} /> + {}} + />
); From 326f4502acf92882b968c25e7cd92efac950a946 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 08:08:50 -0700 Subject: [PATCH 10/14] fix: mark the session table as a client component The row button is an inline handler, so dep-guard requires "use client". --- src/ui/observability/session-table.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/observability/session-table.tsx b/src/ui/observability/session-table.tsx index f3d90a75..c19c939e 100644 --- a/src/ui/observability/session-table.tsx +++ b/src/ui/observability/session-table.tsx @@ -1,3 +1,5 @@ +"use client"; + import { cn } from "../../lib/utils.js"; import { Badge } from "../badge.js"; import { formatCost, formatDuration, formatTokens } from "./format.js"; From c7a8079e54272e4caf964b3d2d48dad50791abb2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 08:08:51 -0700 Subject: [PATCH 11/14] fix: keep unread trace rows at full contrast Fading future steps and the clock total dropped below the axe contrast bar. --- src/ui/observability/timeline.tsx | 2 +- src/ui/observability/trace-view.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ui/observability/timeline.tsx b/src/ui/observability/timeline.tsx index 9b15de07..b2b6fbf4 100644 --- a/src/ui/observability/timeline.tsx +++ b/src/ui/observability/timeline.tsx @@ -141,7 +141,7 @@ export function TraceTimeline({ {formatClock(timeMs)} - / {formatClock(end)} + / {formatClock(end)}
diff --git a/src/ui/observability/trace-view.tsx b/src/ui/observability/trace-view.tsx index 041d048f..902513e7 100644 --- a/src/ui/observability/trace-view.tsx +++ b/src/ui/observability/trace-view.tsx @@ -245,7 +245,6 @@ export function TraceView({
{placed.map(({ span, depth }) => { const selectedRow = span.id === selectedId; - const future = span.startMs > timeMs; const played = playedRatio(span, timeMs); return (
-
- - point.value)} - sparklineLabel="Sessions across the week" - /> - - - - -
- - -
-
-
-

Cost by model

-
    - {shares.map((share) => ( -
  • - - {share.provider} / {share.model} - - - {formatCost(share.costUsd)} · {formatTokens(share.tokens)} - -
  • - ))} -
-
-
-

Latency by run

-
    - {sessions.map((session) => ( -
  • - {session.name} - - {formatDuration(session.durationMs)} ·{" "} - {formatCost(session.costUsd)} - -
  • - ))} -
-
-
- - ); -} - function NavButton({ current, onClick, diff --git a/src/ui/observability/cost-bars.tsx b/src/ui/observability/cost-bars.tsx new file mode 100644 index 00000000..a67d3d80 --- /dev/null +++ b/src/ui/observability/cost-bars.tsx @@ -0,0 +1,50 @@ +import { formatCost } from "./format.js"; + +export type CostBarRow = { + readonly key: string; + readonly label: string; + readonly value: number; + readonly detail: string; +}; + +export function CostBars({ + title, + description, + rows, +}: { + readonly title: string; + readonly description: string; + readonly rows: readonly CostBarRow[]; +}) { + const max = Math.max(0.0001, ...rows.map((row) => row.value)); + return ( +
+
+

{title}

+

{description}

+
+
    + {rows.map((row) => ( +
  • +
    +

    {row.label}

    +
    +
    +
    +
    +

    + {formatCost(row.value)} + {row.detail} +

    +
  • + ))} +
+
+ ); +} diff --git a/src/ui/observability/slots.tsx b/src/ui/observability/finops.tsx similarity index 62% rename from src/ui/observability/slots.tsx rename to src/ui/observability/finops.tsx index cedc5242..e21f1cf3 100644 --- a/src/ui/observability/slots.tsx +++ b/src/ui/observability/finops.tsx @@ -1,6 +1,3 @@ -"use client"; - -import { seriesColor } from "../../lib/chart-palette.js"; import { StatGrid, StatGridItem } from "../stat-grid.js"; import { Table, @@ -10,12 +7,14 @@ import { TableHeader, TableRow, } from "../table.js"; +import { CostBars } from "./cost-bars.js"; import { formatCost, formatDuration, formatSpeed, formatTokens, } from "./format.js"; +import { Ledger } from "./ledger.js"; import { measures, modelShares, @@ -24,10 +23,10 @@ import { shapeOf, stepKeys, usageOf, - type MeasureSort, type UsageBreakdown, } from "./metrics.js"; import type { Trace } from "./model.js"; +import { UsageMix } from "./usage-mix.js"; import { stepTitle } from "./wording.js"; export function WorkflowsFinOps({ @@ -202,76 +201,7 @@ function ProviderNotes({ shares }: { shares: ReturnType }) { ); } -export function Ledger({ - trace, - sort, - selectedId, - onSelect, -}: { - readonly trace: Trace; - readonly sort: MeasureSort; - readonly selectedId?: string; - readonly onSelect?: (id: string) => void; -}) { - const rows = measures(trace, sort); - return ( -
- {turn.title} + + {turn.title} + {turn.note === undefined ? null : ( - {turn.note} + + {turn.note} + )} {formatTokens(turn.tokens)} + {formatTokens(turn.tokens)} + {turn.kept ? "Kept" : "Dropped"}
- - - Step - Provider - Tokens - Cost - Time - Speed - - - - {rows.map((row) => { - const span = trace.spans.find((item) => item.id === row.id); - const title = span === undefined ? row.name : stepTitle(span); - return ( - - - {onSelect === undefined ? ( - title - ) : ( - - )} - - - {row.provider === "—" ? "—" : `${row.provider}/${row.model}`} - - - {row.tokens === 0 ? "—" : formatTokens(row.tokens)} - - - {row.costUsd === 0 ? "—" : formatCost(row.costUsd)} - - - {formatDuration(row.durationMs)} - - - {formatSpeed(row.tokensPerSecond)} - - - ); - })} - -
- ); -} - -export function CostPicture({ trace }: { readonly trace: Trace }) { +export function StepCosts({ trace }: { readonly trace: Trace }) { const priced = measures(trace, "cost").filter((row) => row.costUsd > 0); return ( -

Usage

-

- Cache read is a slice of input, not an extra charge on top of it. -

-
    - {USAGE_PARTS.map(([key, label]) => { - const value = usage[key]; - return ( -
  • - {label} - - - - - {value === 0 ? "—" : formatTokens(value)} - -
  • - ); - })} -
-
- ); -} - export function Shape({ trace }: { readonly trace: Trace }) { const nodes = shapeOf(trace); const keys = stepKeys(trace.spans); @@ -412,50 +282,3 @@ function titlesFor( } return titles.join(" · "); } - -function CostBars({ - title, - description, - rows, -}: { - title: string; - description: string; - rows: readonly { - key: string; - label: string; - value: number; - detail: string; - }[]; -}) { - const max = Math.max(0.0001, ...rows.map((row) => row.value)); - return ( -
-
-

{title}

-

{description}

-
-
    - {rows.map((row) => ( -
  • -
    -

    {row.label}

    -
    -
    -
    -
    -

    - {formatCost(row.value)} - {row.detail} -

    -
  • - ))} -
-
- ); -} diff --git a/src/ui/observability/ledger.tsx b/src/ui/observability/ledger.tsx new file mode 100644 index 00000000..8cdc580f --- /dev/null +++ b/src/ui/observability/ledger.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../table.js"; +import { + formatCost, + formatDuration, + formatSpeed, + formatTokens, +} from "./format.js"; +import { measures, type MeasureSort } from "./metrics.js"; +import type { Trace } from "./model.js"; +import { stepTitle } from "./wording.js"; + +export function Ledger({ + trace, + sort, + selectedId, + onSelect, +}: { + readonly trace: Trace; + readonly sort: MeasureSort; + readonly selectedId?: string; + readonly onSelect?: (id: string) => void; +}) { + const rows = measures(trace, sort); + return ( + + + + Step + Provider + Tokens + Cost + Time + Speed + + + + {rows.map((row) => { + const span = trace.spans.find((item) => item.id === row.id); + const title = span === undefined ? row.name : stepTitle(span); + return ( + + + {onSelect === undefined ? ( + title + ) : ( + + )} + + + {row.provider === "—" ? "—" : `${row.provider}/${row.model}`} + + + {row.tokens === 0 ? "—" : formatTokens(row.tokens)} + + + {row.costUsd === 0 ? "—" : formatCost(row.costUsd)} + + + {formatDuration(row.durationMs)} + + + {formatSpeed(row.tokensPerSecond)} + + + ); + })} + +
+ ); +} diff --git a/src/ui/observability/overview.tsx b/src/ui/observability/overview.tsx new file mode 100644 index 00000000..35449b60 --- /dev/null +++ b/src/ui/observability/overview.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { Button } from "../button.js"; +import { StatGrid, StatGridItem } from "../stat-grid.js"; +import { AreaChart } from "./area-chart.js"; +import { formatCost, formatDuration, formatTokens } from "./format.js"; +import { observationRows, portfolio, rollup, usageOf } from "./metrics.js"; +import type { SeriesPoint, SessionSummary, Trace } from "./model.js"; +import { UsageMix } from "./usage-mix.js"; + +export function WeekOverview({ + series, + failures, + sessions, + traces, + onOpen, +}: { + readonly series: readonly SeriesPoint[]; + readonly failures?: readonly SeriesPoint[]; + readonly sessions: readonly SessionSummary[]; + readonly traces: readonly Trace[]; + readonly onOpen: () => void; +}) { + const failed = sessions.filter( + (session) => session.status === "error", + ).length; + const tokens = sessions.reduce((sum, session) => sum + session.tokens, 0); + const cost = traces.reduce((sum, trace) => sum + rollup(trace).costUsd, 0); + const usage = traces.reduce( + (sum, trace) => { + const part = usageOf(trace); + return { + input: sum.input + part.input, + output: sum.output + part.output, + cacheRead: sum.cacheRead + part.cacheRead, + cacheWrite: sum.cacheWrite + part.cacheWrite, + thinking: sum.thinking + part.thinking, + }; + }, + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + ); + const shares = portfolio(traces); + const steps = observationRows(traces).length; + return ( +
+
+

This week

+ +
+ + point.value)} + sparklineLabel="Sessions across the week" + /> + + + + +
+ + +
+
+
+

Cost by model

+
    + {shares.map((share) => ( +
  • + + {share.provider} / {share.model} + + + {formatCost(share.costUsd)} · {formatTokens(share.tokens)} + +
  • + ))} +
+
+
+

Latency by run

+
    + {sessions.map((session) => ( +
  • + {session.name} + + {formatDuration(session.durationMs)} ·{" "} + {formatCost(session.costUsd)} + +
  • + ))} +
+
+
+
+ ); +} diff --git a/src/ui/observability/span-inspector.tsx b/src/ui/observability/span-inspector.tsx new file mode 100644 index 00000000..8219f610 --- /dev/null +++ b/src/ui/observability/span-inspector.tsx @@ -0,0 +1,443 @@ +import { Badge } from "../badge.js"; +import { InspectorKv } from "../inspector-shell.js"; +import { + formatBytes, + formatClock, + formatDuration, + formatSpeed, + formatTokens, + spanTokens, +} from "./format.js"; +import { usageOfSpan, type UsageBreakdown } from "./metrics.js"; +import type { + AgentBrief as AgentBriefModel, + Compaction, + ContextChange, + DirectorNudge, + GrantUse, + MediaItem, + NetworkHop, + SignatureStatus, + Span, + TraceLayers, +} from "./model.js"; +import { revealedText, spanEnd } from "./playback.js"; +import { UsageMix } from "./usage-mix.js"; +import { compactionLine } from "./wording.js"; + +function usageTotal(usage: UsageBreakdown): number { + return ( + usage.input + + usage.output + + usage.cacheRead + + usage.cacheWrite + + usage.thinking + ); +} + +export function SpanInspector({ + traceSpans, + span, + timeMs, + layers, + playing, +}: { + traceSpans: readonly Span[]; + span: Span; + timeMs: number; + layers: TraceLayers; + playing: boolean; +}) { + const thoughts = thoughtSpans(traceSpans, span); + return ( +
+ {usageTotal(usageOfSpan(span)) > 0 ? ( + + ) : null} + 0 + ? spanTokens(span) / (span.durationMs / 1000) + : null, + ), + mono: true, + }, + ]), + ...(span.usage === undefined + ? [] + : [ + { + label: "Tokens", + value: formatTokens(spanTokens(span)), + mono: true, + }, + ]), + ...(span.signature === undefined + ? [] + : [ + { + label: "Signature", + value: , + }, + ]), + ...(span.attributes ?? []).map((attribute) => ({ + label: attribute.label, + value: attribute.value, + mono: true, + })), + ]} + /> + {span.nudge === undefined ? null : } + {span.compaction === undefined ? null : ( + + )} + {span.contextChange === undefined ? null : ( + + )} + {layers.grants && span.grant !== undefined ? ( + + ) : null} + {layers.reasoning + ? thoughts.map((thought) => ( + + )) + : null} + {layers.io && span.input !== undefined ? ( + + ) : null} + {layers.io && span.output !== undefined ? ( + + ) : null} + {layers.media && span.media !== undefined && span.media.length > 0 ? ( + + ) : null} + {layers.network && + span.network !== undefined && + span.network.length > 0 ? ( + + ) : null} + {layers.raw ? ( + + ) : null} +
+ ); +} + +function thoughtSpans(spans: readonly Span[], span: Span): readonly Span[] { + if (span.thinking !== undefined || span.redacted === true) return [span]; + return spans.filter( + (item) => + item.parentId === span.id && + item.kind === "reasoning" && + (item.thinking !== undefined || item.redacted === true), + ); +} + +export function AgentBrief({ brief }: { brief: AgentBriefModel }) { + return ( +
+
+

System prompt

+

+ {brief.systemPrompt} +

+
+
+

Tools

+
    + {brief.tools.map((tool) => ( +
  • + + {tool.name} + + {tool.description} +
  • + ))} +
+
+
+ ); +} + +function NudgeBlock({ nudge }: { nudge: DirectorNudge }) { + return ( +
+

{nudge.action}

+

{nudge.because}

+
+ ); +} + +function CompactionBlock({ compaction }: { compaction: Compaction }) { + return ( +
+
+

Compaction

+

+ {compaction.turnsBefore} turns down to {compaction.turnsAfter} + {` · ${compactionLine(compaction)}`} +

+

{compaction.detail}

+

+ {compaction.compactor} v{compaction.version} · {compaction.reason} +

+
+ + + + + + + + + + {compaction.turns.map((turn) => ( + + + + + + ))} + +
TurnTokensKept
+ + {turn.title} + + {turn.note === undefined ? null : ( + + {turn.note} + + )} + + {formatTokens(turn.tokens)} + {turn.kept ? "Kept" : "Dropped"}
+
+ ); +} + +function ContextBlock({ change }: { change: ContextChange }) { + return ( +
+

Context transform

+

{change.summary}

+

+ {change.strategy} v{change.version} · {change.trigger} · {change.reason} +

+
+ ); +} + +function ThoughtBlock({ + span, + timeMs, + playing, +}: { + span: Span; + timeMs: number; + playing: boolean; +}) { + const started = timeMs > span.startMs; + const done = timeMs >= spanEnd(span); + const streaming = playing && started && !done; + return ( +
+

Thinking

+ {span.redacted === true || span.thinking === undefined ? ( +

Redacted by the model.

+ ) : playing && !started ? ( +

+ Starts at {formatClock(span.startMs)}. +

+ ) : ( +

+ {streaming + ? revealedText(span.thinking, span, timeMs) + : span.thinking} + {streaming ? ( + + ) : null} +

+ )} +
+ ); +} + +function GrantBlock({ grant }: { grant: GrantUse }) { + return ( +
+
+

Grant

+ + {grant.blocked ? Blocked : null} +
+ {grant.blockReason === undefined ? null : ( +

{grant.blockReason}

+ )} + {grant.resolvedBy === null ? ( +

+ Nothing matched. Failed closed. +

+ ) : ( + + )} + {grant.matching.length === 0 ? null : ( +
    + {grant.matching.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +function GrantLine({ + label, + grant, +}: { + label: string; + grant: NonNullable; +}) { + const exact = + grant.specificity === undefined + ? "" + : ` · specificity ${grant.specificity}`; + return ( +

+ {label} + {grant.id} · {grant.effect} · {grant.origin} +
+ {grant.resource} {grant.action} + {exact} +

+ ); +} + +function EffectBadge({ effect }: { effect: GrantUse["effect"] }) { + const tone = + effect === "allow" + ? "success" + : effect === "ask" + ? "warning" + : effect === "none" + ? "neutral" + : "danger"; + return {effect}; +} + +function SignatureBadge({ status }: { status: SignatureStatus }) { + const tone = + status === "valid" + ? "success" + : status === "invalid" + ? "danger" + : "warning"; + return {status}; +} + +function TextBlock({ label, text }: { label: string; text: string }) { + return ( +
+

{label}

+
+        {text}
+      
+
+ ); +} + +function MediaBlock({ items }: { items: readonly MediaItem[] }) { + return ( +
+

Attached

+
    + {items.map((item) => ( +
  • + {item.mediaType.startsWith("image/") ? ( +
    + {item.mediaType} +
    + ) : null} +

    {item.name}

    +

    + {item.mediaType} · {formatBytes(item.bytes)} +

    + {item.note === undefined ? null : ( +

    {item.note}

    + )} +
  • + ))} +
+
+ ); +} + +function NetworkBlock({ hops }: { hops: readonly NetworkHop[] }) { + return ( +
+

Network

+
    + {hops.map((hop) => ( +
  • +

    + {hop.method} {hop.url} +

    +

    + {hop.status === null ? "no status" : hop.status} + {formatBytes(hop.bytes)} + {hop.external ? ( + External + ) : ( + Internal + )} +

    +
  • + ))} +
+
+ ); +} + +function rawSpan(span: Span): string { + return JSON.stringify(span, null, 2); +} diff --git a/src/ui/observability/trace-header.tsx b/src/ui/observability/trace-header.tsx new file mode 100644 index 00000000..6e190687 --- /dev/null +++ b/src/ui/observability/trace-header.tsx @@ -0,0 +1,118 @@ +import { Badge } from "../badge.js"; +import { + formatCost, + formatDuration, + formatTokens, + spanTokens, +} from "./format.js"; +import type { Span, SpanStatus, Trace } from "./model.js"; +import { stepTitle } from "./wording.js"; + +export const STATUS_LABEL: Record = { + running: "Running", + ok: "Done", + error: "Failed", + waiting: "Waiting", + denied: "Denied", +}; + +export const STATUS_TONE = { + running: "info", + ok: "success", + error: "danger", + waiting: "warning", + denied: "danger", +} as const; + +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 ( +
+
+
+
+

+ {trace.name} +

+ + {trace.kind === "agent" ? "Agent" : "Workflow"} + + + {STATUS_LABEL[trace.status]} + +
+

+ {trace.actor} · {trace.startedAt} +

+
+
+ + + + +
+
+ {trace.placement === undefined ? null : ( +
+ + + + + + + +
+ )} +
+ ); +} + +export function LiveGrant({ span }: { readonly span: Span | undefined }) { + if (span?.grant === undefined) { + return ( +

+ No grant at the playhead. +

+ ); + } + const grant = span.grant; + const winner = grant.resolvedBy; + const stopped = grant.blocked ? " · blocked" : ""; + const by = winner === null ? "" : ` · ${winner.id}`; + return ( +

+ Grant · + {stepTitle(span)} · {grant.effect} + {stopped} + {by} +

+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function PlacementItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/ui/observability/trace-steps.tsx b/src/ui/observability/trace-steps.tsx new file mode 100644 index 00000000..3f43fb35 --- /dev/null +++ b/src/ui/observability/trace-steps.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { seriesColor } from "../../lib/chart-palette.js"; +import { cn } from "../../lib/utils.js"; +import { formatDuration } from "./format.js"; +import type { Span } from "./model.js"; +import { spanEnd } from "./playback.js"; +import type { PlacedSpan } from "./tree.js"; +import { rowMarker, stepTitle } from "./wording.js"; + +export function TraceSteps({ + placed, + end, + timeMs, + selectedId, + onSeek, + rowRef, +}: { + readonly placed: readonly PlacedSpan[]; + readonly end: number; + readonly timeMs: number; + readonly selectedId: string; + readonly onSeek: (id: string, startMs: number) => void; + readonly rowRef?: (id: string, node: HTMLButtonElement | null) => void; +}) { + const index = placed.findIndex((row) => row.span.id === selectedId); + function move(delta: number) { + const next = placed[index + delta]; + if (next !== undefined) onSeek(next.span.id, next.span.startMs); + } + return ( +
{ + if (event.key === "ArrowDown") { + event.preventDefault(); + move(1); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + move(-1); + } + }} + > +
+ Step + + 0 + {formatDuration(end)} + +
+ {placed.map(({ span, depth }) => { + const selectedRow = span.id === selectedId; + const played = playedRatio(span, timeMs); + return ( +
+ + + ); +} + +function playedRatio(span: Span, timeMs: number): number { + if (timeMs <= span.startMs) return 0; + if (timeMs >= spanEnd(span)) return 1; + return (timeMs - span.startMs) / Math.max(span.durationMs, 1); +} + +function barBox(span: Span, end: number): { left: string; width: string } { + const width = (Math.max(span.durationMs, 1) / end) * 100; + return { + left: `${(span.startMs / end) * 100}%`, + width: `max(4px, ${width}%)`, + }; +} + +function ink(amount: number): string { + return `color-mix(in oklab, var(--foreground) ${amount}%, transparent)`; +} + +function barColor(span: Span): string { + if (span.status === "error" || span.status === "denied") + return "var(--destructive)"; + if (span.status === "waiting") return "var(--warn)"; + if (span.status === "running") return "var(--foreground)"; + if (span.kind === "network") return seriesColor(0); + if (span.kind === "mail" || span.kind === "attachment") return seriesColor(1); + if (span.kind === "provision" || span.kind === "reactor") + return seriesColor(3); + if (span.kind === "reasoning") return ink(40); + if (span.kind === "compact") return seriesColor(2); + if (span.kind === "transform") return seriesColor(3); + if (span.kind === "nudge") return ink(60); + return ink(75); +} diff --git a/src/ui/observability/trace-view.tsx b/src/ui/observability/trace-view.tsx index 902513e7..f1212453 100644 --- a/src/ui/observability/trace-view.tsx +++ b/src/ui/observability/trace-view.tsx @@ -3,50 +3,23 @@ import { useEffect, useRef } from "react"; import { useControllableState } from "../../hooks/use-controllable-state.js"; -import { seriesColor } from "../../lib/chart-palette.js"; import { cn } from "../../lib/utils.js"; import { Badge } from "../badge.js"; +import { InspectorHeader, InspectorShell } from "../inspector-shell.js"; +import { formatClock, formatDuration } from "./format.js"; +import { DEFAULT_LAYERS, type Trace, type TraceLayers } from "./model.js"; +import { deepestAt, grantAt, visibleSpans } from "./playback.js"; +import { AgentBrief, SpanInspector } from "./span-inspector.js"; import { - InspectorHeader, - InspectorKv, - InspectorShell, -} from "../inspector-shell.js"; -import { - formatBytes, - formatClock, - formatCost, - formatDuration, - formatSpeed, - formatTokens, - spanTokens, -} from "./format.js"; -import { - DEFAULT_LAYERS, - type AgentBrief, - type Compaction, - type ContextChange, - type DirectorNudge, - type GrantUse, - type MediaItem, - type NetworkHop, - type SignatureStatus, - type Span, - type SpanStatus, - type Trace, - type TraceLayers, -} from "./model.js"; -import { usageOfSpan, type UsageBreakdown } from "./metrics.js"; -import { UsageMix } from "./slots.js"; -import { - deepestAt, - grantAt, - revealedText, - spanEnd, - visibleSpans, -} from "./playback.js"; + LiveGrant, + STATUS_LABEL, + STATUS_TONE, + TraceHeader, +} from "./trace-header.js"; +import { TraceSteps } from "./trace-steps.js"; import { TraceTimeline } from "./timeline.js"; import { placeSpans, traceEnd } from "./tree.js"; -import { compactionLine, rowMarker, stepTitle } from "./wording.js"; +import { rowMarker, stepTitle } from "./wording.js"; export type TraceViewProps = { readonly trace: Trace; @@ -63,22 +36,6 @@ export type TraceViewProps = { readonly className?: string; }; -const STATUS_LABEL: Record = { - running: "Running", - ok: "Done", - error: "Failed", - waiting: "Waiting", - denied: "Denied", -}; - -const STATUS_TONE = { - running: "info", - ok: "success", - error: "danger", - waiting: "warning", - denied: "danger", -} as const; - export function AgentTrace(props: TraceViewProps) { return ; } @@ -126,7 +83,6 @@ export function TraceView({ const selected = trace.spans.find((span) => span.id === selectedId); const end = Math.max(trace.durationMs, traceEnd(trace.spans), 1); const liveGrant = layers.grants ? grantAt(shown, timeMs) : undefined; - const index = placed.findIndex((row) => row.span.id === selectedId); const rowRefs = useRef(new Map()); useEffect(() => { @@ -146,66 +102,10 @@ export function TraceView({ setTimeMs(at); } - function move(delta: number) { - const next = placed[index + delta]; - if (next !== undefined) seek(next.span.id, next.span.startMs); - } - - 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 (
-
-
-
-
-

- {trace.name} -

- - {trace.kind === "agent" ? "Agent" : "Workflow"} - - - {STATUS_LABEL[trace.status]} - -
-

- {trace.actor} · {trace.startedAt} -

-
-
- - - - -
-
- {trace.placement === undefined ? null : ( -
- - - - - - - -
- )} -
- {trace.brief === undefined ? null : ( - - )} - + + {trace.brief === undefined ? null : }
-
-
{ - if (event.key === "ArrowDown") { - event.preventDefault(); - move(1); - } - if (event.key === "ArrowUp") { - event.preventDefault(); - move(-1); - } + { + if (node === null) rowRefs.current.delete(id); + else rowRefs.current.set(id, node); }} - > -
- Step - - 0 - {formatDuration(end)} - -
- {placed.map(({ span, depth }) => { - const selectedRow = span.id === selectedId; - const played = playedRatio(span, timeMs); - return ( -
- - + /> {selected === undefined ? null : ( - ); } - -function playedRatio(span: Span, timeMs: number): number { - if (timeMs <= span.startMs) return 0; - if (timeMs >= spanEnd(span)) return 1; - return (timeMs - span.startMs) / Math.max(span.durationMs, 1); -} - -function barBox(span: Span, end: number): { left: string; width: string } { - const width = (Math.max(span.durationMs, 1) / end) * 100; - return { - left: `${(span.startMs / end) * 100}%`, - width: `max(4px, ${width}%)`, - }; -} - -function ink(amount: number): string { - return `color-mix(in oklab, var(--foreground) ${amount}%, transparent)`; -} - -function barColor(span: Span): string { - if (span.status === "error" || span.status === "denied") - return "var(--destructive)"; - if (span.status === "waiting") return "var(--warn)"; - if (span.status === "running") return "var(--foreground)"; - if (span.kind === "network") return seriesColor(0); - if (span.kind === "mail" || span.kind === "attachment") return seriesColor(1); - if (span.kind === "provision" || span.kind === "reactor") - return seriesColor(3); - if (span.kind === "reasoning") return ink(40); - if (span.kind === "compact") return seriesColor(2); - if (span.kind === "transform") return seriesColor(3); - if (span.kind === "nudge") return ink(60); - return ink(75); -} - -function usageTotal(usage: UsageBreakdown): number { - return ( - usage.input + - usage.output + - usage.cacheRead + - usage.cacheWrite + - usage.thinking - ); -} - -function Metric({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function PlacementItem({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function LiveGrant({ span }: { span: Span | undefined }) { - if (span?.grant === undefined) { - return ( -

- No grant at the playhead. -

- ); - } - const grant = span.grant; - const winner = grant.resolvedBy; - const stopped = grant.blocked ? " · blocked" : ""; - const by = winner === null ? "" : ` · ${winner.id}`; - return ( -

- Grant · - {stepTitle(span)} · {grant.effect} - {stopped} - {by} -

- ); -} - -function SpanDetail({ - traceSpans, - span, - timeMs, - layers, - playing, -}: { - traceSpans: readonly Span[]; - span: Span; - timeMs: number; - layers: TraceLayers; - playing: boolean; -}) { - const thoughts = thoughtSpans(traceSpans, span); - return ( -
- {usageTotal(usageOfSpan(span)) > 0 ? ( - - ) : null} - 0 - ? spanTokens(span) / (span.durationMs / 1000) - : null, - ), - mono: true, - }, - ]), - ...(span.usage === undefined - ? [] - : [ - { - label: "Tokens", - value: formatTokens(spanTokens(span)), - mono: true, - }, - ]), - ...(span.signature === undefined - ? [] - : [ - { - label: "Signature", - value: , - }, - ]), - ...(span.attributes ?? []).map((attribute) => ({ - label: attribute.label, - value: attribute.value, - mono: true, - })), - ]} - /> - {span.nudge === undefined ? null : } - {span.compaction === undefined ? null : ( - - )} - {span.contextChange === undefined ? null : ( - - )} - {layers.grants && span.grant !== undefined ? ( - - ) : null} - {layers.reasoning - ? thoughts.map((thought) => ( - - )) - : null} - {layers.io && span.input !== undefined ? ( - - ) : null} - {layers.io && span.output !== undefined ? ( - - ) : null} - {layers.media && span.media !== undefined && span.media.length > 0 ? ( - - ) : null} - {layers.network && - span.network !== undefined && - span.network.length > 0 ? ( - - ) : null} - {layers.raw ? ( - - ) : null} -
- ); -} - -function thoughtSpans(spans: readonly Span[], span: Span): readonly Span[] { - if (span.thinking !== undefined || span.redacted === true) return [span]; - return spans.filter( - (item) => - item.parentId === span.id && - item.kind === "reasoning" && - (item.thinking !== undefined || item.redacted === true), - ); -} - -function AgentBriefPanel({ brief }: { brief: AgentBrief }) { - return ( -
-
-

System prompt

-

- {brief.systemPrompt} -

-
-
-

Tools

-
    - {brief.tools.map((tool) => ( -
  • - - {tool.name} - - {tool.description} -
  • - ))} -
-
-
- ); -} - -function NudgeBlock({ nudge }: { nudge: DirectorNudge }) { - return ( -
-

{nudge.action}

-

{nudge.because}

-
- ); -} - -function CompactionBlock({ compaction }: { compaction: Compaction }) { - return ( -
-
-

Compaction

-

- {compaction.turnsBefore} turns down to {compaction.turnsAfter} - {` · ${compactionLine(compaction)}`} -

-

{compaction.detail}

-

- {compaction.compactor} v{compaction.version} · {compaction.reason} -

-
- - - - - - - - - - {compaction.turns.map((turn) => ( - - - - - - ))} - -
TurnTokensKept
- - {turn.title} - - {turn.note === undefined ? null : ( - - {turn.note} - - )} - - {formatTokens(turn.tokens)} - {turn.kept ? "Kept" : "Dropped"}
-
- ); -} - -function ContextBlock({ change }: { change: ContextChange }) { - return ( -
-

Context transform

-

{change.summary}

-

- {change.strategy} v{change.version} · {change.trigger} · {change.reason} -

-
- ); -} - -function ThoughtBlock({ - span, - timeMs, - playing, -}: { - span: Span; - timeMs: number; - playing: boolean; -}) { - const started = timeMs > span.startMs; - const done = timeMs >= spanEnd(span); - const streaming = playing && started && !done; - return ( -
-

Thinking

- {span.redacted === true || span.thinking === undefined ? ( -

Redacted by the model.

- ) : playing && !started ? ( -

- Starts at {formatClock(span.startMs)}. -

- ) : ( -

- {streaming - ? revealedText(span.thinking, span, timeMs) - : span.thinking} - {streaming ? ( - - ) : null} -

- )} -
- ); -} - -function GrantBlock({ grant }: { grant: GrantUse }) { - return ( -
-
-

Grant

- - {grant.blocked ? Blocked : null} -
- {grant.blockReason === undefined ? null : ( -

{grant.blockReason}

- )} - {grant.resolvedBy === null ? ( -

- Nothing matched. Failed closed. -

- ) : ( - - )} - {grant.matching.length === 0 ? null : ( -
    - {grant.matching.map((item) => ( -
  • - -
  • - ))} -
- )} -
- ); -} - -function GrantLine({ - label, - grant, -}: { - label: string; - grant: NonNullable; -}) { - const exact = - grant.specificity === undefined - ? "" - : ` · specificity ${grant.specificity}`; - return ( -

- {label} - {grant.id} · {grant.effect} · {grant.origin} -
- {grant.resource} {grant.action} - {exact} -

- ); -} - -function EffectBadge({ effect }: { effect: GrantUse["effect"] }) { - const tone = - effect === "allow" - ? "success" - : effect === "ask" - ? "warning" - : effect === "none" - ? "neutral" - : "danger"; - return {effect}; -} - -function SignatureBadge({ status }: { status: SignatureStatus }) { - const tone = - status === "valid" - ? "success" - : status === "invalid" - ? "danger" - : "warning"; - return {status}; -} - -function TextBlock({ label, text }: { label: string; text: string }) { - return ( -
-

{label}

-
-        {text}
-      
-
- ); -} - -function MediaBlock({ items }: { items: readonly MediaItem[] }) { - return ( -
-

Attached

-
    - {items.map((item) => ( -
  • - {item.mediaType.startsWith("image/") ? ( -
    - {item.mediaType} -
    - ) : null} -

    {item.name}

    -

    - {item.mediaType} · {formatBytes(item.bytes)} -

    - {item.note === undefined ? null : ( -

    {item.note}

    - )} -
  • - ))} -
-
- ); -} - -function NetworkBlock({ hops }: { hops: readonly NetworkHop[] }) { - return ( -
-

Network

-
    - {hops.map((hop) => ( -
  • -

    - {hop.method} {hop.url} -

    -

    - {hop.status === null ? "no status" : hop.status} - {formatBytes(hop.bytes)} - {hop.external ? ( - External - ) : ( - Internal - )} -

    -
  • - ))} -
-
- ); -} - -function rawSpan(span: Span): string { - return JSON.stringify(span, null, 2); -} diff --git a/src/ui/observability/usage-mix.tsx b/src/ui/observability/usage-mix.tsx new file mode 100644 index 00000000..38073246 --- /dev/null +++ b/src/ui/observability/usage-mix.tsx @@ -0,0 +1,63 @@ +import { seriesColor } from "../../lib/chart-palette.js"; +import { formatTokens } from "./format.js"; +import type { UsageBreakdown } from "./metrics.js"; + +const USAGE_PARTS = [ + ["input", "Input"], + ["output", "Output"], + ["cacheRead", "Cache read"], + ["cacheWrite", "Cache write"], + ["thinking", "Thinking"], +] as const; + +const USAGE_BAR = { + input: seriesColor(0), + output: seriesColor(1), + cacheRead: seriesColor(3), + cacheWrite: seriesColor(2), + thinking: "color-mix(in oklab, var(--foreground) 55%, transparent)", +} as const; + +export function UsageMix({ usage }: { readonly usage: UsageBreakdown }) { + const max = Math.max( + 1, + usage.input, + usage.output, + usage.cacheRead, + usage.cacheWrite, + usage.thinking, + ); + return ( +
+

Usage

+

+ Cache read is a slice of input, not an extra charge on top of it. +

+
    + {USAGE_PARTS.map(([key, label]) => { + const value = usage[key]; + return ( +
  • + {label} + + + + + {value === 0 ? "—" : formatTokens(value)} + +
  • + ); + })} +
+
+ ); +} diff --git a/stories/observability/agents.stories.tsx b/stories/observability/agents.stories.tsx new file mode 100644 index 00000000..c839648b --- /dev/null +++ b/stories/observability/agents.stories.tsx @@ -0,0 +1,28 @@ +import { + WorkflowFinOps, + WorkflowsFinOps, +} from "../../src/ui/observability/finops.js"; +import { AgentTrace } from "../../src/ui/observability/trace-view.js"; +import { trace, tracesOf } from "./fixtures.js"; + +export default { title: "Observability / Agents" }; + +export const AgentsFinOpsOverview = () => ( +
+ +
+); +AgentsFinOpsOverview.storyName = "Agents FinOps Overview"; + +export const AgentFinOpsOverview = () => ( +
+ +
+); +AgentFinOpsOverview.storyName = "Agent FinOps Overview"; + +export const Agent = () => ( +
+ +
+); diff --git a/stories/observability/app.stories.tsx b/stories/observability/app.stories.tsx new file mode 100644 index 00000000..73581098 --- /dev/null +++ b/stories/observability/app.stories.tsx @@ -0,0 +1,13 @@ +import { ObservabilityApp } from "../../src/blocks/observability/app.js"; +import { FAILURES, SERIES, SESSIONS, TRACES } from "./fixtures.js"; + +export default { title: "Observability / App" }; + +export const App = () => ( + +); diff --git a/stories/observability/fixtures.ts b/stories/observability/fixtures.ts index 0237e2c7..620adfa7 100644 --- a/stories/observability/fixtures.ts +++ b/stories/observability/fixtures.ts @@ -860,3 +860,13 @@ export const TRACES: Record = { ], }, }; + +export function trace(id: string): Trace { + const value = TRACES[id]; + if (value === undefined) throw new Error(`Missing fixture ${id}`); + return value; +} + +export function tracesOf(kind: Trace["kind"]): readonly Trace[] { + return Object.values(TRACES).filter((item) => item.kind === kind); +} diff --git a/stories/observability/observability.stories.tsx b/stories/observability/observability.stories.tsx deleted file mode 100644 index 644f0111..00000000 --- a/stories/observability/observability.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { ObservabilityApp } from "../../src/blocks/observability/app.js"; -import { AreaChart } from "../../src/ui/observability/area-chart.js"; -import { SessionTable } from "../../src/ui/observability/session-table.js"; -import { - WorkflowFinOps, - WorkflowsFinOps, -} from "../../src/ui/observability/slots.js"; -import { - AgentTrace, - WorkflowTrace, -} from "../../src/ui/observability/trace-view.js"; -import { FAILURES, SERIES, SESSIONS, TRACES } from "./fixtures.js"; - -export default { title: "Observability / Spike" }; - -export const App = () => ( - -); - -export const Area = () => ( -
- -
-); - -export const Sessions = () => ( -
- {}} - /> -
-); - -function trace(id: string) { - const value = TRACES[id]; - if (value === undefined) throw new Error(`Missing fixture ${id}`); - return value; -} - -export const Agent = () => ( -
- -
-); - -export const WorkflowsFinOpsOverview = () => ( -
- -
-); -WorkflowsFinOpsOverview.storyName = "Workflows FinOps Overview"; - -export const WorkflowFinOpsOverview = () => ( -
- -
-); -WorkflowFinOpsOverview.storyName = "Workflow FinOps Overview"; - -export const Workflow = () => ( -
- -
-); -Workflow.storyName = "Workflow"; diff --git a/stories/observability/overview.stories.tsx b/stories/observability/overview.stories.tsx new file mode 100644 index 00000000..0df9219c --- /dev/null +++ b/stories/observability/overview.stories.tsx @@ -0,0 +1,50 @@ +import { AreaChart } from "../../src/ui/observability/area-chart.js"; +import { Observations } from "../../src/ui/observability/observations.js"; +import { WeekOverview } from "../../src/ui/observability/overview.js"; +import { SessionTable } from "../../src/ui/observability/session-table.js"; +import { FAILURES, SERIES, SESSIONS, TRACES } from "./fixtures.js"; + +export default { title: "Observability / Overview" }; + +export const Week = () => ( +
+ {}} + /> +
+); + +export const SessionsChart = () => ( +
+ +
+); +SessionsChart.storyName = "Sessions chart"; + +export const Sessions = () => ( +
+ {}} + /> +
+); + +export const ObservationsPage = () => ( +
+ {}} /> +
+); +ObservationsPage.storyName = "Observations"; diff --git a/stories/observability/parts.stories.tsx b/stories/observability/parts.stories.tsx new file mode 100644 index 00000000..93639974 --- /dev/null +++ b/stories/observability/parts.stories.tsx @@ -0,0 +1,92 @@ +import { Ledger } from "../../src/ui/observability/ledger.js"; +import { usageOf } from "../../src/ui/observability/metrics.js"; +import { DEFAULT_LAYERS } from "../../src/ui/observability/model.js"; +import { Shape, StepCosts } from "../../src/ui/observability/finops.js"; +import { + AgentBrief, + SpanInspector, +} from "../../src/ui/observability/span-inspector.js"; +import { TraceSteps } from "../../src/ui/observability/trace-steps.js"; +import { TraceTimeline } from "../../src/ui/observability/timeline.js"; +import { placeSpans, traceEnd } from "../../src/ui/observability/tree.js"; +import { UsageMix } from "../../src/ui/observability/usage-mix.js"; +import { trace } from "./fixtures.js"; + +export default { title: "Observability / Parts" }; + +const review = trace("wf-review"); +const editor = trace("ag-editor"); + +export const Usage = () => ( +
+ +
+); + +export const LedgerPage = () => ( +
+ +
+); +LedgerPage.storyName = "Ledger"; + +export const CostByStep = () => ( +
+ +
+); +CostByStep.storyName = "Cost by step"; + +export const RunShape = () => ( +
+ +
+); +RunShape.storyName = "Shape"; + +export const Timeline = () => ( +
+ {}} + onPlayingChange={() => {}} + /> +
+); + +export const Steps = () => ( +
+ {}} + /> +
+); + +export const Brief = () => { + if (editor.brief === undefined) + throw new Error("editor fixture has no brief"); + return ; +}; + +export const Inspector = () => { + const span = editor.spans.find((item) => item.id === "e5"); + if (span === undefined) throw new Error("missing compaction span"); + return ( +
+ +
+ ); +}; diff --git a/stories/observability/workflows.stories.tsx b/stories/observability/workflows.stories.tsx new file mode 100644 index 00000000..a8419a4d --- /dev/null +++ b/stories/observability/workflows.stories.tsx @@ -0,0 +1,29 @@ +import { + WorkflowFinOps, + WorkflowsFinOps, +} from "../../src/ui/observability/finops.js"; +import { WorkflowTrace } from "../../src/ui/observability/trace-view.js"; +import { trace, tracesOf } from "./fixtures.js"; + +export default { title: "Observability / Workflows" }; + +export const WorkflowsFinOpsOverview = () => ( +
+ +
+); +WorkflowsFinOpsOverview.storyName = "Workflows FinOps Overview"; + +export const WorkflowFinOpsOverview = () => ( +
+ +
+); +WorkflowFinOpsOverview.storyName = "Workflow FinOps Overview"; + +export const Workflow = () => ( +
+ +
+); +Workflow.storyName = "Workflow"; From 4a0acbc26c85dcb2a25632aefcec1895d131810a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 23 Oct 2026 11:36:04 -0700 Subject: [PATCH 13/14] feat: show one step, and drop the shape page A step is the row and what it did. Shape, the ledger, and the chart were slices of other pages, so they are no longer their own stories. --- src/ui/observability/finops.tsx | 86 ------------- src/ui/observability/metrics.ts | 33 ----- src/ui/observability/trace-steps.tsx | 136 ++++++++++++--------- stories/observability/overview.stories.tsx | 15 --- stories/observability/parts.stories.tsx | 92 -------------- stories/observability/step.stories.tsx | 65 ++++++++++ 6 files changed, 146 insertions(+), 281 deletions(-) delete mode 100644 stories/observability/parts.stories.tsx create mode 100644 stories/observability/step.stories.tsx diff --git a/src/ui/observability/finops.tsx b/src/ui/observability/finops.tsx index e21f1cf3..85e0e721 100644 --- a/src/ui/observability/finops.tsx +++ b/src/ui/observability/finops.tsx @@ -16,18 +16,14 @@ import { } from "./format.js"; import { Ledger } from "./ledger.js"; import { - measures, modelShares, portfolio, rollup, - shapeOf, - stepKeys, usageOf, type UsageBreakdown, } from "./metrics.js"; import type { Trace } from "./model.js"; import { UsageMix } from "./usage-mix.js"; -import { stepTitle } from "./wording.js"; export function WorkflowsFinOps({ traces, @@ -200,85 +196,3 @@ function ProviderNotes({ shares }: { shares: ReturnType }) {
); } - -export function StepCosts({ trace }: { readonly trace: Trace }) { - const priced = measures(trace, "cost").filter((row) => row.costUsd > 0); - return ( - { - const span = trace.spans.find((item) => item.id === row.id); - return { - key: row.id, - label: span === undefined ? row.name : stepTitle(span), - value: row.costUsd, - detail: formatTokens(row.tokens), - }; - })} - /> - ); -} - -export function Shape({ trace }: { readonly trace: Trace }) { - const nodes = shapeOf(trace); - const keys = stepKeys(trace.spans); - return ( -
-
-

Shape

-

- Steps that share a name collapse into one node. The count is how many - times it ran. -

-
- - - - Node - Count - Time - Tokens - Cost - - - - {nodes.map((node) => ( - - - - {titlesFor(trace, keys, node.key)} - - - {node.count} - - {formatDuration(node.durationMs)} - - - {node.tokens === 0 ? "—" : formatTokens(node.tokens)} - - - {node.costUsd === 0 ? "—" : formatCost(node.costUsd)} - - - ))} - -
-
- ); -} - -function titlesFor( - trace: Trace, - keys: ReadonlyMap, - nodeKey: string, -): string { - const titles: string[] = []; - for (const span of trace.spans) { - const full = keys.get(span.id) ?? span.id; - if (full.replace(/\.\d+$/, "") !== nodeKey) continue; - const title = stepTitle(span); - if (!titles.includes(title)) titles.push(title); - } - return titles.join(" · "); -} diff --git a/src/ui/observability/metrics.ts b/src/ui/observability/metrics.ts index 472a994f..ebf82fd5 100644 --- a/src/ui/observability/metrics.ts +++ b/src/ui/observability/metrics.ts @@ -56,15 +56,6 @@ export type UsageBreakdown = { readonly thinking: number; }; -export type ShapeNode = { - readonly key: string; - readonly name: string; - readonly count: number; - readonly durationMs: number; - readonly tokens: number; - readonly costUsd: number; -}; - export type ObservationRow = { readonly traceId: string; readonly traceName: string; @@ -246,30 +237,6 @@ export function usageOfSpan(span: Span): UsageBreakdown { }; } -/** Same step name collapsed, with a count. Loops show up as one node. */ -export function shapeOf(trace: Trace): readonly ShapeNode[] { - const grouped = new Map(); - for (const span of trace.spans) { - const key = `${span.kind}:${span.name}`; - const current = grouped.get(key) ?? { - key, - name: span.name, - count: 0, - durationMs: 0, - tokens: 0, - costUsd: 0, - }; - grouped.set(key, { - ...current, - count: current.count + 1, - durationMs: current.durationMs + span.durationMs, - tokens: current.tokens + spanTokens(span), - costUsd: current.costUsd + (span.usage?.costUsd ?? 0), - }); - } - return [...grouped.values()].sort((a, b) => b.durationMs - a.durationMs); -} - export function observationRows( traces: readonly Trace[], ): readonly ObservationRow[] { diff --git a/src/ui/observability/trace-steps.tsx b/src/ui/observability/trace-steps.tsx index 3f43fb35..adee9f1e 100644 --- a/src/ui/observability/trace-steps.tsx +++ b/src/ui/observability/trace-steps.tsx @@ -53,62 +53,88 @@ export function TraceSteps({ {formatDuration(end)}
- {placed.map(({ span, depth }) => { - const selectedRow = span.id === selectedId; - const played = playedRatio(span, timeMs); - return ( + {placed.map(({ span, depth }) => ( + onSeek(span.id, span.startMs)} + buttonRef={(node) => rowRef?.(span.id, node)} + /> + ))} +
+ ); +} + +export function Step({ + span, + depth, + end, + timeMs, + selected, + onSelect, + buttonRef, +}: { + readonly span: Span; + readonly depth: number; + readonly end: number; + readonly timeMs: number; + readonly selected: boolean; + readonly onSelect: () => void; + readonly buttonRef?: (node: HTMLButtonElement | null) => void; +}) { + const played = playedRatio(span, timeMs); + return ( +
+ +