refactor(lens): restructure the trace drawer and polish its layout

Split the 547-line TraceDrawer into run/, tree/, span/, content/ and
conversation/ modules. Step rows now sit on one line with colored span
family tiles, and the per-row timing bar moved into an optional Waterfall
layout with a time axis. The steps and details panes are separated by the
shadcn Resizable handle, with the split remembered per orientation.

Span payloads go through one pure classifier (payloadView) that picks
messages, a tool result, a nested field tree or text. JSON-encoded field
values unfold into a tree, prose renders as markdown, repr and tracebacks
stay monospace, and every section offers a Raw view. LangChain's
serialized messages now render as conversation cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-04 11:56:07 -07:00
parent 94b7736e5d
commit 35437dd7f5
54 changed files with 2970 additions and 1987 deletions

View file

@ -47,6 +47,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-reconciler": "0.33.0",
"react-resizable-panels": "4.14.1",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
@ -10649,6 +10650,16 @@
}
}
},
"node_modules/react-resizable-panels": {
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.14.1.tgz",
"integrity": "sha512-OB1bXDNTLcGgTTbaX6Dn5efZhlMboSnkfr1w4xpsJTHPEbRJpACBhXpgRCXozgPO1ztgFeZRUe5J3mFNCVKm5g==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-syntax-highlighter": {
"version": "15.6.6",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",

View file

@ -64,6 +64,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-reconciler": "0.33.0",
"react-resizable-panels": "4.14.1",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",

View file

@ -208,9 +208,12 @@
--trace-tab-active: oklch(0.95 0.025 205);
--trace-tab-hover: oklch(0.93 0.02 215);
--trace-tag: oklch(0.95 0.02 205);
--trace-chain: oklch(0.56 0.17 255);
--trace-llm: oklch(0.6 0.13 215);
--trace-tool: oklch(0.64 0.14 165);
--trace-chain: #2a78d6;
--trace-llm: #eb6834;
--trace-tool: #1baf7a;
--trace-chain-soft: color-mix(in oklab, #2a78d6 12%, transparent);
--trace-llm-soft: color-mix(in oklab, #eb6834 12%, transparent);
--trace-tool-soft: color-mix(in oklab, #1baf7a 14%, transparent);
--trace-glyph: oklch(0.99 0 0);
--trace-human: oklch(0.5 0.15 260);
--trace-human-glyph: oklch(0.95 0.04 210);
@ -282,9 +285,12 @@
--trace-tab-active: oklch(0.28 0.03 215);
--trace-tab-hover: oklch(0.32 0.03 220);
--trace-tag: oklch(0.28 0.03 215);
--trace-chain: oklch(0.6 0.17 255);
--trace-llm: oklch(0.64 0.13 215);
--trace-tool: oklch(0.68 0.14 165);
--trace-chain: #3987e5;
--trace-llm: #d95926;
--trace-tool: #199e70;
--trace-chain-soft: color-mix(in oklab, #3987e5 24%, transparent);
--trace-llm-soft: color-mix(in oklab, #d95926 24%, transparent);
--trace-tool-soft: color-mix(in oklab, #199e70 24%, transparent);
--trace-glyph: oklch(0.99 0 0);
--trace-human: oklch(0.56 0.15 260);
--trace-human-glyph: oklch(0.95 0.04 210);
@ -320,6 +326,9 @@
--color-trace-chain: var(--trace-chain);
--color-trace-llm: var(--trace-llm);
--color-trace-tool: var(--trace-tool);
--color-trace-chain-soft: var(--trace-chain-soft);
--color-trace-llm-soft: var(--trace-llm-soft);
--color-trace-tool-soft: var(--trace-tool-soft);
--color-trace-glyph: var(--trace-glyph);
--color-trace-human: var(--trace-human);
--color-trace-human-glyph: var(--trace-human-glyph);

View file

@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { Inspector } from "@/components/shared/Inspector";
import { Button } from "@/components/ui/button";
import { RunView } from "@/components/lens/traces/detail/TraceDrawer";
import { RunView } from "@/components/lens/traces/detail/run/RunView";
import { useLocalRunSelection } from "@/components/lens/traces/routing";
import { lensQueries } from "../data/queries";

View file

@ -11,7 +11,7 @@ import type { OwnedFinding } from "../model/inbox";
import type { Finding, Lens } from "../model/types";
import { FindingPanel, ownedFindingKey } from "./FindingDetails";
vi.mock("@/components/lens/traces/detail/TraceDrawer", () => ({
vi.mock("@/components/lens/traces/detail/run/RunView", () => ({
RunView: ({ traceId, selection }: { traceId: string; selection: RunSelection }) => (
<div data-testid="run-view">
{traceId} at {selection.spanId}

View file

@ -1,44 +0,0 @@
"use client";
import { type KeyValue, KeyValueRows } from "./KeyValueRows";
import { Card } from "./MessageCard";
import type { Span } from "../types";
interface AttributesDetailProps {
traceId: string;
span: Span;
attributes: Record<string, string> | undefined;
isLoading: boolean;
}
export function DetailGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section aria-label={title} className="flex flex-col gap-2">
<h3 className="py-1 text-sm font-medium text-trace-text">{title}</h3>
<Card className="px-3 py-2.5">{children}</Card>
</section>
);
}
/** Ids, then the raw OTEL attributes, as dot-bulleted key / value rows. */
export function AttributesDetail({ traceId, span, attributes, isLoading }: AttributesDetailProps) {
const ids: KeyValue[] = [
["trace_id", traceId],
["span_id", span.span_id],
["parent_span_id", span.parent_span_id ?? "—"],
];
const attributeEntries: KeyValue[] = Object.entries(attributes ?? {}).sort(([a], [b]) => a.localeCompare(b));
return (
<div className="flex flex-col gap-4 px-7 pt-1 pb-4">
<DetailGroup title="Identifiers">
<KeyValueRows entries={ids} mono />
</DetailGroup>
{attributeEntries.length > 0 && (
<DetailGroup title="Attributes">
<KeyValueRows entries={attributeEntries} />
</DetailGroup>
)}
{isLoading && <div className="text-sm text-trace-duration">Loading attributes…</div>}
</div>
);
}

View file

@ -1,259 +0,0 @@
"use client";
import { useTracesApi } from "../api";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { useState } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { type KeyValue, KeyValueRows, objectEntries } from "./KeyValueRows";
import { Card, MessageCard, Section, ToolResultCard } from "./MessageCard";
import {
classifyTraceReadFailure,
isRetryableTraceRead,
traceReadRetry,
traceReadRetryDelay,
} from "../list/traceReadFailure";
import type { ErrorSource } from "../tree";
import type { Span, SpanDetail, SpanErrorPage, TraceMessage, UIContent, UIMessage } from "../types";
import { errorSource, parseJson, parseMessages, prettyPayload } from "../utils";
const ERROR_SOURCE_LABEL: Record<ErrorSource, string> = { tool: "Tool", model: "Model", litellm: "LiteLLM" };
const TRACEBACK_MARKER = "Traceback (most recent call last):";
const STATUS_TEXT = "px-3 py-2 text-sm text-muted-foreground";
const PAYLOAD_PRE = "font-mono text-sm leading-normal break-words whitespace-pre-wrap text-foreground";
/** Exporters record `repr(exc)` + traceback with no separator; keep the exception line. */
export const errorHeadline = (error: string): string =>
(error.split(TRACEBACK_MARKER, 1)[0].split("\n")[0] ?? "").trim() || error.trim();
/** `ValueError('x not found')` → "ValueError"; plain text → "error". */
const errorReason = (headline: string): string => /^([A-Za-z_][\w.]*)\(/.exec(headline)?.[1] ?? "error";
/** Shared lazy fetch of one span's full input / output / attributes. */
export function useSpanDetail(accessToken: string, traceId: string, spanId: string | null, traceRef?: string) {
const traces = useTracesApi(accessToken);
const queryOptions: UseQueryOptions<SpanDetail, Error> = {
queryKey: ["agentTraceSpan", traceId, traceRef, spanId, accessToken],
queryFn: () => traces.span(traceId, spanId as string, traceRef),
enabled: spanId !== null,
staleTime: Infinity,
};
return useQuery(queryOptions);
}
export function ErrorBlock({ span }: { span: Span }) {
const source = errorSource(span);
if (!source) return null;
const headline = errorHeadline(span.error ?? "") || "Span reported an error status.";
return (
<section
aria-label="Error"
className="mx-3 mb-3 rounded-sm border border-destructive/40 bg-destructive/5 px-3 py-2.5"
>
<div className="flex items-center gap-2 text-sm leading-tight font-medium text-destructive">
<AlertTriangle className="size-3.5" />
{ERROR_SOURCE_LABEL[source]} · {errorReason(headline)}
</div>
<pre className={cn("mt-2", PAYLOAD_PRE)}>{headline}</pre>
</section>
);
}
function FieldsCard({ entries }: { entries: readonly KeyValue[] }) {
return (
<Card className="px-3 py-2.5">
<KeyValueRows entries={entries} />
</Card>
);
}
function TextCard({ text }: { text: string }) {
return (
<Card className="px-3 py-2.5">
<pre className={cn("max-h-96 overflow-auto", PAYLOAD_PRE)}>{text}</pre>
</Card>
);
}
function PlainPayload({ value }: { value: string }) {
const entries = objectEntries(parseJson(value));
if (entries && entries.length > 0) return <FieldsCard entries={entries} />;
return <TextCard text={prettyPayload(value)} />;
}
function Messages({ messages, model }: { messages: TraceMessage[]; model: string | null }) {
return (
<>
{messages.map((message, i) => (
<MessageCard key={`${message.role}-${i}`} message={message} model={model} />
))}
</>
);
}
const toTraceMessage = (message: UIMessage): TraceMessage => ({
...message,
tool_calls: message.tool_calls?.map((call) => ({
name: call.name,
args: parseJson(call.arguments) ?? call.arguments,
})),
});
interface PayloadProps {
value: string;
span: Span;
role: "input" | "output";
}
const isToolResult = ({ span, role }: Omit<PayloadProps, "value">): boolean =>
span.type === "tool" && role === "output";
function ToolResult({ value, span }: Omit<PayloadProps, "role">) {
return <ToolResultCard name={span.name} result={value} failed={span.status === "error"} />;
}
const singleText = (content: UIContent): string | null =>
content.kind === "messages" && content.messages.length === 1 && !content.messages[0].tool_calls?.length
? content.messages[0].content
: null;
function UIPayload({ content, ...props }: PayloadProps & { content: UIContent }) {
const toolText = isToolResult(props) ? singleText(content) : null;
if (toolText !== null) return <ToolResult {...props} value={toolText} />;
if (content.kind === "messages") {
return <Messages messages={content.messages.map(toTraceMessage)} model={props.span.model} />;
}
if (isToolResult(props)) return <ToolResult {...props} />;
if (content.kind === "fields" && content.fields.length > 0) {
return <FieldsCard entries={content.fields.map((field): KeyValue => [field.key, field.value])} />;
}
return <TextCard text={content.kind === "text" ? content.text : props.value} />;
}
function Payload(props: PayloadProps) {
const messages = parseMessages(props.value);
if (messages) return <Messages messages={messages} model={props.span.model} />;
if (isToolResult(props)) return <ToolResult {...props} />;
return <PlainPayload value={props.value} />;
}
function SpanPayload({ content, ...props }: PayloadProps & { content: UIContent | undefined }) {
return content ? <UIPayload content={content} {...props} /> : <Payload {...props} />;
}
interface DetailContentProps {
accessToken: string;
traceId: string;
traceRef?: string;
span: Span;
}
function DiagnosticContent({ accessToken, traceId, traceRef, span }: DetailContentProps) {
const traces = useTracesApi(accessToken);
const [opened, setOpened] = useState(false);
const [cursor, setCursor] = useState<string | null>(null);
const queryOptions: UseQueryOptions<SpanErrorPage, Error> = {
queryKey: ["agentTraceSpanError", traceId, traceRef, span.span_id, accessToken, cursor],
queryFn: () => traces.spanError(traceId, span.span_id, { traceRef, cursor }),
enabled: opened,
staleTime: Infinity,
gcTime: 0,
retry: traceReadRetry,
retryDelay: traceReadRetryDelay,
};
const query = useQuery(queryOptions);
const failure = query.error ? classifyTraceReadFailure(query.error) : null;
return (
<section aria-label="Stored diagnostic" className="mx-3 mb-3 space-y-2">
{span.error_truncated && <p className="text-xs text-muted-foreground">Error preview truncated</p>}
{!opened && (
<Button variant="outline" size="sm" onClick={() => setOpened(true)}>
View stored diagnostic
</Button>
)}
{opened && query.isPending && <p role="status">Loading diagnostic…</p>}
{failure && (
<div role="alert">
Could not load diagnostic: {failure.message}
{isRetryableTraceRead(failure) ? (
<Button variant="outline" size="sm" onClick={() => query.refetch()}>
Retry
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setCursor(null)}>
Back to beginning
</Button>
)}
</div>
)}
{opened && query.data && (
<>
<TextCard text={query.data.message} />
<p className="text-xs text-muted-foreground">
{cursor ? "Continuation" : "Beginning"} of stored diagnostic ({query.data.total_chars.toLocaleString()}{" "}
characters)
</p>
{query.data.next_cursor && (
<Button variant="outline" size="sm" onClick={() => setCursor(query.data.next_cursor)}>
Next section
</Button>
)}
{cursor && (
<Button variant="outline" size="sm" onClick={() => setCursor(null)}>
Back to beginning
</Button>
)}
</>
)}
</section>
);
}
/** Content tab: the error first (if any), then collapsible Input and Output rendered as chat cards. */
export function DetailContent({ accessToken, traceId, traceRef, span }: DetailContentProps) {
const detailQuery = useSpanDetail(accessToken, traceId, span.span_id, traceRef);
const detail = detailQuery.data;
const empty = detail && !detail.input && !detail.output;
const inputMessages =
detail?.input_ui?.kind === "messages"
? detail.input_ui.messages.length
: parseMessages(detail?.input ?? "")?.length;
return (
<div className="flex flex-col pt-1 pb-4">
<ErrorBlock span={span} />
{span.error && (
<DiagnosticContent
key={`${traceId}:${traceRef}:${span.span_id}`}
accessToken={accessToken}
traceId={traceId}
traceRef={traceRef}
span={span}
/>
)}
{detailQuery.isLoading && <div className={STATUS_TEXT}>Loading span…</div>}
{detailQuery.isError && <div className={STATUS_TEXT}>Could not load span: {detailQuery.error.message}</div>}
{detail?.input ? (
<Section
key={`${span.span_id}:${inputMessages}`}
title="Input"
count={inputMessages}
defaultOpen={!inputMessages || inputMessages <= 8}
>
<SpanPayload value={detail.input} content={detail.input_ui} span={span} role="input" />
</Section>
) : null}
{detail?.output ? (
<Section title="Output">
<SpanPayload value={detail.output} content={detail.output_ui} span={span} role="output" />
</Section>
) : null}
{empty && span.status !== "error" && (
<div className="py-12 text-center text-sm text-muted-foreground">No content recorded for this span.</div>
)}
</div>
);
}

View file

@ -1,240 +0,0 @@
"use client";
import { PanelRightClose } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { AttributesDetail } from "./AttributesDetail";
import CopyButton from "@/components/shared/CopyButton";
import { DetailContent, errorHeadline, useSpanDetail } from "./DetailContent";
import { IdChip } from "../ui/IdChip";
import { PaneBar } from "../ui/PaneBar";
import { RequestDetail } from "./RequestDetail";
import { SpanIcon } from "../ui/SpanIcon";
import { useTracesApi } from "../api";
import { SPAN_TABS, type SpanTab } from "../routing";
import type { GroupRowData, TreeRow } from "../tree";
import type { Span, SpanType, Trace } from "../types";
import { fmtMs, fmtTok } from "../utils";
interface SpanTabProps {
spanTab: SpanTab;
onSpanTabChange: (tab: SpanTab) => void;
}
interface DetailPaneProps extends SpanTabProps {
trace: Trace;
row: TreeRow | undefined;
accessToken: string;
onClose: () => void;
}
const TAB_LABELS: Record<SpanTab, string> = { content: "Content", request: "Request", attributes: "Attributes" };
function PaneHeader({
type,
model,
failed,
title,
idValue,
onClose,
}: {
type: SpanType;
model: string | null;
failed: boolean;
title: React.ReactNode;
idValue?: string;
onClose: () => void;
}) {
return (
<PaneBar className="justify-between gap-3">
<div className="flex min-w-0 items-center">
<span className="mr-2 shrink-0">
<SpanIcon type={type} model={model} error={failed} size="md" />
</span>
<h2 className="min-w-0 truncate text-sm leading-5 font-semibold text-foreground">{title}</h2>
{idValue && (
<span className="ml-2 flex shrink-0">
<IdChip value={idValue} label="Copy span ID" />
</span>
)}
</div>
<Button
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close details"
className="size-6 shrink-0 rounded-sm text-muted-foreground"
>
<PanelRightClose className="size-4" />
</Button>
</PaneBar>
);
}
function PaneFooter({ children }: { children: React.ReactNode }) {
return <div className="flex h-10 shrink-0 items-center gap-3 border-t bg-background px-3">{children}</div>;
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<span>
<span className="text-muted-foreground">{label} </span>
{value}
</span>
);
}
function SpanPane({
trace,
span,
accessToken,
spanTab: tab,
onSpanTabChange,
onClose,
}: SpanTabProps & {
trace: Trace;
span: Span;
accessToken: string;
onClose: () => void;
}) {
const handoff = useTracesApi(accessToken).handoff(trace.summary.trace_id, span.span_id, trace.summary.trace_ref);
const traceId = trace.summary.trace_id;
const detailQuery = useSpanDetail(
accessToken,
traceId,
tab === "attributes" ? span.span_id : null,
trace.summary.trace_ref,
);
const tokens = span.input_tokens + span.output_tokens;
return (
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Span details">
<PaneHeader
type={span.type}
model={span.model}
failed={span.status === "error"}
title={span.type === "llm" ? span.model || span.name : span.name}
idValue={span.span_id}
onClose={onClose}
/>
<Tabs value={tab} onValueChange={(value) => onSpanTabChange(value as SpanTab)} className="min-h-0 flex-1 gap-0">
<PaneBar>
<TabsList variant="line" aria-label="Span detail sections" className="h-full gap-4 px-0">
{SPAN_TABS.map((id) => (
<TabsTrigger key={id} value={id} className="flex-none px-0 text-xs">
{TAB_LABELS[id]}
</TabsTrigger>
))}
</TabsList>
</PaneBar>
<TabsContent value="content" className="min-h-0 overflow-auto pt-3">
<DetailContent accessToken={accessToken} traceId={traceId} traceRef={trace.summary.trace_ref} span={span} />
</TabsContent>
<TabsContent value="request" className="min-h-0 overflow-auto pt-3">
<RequestDetail span={span} accessToken={accessToken} traceStartMs={Date.parse(trace.summary.start_time)} />
</TabsContent>
<TabsContent value="attributes" className="min-h-0 overflow-auto pt-3">
<AttributesDetail
traceId={traceId}
span={span}
attributes={detailQuery.data?.attributes}
isLoading={detailQuery.isLoading}
/>
</TabsContent>
</Tabs>
<PaneFooter>
<CopyButton variant="action" value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground tabular-nums">
<Meta label="time" value={fmtMs(span.duration_ms)} />
{tokens > 0 && <Meta label="tokens" value={fmtTok(tokens)} />}
</div>
</PaneFooter>
</aside>
);
}
function GroupMetric({ label, value }: { label: string; value: string }) {
return (
<div className="border-b border-border/60 py-3">
<div className="text-sm font-medium text-muted-foreground">{label}</div>
<div className="mt-1 text-sm text-foreground tabular-nums">{value}</div>
</div>
);
}
/** ×N group: rollup of every invocation plus the first failure's message. */
function GroupPane({
trace,
row,
accessToken,
onClose,
}: {
trace: Trace;
row: GroupRowData;
accessToken: string;
onClose: () => void;
}) {
const tokens = row.members.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0);
const firstFailure = row.members.find((m) => m.status === "error" && m.error);
const sample = (firstFailure ?? row.members[0]).span_id;
const handoff = useTracesApi(accessToken).handoff(trace.summary.trace_id, sample, trace.summary.trace_ref);
return (
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Group details">
<PaneHeader
type={row.type}
model={row.members[0]?.model ?? null}
failed={row.failedCount > 0}
title={
<>
{row.name} <span className="text-muted-foreground">×{row.members.length}</span>
</>
}
onClose={onClose}
/>
<div className="min-h-0 flex-1 overflow-auto px-3 py-3">
<div className="grid grid-cols-2 gap-x-5">
<GroupMetric label="Invocations" value={row.members.length.toLocaleString()} />
<GroupMetric label="Failed" value={row.failedCount.toLocaleString()} />
<GroupMetric label="p50 latency" value={fmtMs(row.p50Duration)} />
<GroupMetric label="Tokens" value={fmtTok(tokens)} />
<GroupMetric label="Agent" value={row.agent || "—"} />
<GroupMetric label="Type" value={row.type} />
</div>
{firstFailure?.error && (
<section className="mt-3 rounded-sm border border-destructive/30 px-3.5 py-3">
<div className="text-xs font-medium text-destructive">Failure pattern</div>
<p className="mt-2 font-mono text-xs leading-5 text-foreground">{errorHeadline(firstFailure.error)}</p>
</section>
)}
</div>
<PaneFooter>
<CopyButton variant="action" value={handoff.text} label="Copy group sample" copiedLabel={handoff.copied} />
</PaneFooter>
</aside>
);
}
/** Right pane of the run view: switches on the selected tree row. */
export function DetailPane({ trace, row, accessToken, spanTab, onSpanTabChange, onClose }: DetailPaneProps) {
if (!row || row.kind === "load-more") {
return (
<div className="grid h-full place-items-center bg-background text-sm text-muted-foreground">
Select a span to inspect it.
</div>
);
}
if (row.kind === "group")
return <GroupPane key={row.id} trace={trace} row={row} accessToken={accessToken} onClose={onClose} />;
return (
<SpanPane
key={row.id}
trace={trace}
span={row.span}
accessToken={accessToken}
spanTab={spanTab}
onSpanTabChange={onSpanTabChange}
onClose={onClose}
/>
);
}

View file

@ -1,103 +0,0 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "../ui/Collapse";
const LONG_VALUE_CHARS = 90;
const ID_KEY = /(^|_)id$/i;
export type KeyValue = readonly [key: string, value: string];
export const isLongValue = (value: string): boolean => value.length > LONG_VALUE_CHARS || value.includes("\n");
export const displayValue = (value: unknown): string => {
if (typeof value === "string") return value;
if (value === null || value === undefined) return String(value);
return JSON.stringify(value);
};
export const objectEntries = (value: unknown): KeyValue[] | null => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return Object.entries(value).map(([key, v]): KeyValue => [key, displayValue(v)]);
};
const ROW = "flex min-w-0 items-baseline gap-3 border-b border-border/60 py-2 last:border-0";
const KEY = "w-24 shrink-0 break-words text-xs leading-5 font-normal";
function Lead({ children }: { children: React.ReactNode }) {
return <span className="flex size-3 shrink-0 items-center justify-center self-center">{children}</span>;
}
function KeyValueRow({ entry, mono }: { entry: KeyValue; mono: boolean }) {
const [key, value] = entry;
const [open, setOpen] = useState(false);
const valueClass = cn(
"min-w-0 flex-1 break-words text-left text-sm leading-5 text-foreground",
mono || ID_KEY.test(key) ? "font-mono" : "font-sans",
);
if (!isLongValue(value)) {
return (
<li className={ROW}>
<span className={cn(KEY, "text-muted-foreground")}>{key}</span>
<span className={cn(valueClass, "whitespace-pre-wrap")} title={value}>
{value}
</span>
</li>
);
}
const head = (
<>
<Lead>
<FoldChevron open={open} className="size-3 text-foreground" />
</Lead>
<span className={cn(KEY, "text-muted-foreground")}>{key}</span>
</>
);
const toggle = {
type: "button",
"aria-expanded": open,
"aria-label": `${open ? "Collapse" : "Expand"} ${key}`,
onClick: () => setOpen((prev) => !prev),
} as const;
return (
<li className={ROW}>
{open ? (
<>
<button {...toggle} className="flex shrink-0 cursor-pointer items-baseline gap-2.5 text-left">
{head}
</button>
<pre className={cn(valueClass, "leading-normal break-words whitespace-pre-wrap")}>{value}</pre>
</>
) : (
<button {...toggle} className="flex min-w-0 flex-1 cursor-pointer items-baseline gap-2.5 text-left">
{head}
<span className={cn(valueClass, "truncate")}>{value.replace(/\n/g, "\\n")}</span>
</button>
)}
</li>
);
}
export function KeyValueRows({
entries,
mono = false,
className,
}: {
entries: readonly KeyValue[];
mono?: boolean;
className?: string;
}) {
return (
<ul className={cn("flex flex-col", className)}>
{entries.map((entry) => (
<KeyValueRow key={entry[0]} entry={entry} mono={mono} />
))}
</ul>
);
}

View file

@ -1,221 +0,0 @@
"use client";
import { Wrench } from "lucide-react";
import { useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "../ui/Collapse";
import CopyButton from "@/components/shared/CopyButton";
import { displayValue, type KeyValue, KeyValueRows, objectEntries } from "./KeyValueRows";
import type { TraceMessage, TraceToolCall } from "../types";
const ROLE_LABEL: Record<string, string> = { user: "User", system: "System", assistant: "Assistant", tool: "Tool" };
const BLOCK_GAP = "mb-[1lh] last:mb-0";
const markdownComponents: Components = {
p: ({ node, ...props }) => <p className={BLOCK_GAP} {...props} />,
ul: ({ node, ...props }) => <ul className={cn(BLOCK_GAP, "list-disc pl-6")} {...props} />,
ol: ({ node, ...props }) => <ol className={cn(BLOCK_GAP, "list-decimal pl-6")} {...props} />,
li: ({ node, ...props }) => <li className="pl-0.5 [&>p]:mb-0 [&>ol]:mb-0 [&>ul]:mb-0" {...props} />,
h1: ({ node, ...props }) => <h1 className={cn(BLOCK_GAP, "font-semibold")} {...props} />,
h2: ({ node, ...props }) => <h2 className={cn(BLOCK_GAP, "font-semibold")} {...props} />,
h3: ({ node, ...props }) => <h3 className={cn(BLOCK_GAP, "font-semibold")} {...props} />,
blockquote: ({ node, ...props }) => (
<blockquote className={cn(BLOCK_GAP, "border-l-2 border-border pl-3 text-muted-foreground")} {...props} />
),
pre: ({ node, ...props }) => (
<pre
className={cn(
BLOCK_GAP,
"max-w-full overflow-x-auto rounded-sm bg-muted/40 p-2.5 font-mono text-sm leading-normal tracking-normal",
)}
{...props}
/>
),
code: ({ node, className, ...props }) => <code className={cn("font-mono", className)} {...props} />,
a: ({ node, ...props }) => <a className="text-primary underline" target="_blank" rel="noreferrer" {...props} />,
img: ({ alt }) => <span>{alt || "Image omitted"}</span>,
};
export function Markdown({ text }: { text: string }) {
return (
<div className="text-sm leading-6 break-words text-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{text}
</ReactMarkdown>
</div>
);
}
export function Card({ children, className }: { children: React.ReactNode; className?: string }) {
return <section className={cn("rounded-md border border-border bg-background", className)}>{children}</section>;
}
const TILE = "grid size-4 shrink-0 place-items-center rounded-sm p-0.5";
function RoleTile({ failed = false }: { role: string; failed?: boolean }) {
return <Wrench className={cn("size-3.5 shrink-0", failed ? "text-destructive" : "text-muted-foreground")} />;
}
function FoldTile({ label, open, onToggle }: { label: string; open: boolean; onToggle: () => void }) {
return (
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${label}`}
onClick={onToggle}
className={cn(
TILE,
"group/tile cursor-pointer transition-colors duration-150 group-hover/header:bg-muted focus-visible:bg-muted motion-reduce:transition-none",
"text-muted-foreground",
)}
>
<FoldChevron open={open} className="size-3 text-muted-foreground" />
</button>
);
}
const HEADER =
"group/header flex min-w-0 items-center gap-2 border border-border bg-background py-2 pr-2 pl-3 transition-colors hover:bg-muted/30";
const LABEL = "min-w-0 truncate text-xs leading-5 font-medium text-foreground";
const CARD_COPY =
"ml-auto size-5 shrink-0 rounded-sm text-muted-foreground opacity-60 transition-opacity duration-150 group-hover/header:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [&_svg]:size-4";
const INLINE_COPY = "size-4 shrink-0 rounded-sm text-muted-foreground";
export function ToolCallBlock({ call }: { call: TraceToolCall }) {
const argEntries = objectEntries(call.args);
const entries: KeyValue[] = argEntries?.length ? argEntries : [["arguments", displayValue(call.args)]];
return (
<div className="space-y-1">
<div className="flex items-center gap-2 py-1">
<RoleTile role="tool" />
<span className="min-w-0 truncate text-sm font-medium">{call.name}</span>
<CopyButton
variant="action"
value={call.name}
label={`Copy ${call.name} name`}
iconOnly
className={INLINE_COPY}
/>
</div>
<KeyValueRows entries={entries} />
</div>
);
}
export function MessageCard({ message }: { message: TraceMessage; model: string | null }) {
const [open, setOpen] = useState(true);
const label = message.role === "tool" ? message.name ?? "Tool" : ROLE_LABEL[message.role] ?? message.role;
if (message.role === "tool") return <ToolResultCard name={label} result={message.content} />;
const calls = message.tool_calls ?? [];
const copyValue = message.content || calls.map((c) => `${c.name}(${displayValue(c.args)})`).join("\n");
const hasBody = Boolean(message.content) || calls.length > 0;
const expanded = open && hasBody;
return (
<article className="rounded-md">
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-sm border-b-0" : "rounded-sm")}>
<FoldTile label={label} open={open} onToggle={() => setOpen((v) => !v)} />
<span className={LABEL}>{label}</span>
<CopyButton variant="action" value={copyValue} label={`Copy ${label}`} iconOnly className={CARD_COPY} />
</div>
{expanded && (
<div
className={cn(
"flex flex-col gap-3 rounded-b-md border border-t-0 border-border bg-background px-3 pt-2 pb-3",
message.role === "system" && "[&_*]:text-muted-foreground",
)}
>
{message.content && <Markdown text={message.content} />}
{calls.map((call, i) => (
<ToolCallBlock key={`${call.name}-${i}`} call={call} />
))}
</div>
)}
</article>
);
}
const LONG_RESULT_CHARS = 120;
/** Tool result card: one line by default; long or multiline results expand in place. Red when the tool failed. */
export function ToolResultCard({ name, result, failed = false }: { name: string; result: string; failed?: boolean }) {
const [open, setOpen] = useState(false);
const expandable = result.length > LONG_RESULT_CHARS || result.includes("\n");
const tone = failed ? "text-destructive" : "text-muted-foreground";
return (
<article className="flex flex-col">
<div className={cn(HEADER, open ? "rounded-t-sm" : "rounded-sm")}>
<RoleTile role="tool" failed={failed} />
<span className="flex min-w-0 max-w-[50%] shrink-0 items-center gap-1.5">
<span className={cn(LABEL, failed && "text-destructive")}>{name}</span>
<CopyButton variant="action" value={name} label={`Copy ${name} name`} iconOnly className={INLINE_COPY} />
</span>
{expandable ? (
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${name} result`}
onClick={() => setOpen((prev) => !prev)}
className={cn("flex min-w-0 cursor-pointer items-center gap-1 text-left", tone)}
>
<FoldChevron open={open} className="size-3 shrink-0" />
{!open && <span className="min-w-0 truncate text-sm leading-tight">{result}</span>}
</button>
) : (
<span className={cn("min-w-0 break-words text-sm leading-5", tone)}>{result || "No output"}</span>
)}
<CopyButton variant="action" value={result} label={`Copy ${name} result`} iconOnly className={CARD_COPY} />
</div>
{open && (
<pre
className={cn(
"max-h-80 overflow-auto rounded-b-sm border border-t-0 border-border bg-muted/40 px-3 py-2 font-mono text-xs leading-normal break-words whitespace-pre-wrap",
tone,
)}
>
{result}
</pre>
)}
</article>
);
}
/** Collapsible "Input" / "Output" section: sticky header, chevron turns, body snaps open and shut. */
export function Section({
title,
children,
defaultOpen = true,
count,
}: {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
count?: number;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section aria-label={title}>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((prev) => !prev)}
className="sticky top-0 z-sticky-pinned mb-2 flex h-10 w-full cursor-pointer items-center gap-2.5 bg-background px-3 text-left transition-colors duration-150 hover:bg-muted/40 motion-reduce:transition-none"
>
<FoldChevron open={open} className="size-3 shrink-0 text-foreground" />
<span className="text-sm leading-tight font-medium text-foreground">{title}</span>{" "}
{count !== undefined && (
<span className="text-xs font-normal text-muted-foreground">
{count} {count === 1 ? "message" : "messages"}
</span>
)}
</button>
<div hidden={!open} inert={!open} className="flex flex-col gap-3 px-3 pb-4">
{children}
</div>
</section>
);
}

View file

@ -1,79 +0,0 @@
"use client";
import { ArrowUpRight } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { LogDetailsDrawer } from "../../../logs/detail";
import { formatCost } from "../list/AgentTracesTable";
import { DetailGroup } from "./AttributesDetail";
import CopyButton from "@/components/shared/CopyButton";
import { type KeyValue, KeyValueRows } from "./KeyValueRows";
import type { Span } from "../types";
import { fmtMs, fmtTok } from "../utils";
import { useSpanRequestLog } from "./useSpanRequestLog";
interface RequestDetailProps {
span: Span;
accessToken: string;
/** The run's start, so the request log is looked up at the span's time, whatever range the tabs show. */
traceStartMs: number;
}
/** Request tab for LLM spans; "Open request log" opens the LiteLLM request drawer over the run. */
export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetailProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const spanStartMs = traceStartMs + span.start_offset_ms;
const logQuery = useSpanRequestLog(accessToken, span.litellm_request_id, spanStartMs, drawerOpen);
const lookupDone = drawerOpen && logQuery.isSuccess;
const logNotFound = lookupDone && logQuery.data === null;
if (span.type !== "llm") {
return <div className="py-16 text-center text-sm text-trace-duration">This span is not a model request.</div>;
}
const usage: KeyValue[] = [
["model", span.model ?? "—"],
["cost", span.spend == null ? "—" : formatCost(span.spend)],
["input_tokens", fmtTok(span.input_tokens)],
["output_tokens", fmtTok(span.output_tokens)],
["total_tokens", fmtTok(span.input_tokens + span.output_tokens)],
["latency", fmtMs(span.duration_ms)],
];
return (
<div className="flex flex-col gap-4 px-7 pt-1 pb-4">
<DetailGroup title="Usage">
<KeyValueRows entries={usage} />
</DetailGroup>
<DetailGroup title="LiteLLM request">
{span.litellm_request_id ? (
<div className="flex flex-col gap-2.5">
<div className="flex min-w-0 items-center gap-1">
<KeyValueRows entries={[["request_id", span.litellm_request_id]]} mono className="min-w-0 flex-1" />
<CopyButton variant="action" value={span.litellm_request_id} label="Copy request ID" iconOnly />
</div>
<Button
variant="outline"
size="xs"
className="self-start border-trace-border text-sm text-trace-text-2 shadow-trace-xs"
onClick={() => setDrawerOpen(true)}
>
Open request log <ArrowUpRight className="size-3.5" />
</Button>
{logNotFound && <p className="text-sm text-trace-duration">No request log found for this call.</p>}
</div>
) : (
<p className="text-sm text-trace-duration">Not linked to a LiteLLM request</p>
)}
</DetailGroup>
<LogDetailsDrawer
open={drawerOpen && Boolean(logQuery.data)}
onClose={() => setDrawerOpen(false)}
logEntry={logQuery.data ?? null}
accessToken={accessToken}
/>
</div>
);
}

View file

@ -1,415 +0,0 @@
"use client";
import { MoreHorizontal, PanelRightOpen, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "../ui/Collapse";
import { PaneBar } from "../ui/PaneBar";
import { groupFacts, SpanHoverCard, spanFacts } from "./SpanHoverCard";
import { SpanIcon } from "../ui/SpanIcon";
import type { GroupRowData, SpanRowData, TreeRow } from "../tree";
import type { TraceSummary } from "../types";
import { fmtMs, previewText, type TreeGuide, treeGuides } from "../utils";
interface SpanTreeProps {
rows: TreeRow[];
summary: TraceSummary;
selectedId: string;
hideFramework: boolean;
onSelect: (id: string) => void;
onToggleHideFramework: (checked: boolean) => void;
onToggleSpan: (id: string) => void;
onToggleGroup: (id: string) => void;
onLoadMore: (groupId: string) => void;
onOpenDetails?: () => void;
query: string;
onQueryChange: (query: string) => void;
errorsOnly: boolean;
onErrorsOnlyChange: (enabled: boolean) => void;
filtering: boolean;
onClearFilters: () => void;
onCollapseAll: () => void;
}
const rowClass = (selected: boolean): string =>
cn(
"relative flex w-full items-stretch border-l-2 px-3.5 text-left outline-none transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] focus-visible:shadow-[inset_0_0_0_2px_var(--ring)] motion-reduce:transition-none",
selected ? "border-l-primary bg-accent" : "border-l-transparent hover:bg-muted/60",
);
const LINE = "border-l border-dashed border-border";
const ELBOW = "border-b border-l border-dashed border-border";
const NAME = "line-clamp-2 break-words text-sm leading-tight font-medium";
const META = "text-xs leading-4 text-muted-foreground tabular-nums";
/** One 20px column per ancestor: pass-through rails for open branches, an elbow into this row's tile. */
function Gutters({ depth, guide }: { depth: number; guide: TreeGuide }) {
return Array.from({ length: depth }, (_, level) => {
const parentColumn = level === depth - 1;
const passThrough = parentColumn ? !guide.last : guide.rails[level] === true;
return (
<span key={level} aria-hidden="true" data-testid="tree-gutter" className="relative w-5 shrink-0">
{passThrough && (
<span data-guide="rail" className={cn("absolute inset-y-0 left-[calc(50%-0.5px)] w-px", LINE)} />
)}
{parentColumn && (
<span data-guide="elbow" className={cn("absolute top-0 left-[calc(50%-0.5px)] h-3.5 w-1/2", ELBOW)} />
)}
</span>
);
});
}
function TileColumn({ tile, stem }: { tile: React.ReactNode; stem: boolean }) {
return (
<span className="flex w-5 shrink-0 flex-col items-center pt-1">
<span className="relative z-raised">{tile}</span>
{stem && <span aria-hidden="true" data-guide="stem" className={cn("w-px grow", LINE)} />}
</span>
);
}
interface RowProps {
guide: TreeGuide;
selected: boolean;
traceStartMs: number;
totalMs: number;
onSelect: (id: string) => void;
onToggleSpan: (id: string) => void;
onToggleGroup: (id: string) => void;
filtering: boolean;
}
function Caret({ open, onToggle, label }: { open: boolean; onToggle: () => void; label: string }) {
return (
<span
role="button"
tabIndex={-1}
aria-label={label}
className="grid size-6 shrink-0 place-items-center self-start rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 hover:bg-muted motion-reduce:transition-none"
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
>
<FoldChevron open={open} className="size-4" />
</span>
);
}
function RowBody({ children, trailing }: { children: React.ReactNode; trailing: React.ReactNode }) {
return (
<span className="ml-2 grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 py-1">
<span className="flex min-w-0 flex-col gap-1">{children}</span>
{trailing}
</span>
);
}
interface WaterfallProps {
startMs: number;
durationMs: number;
totalMs: number;
tone: string;
}
/** Thin bar showing where this span sits inside the run's timeline. */
function Waterfall({ startMs, durationMs, totalMs, tone }: WaterfallProps) {
const left = totalMs > 0 ? Math.min(99, (startMs / totalMs) * 100) : 0;
const width = totalMs > 0 ? Math.max(0.8, Math.min(100 - left, (durationMs / totalMs) * 100)) : 0.8;
return (
<span
aria-hidden="true"
data-testid="span-waterfall"
className="relative block h-[3px] w-full overflow-hidden rounded-full bg-muted"
>
<span className={cn("absolute inset-y-0 rounded-full", tone)} style={{ left: `${left}%`, width: `${width}%` }} />
</span>
);
}
const barTone = (type: string, failed: boolean): string => {
if (failed) return "bg-destructive";
if (type === "tool") return "bg-muted-foreground/40";
if (type === "llm") return "bg-muted-foreground/50";
return "bg-muted-foreground/60";
};
function SpanRow({
row,
guide,
selected,
traceStartMs,
totalMs,
onSelect,
onToggleSpan,
filtering,
}: RowProps & { row: SpanRowData }) {
const { span } = row;
const failed = span.status === "error";
const duration = <span className={cn(META, "shrink-0")}>{fmtMs(span.duration_ms)}</span>;
const caret = row.hasChildren && (
<Caret open={!row.collapsed} label={row.collapsed ? "Expand" : "Collapse"} onToggle={() => onToggleSpan(row.id)} />
);
const leafDuration = !row.hasChildren && <span className="flex min-h-5 items-center">{duration}</span>;
return (
<SpanHoverCard facts={spanFacts(span)} traceStartMs={traceStartMs}>
<button
type="button"
role="treeitem"
aria-expanded={row.hasChildren ? !row.collapsed : undefined}
aria-selected={selected}
data-row-id={row.id}
onClick={() => onSelect(row.id)}
onDoubleClick={() => row.hasChildren && onToggleSpan(row.id)}
className={rowClass(selected)}
>
<Gutters depth={row.depth} guide={guide} />
<TileColumn tile={<SpanIcon type={span.type} model={span.model} error={failed} />} stem={guide.stem} />
<RowBody trailing={caret || leafDuration}>
<span className={cn("flex min-w-0 items-center gap-2", row.hasChildren ? "min-h-6" : "min-h-5")}>
<span className={cn(NAME, failed ? "text-destructive" : "text-foreground")}>{span.name}</span>
{row.hasChildren && duration}
</span>
{span.type === "agent" && span.parent_span_id && span.input_preview ? (
<span className="truncate text-xs text-muted-foreground" title={previewText(span.input_preview)}>
{previewText(span.input_preview)}
</span>
) : (
filtering && (
<span className="truncate text-xs text-muted-foreground">
{previewText(span.input_preview) || span.agent}
</span>
)
)}
<Waterfall
startMs={span.start_offset_ms}
durationMs={span.duration_ms}
totalMs={totalMs}
tone={barTone(span.type, failed)}
/>
</RowBody>
</button>
</SpanHoverCard>
);
}
function GroupRow({
row,
guide,
selected,
traceStartMs,
totalMs,
onSelect,
onToggleGroup,
}: RowProps & { row: GroupRowData }) {
const groupStart = Math.min(...row.members.map((m) => m.start_offset_ms));
const groupEnd = Math.max(...row.members.map((m) => m.start_offset_ms + m.duration_ms));
return (
<SpanHoverCard facts={groupFacts(row)} traceStartMs={traceStartMs}>
<button
type="button"
role="treeitem"
aria-expanded={row.expanded}
aria-selected={selected}
data-row-id={row.id}
onClick={() => {
onSelect(row.id);
onToggleGroup(row.id);
}}
className={rowClass(selected)}
>
<Gutters depth={row.depth} guide={guide} />
<TileColumn
tile={<SpanIcon type={row.type} model={row.members[0]?.model ?? null} error={row.failedCount > 0} />}
stem={guide.stem}
/>
<RowBody
trailing={
<Caret
open={row.expanded}
label={row.expanded ? "Collapse" : "Expand"}
onToggle={() => onToggleGroup(row.id)}
/>
}
>
<span className="flex min-h-6 min-w-0 items-center gap-2">
<span className={cn(NAME, row.isFailureGroup ? "text-destructive" : "text-foreground")}>{row.name}</span>
<span className={cn(META, "shrink-0")}>×{row.members.length}</span>
</span>
{row.failedCount > 0 && <span className={cn(META, "text-destructive")}>{row.failedCount} failed</span>}
<Waterfall
startMs={groupStart}
durationMs={groupEnd - groupStart}
totalMs={totalMs}
tone={barTone(row.type, row.isFailureGroup)}
/>
</RowBody>
</button>
</SpanHoverCard>
);
}
export function SpanTree({
rows,
summary,
selectedId,
hideFramework,
onSelect,
onToggleHideFramework,
onToggleSpan,
onToggleGroup,
onLoadMore,
onOpenDetails,
query,
onQueryChange,
errorsOnly,
onErrorsOnlyChange,
filtering,
onClearFilters,
onCollapseAll,
}: SpanTreeProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const guides = useMemo(() => treeGuides(rows.map((row) => row.depth)), [rows]);
const traceStartMs = Date.parse(summary.start_time);
useEffect(() => {
scrollRef.current
?.querySelector(`[data-row-id="${CSS.escape(selectedId)}"]`)
?.scrollIntoView?.({ block: "nearest" });
}, [selectedId]);
return (
<section className="flex h-full min-h-0 min-w-0 flex-col border-r border-border bg-card" aria-label="Run spans">
<PaneBar className="justify-between">
<span className="text-sm font-medium">
Steps{" "}
<span className="ml-1 text-xs font-normal text-muted-foreground">
{filtering && `${rows.length.toLocaleString()} of `}
{summary.span_count.toLocaleString()}
</span>
</span>
<div className="flex items-center gap-1">
{onOpenDetails && (
<Button variant="ghost" size="icon-xs" onClick={onOpenDetails} aria-label="Show details">
<PanelRightOpen className="size-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Step display options" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuCheckboxItem checked={hideFramework} onCheckedChange={onToggleHideFramework}>
Hide framework spans
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCollapseAll}>Collapse branches</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</PaneBar>
<PaneBar>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search steps"
placeholder="Search steps"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
className="h-7 pr-7 pl-7 text-xs shadow-none md:text-xs"
/>
{query && (
<Button
variant="ghost"
size="icon-xs"
aria-label="Clear step search"
className="absolute top-1/2 right-0.5 -translate-y-1/2"
onClick={() => onQueryChange("")}
>
<X className="size-3" />
</Button>
)}
</div>
<Button
variant={!errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={!errorsOnly}
onClick={() => onErrorsOnlyChange(false)}
>
All
</Button>
<Button
variant={errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={errorsOnly}
onClick={() => onErrorsOnlyChange(true)}
>
Errors <span className="ml-1 text-muted-foreground">{summary.error_count}</span>
</Button>
{filtering && (
<span role="status" className="sr-only">
{rows.length} found
</span>
)}
</PaneBar>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto py-2">
{rows.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
<p>No matching steps</p>
<Button variant="link" size="sm" onClick={onClearFilters}>
Clear filters
</Button>
</div>
)}
<div role="tree" aria-label="Spans in time order">
{rows.map((row, i) => {
if (row.kind === "load-more") {
return (
<button
key={row.id}
type="button"
onClick={() => onLoadMore(row.groupId)}
className={cn(rowClass(false), "h-8 text-sm text-muted-foreground")}
>
<Gutters depth={row.depth} guide={guides[i]} />
<span className="ml-0.5 flex items-center gap-2">
<MoreHorizontal className="relative z-raised size-3.5" /> Load 20 more
<span className="text-muted-foreground">({row.remaining} remaining)</span>
</span>
</button>
);
}
const shared = {
guide: guides[i],
selected: selectedId === row.id,
traceStartMs,
totalMs: summary.duration_ms,
onSelect,
onToggleSpan,
onToggleGroup,
filtering,
};
return row.kind === "group" ? (
<GroupRow key={row.id} row={row} {...shared} />
) : (
<SpanRow key={row.id} row={row} {...shared} />
);
})}
</div>
</div>
</section>
);
}

View file

@ -1,547 +0,0 @@
"use client";
import { type TraceHandoff, useTracesApi } from "../api";
import { QueryErrorResetBoundary, useQueryClient, useSuspenseInfiniteQuery } from "@tanstack/react-query";
import { ArrowLeft, Check, Copy } from "lucide-react";
import { Suspense, useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useTimeout } from "usehooks-ts";
import { useShortcut } from "@/components/shared/useShortcut";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { DetailPane } from "./DetailPane";
import { IdChip } from "../ui/IdChip";
import { formatCost } from "../list/AgentTracesTable";
import { SpanIcon } from "../ui/SpanIcon";
import { SpanTree } from "./SpanTree";
import { TraceConversation } from "./TraceConversation";
import { FrameworkLogo, traceFramework } from "../ui/TraceFramework";
import { type RunSelection, traceKey } from "../routing";
import type { SpanTreeState, TreeRow } from "../tree";
import {
classifyTraceReadFailure,
isRetryableTraceRead,
traceReadRetry,
traceReadRetryDelay,
} from "../list/traceReadFailure";
import type { TraceReadFailure } from "../list/traceReadFailure";
import type { Trace } from "../types";
import {
buildTreeRows,
firstErrorSpan,
fmtMs,
fmtTok,
findTraceSteps,
GROUP_PAGE_SIZE,
isFrameworkSpan,
nearestVisibleSpanId,
revealSpanInState,
traceAgentNames,
traceDisplayName,
} from "../utils";
const INITIAL_STATE: SpanTreeState = {
hideFramework: true,
collapsedSpanIds: new Set(),
expandedGroupIds: new Set(),
groupRevealCounts: {},
};
/** First failed span if the run has errors (with its tree path opened), otherwise the root agent. */
export function initialRunSelection(
trace: Trace,
initialSpanId?: string,
): { selectedId: string; state: SpanTreeState } {
if (initialSpanId && trace.spans.some((span) => span.span_id === initialSpanId)) {
const selectedId = nearestVisibleSpanId(trace.spans, initialSpanId, false);
const state = revealSpanInState(trace.spans, { ...INITIAL_STATE, hideFramework: false }, selectedId);
return { selectedId, state };
}
const initialState = {
...INITIAL_STATE,
collapsedSpanIds: new Set(
trace.spans.filter((span) => span.type === "agent" && span.parent_span_id !== null).map((span) => span.span_id),
),
};
const failed = firstErrorSpan(trace.spans);
if (!failed || failed.parent_span_id === null) {
const root = trace.spans.find((s) => s.parent_span_id === null);
return { selectedId: root?.span_id ?? "", state: initialState };
}
const visibleFailure = trace.spans
.filter((s) => s.status === "error" && s.parent_span_id !== null && !isFrameworkSpan(s))
.sort((a, b) => a.start_offset_ms - b.start_offset_ms)[0];
const selectedId = visibleFailure?.span_id ?? nearestVisibleSpanId(trace.spans, failed.span_id, true);
return { selectedId, state: revealSpanInState(trace.spans, initialState, selectedId) };
}
const toggle = (set: ReadonlySet<string>, id: string): Set<string> => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
};
function CopyForAgent({ handoff }: { handoff: TraceHandoff }) {
const [copied, setCopied] = useState(false);
useTimeout(() => setCopied(false), copied ? 1600 : null);
return (
<Button
variant="outline"
size="xs"
className="h-7 shrink-0 gap-1.5 rounded-md text-xs shadow-none"
onClick={async () => setCopied(await copyToClipboard(handoff.text, handoff.copied))}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? "Copied" : "Copy for agent"}
</Button>
);
}
function Stat({ label, value, error = false }: { label: string; value: string; error?: boolean }) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs tabular-nums",
error ? "text-destructive" : "text-muted-foreground",
)}
>
<span>{label}</span> <span className={cn("font-medium", !error && "text-foreground")}>{value}</span>
</span>
);
}
function RunIcon({ summary, failed }: { summary: Trace["summary"]; failed: boolean }) {
const framework = traceFramework(summary);
if (!framework) return <SpanIcon type="agent" error={failed} size="lg" />;
return (
<span
className="inline-flex shrink-0 items-center gap-1.5 text-sm text-foreground"
data-testid="run-framework"
title={framework.label}
>
<FrameworkLogo framework={framework} />
{traceAgentNames(summary).join(", ") || framework.label}
</span>
);
}
function RunHeader({
trace,
handoff,
onBack,
embedded,
}: {
trace: Trace;
handoff: TraceHandoff;
onBack: () => void;
embedded: boolean;
}) {
const { summary } = trace;
const failed = summary.status === "error";
return (
<header className="shrink-0 border-b bg-background px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
{!embedded && (
<Button variant="ghost" size="icon-xs" onClick={onBack} aria-label="Back to runs">
<ArrowLeft className="size-4" />
</Button>
)}
<RunIcon summary={summary} failed={failed} />
<h1 className="min-w-0 truncate text-base font-semibold">{traceDisplayName(summary)}</h1>
<IdChip value={summary.trace_id} label="Copy trace ID" />
<TabsList aria-label="Trace view" className="ml-auto shrink-0 group-data-horizontal/tabs:h-8">
<TabsTrigger value="steps" className="text-xs">
Steps
</TabsTrigger>
<TabsTrigger value="conversation" className="text-xs">
Conversation
</TabsTrigger>
</TabsList>
<div className="shrink-0">
<CopyForAgent handoff={handoff} />
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2">
<span className={cn("text-xs font-medium", failed ? "text-destructive" : "text-muted-foreground")}>
{failed ? "Failed" : "Completed"}
</span>
<Stat label="Duration" value={fmtMs(summary.duration_ms)} />
<Stat label="Steps" value={summary.span_count.toLocaleString()} />
<Stat label="Tokens" value={fmtTok(summary.input_tokens + summary.output_tokens)} />
<Stat label="Cost" value={summary.spend == null ? "Not reported" : formatCost(summary.spend)} />
{summary.error_count > 0 && <Stat label="Step errors" value={summary.error_count.toLocaleString()} error />}
</div>
</header>
);
}
interface RunBodyProps {
trace: Trace;
accessToken: string;
selection: RunSelection;
embedded: boolean;
stale: boolean;
}
/** Tree + detail pane for one loaded run. Arrows move and fold steps; J/K also move unless the drawer owns them. */
function RunBody({ trace, accessToken, selection, embedded, stale }: RunBodyProps) {
const { view, selectSpan, setView, stepQuery: query, setStepQuery: setQuery, errorsOnly, setErrorsOnly } = selection;
const [initial] = useState(() => initialRunSelection(trace, selection.spanId ?? undefined));
const [state, setState] = useState<SpanTreeState>(initial.state);
const selectedId = selection.spanId ?? initial.selectedId;
const [detailOpen, setDetailOpen] = useState(true);
const filtering = Boolean(query.trim()) || errorsOnly;
const treeRows = useMemo(() => buildTreeRows(trace.spans, state), [trace, state]);
const rows = useMemo<TreeRow[]>(
() =>
filtering
? findTraceSteps(trace.spans, query, errorsOnly, state.hideFramework).map((span) => ({
kind: "span",
id: span.span_id,
span,
depth: 0,
hasChildren: false,
collapsed: false,
}))
: treeRows,
[trace, state.hideFramework, query, errorsOnly, filtering, treeRows],
);
const selectedRow: TreeRow | undefined =
rows.find((row) => row.id === selectedId) ?? treeRows.find((row) => row.id === selectedId) ?? rows[0];
const select = useCallback(
(id: string) => {
selectSpan(id);
setDetailOpen(true);
setState((prev) => revealSpanInState(trace.spans, prev, id));
},
[trace.spans, selectSpan],
);
const toggleSpan = useCallback(
(id: string) => setState((prev) => ({ ...prev, collapsedSpanIds: toggle(prev.collapsedSpanIds, id) })),
[],
);
const toggleGroup = useCallback(
(id: string) => setState((prev) => ({ ...prev, expandedGroupIds: toggle(prev.expandedGroupIds, id) })),
[],
);
const loadMore = useCallback(
(groupId: string) =>
setState((prev) => ({
...prev,
groupRevealCounts: {
...prev.groupRevealCounts,
[groupId]: (prev.groupRevealCounts[groupId] ?? GROUP_PAGE_SIZE) + GROUP_PAGE_SIZE,
},
})),
[],
);
const setRowExpanded = (row: TreeRow, expand: boolean) => {
if (row.kind === "span" && row.hasChildren && row.collapsed === expand) toggleSpan(row.id);
if (row.kind === "group" && row.expanded !== expand) toggleGroup(row.id);
};
const moveBy = (delta: number) => {
const index = rows.findIndex((row) => row.id === selectedRow?.id);
const next = rows[Math.min(rows.length - 1, Math.max(0, index + delta))];
if (next) select(next.id);
};
const fold = (expand: boolean) => {
const row = rows.find((candidate) => candidate.id === selectedRow?.id);
if (row) setRowExpanded(row, expand);
};
const active = !stale && view === "steps";
const pane = { layer: "pane", enabled: active } as const;
useShortcut("down", () => moveBy(1), { ...pane, description: "step" });
useShortcut("up", () => moveBy(-1), { ...pane, description: "step" });
useShortcut("j", () => moveBy(1), { ...pane, enabled: active && !embedded, description: "move" });
useShortcut("k", () => moveBy(-1), { ...pane, enabled: active && !embedded, description: "move" });
useShortcut("left", () => fold(false), { ...pane, description: "fold" });
useShortcut("right", () => fold(true), { ...pane, description: "fold" });
useShortcut("escape", () => setDetailOpen(false), { ...pane, enabled: active && detailOpen, description: "close" });
if (view === "conversation")
return (
<TabsContent value="conversation" className="flex min-h-0 flex-1">
<TraceConversation
trace={trace}
accessToken={accessToken}
onOpenStep={(id) => {
select(id);
setView("steps");
}}
/>
</TabsContent>
);
return (
<TabsContent
value="steps"
className={cn(
"grid min-h-0 flex-1",
detailOpen
? "grid-cols-1 grid-rows-2 @[640px]/trace:grid-cols-[clamp(280px,38%,340px)_minmax(0,1fr)] @[640px]/trace:grid-rows-1"
: "grid-cols-1",
)}
>
<SpanTree
rows={rows}
summary={trace.summary}
selectedId={selectedRow?.id ?? selectedId}
hideFramework={state.hideFramework}
onSelect={select}
onToggleHideFramework={(hideFramework) => setState((prev) => ({ ...prev, hideFramework }))}
onToggleSpan={toggleSpan}
onToggleGroup={toggleGroup}
onLoadMore={loadMore}
onOpenDetails={detailOpen ? undefined : () => setDetailOpen(true)}
query={query}
onQueryChange={setQuery}
errorsOnly={errorsOnly}
onErrorsOnlyChange={setErrorsOnly}
filtering={filtering}
onClearFilters={() => {
setQuery("");
setErrorsOnly(false);
}}
onCollapseAll={() =>
setState((prev) => ({
...prev,
collapsedSpanIds: new Set(
trace.spans.filter((span) => span.parent_span_id !== null).map((span) => span.span_id),
),
expandedGroupIds: new Set(),
}))
}
/>
{detailOpen && (
<div className="min-h-0 min-w-0">
<DetailPane
trace={trace}
row={selectedRow}
accessToken={accessToken}
spanTab={selection.spanTab}
onSpanTabChange={selection.setSpanTab}
onClose={() => setDetailOpen(false)}
/>
</div>
)}
</TabsContent>
);
}
interface RunViewProps {
traceId: string;
traceRef?: string;
selection: RunSelection;
accessToken: string;
onBack: () => void;
/** Rendered inside the side drawer: the drawer owns closing and sizing. */
embedded?: boolean;
}
interface PagingBannerProps {
readonly loaded: number;
readonly total: number;
readonly failure: TraceReadFailure | null;
readonly busy: boolean;
readonly onLoadMore: () => void;
readonly onRefresh: () => void;
}
/**
* Progress through the steps, or what the loaded steps are still worth after a later page failed.
* Retry keeps the loaded pages and asks for the same page again; Refresh starts a new traversal.
* Only a temporary failure gets a Retry, because a changed, invalid, or oversized read fails the
* same way every time.
*/
function PagingBanner({ loaded, total, failure, busy, onLoadMore, onRefresh }: PagingBannerProps) {
const retryable = failure !== null && isRetryableTraceRead(failure);
const nextLabel = failure ? "Retry" : "Load more steps";
return (
<div className="flex items-center justify-between gap-3 border-b px-3 py-2 text-xs" role="status">
<span>
{failure
? pageFailureMessage(failure)
: `Showing ${loaded.toLocaleString()} of ${total.toLocaleString()} steps`}
</span>
{failure && (
<Button size="xs" variant={retryable ? "ghost" : "outline"} disabled={busy} onClick={onRefresh}>
Refresh trace
</Button>
)}
{(!failure || retryable) && (
<Button size="xs" variant="outline" disabled={busy} onClick={onLoadMore}>
{busy ? "Loading…" : nextLabel}
</Button>
)}
</div>
);
}
/** What the loaded steps are still worth after a later page fails, and what the user can do about it. */
export function pageFailureMessage(failure: TraceReadFailure): string {
switch (failure.kind) {
case "changed":
return "This trace changed while you were browsing. Refresh to continue from its latest steps.";
case "invalid":
return "This page could not be continued. Refresh the trace to start over.";
case "too_large":
return "A step on the next page is too large to load here. Your loaded steps are still available.";
case "unavailable":
case "unknown":
return "Could not load more steps. Your loaded steps are still available.";
}
}
function selectedSpanMissing(trace: Trace, spanId: string | null): boolean {
return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId));
}
function RunLoading({ embedded }: { embedded: boolean }) {
return (
<div
role="status"
aria-label="Loading trace"
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
>
{embedded ? (
[72, 48, 88, 60, 80].map((w) => (
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
))
) : (
<UiLoadingSpinner className="size-6 text-muted-foreground" />
)}
</div>
);
}
function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
return (
<div className="p-6 text-xs" data-testid="run-view-error">
<button
type="button"
onClick={onBack}
className="mb-4 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" /> Back to traces
</button>
<h1 className="mb-2 text-sm font-medium">Could not load trace</h1>
<span className="text-muted-foreground">{error instanceof Error ? error.message : "Unknown error"}</span>
<Button variant="outline" size="sm" className="ml-3" onClick={onRetry}>
Retry
</Button>
</div>
);
}
/** Keeps the shown run on screen, inert, while the next one loads. */
export function RunView(props: RunViewProps) {
const traceId = useDeferredValue(props.traceId);
const traceRef = useDeferredValue(props.traceRef);
const switching = traceId !== props.traceId || traceRef !== props.traceRef;
const shownKey = traceKey({ traceId, traceRef });
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[shownKey]}
fallbackRender={({ error, resetErrorBoundary }) => (
<RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} />
)}
>
<Suspense fallback={<RunLoading embedded={props.embedded ?? false} />}>
<LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
function LoadedRun({
traceId,
traceRef,
selection,
accessToken,
onBack,
embedded = false,
switching,
}: RunViewProps & { switching: boolean }) {
const traces = useTracesApi(accessToken);
const queryClient = useQueryClient();
const queryKey = ["agentTrace", traceId, traceRef, accessToken];
const traceQueryOptions = {
queryKey,
queryFn: ({ pageParam }: { pageParam: string | null }) => traces.trace(traceId, traceRef, pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage: Trace) => lastPage.next_cursor ?? undefined,
staleTime: 30_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: traceReadRetry,
retryDelay: traceReadRetryDelay,
};
const traceQuery = useSuspenseInfiniteQuery(traceQueryOptions);
const refreshTrace = () => queryClient.resetQueries({ queryKey, exact: true });
const failure = traceQuery.error ? classifyTraceReadFailure(traceQuery.error) : null;
const trace = useMemo(() => {
const [first, ...rest] = traceQuery.data.pages;
return { ...first, spans: [first, ...rest].flatMap((page) => page.spans) };
}, [traceQuery.data]);
const seekingSpan = !switching && selectedSpanMissing(trace, selection.spanId);
const { hasNextPage, isFetching, isError, fetchNextPage } = traceQuery;
const canSeek = seekingSpan && hasNextPage;
useEffect(() => {
if (canSeek && !isFetching && !isError) void fetchNextPage();
}, [canSeek, isFetching, isError, fetchNextPage]);
return (
<Tabs
value={selection.view}
onValueChange={(value) => selection.setView(value as RunSelection["view"])}
className={cn(
"@container/trace flex flex-1 flex-col gap-0 overflow-hidden bg-background",
embedded ? "min-h-0" : "min-h-[560px] border-y border-border",
switching && "opacity-60 transition-opacity delay-150 duration-150 motion-reduce:transition-none",
)}
aria-busy={switching}
inert={switching}
data-testid="run-view"
>
<RunHeader
trace={trace}
handoff={traces.handoff(trace.summary.trace_id, null, trace.summary.trace_ref)}
onBack={onBack}
embedded={embedded}
/>
{(traceQuery.hasNextPage || failure) && (
<PagingBanner
loaded={trace.spans.length}
total={trace.summary.span_count}
failure={failure}
busy={traceQuery.isFetching}
onLoadMore={() => void traceQuery.fetchNextPage()}
onRefresh={() => void refreshTrace()}
/>
)}
<RunBody
key={seekingSpan ? "seeking" : "loaded"}
trace={trace}
accessToken={accessToken}
selection={selection}
embedded={embedded}
stale={switching}
/>
</Tabs>
);
}

View file

@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import CopyButton from "@/components/shared/CopyButton";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "../../ui/Collapse";
const COPY =
"size-6 shrink-0 rounded-sm text-muted-foreground opacity-0 transition-opacity duration-150 group-hover/block:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none";
interface BlockProps {
icon: React.ReactNode;
label: React.ReactNode;
/** Names the fold toggle and copy button for assistive tech. */
name: string;
copyValue?: string;
meta?: React.ReactNode;
tone?: "default" | "muted" | "failed";
defaultOpen?: boolean;
children?: React.ReactNode;
}
/** A titled card in the content tab (a message, a tool call, a field set) whose body folds from its header. */
export function Block({
icon,
label,
name,
copyValue,
meta,
tone = "default",
defaultOpen = true,
children,
}: BlockProps) {
const [open, setOpen] = useState(defaultOpen);
const foldable = children !== undefined && children !== null && children !== false;
const expanded = open && foldable;
return (
<article
className={cn(
"group/block overflow-hidden rounded-lg border bg-card",
tone === "failed" ? "border-destructive/40" : "border-border",
)}
>
<header className="flex min-h-9 items-center gap-2 py-1 pr-1.5 pl-2">
{foldable ? (
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${name}`}
onClick={() => setOpen((value) => !value)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm py-0.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<FoldChevron open={open} className="size-3.5 shrink-0 text-muted-foreground" />
{icon}
<span className="min-w-0 truncate text-sm font-medium text-foreground">{label}</span>
</button>
) : (
<span className="flex min-w-0 flex-1 items-center gap-2 py-0.5 pl-5.5">
{icon}
<span className="min-w-0 truncate text-sm font-medium text-foreground">{label}</span>
</span>
)}
{meta}
{copyValue !== undefined && (
<CopyButton variant="action" value={copyValue} label={`Copy ${name}`} iconOnly className={COPY} />
)}
</header>
{expanded && (
<div className={cn("border-t border-border/70 px-3 py-3", tone === "muted" && "[&_*]:text-muted-foreground")}>
{children}
</div>
)}
</article>
);
}
const BADGE_TONE = {
user: "bg-trace-chain-soft text-trace-chain",
assistant: "bg-trace-llm-soft text-trace-llm",
tool: "bg-trace-tool-soft text-trace-tool",
neutral: "bg-muted text-muted-foreground",
failed: "bg-destructive/10 text-destructive",
} as const;
export type BadgeTone = keyof typeof BADGE_TONE;
export function BlockBadge({ tone, children }: { tone: BadgeTone; children: React.ReactNode }) {
return (
<span className={cn("grid size-5 shrink-0 place-items-center rounded-md [&_svg]:size-3", BADGE_TONE[tone])}>
{children}
</span>
);
}

View file

@ -0,0 +1,105 @@
"use client";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { useTracesApi } from "../../api";
import type { Span, SpanDetail, UIContent } from "../../types";
import { parseMessages, prettyPayload } from "../../utils";
import { Payload, TextBody } from "./PayloadBody";
import { payloadView } from "./payload";
import { Section } from "./Section";
import { ErrorBlock, StoredDiagnostic } from "./SpanError";
const STATUS_TEXT = "px-4 py-2 text-sm text-muted-foreground";
const COLLAPSE_INPUT_ABOVE = 8;
/** Shared lazy fetch of one span's full input / output / attributes. */
export function useSpanDetail(accessToken: string, traceId: string, spanId: string | null, traceRef?: string) {
const traces = useTracesApi(accessToken);
const queryOptions: UseQueryOptions<SpanDetail, Error> = {
queryKey: ["agentTraceSpan", traceId, traceRef, spanId, accessToken],
queryFn: () => traces.span(traceId, spanId as string, traceRef),
enabled: spanId !== null,
staleTime: Infinity,
};
return useQuery(queryOptions);
}
const messageCount = (raw: string, content: UIContent | undefined): number | undefined =>
content?.kind === "messages" ? content.messages.length : parseMessages(raw)?.length;
function PayloadSection({
title,
raw,
content,
span,
role,
}: {
title: string;
raw: string;
content: UIContent | undefined;
span: Span;
role: "input" | "output";
}) {
const view = payloadView(raw, content, span.type === "tool" && role === "output");
const count = role === "input" ? messageCount(raw, content) : undefined;
return (
<Section
key={`${span.span_id}:${count}`}
title={title}
count={count}
defaultOpen={!count || count <= COLLAPSE_INPUT_ABOVE}
>
{(mode) =>
mode === "Raw" ? (
<TextBody text={prettyPayload(raw)} format="code" />
) : (
<Payload view={view} name={span.name} failed={span.status === "error"} />
)
}
</Section>
);
}
interface ContentTabProps {
accessToken: string;
traceId: string;
traceRef?: string;
span: Span;
}
/** The error first (if any), then collapsible Input and Output. */
export function ContentTab({ accessToken, traceId, traceRef, span }: ContentTabProps) {
const detailQuery = useSpanDetail(accessToken, traceId, span.span_id, traceRef);
const detail = detailQuery.data;
const empty = detail && !detail.input && !detail.output;
return (
<div className="flex flex-col pb-4">
{(span.status === "error" || span.error) && (
<div className="flex flex-col gap-2 px-4 pt-3 pb-1">
<ErrorBlock span={span} />
{span.error && (
<StoredDiagnostic
key={`${traceId}:${traceRef}:${span.span_id}`}
accessToken={accessToken}
traceId={traceId}
traceRef={traceRef}
span={span}
/>
)}
</div>
)}
{detailQuery.isLoading && <div className={STATUS_TEXT}>Loading span…</div>}
{detailQuery.isError && <div className={STATUS_TEXT}>Could not load span: {detailQuery.error.message}</div>}
{detail?.input ? (
<PayloadSection title="Input" raw={detail.input} content={detail.input_ui} span={span} role="input" />
) : null}
{detail?.output ? (
<PayloadSection title="Output" raw={detail.output} content={detail.output_ui} span={span} role="output" />
) : null}
{empty && span.status !== "error" && (
<div className="py-12 text-center text-sm text-muted-foreground">No content recorded for this span.</div>
)}
</div>
);
}

View file

@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "../../ui/Collapse";
import { Markdown } from "./Markdown";
import { MessageList } from "./Messages";
import type { FieldEntry, FieldNode } from "./payload";
const LONG_TEXT_CHARS = 90;
const OPEN_CHILD_LIMIT = 8;
const ID_KEY = /(^|_)id$/i;
const ROW = "grid min-w-0 grid-cols-[minmax(5rem,32%)_minmax(0,1fr)] items-baseline gap-x-3 py-1.5";
const KEY = "flex min-w-0 items-baseline gap-1 text-xs leading-5 text-muted-foreground";
const VALUE = "min-w-0 text-sm leading-5 break-words text-foreground";
const isLongText = (text: string): boolean => text.length > LONG_TEXT_CHARS || text.includes("\n");
const nodeSize = (node: FieldNode): number => {
if (node.kind === "object") return node.entries.length;
if (node.kind === "array") return node.items.length;
if (node.kind === "messages") return node.messages.length;
return 0;
};
const summary = (node: FieldNode): string => {
if (node.kind === "object") return `{ ${node.entries.length} ${node.entries.length === 1 ? "key" : "keys"} }`;
if (node.kind === "array") return `[ ${node.items.length} ${node.items.length === 1 ? "item" : "items"} ]`;
if (node.kind === "messages") return `${node.messages.length} ${node.messages.length === 1 ? "message" : "messages"}`;
return node.text.replace(/\s+/g, " ");
};
const isInline = (node: Extract<FieldNode, { kind: "scalar" | "text" }>): boolean =>
node.kind === "scalar" || (node.format !== "markdown" && !isLongText(node.text));
const startsOpen = (node: FieldNode, depth: number): boolean => {
if (node.kind === "text") return node.format === "markdown";
if (node.kind === "scalar") return false;
return depth === 0 && nodeSize(node) <= OPEN_CHILD_LIMIT;
};
function TextValue({ text, format, mono }: { text: string; format: "markdown" | "code" | "plain"; mono: boolean }) {
if (format === "markdown") return <Markdown text={text} />;
return (
<pre
className={cn(
VALUE,
"max-h-96 overflow-auto whitespace-pre-wrap",
(mono || format === "code") && "font-mono text-xs",
)}
>
{text}
</pre>
);
}
function Children({ node, depth, mono }: { node: FieldNode; depth: number; mono: boolean }) {
switch (node.kind) {
case "object":
return <FieldList entries={node.entries} depth={depth + 1} mono={mono} />;
case "array":
return (
<FieldList entries={node.items.map((item, i): FieldEntry => [String(i), item])} depth={depth + 1} mono={mono} />
);
case "messages":
return (
<div className="py-1.5">
<MessageList messages={node.messages} />
</div>
);
case "text":
return <TextValue text={node.text} format={node.format} mono={mono} />;
case "scalar":
return null;
}
}
function FieldRow({ name, node, depth, mono }: { name: string; node: FieldNode; depth: number; mono: boolean }) {
const [open, setOpen] = useState(() => startsOpen(node, depth));
const monoValue = mono || ID_KEY.test(name);
if ((node.kind === "scalar" || node.kind === "text") && isInline(node)) {
const monoText = monoValue || node.kind === "scalar" || node.format === "code";
return (
<li className={ROW}>
<span className={cn(KEY, "pl-4.5")}>
<span className="break-all">{name}</span>
</span>
<span className={cn(VALUE, "whitespace-pre-wrap", monoText && "font-mono text-xs leading-5")}>{node.text}</span>
</li>
);
}
const block = node.kind === "text" || node.kind === "messages";
return (
<li className="min-w-0">
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${name}`}
onClick={() => setOpen((value) => !value)}
className={cn(
ROW,
"w-full cursor-pointer rounded-sm text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className={KEY}>
<FoldChevron open={open} className="size-3.5 shrink-0 self-center" />
<span className="break-all">{name}</span>
</span>
{!open && (
<span className={cn(VALUE, "truncate font-mono text-xs text-muted-foreground")}>{summary(node)}</span>
)}
</button>
{open && (
<div className={cn("min-w-0", block ? "pb-2 pl-4.5" : "ml-1.5 border-l border-border pl-3")}>
<Children node={node} depth={depth} mono={monoValue} />
</div>
)}
</li>
);
}
function FieldList({ entries, depth, mono }: { entries: readonly FieldEntry[]; depth: number; mono: boolean }) {
return (
<ul className="flex min-w-0 flex-col">
{entries.map(([name, node], i) => (
<FieldRow key={`${name}-${i}`} name={name} node={node} depth={depth} mono={mono} />
))}
</ul>
);
}
/** Nested key / value tree: short values inline, long text and nested objects fold open in place. */
export function FieldTree({ entries, mono = false }: { entries: readonly FieldEntry[]; mono?: boolean }) {
return <FieldList entries={entries} depth={0} mono={mono} />;
}

View file

@ -0,0 +1,60 @@
"use client";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/cva.config";
const BLOCK_GAP = "mb-3 last:mb-0";
const components: Components = {
p: ({ node, ...props }) => <p className={BLOCK_GAP} {...props} />,
ul: ({ node, ...props }) => <ul className={cn(BLOCK_GAP, "list-disc space-y-1 pl-5")} {...props} />,
ol: ({ node, ...props }) => <ol className={cn(BLOCK_GAP, "list-decimal space-y-1 pl-5")} {...props} />,
li: ({ node, ...props }) => (
<li className="pl-0.5 [&>ol]:mt-1 [&>ol]:mb-0 [&>p]:mb-0 [&>ul]:mt-1 [&>ul]:mb-0" {...props} />
),
h1: ({ node, ...props }) => <h1 className={cn(BLOCK_GAP, "text-base font-semibold")} {...props} />,
h2: ({ node, ...props }) => <h2 className={cn(BLOCK_GAP, "font-semibold")} {...props} />,
h3: ({ node, ...props }) => <h3 className={cn(BLOCK_GAP, "font-semibold")} {...props} />,
strong: ({ node, ...props }) => <strong className="font-semibold text-foreground" {...props} />,
blockquote: ({ node, ...props }) => (
<blockquote className={cn(BLOCK_GAP, "border-l-2 border-border pl-3 text-muted-foreground")} {...props} />
),
pre: ({ node, ...props }) => (
<pre
className={cn(BLOCK_GAP, "max-w-full overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs leading-relaxed")}
{...props}
/>
),
code: ({ node, className, ...props }) => (
<code
className={cn(
"rounded-sm bg-muted px-1 py-0.5 font-mono text-xs text-foreground [pre_&]:bg-transparent [pre_&]:p-0",
className,
)}
{...props}
/>
),
a: ({ node, ...props }) => (
<a className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer" {...props} />
),
table: ({ node, ...props }) => (
<div className={cn(BLOCK_GAP, "overflow-x-auto")}>
<table className="w-full border-collapse text-left text-xs" {...props} />
</div>
),
th: ({ node, ...props }) => <th className="border-b border-border px-2 py-1.5 font-medium" {...props} />,
td: ({ node, ...props }) => <td className="border-b border-border/60 px-2 py-1.5 align-top" {...props} />,
img: ({ alt }) => <span>{alt || "Image omitted"}</span>,
};
export function Markdown({ text, className }: { text: string; className?: string }) {
return (
<div className={cn("text-sm leading-relaxed break-words text-foreground", className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
</div>
);
}

View file

@ -2,9 +2,10 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MessageCard, Section, ToolResultCard } from "./MessageCard";
import { MessageCard, ToolResultCard } from "./Messages";
import { Section } from "./Section";
vi.mock("../ui/spanProvider", () => ({ useSpanProvider: () => null }));
vi.mock("../../ui/spanProvider", () => ({ useSpanProvider: () => null }));
const LONG_QUERY = "Find every invoice for the customer that was billed twice. ".repeat(3).trim();
@ -29,7 +30,7 @@ describe("ToolResultCard", () => {
describe("MessageCard", () => {
it("folds the card body from the role tile and restores it", async () => {
const user = userEvent.setup();
render(<MessageCard message={{ role: "user", content: "Why was I billed twice?" }} model={null} />);
render(<MessageCard message={{ role: "user", content: "Why was I billed twice?" }} />);
expect(screen.getByText("Why was I billed twice?")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Collapse User" }));
expect(screen.queryByText("Why was I billed twice?")).not.toBeInTheDocument();
@ -46,7 +47,6 @@ describe("MessageCard", () => {
content: "",
tool_calls: [{ name: "search_invoices", args: { customer_id: "acme-404", query: LONG_QUERY } }],
}}
model={null}
/>,
);
expect(screen.getByText("acme-404")).toBeInTheDocument();
@ -58,7 +58,7 @@ describe("MessageCard", () => {
});
it("shows a tool result as one line with the tool name and its output", () => {
render(<MessageCard message={{ role: "tool", name: "ls", content: "No files found" }} model={null} />);
render(<MessageCard message={{ role: "tool", name: "ls", content: "No files found" }} />);
expect(screen.getByText("ls")).toBeInTheDocument();
expect(screen.getByText("No files found")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy ls result" })).toBeInTheDocument();

View file

@ -0,0 +1,148 @@
"use client";
import { Bot, Settings2, User, Wrench } from "lucide-react";
import { useState } from "react";
import CopyButton from "@/components/shared/CopyButton";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "../../ui/Collapse";
import type { TraceMessage, TraceToolCall } from "../../types";
import { Block, BlockBadge, type BadgeTone } from "./Block";
import { FieldTree } from "./FieldTree";
import { Markdown } from "./Markdown";
import { fieldNode, type FieldEntry } from "./payload";
const ROLE_LABEL: Record<string, string> = { user: "User", system: "System", assistant: "Assistant", tool: "Tool" };
const ROLE_TONE: Record<string, BadgeTone> = { user: "user", assistant: "assistant", tool: "tool", system: "neutral" };
const ROLE_ICON: Record<string, React.ReactNode> = {
user: <User />,
assistant: <Bot />,
tool: <Wrench />,
system: <Settings2 />,
};
const argsText = (args: unknown): string => (typeof args === "string" ? args : JSON.stringify(args));
function argEntries(args: unknown): readonly FieldEntry[] {
const node = fieldNode(args);
if (node.kind === "object" && node.entries.length > 0) return node.entries;
return [["arguments", node]];
}
export function ToolCallBlock({ call }: { call: TraceToolCall }) {
return (
<div className="rounded-md border border-border bg-background">
<div className="flex items-center gap-2 border-b border-border/70 px-2.5 py-1.5">
<BlockBadge tone="tool">
<Wrench />
</BlockBadge>
<span className="min-w-0 truncate font-mono text-xs font-medium text-foreground">{call.name}</span>
<CopyButton
variant="action"
value={`${call.name}(${argsText(call.args)})`}
label={`Copy ${call.name} call`}
iconOnly
className="ml-auto size-6 text-muted-foreground"
/>
</div>
<div className="px-2.5 py-1">
<FieldTree entries={argEntries(call.args)} />
</div>
</div>
);
}
export function MessageCard({ message }: { message: TraceMessage }) {
const label = ROLE_LABEL[message.role] ?? message.role;
if (message.role === "tool") return <ToolResultCard name={message.name || label} result={message.content} />;
const calls = message.tool_calls ?? [];
const copyValue = message.content || calls.map((c) => `${c.name}(${argsText(c.args)})`).join("\n");
const hasBody = Boolean(message.content) || calls.length > 0;
return (
<Block
icon={<BlockBadge tone={ROLE_TONE[message.role] ?? "neutral"}>{ROLE_ICON[message.role] ?? <User />}</BlockBadge>}
label={label}
name={label}
copyValue={copyValue}
tone={message.role === "system" ? "muted" : "default"}
defaultOpen={message.role !== "system"}
>
{hasBody && (
<div className="flex flex-col gap-3">
{message.content && <Markdown text={message.content} />}
{calls.map((call, i) => (
<ToolCallBlock key={`${call.name}-${i}`} call={call} />
))}
</div>
)}
</Block>
);
}
export function MessageList({ messages }: { messages: readonly TraceMessage[] }) {
return (
<div className="flex flex-col gap-2">
{messages.map((message, i) => (
<MessageCard key={`${message.role}-${i}`} message={message} />
))}
</div>
);
}
const LONG_RESULT_CHARS = 120;
/** A tool's result: one line by default, long or multiline results expand in place. Red when the tool failed. */
export function ToolResultCard({ name, result, failed = false }: { name: string; result: string; failed?: boolean }) {
const [open, setOpen] = useState(false);
const expandable = result.length > LONG_RESULT_CHARS || result.includes("\n");
const tone = failed ? "text-destructive" : "text-foreground";
return (
<article
className={cn(
"group/block overflow-hidden rounded-lg border bg-card",
failed ? "border-destructive/40" : "border-border",
)}
>
<div className="flex min-h-9 items-center gap-2 py-1 pr-1.5 pl-2.5">
<BlockBadge tone={failed ? "failed" : "tool"}>
<Wrench />
</BlockBadge>
<span className={cn("shrink-0 font-mono text-xs font-medium", failed ? "text-destructive" : "text-foreground")}>
{name}
</span>
{expandable ? (
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${name} result`}
onClick={() => setOpen((value) => !value)}
className={cn("flex min-w-0 flex-1 cursor-pointer items-center gap-1 text-left text-muted-foreground")}
>
<FoldChevron open={open} className="size-3.5 shrink-0" />
{!open && <span className="min-w-0 truncate text-sm">{result}</span>}
</button>
) : (
<span className={cn("min-w-0 flex-1 text-sm break-words", tone)}>{result || "No output"}</span>
)}
<CopyButton
variant="action"
value={result}
label={`Copy ${name} result`}
iconOnly
className="size-6 shrink-0 text-muted-foreground"
/>
</div>
{open && (
<pre
className={cn(
"max-h-96 overflow-auto border-t border-border/70 bg-muted/40 px-3 py-2.5 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap",
tone,
)}
>
{result}
</pre>
)}
</article>
);
}

View file

@ -0,0 +1,54 @@
"use client";
import { Braces } from "lucide-react";
import { cn } from "@/lib/cva.config";
import { Block, BlockBadge } from "./Block";
import { FieldTree } from "./FieldTree";
import { Markdown } from "./Markdown";
import { MessageList, ToolResultCard } from "./Messages";
import type { PayloadView, TextFormat } from "./payload";
const TEXT_CARD = "rounded-lg border border-border bg-card px-3.5 py-3";
export function TextBody({ text, format }: { text: string; format: TextFormat }) {
if (format === "markdown") return <Markdown text={text} className={TEXT_CARD} />;
return (
<pre
className={cn(
TEXT_CARD,
"max-h-[32rem] overflow-auto leading-relaxed break-words whitespace-pre-wrap text-foreground",
format === "code" ? "bg-muted/40 font-mono text-xs" : "font-sans text-sm",
)}
>
{text}
</pre>
);
}
export function Payload({ view, name, failed }: { view: PayloadView; name: string; failed: boolean }) {
switch (view.kind) {
case "messages":
return <MessageList messages={view.messages} />;
case "tool-result":
return <ToolResultCard name={name} result={view.text} failed={failed} />;
case "fields":
return (
<Block
icon={
<BlockBadge tone="neutral">
<Braces />
</BlockBadge>
}
label="Fields"
name="fields"
meta={<span className="text-xs text-muted-foreground tabular-nums">{view.entries.length}</span>}
>
<FieldTree entries={view.entries} />
</Block>
);
case "text":
return <TextBody text={view.text} format={view.format} />;
}
}

View file

@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "../../ui/Collapse";
const MODES = ["Formatted", "Raw"] as const;
export type SectionMode = (typeof MODES)[number];
function ModeSwitch({
mode,
onChange,
title,
}: {
mode: SectionMode;
onChange: (mode: SectionMode) => void;
title: string;
}) {
return (
<div role="radiogroup" aria-label={`${title} format`} className="flex items-center rounded-md bg-muted p-0.5">
{MODES.map((option) => (
<button
key={option}
type="button"
role="radio"
aria-checked={mode === option}
onClick={() => onChange(option)}
className={cn(
"h-6 rounded-sm px-2 text-xs transition-colors duration-150 motion-reduce:transition-none",
mode === option
? "bg-background font-medium text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{option}
</button>
))}
</div>
);
}
interface SectionProps {
title: string;
count?: number;
defaultOpen?: boolean;
/** When given, the header offers a Formatted / Raw switch and the body is a render function of the mode. */
children: React.ReactNode | ((mode: SectionMode) => React.ReactNode);
}
/** Collapsible "Input" / "Output" section with a sticky header. */
export function Section({ title, count, defaultOpen = true, children }: SectionProps) {
const [open, setOpen] = useState(defaultOpen);
const [mode, setMode] = useState<SectionMode>("Formatted");
const switchable = typeof children === "function";
return (
<section aria-label={title} className="flex flex-col">
<div className="sticky top-0 z-sticky-pinned flex h-11 items-center gap-2 bg-background px-4">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
className="-ml-1 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm px-1 py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<FoldChevron open={open} className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm font-semibold text-foreground">{title}</span>{" "}
{count !== undefined && (
<span className="text-xs text-muted-foreground">
{count} {count === 1 ? "message" : "messages"}
</span>
)}
</button>
{switchable && open && <ModeSwitch mode={mode} onChange={setMode} title={title} />}
</div>
<div hidden={!open} inert={!open} className="flex flex-col gap-2 px-4 pb-5">
{switchable ? children(mode) : children}
</div>
</section>
);
}

View file

@ -0,0 +1,123 @@
"use client";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { TriangleAlert } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useTracesApi } from "../../api";
import {
classifyTraceReadFailure,
isRetryableTraceRead,
traceReadRetry,
traceReadRetryDelay,
} from "../../list/traceReadFailure";
import type { ErrorSource } from "../../tree";
import type { Span, SpanErrorPage } from "../../types";
import { errorSource } from "../../utils";
import { TextBody } from "./PayloadBody";
const ERROR_SOURCE_LABEL: Record<ErrorSource, string> = { tool: "Tool", model: "Model", litellm: "LiteLLM" };
const TRACEBACK_MARKER = "Traceback (most recent call last):";
/** Exporters record `repr(exc)` + traceback with no separator; keep the exception line. */
export const errorHeadline = (error: string): string =>
(error.split(TRACEBACK_MARKER, 1)[0].split("\n")[0] ?? "").trim() || error.trim();
/** `ValueError('x not found')` → "ValueError"; plain text → "error". */
const errorReason = (headline: string): string => /^([A-Za-z_][\w.]*)\(/.exec(headline)?.[1] ?? "error";
export function ErrorBlock({ span }: { span: Span }) {
const source = errorSource(span);
if (!source) return null;
const headline = errorHeadline(span.error ?? "") || "Span reported an error status.";
return (
<section aria-label="Error" className="rounded-lg border border-destructive/40 bg-destructive/5 px-3.5 py-3">
<div className="flex items-center gap-2 text-sm font-semibold text-destructive">
<TriangleAlert className="size-4" />
{ERROR_SOURCE_LABEL[source]} · {errorReason(headline)}
</div>
<pre className="mt-2 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-foreground">
{headline}
</pre>
</section>
);
}
interface DiagnosticProps {
accessToken: string;
traceId: string;
traceRef?: string;
span: Span;
}
/** The full stored error, fetched only on request and paged section by section. */
export function StoredDiagnostic({ accessToken, traceId, traceRef, span }: DiagnosticProps) {
const traces = useTracesApi(accessToken);
const [opened, setOpened] = useState(false);
const [cursor, setCursor] = useState<string | null>(null);
const queryOptions: UseQueryOptions<SpanErrorPage, Error> = {
queryKey: ["agentTraceSpanError", traceId, traceRef, span.span_id, accessToken, cursor],
queryFn: () => traces.spanError(traceId, span.span_id, { traceRef, cursor }),
enabled: opened,
staleTime: Infinity,
gcTime: 0,
retry: traceReadRetry,
retryDelay: traceReadRetryDelay,
};
const query = useQuery(queryOptions);
const failure = query.error ? classifyTraceReadFailure(query.error) : null;
return (
<section aria-label="Stored diagnostic" className="flex flex-col items-start gap-2">
{span.error_truncated && <p className="text-xs text-muted-foreground">Error preview truncated</p>}
{!opened && (
<Button variant="outline" size="xs" onClick={() => setOpened(true)}>
View stored diagnostic
</Button>
)}
{opened && query.isPending && (
<p role="status" className="text-xs text-muted-foreground">
Loading diagnostic…
</p>
)}
{failure && (
<div role="alert" className="flex items-center gap-2 text-xs">
Could not load diagnostic: {failure.message}
{isRetryableTraceRead(failure) || !cursor ? (
<Button variant="outline" size="xs" onClick={() => query.refetch()}>
Retry
</Button>
) : (
<Button variant="outline" size="xs" onClick={() => setCursor(null)}>
Back to beginning
</Button>
)}
</div>
)}
{opened && query.data && (
<>
<div className="w-full">
<TextBody text={query.data.message} format="code" />
</div>
<p className="text-xs text-muted-foreground">
{cursor ? "Continuation" : "Beginning"} of stored diagnostic ({query.data.total_chars.toLocaleString()}{" "}
characters)
</p>
<div className="flex gap-2">
{query.data.next_cursor && (
<Button variant="outline" size="xs" onClick={() => setCursor(query.data.next_cursor)}>
Next section
</Button>
)}
{cursor && (
<Button variant="outline" size="xs" onClick={() => setCursor(null)}>
Back to beginning
</Button>
)}
</div>
</>
)}
</section>
);
}

View file

@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import { fieldNode, payloadView, textFormat } from "./payload";
describe("textFormat", () => {
it.each([
["StopEvent(result=AgentOutput(response='hi'))", "code"],
["<workflows.context.Context object at 0x1>", "code"],
["Traceback (most recent call last):\n File x", "code"],
["An **agent trace** is a record", "markdown"],
["# Findings\nnone", "markdown"],
["Steps:\n- read\n- write", "markdown"],
["see `answer.md`", "markdown"],
["read [the post](https://x.test)", "markdown"],
["What is an agent trace?", "plain"],
["calls f (twice)", "plain"],
])("reads %j as %s", (text, format) => {
expect(textFormat(text)).toBe(format);
});
});
describe("fieldNode", () => {
it("unfolds JSON-encoded strings into a nested tree", () => {
expect(fieldNode('{"config": {"steps": [1, null]}, "ok": true}')).toEqual({
kind: "object",
entries: [
[
"config",
{
kind: "object",
entries: [
[
"steps",
{
kind: "array",
items: [
{ kind: "scalar", text: "1" },
{ kind: "scalar", text: "null" },
],
},
],
],
},
],
["ok", { kind: "scalar", text: "true" }],
],
});
});
it("keeps a string that only starts like JSON as text", () => {
expect(fieldNode("[not json")).toEqual({ kind: "text", text: "[not json", format: "plain" });
});
it("reads a nested message list as a conversation, but not an empty list", () => {
const node = fieldNode([{ type: "human", data: { content: "What is an agent trace?" } }]);
expect(node).toEqual({ kind: "messages", messages: [{ role: "user", content: "What is an agent trace?" }] });
expect(fieldNode([])).toEqual({ kind: "array", items: [] });
});
});
describe("payloadView", () => {
it("renders standard fields as a tree with JSON values unfolded", () => {
const view = payloadView(
"raw",
{
kind: "fields",
fields: [
{ key: "start_event", value: "AgentWorkflowStartEvent()" },
{ key: "tags", value: '{"run_id": "r1"}' },
],
},
false,
);
expect(view).toEqual({
kind: "fields",
entries: [
["start_event", { kind: "text", text: "AgentWorkflowStartEvent()", format: "code" }],
["tags", { kind: "object", entries: [["run_id", { kind: "text", text: "r1", format: "plain" }]] }],
],
});
});
it("reads standard text by its format and falls back to the raw text for empty fields", () => {
expect(payloadView("", { kind: "text", text: "An **answer**" }, false)).toEqual({
kind: "text",
text: "An **answer**",
format: "markdown",
});
expect(payloadView("raw text", { kind: "fields", fields: [] }, false)).toEqual({
kind: "text",
text: "raw text",
format: "plain",
});
});
it("shows a tool's output as its result whatever shape the store sent", () => {
expect(payloadView("raw", { kind: "messages", messages: [{ role: "tool", content: "denied" }] }, true)).toEqual({
kind: "tool-result",
text: "denied",
});
expect(payloadView("raw", { kind: "text", text: "42" }, true)).toEqual({ kind: "tool-result", text: "42" });
expect(payloadView('{"a": 1}', { kind: "fields", fields: [{ key: "a", value: "1" }] }, true)).toEqual({
kind: "tool-result",
text: '{"a": 1}',
});
expect(payloadView("plain output", undefined, true)).toEqual({ kind: "tool-result", text: "plain output" });
});
it("keeps a tool's message with calls as a conversation", () => {
const view = payloadView(
"raw",
{
kind: "messages",
messages: [{ role: "assistant", content: "", tool_calls: [{ name: "f", arguments: '{"x": 1}' }] }],
},
true,
);
expect(view).toEqual({
kind: "messages",
messages: [{ role: "assistant", content: "", tool_calls: [{ name: "f", args: { x: 1 } }] }],
});
});
it("classifies raw payloads: messages, objects, lists and text", () => {
expect(payloadView('[{"role":"user","content":"hi"}]', undefined, false)).toEqual({
kind: "messages",
messages: [{ role: "user", content: "hi" }],
});
expect(payloadView('{"file": "/tmp/x"}', undefined, false)).toEqual({
kind: "fields",
entries: [["file", { kind: "text", text: "/tmp/x", format: "plain" }]],
});
expect(payloadView("[1, 2]", undefined, false)).toEqual({
kind: "fields",
entries: [
[
"value",
{
kind: "array",
items: [
{ kind: "scalar", text: "1" },
{ kind: "scalar", text: "2" },
],
},
],
],
});
expect(payloadView("{}", undefined, false)).toEqual({ kind: "text", text: "{}", format: "plain" });
expect(payloadView("StopEvent(result=1)", undefined, false)).toEqual({
kind: "text",
text: "StopEvent(result=1)",
format: "code",
});
});
});

View file

@ -0,0 +1,98 @@
import type { TraceMessage, UIContent, UIMessage } from "../../types";
import { parseJson, parseMessages } from "../../utils";
export type TextFormat = "markdown" | "code" | "plain";
export type FieldNode =
| { readonly kind: "object"; readonly entries: readonly FieldEntry[] }
| { readonly kind: "array"; readonly items: readonly FieldNode[] }
| { readonly kind: "messages"; readonly messages: readonly TraceMessage[] }
| { readonly kind: "text"; readonly text: string; readonly format: TextFormat }
| { readonly kind: "scalar"; readonly text: string };
export type FieldEntry = readonly [key: string, node: FieldNode];
export type PayloadView =
| { readonly kind: "messages"; readonly messages: readonly TraceMessage[] }
| { readonly kind: "tool-result"; readonly text: string }
| { readonly kind: "fields"; readonly entries: readonly FieldEntry[] }
| { readonly kind: "text"; readonly text: string; readonly format: TextFormat };
const MARKDOWN_SYNTAX = /(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)|\*\*[^*]+\*\*|`[^`\n]+`|\[[^\]]+\]\([^)]+\)/;
const CODE_LIKE = /^\s*(?:[A-Za-z_][\w.]*\(|<[\w.]+[\s>]|Traceback \(most recent call last\))/;
export function textFormat(text: string): TextFormat {
if (CODE_LIKE.test(text)) return "code";
if (MARKDOWN_SYNTAX.test(text)) return "markdown";
return "plain";
}
const scalarText = (value: unknown): string => (value === null || value === undefined ? "null" : String(value));
const looksLikeJson = (text: string): boolean => /^\s*[[{]/.test(text);
export function fieldNode(value: unknown): FieldNode {
if (typeof value === "string") {
const parsed = looksLikeJson(value) ? parseJson(value) : null;
if (typeof parsed === "object" && parsed !== null) return fieldNode(parsed);
return { kind: "text", text: value, format: textFormat(value) };
}
if (Array.isArray(value)) {
const messages = value.length > 0 ? parseMessages(JSON.stringify(value)) : null;
if (messages) return { kind: "messages", messages };
return { kind: "array", items: value.map(fieldNode) };
}
if (typeof value === "object" && value !== null) {
return { kind: "object", entries: Object.entries(value).map(([key, v]): FieldEntry => [key, fieldNode(v)]) };
}
return { kind: "scalar", text: scalarText(value) };
}
export const fieldEntries = (pairs: readonly (readonly [string, string])[]): readonly FieldEntry[] =>
pairs.map(([key, value]): FieldEntry => [key, fieldNode(value)]);
export const toTraceMessage = (message: UIMessage): TraceMessage => ({
...message,
tool_calls: message.tool_calls?.map((call) => ({
name: call.name,
args: parseJson(call.arguments) ?? call.arguments,
})),
});
const singleText = (messages: readonly UIMessage[]): string | null =>
messages.length === 1 && !messages[0].tool_calls?.length ? messages[0].content : null;
function textView(text: string): PayloadView {
return { kind: "text", text, format: textFormat(text) };
}
function standardView(content: UIContent, raw: string, toolOutput: boolean): PayloadView {
switch (content.kind) {
case "messages": {
const toolText = toolOutput ? singleText(content.messages) : null;
if (toolText !== null) return { kind: "tool-result", text: toolText };
return { kind: "messages", messages: content.messages.map(toTraceMessage) };
}
case "fields":
if (toolOutput) return { kind: "tool-result", text: raw };
if (content.fields.length === 0) return textView(raw);
return { kind: "fields", entries: fieldEntries(content.fields.map((field) => [field.key, field.value])) };
case "text":
return toolOutput ? { kind: "tool-result", text: content.text } : textView(content.text);
}
}
function rawView(raw: string, toolOutput: boolean): PayloadView {
const messages = parseMessages(raw);
if (messages) return { kind: "messages", messages };
if (toolOutput) return { kind: "tool-result", text: raw };
const node = fieldNode(raw);
if (node.kind === "object" && node.entries.length > 0) return { kind: "fields", entries: node.entries };
if (node.kind === "array" || node.kind === "messages") return { kind: "fields", entries: [["value", node]] };
return textView(raw);
}
/** How a span's input or output reads best: the standard UI shape when the store sent one, else the raw payload. */
export function payloadView(raw: string, content: UIContent | undefined, toolOutput: boolean): PayloadView {
return content ? standardView(content, raw, toolOutput) : rawView(raw, toolOutput);
}

View file

@ -2,19 +2,19 @@ import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ComponentProps } from "react";
import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils";
import { RunView } from "./TraceDrawer";
import { useOpenTraceRouting } from "../routing";
import { renderWithProviders, testQueryClient } from "../../../../../../tests/test-utils";
import { RunView } from "../run/RunView";
import { useOpenTraceRouting } from "../../routing";
import { TraceConversation } from "./TraceConversation";
import type { SpanDetail, Trace } from "../types";
import research from "../__fixtures__/research_trace.json";
import type { SpanDetail, Trace } from "../../types";
import research from "../../__fixtures__/research_trace.json";
vi.mock("../../../networking", () => ({
vi.mock("../../../../networking", () => ({
agentTraceCall: vi.fn(),
agentTraceSpanCall: vi.fn(),
getProxyBaseUrl: () => "http://proxy.test",
}));
import { agentTraceCall, agentTraceSpanCall } from "../../../networking";
import { agentTraceCall, agentTraceSpanCall } from "../../../../networking";
function RoutedRunView(props: Omit<ComponentProps<typeof RunView>, "selection">) {
const { selection } = useOpenTraceRouting();

View file

@ -5,13 +5,14 @@ import { useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
import { cn } from "@/lib/cva.config";
import CopyButton from "@/components/shared/CopyButton";
import { useTracesApi } from "../api";
import { useTracesApi } from "../../api";
import { Button } from "@/components/ui/button";
import { buildConversation, conversationSteps, CONVERSATION_PAGE_SIZE, type ConversationItem } from "./conversation";
import { ErrorBlock } from "./DetailContent";
import { Markdown, ToolCallBlock } from "./MessageCard";
import type { SpanDetail, Trace, TraceMessage } from "../types";
import { fmtMs } from "../utils";
import { ErrorBlock } from "../content/SpanError";
import { Markdown } from "../content/Markdown";
import { ToolCallBlock } from "../content/Messages";
import type { SpanDetail, Trace, TraceMessage } from "../../types";
import { fmtMs } from "../../utils";
export function TraceConversation({
trace,

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildConversation, conversationSteps, newConversationMessages } from "./conversation";
import type { Span, SpanDetail, TraceMessage } from "../types";
import research from "../__fixtures__/research_trace.json";
import type { Span, SpanDetail, TraceMessage } from "../../types";
import research from "../../__fixtures__/research_trace.json";
const root = { ...research.spans[0], span_id: "root", parent_span_id: null, type: "agent" } as Span;
const user: TraceMessage = { role: "user", content: "Find my order" };

View file

@ -1,5 +1,6 @@
import type { Span, SpanDetail, TraceMessage, TraceToolCall, UIContent } from "../types";
import { isFrameworkSpan, parseJson, parseMessages, prettyPayload } from "../utils";
import type { Span, SpanDetail, TraceMessage, TraceToolCall, UIContent } from "../../types";
import { isFrameworkSpan, parseJson, parseMessages, prettyPayload } from "../../utils";
import { toTraceMessage } from "../content/payload";
export const CONVERSATION_PAGE_SIZE = 20;
@ -22,14 +23,7 @@ function contentText(value: string, content?: UIContent): string {
}
function messages(value: string, content: UIContent | undefined, role: string): TraceMessage[] {
if (content?.kind === "messages")
return content.messages.map((message) => ({
...message,
tool_calls: message.tool_calls?.map((call) => ({
name: call.name,
args: parseJson(call.arguments) ?? call.arguments,
})),
}));
if (content?.kind === "messages") return content.messages.map(toTraceMessage);
const parsed = !content ? parseMessages(value) : null;
if (parsed) return parsed;
const text = contentText(value, content);

View file

@ -0,0 +1,64 @@
"use client";
import { Button } from "@/components/ui/button";
import { isRetryableTraceRead, type TraceReadFailure } from "../../list/traceReadFailure";
interface PagingBannerProps {
readonly loaded: number;
readonly total: number;
readonly failure: TraceReadFailure | null;
readonly busy: boolean;
readonly onLoadMore: () => void;
readonly onRefresh: () => void;
}
/** What the loaded steps are still worth after a later page fails, and what the user can do about it. */
function pageFailureMessage(failure: TraceReadFailure): string {
switch (failure.kind) {
case "changed":
return "This trace changed while you were browsing. Refresh to continue from its latest steps.";
case "invalid":
return "This page could not be continued. Refresh the trace to start over.";
case "too_large":
return "A step on the next page is too large to load here. Your loaded steps are still available.";
case "unavailable":
case "unknown":
return "Could not load more steps. Your loaded steps are still available.";
}
}
/**
* Progress through the steps, or what the loaded steps are still worth after a later page failed.
* Retry keeps the loaded pages and asks for the same page again; Refresh starts a new traversal.
* Only a temporary failure gets a Retry, because a changed, invalid, or oversized read fails the
* same way every time.
*/
export function PagingBanner({ loaded, total, failure, busy, onLoadMore, onRefresh }: PagingBannerProps) {
const retryable = failure !== null && isRetryableTraceRead(failure);
const nextLabel = failure ? "Retry" : "Load more steps";
return (
<div
className="flex items-center justify-between gap-3 border-b bg-muted/40 px-4 py-1.5 text-xs text-muted-foreground"
role="status"
>
<span>
{failure
? pageFailureMessage(failure)
: `Showing ${loaded.toLocaleString()} of ${total.toLocaleString()} steps`}
</span>
<span className="flex items-center gap-2">
{failure && (
<Button size="xs" variant={retryable ? "ghost" : "outline"} disabled={busy} onClick={onRefresh}>
Refresh trace
</Button>
)}
{(!failure || retryable) && (
<Button size="xs" variant="outline" disabled={busy} onClick={onLoadMore}>
{busy ? "Loading…" : nextLabel}
</Button>
)}
</span>
</div>
);
}

View file

@ -0,0 +1,121 @@
"use client";
import { type RefObject, useRef, useState } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import { useResizeObserver } from "usehooks-ts";
import { useShortcut } from "@/components/shared/useShortcut";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/Resizable";
import { TabsContent } from "@/components/ui/tabs";
import type { RunSelection } from "../../routing";
import type { Trace } from "../../types";
import { TraceConversation } from "../conversation/TraceConversation";
import { DetailPane } from "../span/DetailPane";
import { SpanTree } from "../tree/SpanTree";
import type { TreeLayout } from "../tree/TreeRows";
import { useRunTree } from "./useRunTree";
const SPLIT_LAYOUT_ID = "litellm.lens.runSplit";
const SIDE_BY_SIDE_MIN_PX = 640;
interface RunBodyProps {
trace: Trace;
accessToken: string;
selection: RunSelection;
embedded: boolean;
stale: boolean;
}
/** Tree + detail pane for one loaded run. Arrows move and fold steps; J/K also move unless the drawer owns them. */
export function RunBody({ trace, accessToken, selection, embedded, stale }: RunBodyProps) {
const { view, setView, stepQuery, setStepQuery, errorsOnly, setErrorsOnly } = selection;
const tree = useRunTree(trace, selection);
const [detailOpen, setDetailOpen] = useState(true);
const [layout, setLayout] = useState<TreeLayout>("tree");
const splitRef = useRef<HTMLDivElement>(null);
const { width = SIDE_BY_SIDE_MIN_PX } = useResizeObserver({ ref: splitRef as RefObject<HTMLDivElement> });
const orientation = width >= SIDE_BY_SIDE_MIN_PX ? "horizontal" : "vertical";
const saved = useDefaultLayout({ id: `${SPLIT_LAYOUT_ID}.${orientation}`, panelIds: ["steps", "detail"] });
const select = (id: string) => {
tree.select(id);
setDetailOpen(true);
};
const active = !stale && view === "steps";
const pane = { layer: "pane", enabled: active } as const;
useShortcut("down", () => tree.moveBy(1), { ...pane, description: "step" });
useShortcut("up", () => tree.moveBy(-1), { ...pane, description: "step" });
useShortcut("j", () => tree.moveBy(1), { ...pane, enabled: active && !embedded, description: "move" });
useShortcut("k", () => tree.moveBy(-1), { ...pane, enabled: active && !embedded, description: "move" });
useShortcut("left", () => tree.fold(false), { ...pane, description: "fold" });
useShortcut("right", () => tree.fold(true), { ...pane, description: "fold" });
useShortcut("escape", () => setDetailOpen(false), { ...pane, enabled: active && detailOpen, description: "close" });
if (view === "conversation")
return (
<TabsContent value="conversation" className="flex min-h-0 flex-1">
<TraceConversation
trace={trace}
accessToken={accessToken}
onOpenStep={(id) => {
select(id);
setView("steps");
}}
/>
</TabsContent>
);
return (
<TabsContent value="steps" ref={splitRef} className="flex min-h-0 flex-1">
<ResizablePanelGroup
key={orientation}
orientation={orientation}
defaultLayout={detailOpen ? saved.defaultLayout : undefined}
onLayoutChanged={detailOpen ? saved.onLayoutChanged : undefined}
>
<ResizablePanel id="steps" defaultSize="40%" minSize={orientation === "horizontal" ? 300 : 160} maxSize="65%">
<SpanTree
rows={tree.rows}
summary={trace.summary}
selectedId={tree.selectedId}
layout={layout}
onLayoutChange={setLayout}
hideFramework={tree.hideFramework}
onSelect={select}
onToggleHideFramework={tree.setHideFramework}
onToggleSpan={tree.toggleSpan}
onToggleGroup={tree.toggleGroup}
onLoadMore={tree.loadMore}
onOpenDetails={detailOpen ? undefined : () => setDetailOpen(true)}
query={stepQuery}
onQueryChange={setStepQuery}
errorsOnly={errorsOnly}
onErrorsOnlyChange={setErrorsOnly}
filtering={tree.filtering}
onClearFilters={() => {
setStepQuery("");
setErrorsOnly(false);
}}
onCollapseAll={tree.collapseAll}
/>
</ResizablePanel>
{detailOpen && (
<>
<ResizableHandle withHandle className="bg-border hover:bg-trace-chain" />
<ResizablePanel id="detail" minSize={orientation === "horizontal" ? 360 : 200} className="min-w-0">
<DetailPane
trace={trace}
row={tree.selectedRow}
accessToken={accessToken}
spanTab={selection.spanTab}
onSpanTabChange={selection.setSpanTab}
onClose={() => setDetailOpen(false)}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</TabsContent>
);
}

View file

@ -0,0 +1,117 @@
"use client";
import { ArrowLeft, Check, Copy } from "lucide-react";
import { useState } from "react";
import { useTimeout } from "usehooks-ts";
import { Button } from "@/components/ui/button";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import type { TraceHandoff } from "../../api";
import { formatCost } from "../../list/AgentTracesTable";
import { IdChip } from "../../ui/IdChip";
import { SpanIcon } from "../../ui/SpanIcon";
import { FrameworkLogo, traceFramework } from "../../ui/TraceFramework";
import type { Trace } from "../../types";
import { fmtMs, fmtTok, traceAgentNames, traceDisplayName } from "../../utils";
function CopyForAgent({ handoff }: { handoff: TraceHandoff }) {
const [copied, setCopied] = useState(false);
useTimeout(() => setCopied(false), copied ? 1600 : null);
return (
<Button
variant="outline"
size="xs"
className="h-7 shrink-0 gap-1.5 text-xs shadow-none"
onClick={async () => setCopied(await copyToClipboard(handoff.text, handoff.copied))}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? "Copied" : "Copy for agent"}
</Button>
);
}
function Stat({ label, value, error = false }: { label: string; value: string; error?: boolean }) {
return (
<span className={cn("inline-flex items-center gap-1 tabular-nums", error && "text-destructive")}>
<span>{label}</span> <span className={cn("font-medium", !error && "text-foreground")}>{value}</span>
</span>
);
}
function StatusPill({ failed }: { failed: boolean }) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium",
failed ? "bg-destructive/10 text-destructive" : "bg-trace-ok text-trace-ok-glyph",
)}
>
<span className={cn("size-1.5 rounded-full", failed ? "bg-destructive" : "bg-trace-ok-glyph")} />
{failed ? "Failed" : "Completed"}
</span>
);
}
function RunIcon({ summary, failed }: { summary: Trace["summary"]; failed: boolean }) {
const framework = traceFramework(summary);
if (!framework) return <SpanIcon type="agent" error={failed} size="lg" />;
return (
<span
className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md border border-border px-1.5 text-xs text-muted-foreground"
data-testid="run-framework"
title={framework.label}
>
<FrameworkLogo framework={framework} />
{traceAgentNames(summary).join(", ") || framework.label}
</span>
);
}
interface RunHeaderProps {
trace: Trace;
handoff: TraceHandoff;
onBack: () => void;
embedded: boolean;
}
/** Run identity, view switch and totals in two tight rows. */
export function RunHeader({ trace, handoff, onBack, embedded }: RunHeaderProps) {
const { summary } = trace;
const failed = summary.status === "error";
return (
<header className="flex shrink-0 flex-col gap-2 border-b bg-background px-4 pt-3 pb-2.5">
<div className="flex min-w-0 items-center gap-2">
{!embedded && (
<Button variant="ghost" size="icon-xs" onClick={onBack} aria-label="Back to runs">
<ArrowLeft className="size-4" />
</Button>
)}
<h1 className="min-w-0 truncate text-base font-semibold">{traceDisplayName(summary)}</h1>
<IdChip value={summary.trace_id} label="Copy trace ID" />
<RunIcon summary={summary} failed={failed} />
<div className="ml-auto flex shrink-0 items-center gap-2">
<TabsList aria-label="Trace view" className="group-data-horizontal/tabs:h-7">
<TabsTrigger value="steps" className="px-2.5 text-xs">
Steps
</TabsTrigger>
<TabsTrigger value="conversation" className="px-2.5 text-xs">
Conversation
</TabsTrigger>
</TabsList>
<CopyForAgent handoff={handoff} />
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<StatusPill failed={failed} />
<Stat label="Duration" value={fmtMs(summary.duration_ms)} />
<Stat label="Steps" value={summary.span_count.toLocaleString()} />
<Stat label="Tokens" value={fmtTok(summary.input_tokens + summary.output_tokens)} />
<Stat label="Cost" value={summary.spend == null ? "Not reported" : formatCost(summary.spend)} />
{summary.error_count > 0 && <Stat label="Step errors" value={summary.error_count.toLocaleString()} error />}
</div>
</header>
);
}

View file

@ -5,26 +5,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/http/client";
import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils";
import researchTrace from "../__fixtures__/research_trace.json";
import swarmTrace from "../__fixtures__/swarm_trace.json";
import { renderWithProviders, testQueryClient } from "../../../../../../tests/test-utils";
import researchTrace from "../../__fixtures__/research_trace.json";
import swarmTrace from "../../__fixtures__/swarm_trace.json";
import type { ComponentProps } from "react";
import { ShortcutHints } from "@/components/shared/ShortcutHints";
import { initialRunSelection, RunView } from "./TraceDrawer";
import { useOpenTraceRouting } from "../routing";
import { agentHandoffText } from "../api";
import type { Span } from "../types";
import type { Trace } from "../types";
import { traceDisplayName } from "../utils";
import { RunView } from "./RunView";
import { initialRunSelection } from "./useRunTree";
import { tickLabel, timeTicks } from "../tree/timeline";
import { useOpenTraceRouting } from "../../routing";
import { agentHandoffText } from "../../api";
import type { Span } from "../../types";
import type { Trace } from "../../types";
import { traceDisplayName } from "../../utils";
vi.mock("../../../networking", () => ({
vi.mock("../../../../networking", () => ({
agentTraceCall: vi.fn(),
agentTraceSpanCall: vi.fn(),
getProxyBaseUrl: () => "http://proxy.test/",
}));
// DetailPane is built separately; render a stub that exposes which row is selected.
vi.mock("./DetailPane", () => ({
vi.mock("../span/DetailPane", () => ({
DetailPane: ({ row, onClose }: { row?: { id: string }; onClose: () => void }) => (
<div data-testid="detail-pane" data-row-id={row?.id}>
<button type="button" onClick={onClose}>
@ -38,7 +40,7 @@ vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn().mockResolvedValue
import { copyToClipboard } from "@/utils/dataUtils";
import { agentTraceCall } from "../../../networking";
import { agentTraceCall } from "../../../../networking";
const swarm = swarmTrace as Trace;
const research = researchTrace as Trace;
@ -441,6 +443,30 @@ describe("RunView", () => {
expect(header).not.toHaveTextContent("Failed");
});
it("switches the step list to a waterfall with a time axis and back", async () => {
const user = userEvent.setup();
renderRun(research);
const tree = await screen.findByRole("tree", { name: "Spans in time order" });
expect(within(tree).queryAllByTestId("span-waterfall")).toHaveLength(0);
await user.click(screen.getByRole("radio", { name: "Waterfall" }));
expect(screen.getByRole("radio", { name: "Waterfall" })).toBeChecked();
expect(within(tree).getAllByTestId("span-waterfall")).toHaveLength(within(tree).getAllByRole("treeitem").length);
const lastTick = timeTicks(research.summary.duration_ms, 4).at(-1) ?? 0;
expect(lastTick).toBeGreaterThan(0);
expect(screen.getByText(tickLabel(lastTick))).toBeInTheDocument();
await user.click(screen.getByRole("radio", { name: "Tree" }));
expect(within(tree).queryAllByTestId("span-waterfall")).toHaveLength(0);
});
it("separates the steps and details with a resize handle that goes away with the details", async () => {
const user = userEvent.setup();
renderRun(research);
await screen.findByTestId("detail-pane");
expect(screen.getByRole("separator")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "close detail" }));
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
});
it("copies a curl one-liner for Claude / Codex", async () => {
const user = userEvent.setup();
renderRun(research);

View file

@ -0,0 +1,174 @@
"use client";
import { QueryErrorResetBoundary, useQueryClient, useSuspenseInfiniteQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { Suspense, useDeferredValue, useEffect, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import { useTracesApi } from "../../api";
import { classifyTraceReadFailure, traceReadRetry, traceReadRetryDelay } from "../../list/traceReadFailure";
import { type RunSelection, traceKey } from "../../routing";
import type { Trace } from "../../types";
import { PagingBanner } from "./PagingBanner";
import { RunBody } from "./RunBody";
import { RunHeader } from "./RunHeader";
interface RunViewProps {
traceId: string;
traceRef?: string;
selection: RunSelection;
accessToken: string;
onBack: () => void;
/** Rendered inside the side drawer: the drawer owns closing and sizing. */
embedded?: boolean;
}
function selectedSpanMissing(trace: Trace, spanId: string | null): boolean {
return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId));
}
function RunLoading({ embedded }: { embedded: boolean }) {
return (
<div
role="status"
aria-label="Loading trace"
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
>
{embedded ? (
[72, 48, 88, 60, 80].map((w) => (
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
))
) : (
<UiLoadingSpinner className="size-6 text-muted-foreground" />
)}
</div>
);
}
function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
return (
<div className="p-6 text-xs" data-testid="run-view-error">
<button
type="button"
onClick={onBack}
className="mb-4 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" /> Back to traces
</button>
<h1 className="mb-2 text-sm font-medium">Could not load trace</h1>
<span className="text-muted-foreground">{error instanceof Error ? error.message : "Unknown error"}</span>
<Button variant="outline" size="sm" className="ml-3" onClick={onRetry}>
Retry
</Button>
</div>
);
}
/** Keeps the shown run on screen, inert, while the next one loads. */
export function RunView(props: RunViewProps) {
const traceId = useDeferredValue(props.traceId);
const traceRef = useDeferredValue(props.traceRef);
const switching = traceId !== props.traceId || traceRef !== props.traceRef;
const shownKey = traceKey({ traceId, traceRef });
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[shownKey]}
fallbackRender={({ error, resetErrorBoundary }) => (
<RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} />
)}
>
<Suspense fallback={<RunLoading embedded={props.embedded ?? false} />}>
<LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
function LoadedRun({
traceId,
traceRef,
selection,
accessToken,
onBack,
embedded = false,
switching,
}: RunViewProps & { switching: boolean }) {
const traces = useTracesApi(accessToken);
const queryClient = useQueryClient();
const queryKey = ["agentTrace", traceId, traceRef, accessToken];
const traceQueryOptions = {
queryKey,
queryFn: ({ pageParam }: { pageParam: string | null }) => traces.trace(traceId, traceRef, pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage: Trace) => lastPage.next_cursor ?? undefined,
staleTime: 30_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: traceReadRetry,
retryDelay: traceReadRetryDelay,
};
const traceQuery = useSuspenseInfiniteQuery(traceQueryOptions);
const refreshTrace = () => queryClient.resetQueries({ queryKey, exact: true });
const failure = traceQuery.error ? classifyTraceReadFailure(traceQuery.error) : null;
const trace = useMemo(() => {
const [first, ...rest] = traceQuery.data.pages;
return { ...first, spans: [first, ...rest].flatMap((page) => page.spans) };
}, [traceQuery.data]);
const seekingSpan = !switching && selectedSpanMissing(trace, selection.spanId);
const { hasNextPage, isFetching, isError, fetchNextPage } = traceQuery;
const canSeek = seekingSpan && hasNextPage;
useEffect(() => {
if (canSeek && !isFetching && !isError) void fetchNextPage();
}, [canSeek, isFetching, isError, fetchNextPage]);
return (
<Tabs
value={selection.view}
onValueChange={(value) => selection.setView(value as RunSelection["view"])}
className={cn(
"@container/trace flex flex-1 flex-col gap-0 overflow-hidden bg-background",
embedded ? "min-h-0" : "min-h-[560px] border-y border-border",
switching && "opacity-60 transition-opacity delay-150 duration-150 motion-reduce:transition-none",
)}
aria-busy={switching}
inert={switching}
data-testid="run-view"
>
<RunHeader
trace={trace}
handoff={traces.handoff(trace.summary.trace_id, null, trace.summary.trace_ref)}
onBack={onBack}
embedded={embedded}
/>
{(traceQuery.hasNextPage || failure) && (
<PagingBanner
loaded={trace.spans.length}
total={trace.summary.span_count}
failure={failure}
busy={traceQuery.isFetching}
onLoadMore={() => void traceQuery.fetchNextPage()}
onRefresh={() => void refreshTrace()}
/>
)}
<RunBody
key={seekingSpan ? "seeking" : "loaded"}
trace={trace}
accessToken={accessToken}
selection={selection}
embedded={embedded}
stale={switching}
/>
</Tabs>
);
}

View file

@ -0,0 +1,147 @@
import { useCallback, useMemo, useState } from "react";
import type { RunSelection } from "../../routing";
import type { SpanTreeState, TreeRow } from "../../tree";
import type { Trace } from "../../types";
import {
buildTreeRows,
findTraceSteps,
firstErrorSpan,
GROUP_PAGE_SIZE,
isFrameworkSpan,
nearestVisibleSpanId,
revealSpanInState,
} from "../../utils";
const INITIAL_STATE: SpanTreeState = {
hideFramework: true,
collapsedSpanIds: new Set(),
expandedGroupIds: new Set(),
groupRevealCounts: {},
};
/** First failed span if the run has errors (with its tree path opened), otherwise the root agent. */
export function initialRunSelection(
trace: Trace,
initialSpanId?: string,
): { selectedId: string; state: SpanTreeState } {
if (initialSpanId && trace.spans.some((span) => span.span_id === initialSpanId)) {
const selectedId = nearestVisibleSpanId(trace.spans, initialSpanId, false);
const state = revealSpanInState(trace.spans, { ...INITIAL_STATE, hideFramework: false }, selectedId);
return { selectedId, state };
}
const initialState = {
...INITIAL_STATE,
collapsedSpanIds: new Set(
trace.spans.filter((span) => span.type === "agent" && span.parent_span_id !== null).map((span) => span.span_id),
),
};
const failed = firstErrorSpan(trace.spans);
if (!failed || failed.parent_span_id === null) {
const root = trace.spans.find((s) => s.parent_span_id === null);
return { selectedId: root?.span_id ?? "", state: initialState };
}
const visibleFailure = trace.spans
.filter((s) => s.status === "error" && s.parent_span_id !== null && !isFrameworkSpan(s))
.sort((a, b) => a.start_offset_ms - b.start_offset_ms)[0];
const selectedId = visibleFailure?.span_id ?? nearestVisibleSpanId(trace.spans, failed.span_id, true);
return { selectedId, state: revealSpanInState(trace.spans, initialState, selectedId) };
}
const toggle = (set: ReadonlySet<string>, id: string): Set<string> => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
};
/** Fold state, filtering and selection for one run's step tree. */
export function useRunTree(trace: Trace, selection: RunSelection) {
const { selectSpan, stepQuery: query, errorsOnly } = selection;
const [initial] = useState(() => initialRunSelection(trace, selection.spanId ?? undefined));
const [state, setState] = useState<SpanTreeState>(initial.state);
const selectedId = selection.spanId ?? initial.selectedId;
const filtering = Boolean(query.trim()) || errorsOnly;
const treeRows = useMemo(() => buildTreeRows(trace.spans, state), [trace, state]);
const rows = useMemo<TreeRow[]>(
() =>
filtering
? findTraceSteps(trace.spans, query, errorsOnly, state.hideFramework).map((span) => ({
kind: "span",
id: span.span_id,
span,
depth: 0,
hasChildren: false,
collapsed: false,
}))
: treeRows,
[trace, state.hideFramework, query, errorsOnly, filtering, treeRows],
);
const selectedRow: TreeRow | undefined =
rows.find((row) => row.id === selectedId) ?? treeRows.find((row) => row.id === selectedId) ?? rows[0];
const select = useCallback(
(id: string) => {
selectSpan(id);
setState((prev) => revealSpanInState(trace.spans, prev, id));
},
[trace.spans, selectSpan],
);
const toggleSpan = useCallback(
(id: string) => setState((prev) => ({ ...prev, collapsedSpanIds: toggle(prev.collapsedSpanIds, id) })),
[],
);
const toggleGroup = useCallback(
(id: string) => setState((prev) => ({ ...prev, expandedGroupIds: toggle(prev.expandedGroupIds, id) })),
[],
);
const loadMore = useCallback(
(groupId: string) =>
setState((prev) => ({
...prev,
groupRevealCounts: {
...prev.groupRevealCounts,
[groupId]: (prev.groupRevealCounts[groupId] ?? GROUP_PAGE_SIZE) + GROUP_PAGE_SIZE,
},
})),
[],
);
const setHideFramework = (hideFramework: boolean) => setState((prev) => ({ ...prev, hideFramework }));
const collapseAll = () =>
setState((prev) => ({
...prev,
collapsedSpanIds: new Set(trace.spans.filter((span) => span.parent_span_id !== null).map((span) => span.span_id)),
expandedGroupIds: new Set(),
}));
const setRowExpanded = (row: TreeRow, expand: boolean) => {
if (row.kind === "span" && row.hasChildren && row.collapsed === expand) toggleSpan(row.id);
if (row.kind === "group" && row.expanded !== expand) toggleGroup(row.id);
};
const moveBy = (delta: number) => {
const index = rows.findIndex((row) => row.id === selectedRow?.id);
const next = rows[Math.min(rows.length - 1, Math.max(0, index + delta))];
if (next) select(next.id);
};
const fold = (expand: boolean) => {
const row = rows.find((candidate) => candidate.id === selectedRow?.id);
if (row) setRowExpanded(row, expand);
};
return {
rows,
selectedRow,
selectedId: selectedRow?.id ?? selectedId,
hideFramework: state.hideFramework,
filtering,
select,
toggleSpan,
toggleGroup,
loadMore,
setHideFramework,
collapseAll,
moveBy,
fold,
};
}

View file

@ -0,0 +1,36 @@
"use client";
import { fieldEntries } from "../content/payload";
import type { Span } from "../../types";
import { FieldTree } from "../content/FieldTree";
import { DetailGroup } from "./DetailGroup";
interface AttributesTabProps {
traceId: string;
span: Span;
attributes: Record<string, string> | undefined;
isLoading: boolean;
}
/** Ids, then the raw OTEL attributes, as a key / value tree. */
export function AttributesTab({ traceId, span, attributes, isLoading }: AttributesTabProps) {
const ids = fieldEntries([
["trace_id", traceId],
["span_id", span.span_id],
["parent_span_id", span.parent_span_id ?? "—"],
]);
const attributeEntries = fieldEntries(Object.entries(attributes ?? {}).sort(([a], [b]) => a.localeCompare(b)));
return (
<div className="flex flex-col gap-5 px-4 pt-3 pb-5">
<DetailGroup title="Identifiers">
<FieldTree entries={ids} mono />
</DetailGroup>
{attributeEntries.length > 0 && (
<DetailGroup title="Attributes">
<FieldTree entries={attributeEntries} />
</DetailGroup>
)}
{isLoading && <div className="text-sm text-muted-foreground">Loading attributes…</div>}
</div>
);
}

View file

@ -0,0 +1,8 @@
export function DetailGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section aria-label={title} className="flex flex-col gap-2">
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{title}</h3>
<div className="rounded-lg border border-border bg-card px-3 py-1.5">{children}</div>
</section>
);
}

View file

@ -3,20 +3,20 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { type ComponentProps, useState } from "react";
import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils";
import { renderWithProviders, testQueryClient } from "../../../../../../tests/test-utils";
import { DetailPane } from "./DetailPane";
import type { SpanTab } from "../routing";
import { absoluteTime, SpanHoverCard, spanFacts } from "./SpanHoverCard";
import type { GroupRowData, SpanRowData } from "../tree";
import type { Span, SpanDetail, SpanErrorPage, Trace } from "../types";
import type { SpanTab } from "../../routing";
import { absoluteTime, SpanHoverCard, spanFacts } from "../tree/SpanHoverCard";
import type { GroupRowData, SpanRowData } from "../../tree";
import type { Span, SpanDetail, SpanErrorPage, Trace } from "../../types";
vi.mock("../../../networking", () => ({
vi.mock("../../../../networking", () => ({
agentTraceSpanCall: vi.fn(),
agentTraceSpanErrorCall: vi.fn(),
getProxyBaseUrl: () => "http://proxy.test/",
}));
import { agentTraceSpanCall, agentTraceSpanErrorCall } from "../../../networking";
import { agentTraceSpanCall, agentTraceSpanErrorCall } from "../../../../networking";
type SpanFields = Partial<Span> & Pick<Span, "span_id">;
@ -137,6 +137,40 @@ const textDetail: SpanDetail = {
attributes: {},
};
const workflowDetail: SpanDetail = {
span_id: "root",
input: '{"init_state": {"config": {"timeout": null}}}',
input_ui: {
kind: "fields",
fields: [
{ key: "init_state", value: '{"config": {"timeout": null, "workers": 4}}' },
{ key: "start_event", value: "AgentWorkflowStartEvent()" },
],
},
output: "StopEvent(result='done')",
output_ui: { kind: "text", text: "An **agent trace** records each step" },
attributes: {},
};
const langchainDetail: SpanDetail = {
span_id: "root",
input: "",
output: '{"messages": []}',
output_ui: {
kind: "fields",
fields: [
{
key: "messages",
value: JSON.stringify([
{ type: "human", data: { content: "What is an agent trace?" } },
{ type: "ai", data: { content: "A record of every step an agent took." } },
]),
},
],
},
attributes: {},
};
const failedToolMessageDetail: SpanDetail = {
span_id: "tool1",
input: '{"customer_id":"acme-404"}',
@ -174,7 +208,7 @@ describe("DetailPane", () => {
expect(screen.getByRole("tab", { name: "Content" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Request" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Attributes" })).toBeInTheDocument();
expect(await screen.findByText("You are a LiteLLM support agent.")).toBeInTheDocument();
expect(await screen.findByText("Customer acme-404 says billing is wrong.")).toBeVisible();
expect(screen.getAllByText("get_customer_plan").length).toBeGreaterThan(0);
expect(vi.mocked(agentTraceSpanCall)).toHaveBeenCalledWith("sk-test", "t1", "llm1", undefined);
});
@ -282,7 +316,9 @@ describe("DetailPane", () => {
renderPane(spanRow(llm));
const input = await screen.findByRole("region", { name: /^Input/ });
const systemText = "You are a LiteLLM support agent.";
expect(within(input).getByText(systemText, { ignore: "[inert] *" })).toBeInTheDocument();
expect(within(input).queryByText(systemText)).not.toBeInTheDocument();
await user.click(within(input).getByRole("button", { name: "Expand System" }));
expect(within(input).getByText(systemText, { ignore: "[inert] *" })).toBeVisible();
await user.click(within(input).getByRole("button", { name: /^Input/ }));
expect(within(input).getByRole("button", { name: /^Input/ })).toHaveAttribute("aria-expanded", "false");
expect(within(input).queryByText(systemText, { ignore: "[inert] *" })).not.toBeInTheDocument();
@ -322,6 +358,37 @@ describe("DetailPane", () => {
expect(output).not.toHaveTextContent("answer");
});
it("unfolds JSON-encoded field values into a nested tree and shows the raw payload on request", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceSpanCall).mockResolvedValue(workflowDetail);
renderPane(spanRow(root));
const input = await screen.findByRole("region", { name: /^Input/ });
expect(within(input).getByText("AgentWorkflowStartEvent()")).toBeVisible();
expect(within(input).getByRole("button", { name: "Collapse init_state" })).toHaveAttribute("aria-expanded", "true");
expect(within(input).queryByText("workers")).not.toBeInTheDocument();
await user.click(within(input).getByRole("button", { name: "Expand config" }));
expect(within(input).getByText("workers")).toBeVisible();
expect(within(input).getByText("4")).toBeVisible();
const output = screen.getByRole("region", { name: "Output" });
expect(within(output).getByText("agent trace", { selector: "strong" })).toBeVisible();
await user.click(within(output).getByRole("radio", { name: "Raw" }));
expect(within(output).getByText("StopEvent(result='done')", { selector: "pre" })).toBeVisible();
expect(within(output).queryByText("agent trace", { selector: "strong" })).not.toBeInTheDocument();
expect(within(input).getByText("AgentWorkflowStartEvent()")).toBeVisible();
});
it("renders LangChain's serialized messages as conversation cards", async () => {
vi.mocked(agentTraceSpanCall).mockResolvedValue(langchainDetail);
renderPane(spanRow(root));
const output = await screen.findByRole("region", { name: "Output" });
expect(within(output).getByRole("button", { name: "Collapse messages" })).toHaveAttribute("aria-expanded", "true");
expect(within(output).getByRole("button", { name: "Collapse User" })).toBeVisible();
expect(within(output).getByText("What is an agent trace?")).toBeVisible();
expect(within(output).getByText("A record of every step an agent took.")).toBeVisible();
expect(output).not.toHaveTextContent('"type": "human"');
});
it("groups ids and OTEL attributes into separate key / value sections on the Attributes tab", async () => {
const user = userEvent.setup();
renderPane(spanRow(llm));

View file

@ -0,0 +1,37 @@
"use client";
import type { TreeRow } from "../../tree";
import type { Trace } from "../../types";
import { GroupPane } from "./GroupPane";
import { SpanPane, type SpanTabProps } from "./SpanPane";
interface DetailPaneProps extends SpanTabProps {
trace: Trace;
row: TreeRow | undefined;
accessToken: string;
onClose: () => void;
}
/** Right pane of the run view: switches on the selected tree row. */
export function DetailPane({ trace, row, accessToken, spanTab, onSpanTabChange, onClose }: DetailPaneProps) {
if (!row || row.kind === "load-more") {
return (
<div className="grid h-full place-items-center bg-background text-sm text-muted-foreground">
Select a span to inspect it.
</div>
);
}
if (row.kind === "group")
return <GroupPane key={row.id} trace={trace} row={row} accessToken={accessToken} onClose={onClose} />;
return (
<SpanPane
key={row.id}
trace={trace}
span={row.span}
accessToken={accessToken}
spanTab={spanTab}
onSpanTabChange={onSpanTabChange}
onClose={onClose}
/>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import CopyButton from "@/components/shared/CopyButton";
import { useTracesApi } from "../../api";
import type { GroupRowData } from "../../tree";
import type { Trace } from "../../types";
import { fmtMs, fmtTok } from "../../utils";
import { errorHeadline } from "../content/SpanError";
import { PaneHeader } from "./PaneHeader";
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-border bg-card px-3 py-2.5">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-medium text-foreground tabular-nums">{value}</div>
</div>
);
}
/** ×N group: rollup of every invocation plus the first failure's message. */
export function GroupPane({
trace,
row,
accessToken,
onClose,
}: {
trace: Trace;
row: GroupRowData;
accessToken: string;
onClose: () => void;
}) {
const tokens = row.members.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0);
const firstFailure = row.members.find((m) => m.status === "error" && m.error);
const sample = (firstFailure ?? row.members[0]).span_id;
const handoff = useTracesApi(accessToken).handoff(trace.summary.trace_id, sample, trace.summary.trace_ref);
return (
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Group details">
<PaneHeader
type={row.type}
model={row.members[0]?.model ?? null}
failed={row.failedCount > 0}
title={
<>
{row.name} <span className="font-normal text-muted-foreground">×{row.members.length}</span>
</>
}
facts={[]}
actions={
<CopyButton variant="action" value={handoff.text} label="Copy group sample" copiedLabel={handoff.copied} />
}
onClose={onClose}
/>
<div className="min-h-0 flex-1 overflow-auto border-t px-4 py-4">
<div className="grid grid-cols-2 gap-2 @[520px]/trace:grid-cols-3">
<Metric label="Invocations" value={row.members.length.toLocaleString()} />
<Metric label="Failed" value={row.failedCount.toLocaleString()} />
<Metric label="p50 latency" value={fmtMs(row.p50Duration)} />
<Metric label="Tokens" value={fmtTok(tokens)} />
<Metric label="Agent" value={row.agent || "—"} />
<Metric label="Type" value={row.type} />
</div>
{firstFailure?.error && (
<section className="mt-4 rounded-lg border border-destructive/40 bg-destructive/5 px-3.5 py-3">
<div className="text-sm font-semibold text-destructive">Failure pattern</div>
<p className="mt-2 font-mono text-xs leading-relaxed text-foreground">
{errorHeadline(firstFailure.error)}
</p>
</section>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import { PanelRightClose } from "lucide-react";
import { Button } from "@/components/ui/button";
import { IdChip } from "../../ui/IdChip";
import { SpanIcon } from "../../ui/SpanIcon";
import type { SpanType } from "../../types";
interface PaneHeaderProps {
type: SpanType;
model: string | null;
failed: boolean;
title: React.ReactNode;
idValue?: string;
facts: readonly (readonly [label: string, value: string])[];
actions?: React.ReactNode;
onClose: () => void;
}
/** Selected step identity: tile, name, id, then its time and usage on one quiet line. */
export function PaneHeader({ type, model, failed, title, idValue, facts, actions, onClose }: PaneHeaderProps) {
return (
<div className="flex shrink-0 flex-col gap-1.5 px-4 pt-3 pb-2">
<div className="flex min-w-0 items-center gap-2">
<SpanIcon type={type} model={model} error={failed} size="lg" />
<h2 className="min-w-0 truncate text-base font-semibold text-foreground">{title}</h2>
{idValue && <IdChip value={idValue} label="Copy span ID" />}
<div className="ml-auto flex shrink-0 items-center gap-1">
{actions}
<Button
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close details"
className="text-muted-foreground"
>
<PanelRightClose className="size-4" />
</Button>
</div>
</div>
{facts.length > 0 && (
<dl className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-8 text-xs text-muted-foreground tabular-nums">
{facts.map(([label, value]) => (
<div key={label} className="flex items-center gap-1">
<dt>{label}</dt>
<dd className="font-medium text-foreground">{value}</dd>
</div>
))}
</dl>
)}
</div>
);
}

View file

@ -0,0 +1,80 @@
"use client";
import { ArrowUpRight } from "lucide-react";
import { useState } from "react";
import CopyButton from "@/components/shared/CopyButton";
import { Button } from "@/components/ui/button";
import { LogDetailsDrawer } from "../../../../logs/detail";
import { fieldEntries } from "../content/payload";
import { formatCost } from "../../list/AgentTracesTable";
import type { Span } from "../../types";
import { fmtMs, fmtTok } from "../../utils";
import { useSpanRequestLog } from "../useSpanRequestLog";
import { FieldTree } from "../content/FieldTree";
import { DetailGroup } from "./DetailGroup";
interface RequestTabProps {
span: Span;
accessToken: string;
/** The run's start, so the request log is looked up at the span's time, whatever range the tabs show. */
traceStartMs: number;
}
/** Usage and the linked LiteLLM request for an LLM span; "Open request log" opens the request drawer over the run. */
export function RequestTab({ span, accessToken, traceStartMs }: RequestTabProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const logQuery = useSpanRequestLog(
accessToken,
span.litellm_request_id,
traceStartMs + span.start_offset_ms,
drawerOpen,
);
const logNotFound = drawerOpen && logQuery.isSuccess && logQuery.data === null;
if (span.type !== "llm") {
return <div className="py-16 text-center text-sm text-muted-foreground">This span is not a model request.</div>;
}
const usage = fieldEntries([
["model", span.model ?? "—"],
["cost", span.spend == null ? "—" : formatCost(span.spend)],
["input_tokens", fmtTok(span.input_tokens)],
["output_tokens", fmtTok(span.output_tokens)],
["total_tokens", fmtTok(span.input_tokens + span.output_tokens)],
["latency", fmtMs(span.duration_ms)],
]);
return (
<div className="flex flex-col gap-5 px-4 pt-3 pb-5">
<DetailGroup title="Usage">
<FieldTree entries={usage} />
</DetailGroup>
<DetailGroup title="LiteLLM request">
{span.litellm_request_id ? (
<div className="flex flex-col gap-2 py-1.5">
<div className="flex min-w-0 items-center gap-1">
<div className="min-w-0 flex-1">
<FieldTree entries={fieldEntries([["request_id", span.litellm_request_id]])} mono />
</div>
<CopyButton variant="action" value={span.litellm_request_id} label="Copy request ID" iconOnly />
</div>
<Button variant="outline" size="xs" className="self-start" onClick={() => setDrawerOpen(true)}>
Open request log <ArrowUpRight className="size-3.5" />
</Button>
{logNotFound && <p className="text-sm text-muted-foreground">No request log found for this call.</p>}
</div>
) : (
<p className="py-1.5 text-sm text-muted-foreground">Not linked to a LiteLLM request</p>
)}
</DetailGroup>
<LogDetailsDrawer
open={drawerOpen && Boolean(logQuery.data)}
onClose={() => setDrawerOpen(false)}
logEntry={logQuery.data ?? null}
accessToken={accessToken}
/>
</div>
);
}

View file

@ -0,0 +1,85 @@
"use client";
import CopyButton from "@/components/shared/CopyButton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTracesApi } from "../../api";
import { formatCost } from "../../list/AgentTracesTable";
import { SPAN_TABS, type SpanTab } from "../../routing";
import type { Span, Trace } from "../../types";
import { fmtMs, fmtTok } from "../../utils";
import { ContentTab, useSpanDetail } from "../content/ContentTab";
import { AttributesTab } from "./AttributesTab";
import { PaneHeader } from "./PaneHeader";
import { RequestTab } from "./RequestTab";
const TAB_LABELS: Record<SpanTab, string> = { content: "Content", request: "Request", attributes: "Attributes" };
export interface SpanTabProps {
spanTab: SpanTab;
onSpanTabChange: (tab: SpanTab) => void;
}
type Fact = readonly [label: string, value: string];
const spanFacts = (span: Span): readonly Fact[] => {
const tokens = span.input_tokens + span.output_tokens;
const optional: readonly (Fact | null)[] = [
tokens > 0 ? ["Tokens", fmtTok(tokens)] : null,
span.spend != null ? ["Cost", formatCost(span.spend)] : null,
span.type === "llm" && span.model ? ["Step", span.name] : null,
];
return [["Duration", fmtMs(span.duration_ms)], ...optional.filter((fact): fact is Fact => fact !== null)];
};
export function SpanPane({
trace,
span,
accessToken,
spanTab: tab,
onSpanTabChange,
onClose,
}: SpanTabProps & { trace: Trace; span: Span; accessToken: string; onClose: () => void }) {
const { trace_id: traceId, trace_ref: traceRef, start_time: startTime } = trace.summary;
const handoff = useTracesApi(accessToken).handoff(traceId, span.span_id, traceRef);
const detailQuery = useSpanDetail(accessToken, traceId, tab === "attributes" ? span.span_id : null, traceRef);
return (
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Span details">
<PaneHeader
type={span.type}
model={span.model}
failed={span.status === "error"}
title={span.type === "llm" ? span.model || span.name : span.name}
idValue={span.span_id}
facts={spanFacts(span)}
actions={<CopyButton variant="action" value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />}
onClose={onClose}
/>
<Tabs value={tab} onValueChange={(value) => onSpanTabChange(value as SpanTab)} className="min-h-0 flex-1 gap-0">
<div className="shrink-0 border-b px-4">
<TabsList variant="line" aria-label="Span detail sections" className="h-9 gap-4 px-0">
{SPAN_TABS.map((id) => (
<TabsTrigger key={id} value={id} className="flex-none px-0 text-sm">
{TAB_LABELS[id]}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value="content" className="min-h-0 overflow-auto">
<ContentTab accessToken={accessToken} traceId={traceId} traceRef={traceRef} span={span} />
</TabsContent>
<TabsContent value="request" className="min-h-0 overflow-auto">
<RequestTab span={span} accessToken={accessToken} traceStartMs={Date.parse(startTime)} />
</TabsContent>
<TabsContent value="attributes" className="min-h-0 overflow-auto">
<AttributesTab
traceId={traceId}
span={span}
attributes={detailQuery.data?.attributes}
isLoading={detailQuery.isLoading}
/>
</TabsContent>
</Tabs>
</aside>
);
}

View file

@ -4,12 +4,12 @@ import { Check } from "lucide-react";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { formatCost } from "../list/AgentTracesTable";
import { errorHeadline } from "./DetailContent";
import { SpanIcon } from "../ui/SpanIcon";
import type { GroupRowData } from "../tree";
import type { Span, SpanType } from "../types";
import { fmtMs, fmtTok } from "../utils";
import { formatCost } from "../../list/AgentTracesTable";
import { errorHeadline } from "../content/SpanError";
import { SpanIcon } from "../../ui/SpanIcon";
import type { GroupRowData } from "../../tree";
import type { Span, SpanType } from "../../types";
import { fmtMs, fmtTok } from "../../utils";
export const HOVER_OPEN_DELAY_MS = 300;
const HOVER_CLOSE_DELAY_MS = 100;

View file

@ -0,0 +1,259 @@
"use client";
import { ChartGantt, ListTree, MoreHorizontal, PanelRightOpen, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/cva.config";
import type { TreeRow } from "../../tree";
import type { TraceSummary } from "../../types";
import { treeGuides } from "../../utils";
import { BAR_TRACK, tickLabel, timeTicks } from "./timeline";
import { GroupRow, LoadMoreRow, type RowContext, SpanRow, type TreeLayout } from "./TreeRows";
interface SpanTreeProps {
rows: TreeRow[];
summary: TraceSummary;
selectedId: string;
layout: TreeLayout;
onLayoutChange: (layout: TreeLayout) => void;
hideFramework: boolean;
onSelect: (id: string) => void;
onToggleHideFramework: (checked: boolean) => void;
onToggleSpan: (id: string) => void;
onToggleGroup: (id: string) => void;
onLoadMore: (groupId: string) => void;
onOpenDetails?: () => void;
query: string;
onQueryChange: (query: string) => void;
errorsOnly: boolean;
onErrorsOnlyChange: (enabled: boolean) => void;
filtering: boolean;
onClearFilters: () => void;
onCollapseAll: () => void;
}
const LAYOUTS: readonly { id: TreeLayout; label: string; icon: typeof ListTree }[] = [
{ id: "tree", label: "Tree", icon: ListTree },
{ id: "waterfall", label: "Waterfall", icon: ChartGantt },
];
function LayoutSwitch({ layout, onChange }: { layout: TreeLayout; onChange: (layout: TreeLayout) => void }) {
return (
<div role="radiogroup" aria-label="Step layout" className="flex items-center rounded-md bg-muted p-0.5">
{LAYOUTS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
role="radio"
aria-checked={layout === id}
aria-label={label}
title={label}
onClick={() => onChange(id)}
className={cn(
"grid h-6 w-7 place-items-center rounded-sm transition-colors duration-150 motion-reduce:transition-none",
layout === id ? "bg-background text-foreground shadow-xs" : "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-3.5" />
</button>
))}
</div>
);
}
function FilterChip({
pressed,
onClick,
children,
}: {
pressed: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={onClick}
className={cn(
"flex h-7 shrink-0 items-center gap-1 rounded-md px-2 text-xs transition-colors duration-150 motion-reduce:transition-none",
pressed ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}
function TimeAxis({ totalMs }: { totalMs: number }) {
return (
<div aria-hidden="true" className="sticky top-0 z-raised flex h-6 items-end border-b bg-background pr-7 pl-2">
<span className="flex-1" />
<span className={cn("relative h-full shrink-0 border-l border-border/60", BAR_TRACK)}>
{timeTicks(totalMs, 4).map((tick) => (
<span
key={tick}
className="absolute bottom-1 -translate-x-1/2 text-xs text-muted-foreground tabular-nums first:translate-x-0"
style={{ left: `${totalMs > 0 ? (tick / totalMs) * 100 : 0}%` }}
>
{tickLabel(tick)}
</span>
))}
</span>
</div>
);
}
export function SpanTree({
rows,
summary,
selectedId,
layout,
onLayoutChange,
hideFramework,
onSelect,
onToggleHideFramework,
onToggleSpan,
onToggleGroup,
onLoadMore,
onOpenDetails,
query,
onQueryChange,
errorsOnly,
onErrorsOnlyChange,
filtering,
onClearFilters,
onCollapseAll,
}: SpanTreeProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const guides = useMemo(() => treeGuides(rows.map((row) => row.depth)), [rows]);
const traceStartMs = Date.parse(summary.start_time);
useEffect(() => {
scrollRef.current
?.querySelector(`[data-row-id="${CSS.escape(selectedId)}"]`)
?.scrollIntoView?.({ block: "nearest" });
}, [selectedId]);
return (
<section
className="flex h-full min-h-0 min-w-0 flex-col border-r border-border bg-background"
aria-label="Run spans"
>
<div className="flex shrink-0 flex-col gap-2 border-b px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
Steps{" "}
<span className="ml-0.5 text-xs font-normal text-muted-foreground tabular-nums">
{filtering && `${rows.length.toLocaleString()} of `}
{summary.span_count.toLocaleString()}
</span>
</span>
<div className="ml-auto flex items-center gap-1">
<LayoutSwitch layout={layout} onChange={onLayoutChange} />
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Step display options" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuCheckboxItem checked={hideFramework} onCheckedChange={onToggleHideFramework}>
Hide framework spans
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCollapseAll}>Collapse branches</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onOpenDetails && (
<Button variant="ghost" size="icon-xs" onClick={onOpenDetails} aria-label="Show details">
<PanelRightOpen className="size-4" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-1">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search steps"
placeholder="Filter steps"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
className="h-7 pr-7 pl-7 text-xs shadow-none md:text-xs"
/>
{query && (
<Button
variant="ghost"
size="icon-xs"
aria-label="Clear step search"
className="absolute top-1/2 right-0.5 -translate-y-1/2"
onClick={() => onQueryChange("")}
>
<X className="size-3" />
</Button>
)}
</div>
<FilterChip pressed={!errorsOnly} onClick={() => onErrorsOnlyChange(false)}>
All
</FilterChip>
<FilterChip pressed={errorsOnly} onClick={() => onErrorsOnlyChange(true)}>
Errors
<span
className={cn("tabular-nums", summary.error_count > 0 ? "text-destructive" : "text-muted-foreground")}
>
{summary.error_count}
</span>
</FilterChip>
{filtering && (
<span role="status" className="sr-only">
{rows.length} found
</span>
)}
</div>
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto pb-2">
{layout === "waterfall" && rows.length > 0 && <TimeAxis totalMs={summary.duration_ms} />}
{rows.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
<p>No matching steps</p>
<Button variant="link" size="sm" onClick={onClearFilters}>
Clear filters
</Button>
</div>
)}
<div role="tree" aria-label="Spans in time order" className="pt-1">
{rows.map((row, i) => {
if (row.kind === "load-more")
return <LoadMoreRow key={row.id} row={row} guide={guides[i]} onLoadMore={onLoadMore} />;
const ctx: RowContext = {
guide: guides[i],
selected: selectedId === row.id,
layout,
traceStartMs,
totalMs: summary.duration_ms,
filtering,
onSelect,
onToggleSpan,
onToggleGroup,
};
return row.kind === "group" ? (
<GroupRow key={row.id} row={row} ctx={ctx} />
) : (
<SpanRow key={row.id} row={row} ctx={ctx} />
);
})}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,260 @@
"use client";
import { MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "../../ui/Collapse";
import { FAMILY_BAR, SPAN_FAMILY, SpanIcon } from "../../ui/SpanIcon";
import type { GroupRowData, LoadMoreRowData, SpanRowData } from "../../tree";
import type { SpanType } from "../../types";
import { fmtMs, previewText, type TreeGuide } from "../../utils";
import { groupFacts, SpanHoverCard, spanFacts } from "./SpanHoverCard";
import { BAR_TRACK, barGeometry } from "./timeline";
export type TreeLayout = "tree" | "waterfall";
const INDENT = "w-4";
const ROW =
"group/row relative flex h-8 w-full items-center pr-2 pl-2 text-left outline-none transition-colors duration-100 focus-visible:shadow-[inset_0_0_0_2px_var(--ring)] motion-reduce:transition-none";
const GUIDE = "bg-border";
const rowTone = (selected: boolean): string =>
selected
? "bg-trace-chain-soft before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-trace-chain"
: "hover:bg-muted/60";
/** One indent column per ancestor: a rail where that ancestor has more children, an elbow into this row. */
function Guides({ depth, guide }: { depth: number; guide: TreeGuide }) {
return Array.from({ length: depth }, (_, level) => {
const parentColumn = level === depth - 1;
const rail = parentColumn ? !guide.last : guide.rails[level] === true;
return (
<span key={level} aria-hidden="true" data-testid="tree-gutter" className={cn("relative h-full shrink-0", INDENT)}>
{rail && <span data-guide="rail" className={cn("absolute inset-y-0 left-2.5 w-px", GUIDE)} />}
{parentColumn && (
<>
<span data-guide="elbow" className={cn("absolute top-0 left-2.5 h-1/2 w-px", GUIDE)} />
<span className={cn("absolute top-1/2 left-2.5 h-px w-1.5", GUIDE)} />
</>
)}
</span>
);
});
}
function Caret({ open, onToggle }: { open: boolean; onToggle: () => void }) {
return (
<span
role="button"
tabIndex={-1}
aria-label={open ? "Collapse" : "Expand"}
className="grid size-5 shrink-0 place-items-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
>
<FoldChevron open={open} className="size-3.5" />
</span>
);
}
function Bar({
start,
duration,
total,
type,
failed,
}: {
start: number;
duration: number;
total: number;
type: SpanType;
failed: boolean;
}) {
const { left, width } = barGeometry(start, duration, total);
return (
<span
aria-hidden="true"
data-testid="span-waterfall"
className={cn("relative h-full shrink-0 border-l border-border/60", BAR_TRACK)}
>
<span
className={cn(
"absolute top-1/2 h-3 -translate-y-1/2 rounded-sm",
failed ? "bg-destructive" : FAMILY_BAR[SPAN_FAMILY[type]],
)}
style={{ left: `${left}%`, width: `${width}%` }}
/>
</span>
);
}
interface RowFrameProps {
id: string;
depth: number;
guide: TreeGuide;
selected: boolean;
expanded: boolean | undefined;
onClick: () => void;
onDoubleClick?: () => void;
children: React.ReactNode;
}
function RowFrame({ id, depth, guide, selected, expanded, onClick, onDoubleClick, children }: RowFrameProps) {
return (
<button
type="button"
role="treeitem"
aria-expanded={expanded}
aria-selected={selected}
data-row-id={id}
onClick={onClick}
onDoubleClick={onDoubleClick}
className={cn(ROW, rowTone(selected))}
>
<Guides depth={depth} guide={guide} />
{children}
</button>
);
}
function Label({ children }: { children: React.ReactNode }) {
return <span className="flex min-w-0 flex-1 items-center gap-2 pl-1.5">{children}</span>;
}
const NAME = "min-w-0 truncate text-sm";
const META = "shrink-0 text-xs text-muted-foreground tabular-nums";
export interface RowContext {
guide: TreeGuide;
selected: boolean;
layout: TreeLayout;
traceStartMs: number;
totalMs: number;
filtering: boolean;
onSelect: (id: string) => void;
onToggleSpan: (id: string) => void;
onToggleGroup: (id: string) => void;
}
const readablePreview = (preview: string): string => {
const text = previewText(preview);
return /^\s*[[{]/.test(text) ? "" : text;
};
const subtitle = (row: SpanRowData, filtering: boolean): string => {
const { span } = row;
if (filtering) return readablePreview(span.input_preview) || span.agent;
return span.type === "agent" && span.parent_span_id ? readablePreview(span.input_preview) : "";
};
export function SpanRow({ row, ctx }: { row: SpanRowData; ctx: RowContext }) {
const { span } = row;
const failed = span.status === "error";
const hint = ctx.layout === "tree" ? subtitle(row, ctx.filtering) : "";
const modelChip = span.type === "llm" && span.model !== span.name ? span.model : null;
return (
<SpanHoverCard facts={spanFacts(span)} traceStartMs={ctx.traceStartMs}>
<RowFrame
id={row.id}
depth={row.depth}
guide={ctx.guide}
selected={ctx.selected}
expanded={row.hasChildren ? !row.collapsed : undefined}
onClick={() => ctx.onSelect(row.id)}
onDoubleClick={() => row.hasChildren && ctx.onToggleSpan(row.id)}
>
<SpanIcon type={span.type} model={span.model} error={failed} size="md" />
<Label>
<span className={cn(NAME, failed ? "font-medium text-destructive" : "text-foreground")}>{span.name}</span>
{modelChip && (
<span className="min-w-0 shrink truncate rounded-sm border border-border px-1 font-mono text-xs leading-4 text-muted-foreground">
{modelChip}
</span>
)}
{ctx.layout === "tree" && <span className={META}>{fmtMs(span.duration_ms)}</span>}
{hint && (
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground" title={hint}>
{hint}
</span>
)}
</Label>
{ctx.layout === "waterfall" && (
<Bar
start={span.start_offset_ms}
duration={span.duration_ms}
total={ctx.totalMs}
type={span.type}
failed={failed}
/>
)}
<span className="flex w-5 shrink-0 justify-end">
{row.hasChildren && <Caret open={!row.collapsed} onToggle={() => ctx.onToggleSpan(row.id)} />}
</span>
</RowFrame>
</SpanHoverCard>
);
}
export function GroupRow({ row, ctx }: { row: GroupRowData; ctx: RowContext }) {
const start = Math.min(...row.members.map((m) => m.start_offset_ms));
const end = Math.max(...row.members.map((m) => m.start_offset_ms + m.duration_ms));
return (
<SpanHoverCard facts={groupFacts(row)} traceStartMs={ctx.traceStartMs}>
<RowFrame
id={row.id}
depth={row.depth}
guide={ctx.guide}
selected={ctx.selected}
expanded={row.expanded}
onClick={() => {
ctx.onSelect(row.id);
ctx.onToggleGroup(row.id);
}}
>
<SpanIcon type={row.type} model={row.members[0]?.model ?? null} error={row.failedCount > 0} size="md" />
<Label>
<span className={cn(NAME, row.isFailureGroup ? "font-medium text-destructive" : "text-foreground")}>
{row.name}
</span>
<span className="shrink-0 rounded-sm bg-muted px-1 text-xs leading-4 text-muted-foreground tabular-nums">
×{row.members.length}
</span>
{row.failedCount > 0 && <span className={cn(META, "text-destructive")}>{row.failedCount} failed</span>}
</Label>
{ctx.layout === "waterfall" && (
<Bar start={start} duration={end - start} total={ctx.totalMs} type={row.type} failed={row.isFailureGroup} />
)}
<span className="flex w-5 shrink-0 justify-end">
<Caret open={row.expanded} onToggle={() => ctx.onToggleGroup(row.id)} />
</span>
</RowFrame>
</SpanHoverCard>
);
}
export function LoadMoreRow({
row,
guide,
onLoadMore,
}: {
row: LoadMoreRowData;
guide: TreeGuide;
onLoadMore: (groupId: string) => void;
}) {
return (
<button
type="button"
onClick={() => onLoadMore(row.groupId)}
className={cn(ROW, "text-xs text-muted-foreground hover:bg-trace-row-hover hover:text-foreground")}
>
<Guides depth={row.depth} guide={guide} />
<span className="flex items-center gap-2 pl-1.5">
<MoreHorizontal className="size-3.5" /> Load 20 more
<span>({row.remaining} remaining)</span>
</span>
</button>
);
}

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { barGeometry, tickLabel, timeTicks } from "./timeline";
describe("barGeometry", () => {
it("places a span by its share of the run", () => {
expect(barGeometry(2000, 5000, 10_000)).toEqual({ left: 20, width: 50 });
expect(barGeometry(0, 10_000, 10_000)).toEqual({ left: 0, width: 100 });
});
it("keeps instant spans visible and inside the track", () => {
expect(barGeometry(5000, 0, 10_000)).toEqual({ left: 50, width: 0.6 });
expect(barGeometry(10_000, 0, 10_000)).toEqual({ left: 99.4, width: 0.6 });
});
it("clips a span that overruns the run's end and clamps clock skew before the start", () => {
expect(barGeometry(8000, 5000, 10_000)).toEqual({ left: 80, width: 20 });
expect(barGeometry(-500, 1000, 10_000)).toEqual({ left: 0, width: 10 });
});
it("draws a sliver for a run with no duration", () => {
expect(barGeometry(0, 0, 0)).toEqual({ left: 0, width: 0.6 });
});
});
describe("tickLabel", () => {
it.each([
[0, "0"],
[20, "20ms"],
[500, "500ms"],
[2000, "2s"],
[1500, "1.5s"],
[60_000, "1m"],
[90_000, "1.5m"],
])("labels %d ms as %s", (ms, label) => {
expect(tickLabel(ms)).toBe(label);
});
});
describe("timeTicks", () => {
it("picks the smallest round step that fits", () => {
expect(timeTicks(45_400)).toEqual([0, 10_000, 20_000, 30_000, 40_000]);
expect(timeTicks(6870)).toEqual([0, 2000, 4000, 6000]);
expect(timeTicks(52)).toEqual([0, 10, 20, 30, 40, 50]);
});
it("respects the tick budget", () => {
expect(timeTicks(45_400, 3)).toEqual([0, 30_000]);
expect(timeTicks(10_000, 10)).toEqual([0, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10_000]);
});
it("falls back to an even split past the largest step and to a single tick for an empty run", () => {
expect(timeTicks(3_600_000)).toEqual([0, 600_000, 1_200_000, 1_800_000, 2_400_000, 3_000_000, 3_600_000]);
expect(timeTicks(0)).toEqual([0]);
});
});

View file

@ -0,0 +1,34 @@
const MIN_BAR_PERCENT = 0.6;
const TICK_STEPS_MS = [
1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000,
];
export interface BarGeometry {
readonly left: number;
readonly width: number;
}
/** Where a span sits on the run's timeline, in percent of the run; tiny spans keep a visible sliver. */
export function barGeometry(startMs: number, durationMs: number, totalMs: number): BarGeometry {
if (totalMs <= 0) return { left: 0, width: MIN_BAR_PERCENT };
const left = Math.min(100 - MIN_BAR_PERCENT, Math.max(0, (startMs / totalMs) * 100));
const width = Math.max(MIN_BAR_PERCENT, Math.min(100 - left, (durationMs / totalMs) * 100));
return { left, width };
}
export const BAR_TRACK = "w-1/3";
/** Compact label for a round tick: `0`, `500ms`, `2s`, `1.5m`. */
export function tickLabel(ms: number): string {
if (ms === 0) return "0";
if (ms >= 60_000) return `${+(ms / 60_000).toFixed(1)}m`;
if (ms >= 1000) return `${+(ms / 1000).toFixed(1)}s`;
return `${ms}ms`;
}
/** Round tick offsets across the run: the smallest step that fits at most `maxTicks` labels. */
export function timeTicks(totalMs: number, maxTicks = 6): readonly number[] {
if (totalMs <= 0) return [0];
const step = TICK_STEPS_MS.find((candidate) => totalMs / candidate <= maxTicks) ?? Math.ceil(totalMs / maxTicks);
return Array.from({ length: Math.floor(totalMs / step) + 1 }, (_, i) => i * step);
}

View file

@ -22,7 +22,7 @@ vi.mock("../../../networking", () => ({
getProxyBaseUrl: () => "http://localhost:4000",
}));
vi.mock("../detail/TraceDrawer", () => ({
vi.mock("../detail/run/RunView", () => ({
RunView: ({ traceId, onBack }: { traceId: string; onBack: () => void }) => (
<div data-testid="run-view">
run {traceId}

View file

@ -18,7 +18,7 @@ import {
useZoomRouting,
} from "../routing";
import type { TraceSummary } from "../types";
import { RunView } from "../detail/TraceDrawer";
import { RunView } from "../detail/run/RunView";
import { TimeRangeControls } from "./TimeRangeControls";
import { TracesTimeline, type TimeWindow } from "./TracesTimeline";
import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard";

View file

@ -1,5 +0,0 @@
import { cn } from "@/lib/cva.config";
export function PaneBar({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("flex h-10 shrink-0 items-center gap-2 border-b px-3", className)}>{children}</div>;
}

View file

@ -20,14 +20,43 @@ import { cn } from "@/lib/cva.config";
import { useSpanProvider } from "./spanProvider";
import type { SpanType } from "../types";
export type SpanFamily = "orchestration" | "model" | "tool";
export const SPAN_FAMILY: Record<SpanType, SpanFamily> = {
agent: "orchestration",
chain: "orchestration",
framework: "orchestration",
decision: "orchestration",
llm: "model",
embedding: "model",
reranker: "model",
prompt: "model",
tool: "tool",
retriever: "tool",
guardrail: "tool",
evaluator: "tool",
};
const FAMILY_TILE: Record<SpanFamily, string> = {
orchestration: "bg-trace-chain-soft text-trace-chain",
model: "bg-trace-llm-soft text-trace-llm",
tool: "bg-trace-tool-soft text-trace-tool",
};
export const FAMILY_BAR: Record<SpanFamily, string> = {
orchestration: "bg-trace-chain",
model: "bg-trace-llm",
tool: "bg-trace-tool",
};
const SIZE = {
sm: "size-4 rounded-sm",
card: "size-[18px] rounded-sm",
md: "size-5 rounded-full",
lg: "size-5 rounded-full",
md: "size-5 rounded-md",
lg: "size-6 rounded-md",
} as const;
const GLYPH = { sm: "size-3", card: "size-3", md: "size-3", lg: "size-3" } as const;
const LOGO_TILE = "bg-white dark:bg-white";
const GLYPH = { sm: "size-3", md: "size-3.5", lg: "size-4" } as const;
const FAILED_TILE = "bg-destructive/10 text-destructive";
const LOGO_TILE = "bg-white ring-1 ring-border dark:bg-white";
export type IconSize = keyof typeof SIZE;
@ -53,7 +82,11 @@ const TYPE_GLYPH: Record<SpanType, typeof Network> = {
decision: Split,
};
const tileTone = (error: boolean): string => (error ? "text-destructive" : "text-muted-foreground");
const tileTone = (type: SpanType, error: boolean, logo: boolean): string => {
if (error) return FAILED_TILE;
if (logo) return LOGO_TILE;
return FAMILY_TILE[SPAN_FAMILY[type]];
};
export function SpanIcon({ type, model = null, error = false, size = "md" }: SpanIconProps) {
const provider = useSpanProvider(type === "llm" ? model : null);
@ -61,7 +94,7 @@ export function SpanIcon({ type, model = null, error = false, size = "md" }: Spa
const showLogo = provider !== null && !error;
return (
<span
className={cn("grid shrink-0 place-items-center", SIZE[size], showLogo ? LOGO_TILE : tileTone(error))}
className={cn("grid shrink-0 place-items-center", SIZE[size], tileTone(type, error, showLogo))}
data-testid="span-icon"
data-provider={provider ?? undefined}
>

View file

@ -303,6 +303,21 @@ describe("payload helpers", () => {
expect(parseMessages('[{"role":"assistant","tool_calls":[]}]')).toBeNull();
expect(parseMessages('[{"role":"user","content":42}]')).toBeNull();
});
it("reads LangChain's serialized messages with their roles, names and tool calls", () => {
const dumped = [
{ type: "human", data: { content: "What is an agent trace?", name: null } },
{ type: "ai", data: { content: "", tool_calls: [{ name: "task", args: { agent: "search" }, id: "c1" }] } },
{ type: "tool", data: { content: "found it", name: "task" } },
];
expect(parseMessages(JSON.stringify(dumped))).toEqual([
{ role: "user", content: "What is an agent trace?" },
{ role: "assistant", content: "", tool_calls: [{ name: "task", args: { agent: "search" } }] },
{ role: "tool", content: "found it", name: "task" },
]);
expect(parseMessages('[{"type":"function","data":{"content":"x"}}]')).toBeNull();
expect(parseMessages('[{"type":"human","data":{"content":7}}]')).toBeNull();
});
});
describe("treeGuides", () => {

View file

@ -3,7 +3,7 @@
* computes lives here so it can be unit-tested directly.
*/
import { type ErrorSource, type SpanTreeState, type TreeRow } from "./tree";
import type { Span, TraceMessage, TraceSummary } from "./types";
import type { Span, TraceMessage, TraceSummary, TraceToolCall } from "./types";
/* ------------------------------------------------------------------ */
/* Formatting */
@ -316,8 +316,41 @@ export function messageText(content: string): string {
.join("\n\n");
}
const LANGCHAIN_ROLE: Readonly<Record<string, string>> = {
human: "user",
ai: "assistant",
system: "system",
tool: "tool",
};
const langchainToolCalls = (data: object): TraceToolCall[] | undefined => {
const calls: unknown = Reflect.get(data, "tool_calls");
if (!Array.isArray(calls) || calls.length === 0) return undefined;
return calls
.filter((call): call is object => typeof call === "object" && call !== null)
.map((call) => ({ name: String(Reflect.get(call, "name") ?? "tool"), args: Reflect.get(call, "args") ?? {} }));
};
/** LangChain's `dumpd` message: `{"type": "human", "data": {"content": ...}}`. */
const parseLangchainMessage = (value: object): TraceMessage | null => {
const role = LANGCHAIN_ROLE[String(Reflect.get(value, "type"))];
const data: unknown = Reflect.get(value, "data");
if (!role || typeof data !== "object" || data === null) return null;
const content: unknown = Reflect.get(data, "content");
if (typeof content !== "string" && !Array.isArray(content)) return null;
const name: unknown = Reflect.get(data, "name");
const toolCalls = langchainToolCalls(data);
return {
role,
content: messageText(typeof content === "string" ? content : JSON.stringify(content)),
...(typeof name === "string" && name ? { name } : {}),
...(toolCalls ? { tool_calls: toolCalls } : {}),
};
};
const parseMessage = (value: unknown): TraceMessage | null => {
if (typeof value !== "object" || value === null) return null;
if (!("role" in value) && "data" in value) return parseLangchainMessage(value);
const role: unknown = Reflect.get(value, "role");
const content: unknown = Reflect.get(value, "content") ?? Reflect.get(value, "parts");
if (typeof role !== "string" || (typeof content !== "string" && !Array.isArray(content))) return null;

View file

@ -0,0 +1,41 @@
"use client";
import { cn } from "@/lib/cva.config";
import * as ResizablePrimitive from "react-resizable-panels";
function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn("flex h-full w-full aria-[orientation=vertical]:flex-col", className)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && <div className="z-raised flex h-6 w-1 shrink-0 rounded-lg bg-border" />}
</ResizablePrimitive.Separator>
);
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };