diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json
index 37027f215fe..9aa5764548c 100644
--- a/ui/litellm-dashboard/package-lock.json
+++ b/ui/litellm-dashboard/package-lock.json
@@ -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",
diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json
index 388b6c7eab9..81b89337325 100644
--- a/ui/litellm-dashboard/package.json
+++ b/ui/litellm-dashboard/package.json
@@ -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",
diff --git a/ui/litellm-dashboard/src/app/globals.css b/ui/litellm-dashboard/src/app/globals.css
index cad79f64ea0..cfa85817ea6 100644
--- a/ui/litellm-dashboard/src/app/globals.css
+++ b/ui/litellm-dashboard/src/app/globals.css
@@ -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);
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/Evidence.tsx b/ui/litellm-dashboard/src/components/lens/investigations/Evidence.tsx
index 05017e76b68..fa4b716691c 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/Evidence.tsx
+++ b/ui/litellm-dashboard/src/components/lens/investigations/Evidence.tsx
@@ -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";
diff --git a/ui/litellm-dashboard/src/components/lens/investigations/FindingDetails.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/investigations/FindingDetails.integration.test.tsx
index 93942cb8fa9..544a7272e14 100644
--- a/ui/litellm-dashboard/src/components/lens/investigations/FindingDetails.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/lens/investigations/FindingDetails.integration.test.tsx
@@ -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 }) => (
{traceId} at {selection.spanId}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/AttributesDetail.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/AttributesDetail.tsx
deleted file mode 100644
index 4461a3ca429..00000000000
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/AttributesDetail.tsx
+++ /dev/null
@@ -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
| undefined;
- isLoading: boolean;
-}
-
-export function DetailGroup({ title, children }: { title: string; children: React.ReactNode }) {
- return (
-
- );
-}
-
-/** 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 (
-
-
-
-
- {attributeEntries.length > 0 && (
-
-
-
- )}
- {isLoading &&
Loading attributes…
}
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/DetailContent.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/DetailContent.tsx
deleted file mode 100644
index 70e62de65d5..00000000000
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/DetailContent.tsx
+++ /dev/null
@@ -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 = { 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 = {
- 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 (
-
-
-
- {ERROR_SOURCE_LABEL[source]} · {errorReason(headline)}
-
- {headline}
-
- );
-}
-
-function FieldsCard({ entries }: { entries: readonly KeyValue[] }) {
- return (
-
-
-
- );
-}
-
-function TextCard({ text }: { text: string }) {
- return (
-
- {text}
-
- );
-}
-
-function PlainPayload({ value }: { value: string }) {
- const entries = objectEntries(parseJson(value));
- if (entries && entries.length > 0) return ;
- return ;
-}
-
-function Messages({ messages, model }: { messages: TraceMessage[]; model: string | null }) {
- return (
- <>
- {messages.map((message, i) => (
-
- ))}
- >
- );
-}
-
-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): boolean =>
- span.type === "tool" && role === "output";
-
-function ToolResult({ value, span }: Omit) {
- return ;
-}
-
-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 ;
- if (content.kind === "messages") {
- return ;
- }
- if (isToolResult(props)) return ;
- if (content.kind === "fields" && content.fields.length > 0) {
- return [field.key, field.value])} />;
- }
- return ;
-}
-
-function Payload(props: PayloadProps) {
- const messages = parseMessages(props.value);
- if (messages) return ;
- if (isToolResult(props)) return ;
- return ;
-}
-
-function SpanPayload({ content, ...props }: PayloadProps & { content: UIContent | undefined }) {
- return content ? : ;
-}
-
-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(null);
- const queryOptions: UseQueryOptions = {
- 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 (
-
- {span.error_truncated && Error preview truncated
}
- {!opened && (
- setOpened(true)}>
- View stored diagnostic
-
- )}
- {opened && query.isPending && Loading diagnostic…
}
- {failure && (
-
- Could not load diagnostic: {failure.message}
- {isRetryableTraceRead(failure) ? (
- query.refetch()}>
- Retry
-
- ) : (
- setCursor(null)}>
- Back to beginning
-
- )}
-
- )}
- {opened && query.data && (
- <>
-
-
- {cursor ? "Continuation" : "Beginning"} of stored diagnostic ({query.data.total_chars.toLocaleString()}{" "}
- characters)
-
- {query.data.next_cursor && (
- setCursor(query.data.next_cursor)}>
- Next section
-
- )}
- {cursor && (
- setCursor(null)}>
- Back to beginning
-
- )}
- >
- )}
-
- );
-}
-
-/** 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 (
-
-
- {span.error && (
-
- )}
- {detailQuery.isLoading &&
Loading span…
}
- {detailQuery.isError &&
Could not load span: {detailQuery.error.message}
}
- {detail?.input ? (
-
- ) : null}
- {detail?.output ? (
-
- ) : null}
- {empty && span.status !== "error" && (
-
No content recorded for this span.
- )}
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.tsx
deleted file mode 100644
index e030d393ab3..00000000000
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.tsx
+++ /dev/null
@@ -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 = { 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 (
-
-
-
-
-
-
{title}
- {idValue && (
-
-
-
- )}
-
-
-
-
-
- );
-}
-
-function PaneFooter({ children }: { children: React.ReactNode }) {
- return {children}
;
-}
-
-function Meta({ label, value }: { label: string; value: string }) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-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 (
-
-
- onSpanTabChange(value as SpanTab)} className="min-h-0 flex-1 gap-0">
-
-
- {SPAN_TABS.map((id) => (
-
- {TAB_LABELS[id]}
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {tokens > 0 && }
-
-
-
- );
-}
-
-function GroupMetric({ label, value }: { label: string; value: string }) {
- return (
-
- );
-}
-
-/** ×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 (
-
- 0}
- title={
- <>
- {row.name} ×{row.members.length}
- >
- }
- onClose={onClose}
- />
-
-
-
-
-
-
-
-
-
- {firstFailure?.error && (
-
- Failure pattern
- {errorHeadline(firstFailure.error)}
-
- )}
-
-
-
-
-
- );
-}
-
-/** 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 (
-
- Select a span to inspect it.
-
- );
- }
- if (row.kind === "group")
- return ;
- return (
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/KeyValueRows.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/KeyValueRows.tsx
deleted file mode 100644
index 6eb8217df3b..00000000000
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/KeyValueRows.tsx
+++ /dev/null
@@ -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 {children} ;
-}
-
-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 (
-
- {key}
-
- {value}
-
-
- );
- }
-
- const head = (
- <>
-
-
-
- {key}
- >
- );
- const toggle = {
- type: "button",
- "aria-expanded": open,
- "aria-label": `${open ? "Collapse" : "Expand"} ${key}`,
- onClick: () => setOpen((prev) => !prev),
- } as const;
-
- return (
-
- {open ? (
- <>
-
- {head}
-
- {value}
- >
- ) : (
-
- {head}
- {value.replace(/\n/g, "\\n")}
-
- )}
-
- );
-}
-
-export function KeyValueRows({
- entries,
- mono = false,
- className,
-}: {
- entries: readonly KeyValue[];
- mono?: boolean;
- className?: string;
-}) {
- return (
-
- {entries.map((entry) => (
-
- ))}
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.tsx
deleted file mode 100644
index f27023dd1e1..00000000000
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.tsx
+++ /dev/null
@@ -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 = { user: "User", system: "System", assistant: "Assistant", tool: "Tool" };
-
-const BLOCK_GAP = "mb-[1lh] last:mb-0";
-
-const markdownComponents: Components = {
- p: ({ node, ...props }) =>
,
- ul: ({ node, ...props }) => ,
- ol: ({ node, ...props }) => ,
- li: ({ node, ...props }) => ,
- h1: ({ node, ...props }) => ,
- h2: ({ node, ...props }) => ,
- h3: ({ node, ...props }) => ,
- blockquote: ({ node, ...props }) => (
-
- ),
- pre: ({ node, ...props }) => (
-
- ),
- code: ({ node, className, ...props }) => ,
- a: ({ node, ...props }) => ,
- img: ({ alt }) => {alt || "Image omitted"} ,
-};
-
-export function Markdown({ text }: { text: string }) {
- return (
-
-
- {text}
-
-
- );
-}
-
-export function Card({ children, className }: { children: React.ReactNode; className?: string }) {
- return ;
-}
-
-const TILE = "grid size-4 shrink-0 place-items-center rounded-sm p-0.5";
-
-function RoleTile({ failed = false }: { role: string; failed?: boolean }) {
- return ;
-}
-
-function FoldTile({ label, open, onToggle }: { label: string; open: boolean; onToggle: () => void }) {
- return (
-
-
-
- );
-}
-
-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 (
-
-
-
- {call.name}
-
-
-
-
- );
-}
-
-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 ;
- 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 (
-
-
- setOpen((v) => !v)} />
- {label}
-
-
- {expanded && (
-
- {message.content && }
- {calls.map((call, i) => (
-
- ))}
-
- )}
-
- );
-}
-
-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 (
-
-
-
-
- {name}
-
-
- {expandable ? (
- setOpen((prev) => !prev)}
- className={cn("flex min-w-0 cursor-pointer items-center gap-1 text-left", tone)}
- >
-
- {!open && {result} }
-
- ) : (
- {result || "No output"}
- )}
-
-
- {open && (
-
- {result}
-
- )}
-
- );
-}
-
-/** 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 (
-
- 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"
- >
-
- {title} {" "}
- {count !== undefined && (
-
- {count} {count === 1 ? "message" : "messages"}
-
- )}
-
-
- {children}
-
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/RequestDetail.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/RequestDetail.tsx
deleted file mode 100644
index eb42eaba765..00000000000
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/RequestDetail.tsx
+++ /dev/null
@@ -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 This span is not a model request.
;
- }
-
- 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 (
-
-
-
-
-
- {span.litellm_request_id ? (
-
-
-
-
-
-
setDrawerOpen(true)}
- >
- Open request log
-
- {logNotFound &&
No request log found for this call.
}
-
- ) : (
- Not linked to a LiteLLM request
- )}
-
-
setDrawerOpen(false)}
- logEntry={logQuery.data ?? null}
- accessToken={accessToken}
- />
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/SpanTree.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/SpanTree.tsx
deleted file mode 100644
index f225d92c6a1..00000000000
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/SpanTree.tsx
+++ /dev/null
@@ -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 (
-
- {passThrough && (
-
- )}
- {parentColumn && (
-
- )}
-
- );
- });
-}
-
-function TileColumn({ tile, stem }: { tile: React.ReactNode; stem: boolean }) {
- return (
-
- {tile}
- {stem && }
-
- );
-}
-
-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 (
- {
- event.stopPropagation();
- onToggle();
- }}
- >
-
-
- );
-}
-
-function RowBody({ children, trailing }: { children: React.ReactNode; trailing: React.ReactNode }) {
- return (
-
- {children}
- {trailing}
-
- );
-}
-
-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 (
-
-
-
- );
-}
-
-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 = {fmtMs(span.duration_ms)} ;
- const caret = row.hasChildren && (
- onToggleSpan(row.id)} />
- );
- const leafDuration = !row.hasChildren && {duration} ;
- return (
-
- onSelect(row.id)}
- onDoubleClick={() => row.hasChildren && onToggleSpan(row.id)}
- className={rowClass(selected)}
- >
-
- } stem={guide.stem} />
-
-
- {span.name}
- {row.hasChildren && duration}
-
- {span.type === "agent" && span.parent_span_id && span.input_preview ? (
-
- {previewText(span.input_preview)}
-
- ) : (
- filtering && (
-
- {previewText(span.input_preview) || span.agent}
-
- )
- )}
-
-
-
-
- );
-}
-
-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 (
-
- {
- onSelect(row.id);
- onToggleGroup(row.id);
- }}
- className={rowClass(selected)}
- >
-
- 0} />}
- stem={guide.stem}
- />
- onToggleGroup(row.id)}
- />
- }
- >
-
- {row.name}
- ×{row.members.length}
-
- {row.failedCount > 0 && {row.failedCount} failed }
-
-
-
-
- );
-}
-
-export function SpanTree({
- rows,
- summary,
- selectedId,
- hideFramework,
- onSelect,
- onToggleHideFramework,
- onToggleSpan,
- onToggleGroup,
- onLoadMore,
- onOpenDetails,
- query,
- onQueryChange,
- errorsOnly,
- onErrorsOnlyChange,
- filtering,
- onClearFilters,
- onCollapseAll,
-}: SpanTreeProps) {
- const scrollRef = useRef(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 (
-
-
-
- Steps{" "}
-
- {filtering && `${rows.length.toLocaleString()} of `}
- {summary.span_count.toLocaleString()}
-
-
-
- {onOpenDetails && (
-
-
-
- )}
-
- }>
-
-
-
-
- Hide framework spans
-
-
- Collapse branches
-
-
-
-
-
-
-
- onQueryChange(event.target.value)}
- className="h-7 pr-7 pl-7 text-xs shadow-none md:text-xs"
- />
- {query && (
- onQueryChange("")}
- >
-
-
- )}
-
- onErrorsOnlyChange(false)}
- >
- All
-
- onErrorsOnlyChange(true)}
- >
- Errors {summary.error_count}
-
- {filtering && (
-
- {rows.length} found
-
- )}
-
-
- {rows.length === 0 && (
-
-
No matching steps
-
- Clear filters
-
-
- )}
-
- {rows.map((row, i) => {
- if (row.kind === "load-more") {
- return (
- onLoadMore(row.groupId)}
- className={cn(rowClass(false), "h-8 text-sm text-muted-foreground")}
- >
-
-
- Load 20 more
- ({row.remaining} remaining)
-
-
- );
- }
- const shared = {
- guide: guides[i],
- selected: selectedId === row.id,
- traceStartMs,
- totalMs: summary.duration_ms,
- onSelect,
- onToggleSpan,
- onToggleGroup,
- filtering,
- };
- return row.kind === "group" ? (
-
- ) : (
-
- );
- })}
-
-
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/TraceDrawer.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/TraceDrawer.tsx
deleted file mode 100644
index ddc66f23470..00000000000
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/TraceDrawer.tsx
+++ /dev/null
@@ -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, id: string): Set => {
- 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 (
- setCopied(await copyToClipboard(handoff.text, handoff.copied))}
- >
- {copied ? : }
- {copied ? "Copied" : "Copy for agent"}
-
- );
-}
-
-function Stat({ label, value, error = false }: { label: string; value: string; error?: boolean }) {
- return (
-
- {label} {value}
-
- );
-}
-
-function RunIcon({ summary, failed }: { summary: Trace["summary"]; failed: boolean }) {
- const framework = traceFramework(summary);
- if (!framework) return ;
- return (
-
-
- {traceAgentNames(summary).join(", ") || framework.label}
-
- );
-}
-
-function RunHeader({
- trace,
- handoff,
- onBack,
- embedded,
-}: {
- trace: Trace;
- handoff: TraceHandoff;
- onBack: () => void;
- embedded: boolean;
-}) {
- const { summary } = trace;
- const failed = summary.status === "error";
- return (
-
- );
-}
-
-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(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(
- () =>
- 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 (
-
- {
- select(id);
- setView("steps");
- }}
- />
-
- );
-
- return (
-
- 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 && (
-
- setDetailOpen(false)}
- />
-
- )}
-
- );
-}
-
-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 (
-
-
- {failure
- ? pageFailureMessage(failure)
- : `Showing ${loaded.toLocaleString()} of ${total.toLocaleString()} steps`}
-
- {failure && (
-
- Refresh trace
-
- )}
- {(!failure || retryable) && (
-
- {busy ? "Loading…" : nextLabel}
-
- )}
-
- );
-}
-
-/** 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 (
-
- {embedded ? (
- [72, 48, 88, 60, 80].map((w) => (
-
- ))
- ) : (
-
- )}
-
- );
-}
-
-function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
- return (
-
-
- Back to traces
-
-
Could not load trace
-
{error instanceof Error ? error.message : "Unknown error"}
-
- Retry
-
-
- );
-}
-
-/** 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 (
-
- {({ reset }) => (
- (
-
- )}
- >
- }>
-
-
-
- )}
-
- );
-}
-
-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 (
- 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"
- >
-
- {(traceQuery.hasNextPage || failure) && (
- void traceQuery.fetchNextPage()}
- onRefresh={() => void refreshTrace()}
- />
- )}
-
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/Block.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Block.tsx
new file mode 100644
index 00000000000..0cfb2c14518
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Block.tsx
@@ -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 (
+
+
+ {foldable ? (
+ 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"
+ >
+
+ {icon}
+ {label}
+
+ ) : (
+
+ {icon}
+ {label}
+
+ )}
+ {meta}
+ {copyValue !== undefined && (
+
+ )}
+
+ {expanded && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+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 (
+
+ {children}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/ContentTab.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/ContentTab.tsx
new file mode 100644
index 00000000000..16cebc1c712
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/ContentTab.tsx
@@ -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 = {
+ 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 (
+
+ {(mode) =>
+ mode === "Raw" ? (
+
+ ) : (
+
+ )
+ }
+
+ );
+}
+
+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 (
+
+ {(span.status === "error" || span.error) && (
+
+
+ {span.error && (
+
+ )}
+
+ )}
+ {detailQuery.isLoading &&
Loading span…
}
+ {detailQuery.isError &&
Could not load span: {detailQuery.error.message}
}
+ {detail?.input ? (
+
+ ) : null}
+ {detail?.output ? (
+
+ ) : null}
+ {empty && span.status !== "error" && (
+
No content recorded for this span.
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/FieldTree.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/FieldTree.tsx
new file mode 100644
index 00000000000..89644491612
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/FieldTree.tsx
@@ -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): 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 ;
+ return (
+
+ {text}
+
+ );
+}
+
+function Children({ node, depth, mono }: { node: FieldNode; depth: number; mono: boolean }) {
+ switch (node.kind) {
+ case "object":
+ return ;
+ case "array":
+ return (
+ [String(i), item])} depth={depth + 1} mono={mono} />
+ );
+ case "messages":
+ return (
+
+
+
+ );
+ case "text":
+ return ;
+ 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 (
+
+
+ {name}
+
+ {node.text}
+
+ );
+ }
+
+ const block = node.kind === "text" || node.kind === "messages";
+ return (
+
+ 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",
+ )}
+ >
+
+
+ {name}
+
+ {!open && (
+ {summary(node)}
+ )}
+
+ {open && (
+
+
+
+ )}
+
+ );
+}
+
+function FieldList({ entries, depth, mono }: { entries: readonly FieldEntry[]; depth: number; mono: boolean }) {
+ return (
+
+ {entries.map(([name, node], i) => (
+
+ ))}
+
+ );
+}
+
+/** 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 ;
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/Markdown.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Markdown.tsx
new file mode 100644
index 00000000000..44a8090f35b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Markdown.tsx
@@ -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 }) =>
,
+ ul: ({ node, ...props }) => ,
+ ol: ({ node, ...props }) => ,
+ li: ({ node, ...props }) => (
+
+ ),
+ h1: ({ node, ...props }) => ,
+ h2: ({ node, ...props }) => ,
+ h3: ({ node, ...props }) => ,
+ strong: ({ node, ...props }) => ,
+ blockquote: ({ node, ...props }) => (
+
+ ),
+ pre: ({ node, ...props }) => (
+
+ ),
+ code: ({ node, className, ...props }) => (
+
+ ),
+ a: ({ node, ...props }) => (
+
+ ),
+ table: ({ node, ...props }) => (
+
+ ),
+ th: ({ node, ...props }) => ,
+ td: ({ node, ...props }) => ,
+ img: ({ alt }) => {alt || "Image omitted"} ,
+};
+
+export function Markdown({ text, className }: { text: string; className?: string }) {
+ return (
+
+
+ {text}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.test.tsx
similarity index 93%
rename from ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.test.tsx
rename to ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.test.tsx
index 5583e937ed7..03ea1ad2046 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/MessageCard.test.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.test.tsx
@@ -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( );
+ render( );
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( );
+ render( );
expect(screen.getByText("ls")).toBeInTheDocument();
expect(screen.getByText("No files found")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy ls result" })).toBeInTheDocument();
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.tsx
new file mode 100644
index 00000000000..765e7af5bf8
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.tsx
@@ -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 = { user: "User", system: "System", assistant: "Assistant", tool: "Tool" };
+const ROLE_TONE: Record = { user: "user", assistant: "assistant", tool: "tool", system: "neutral" };
+const ROLE_ICON: Record = {
+ user: ,
+ assistant: ,
+ tool: ,
+ system: ,
+};
+
+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 (
+
+
+
+
+
+ {call.name}
+
+
+
+
+
+
+ );
+}
+
+export function MessageCard({ message }: { message: TraceMessage }) {
+ const label = ROLE_LABEL[message.role] ?? message.role;
+ if (message.role === "tool") return ;
+ 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 (
+ {ROLE_ICON[message.role] ?? }}
+ label={label}
+ name={label}
+ copyValue={copyValue}
+ tone={message.role === "system" ? "muted" : "default"}
+ defaultOpen={message.role !== "system"}
+ >
+ {hasBody && (
+
+ {message.content && }
+ {calls.map((call, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function MessageList({ messages }: { messages: readonly TraceMessage[] }) {
+ return (
+
+ {messages.map((message, i) => (
+
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ {name}
+
+ {expandable ? (
+ setOpen((value) => !value)}
+ className={cn("flex min-w-0 flex-1 cursor-pointer items-center gap-1 text-left text-muted-foreground")}
+ >
+
+ {!open && {result} }
+
+ ) : (
+ {result || "No output"}
+ )}
+
+
+ {open && (
+
+ {result}
+
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/PayloadBody.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/PayloadBody.tsx
new file mode 100644
index 00000000000..41ef09e6dc2
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/PayloadBody.tsx
@@ -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 ;
+ return (
+
+ {text}
+
+ );
+}
+
+export function Payload({ view, name, failed }: { view: PayloadView; name: string; failed: boolean }) {
+ switch (view.kind) {
+ case "messages":
+ return ;
+ case "tool-result":
+ return ;
+ case "fields":
+ return (
+
+
+
+ }
+ label="Fields"
+ name="fields"
+ meta={{view.entries.length} }
+ >
+
+
+ );
+ case "text":
+ return ;
+ }
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/Section.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Section.tsx
new file mode 100644
index 00000000000..cc4e6d00779
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Section.tsx
@@ -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 (
+
+ {MODES.map((option) => (
+ 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}
+
+ ))}
+
+ );
+}
+
+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("Formatted");
+ const switchable = typeof children === "function";
+ return (
+
+
+ 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"
+ >
+
+ {title} {" "}
+ {count !== undefined && (
+
+ {count} {count === 1 ? "message" : "messages"}
+
+ )}
+
+ {switchable && open && }
+
+
+ {switchable ? children(mode) : children}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/SpanError.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/SpanError.tsx
new file mode 100644
index 00000000000..e327971d74a
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/SpanError.tsx
@@ -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 = { 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 (
+
+
+
+ {ERROR_SOURCE_LABEL[source]} · {errorReason(headline)}
+
+
+ {headline}
+
+
+ );
+}
+
+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(null);
+ const queryOptions: UseQueryOptions = {
+ 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 (
+
+ {span.error_truncated && Error preview truncated
}
+ {!opened && (
+ setOpened(true)}>
+ View stored diagnostic
+
+ )}
+ {opened && query.isPending && (
+
+ Loading diagnostic…
+
+ )}
+ {failure && (
+
+ Could not load diagnostic: {failure.message}
+ {isRetryableTraceRead(failure) || !cursor ? (
+ query.refetch()}>
+ Retry
+
+ ) : (
+ setCursor(null)}>
+ Back to beginning
+
+ )}
+
+ )}
+ {opened && query.data && (
+ <>
+
+
+
+
+ {cursor ? "Continuation" : "Beginning"} of stored diagnostic ({query.data.total_chars.toLocaleString()}{" "}
+ characters)
+
+
+ {query.data.next_cursor && (
+ setCursor(query.data.next_cursor)}>
+ Next section
+
+ )}
+ {cursor && (
+ setCursor(null)}>
+ Back to beginning
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.test.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.test.ts
new file mode 100644
index 00000000000..cee2742d1f6
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.test.ts
@@ -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"],
+ ["", "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",
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.ts
new file mode 100644
index 00000000000..0cfda77dba1
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.ts
@@ -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);
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/TraceConversation.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx
similarity index 96%
rename from ui/litellm-dashboard/src/components/lens/traces/detail/TraceConversation.integration.test.tsx
rename to ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx
index 1e8dc0b7c9a..ecd0bf13e06 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/TraceConversation.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx
@@ -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, "selection">) {
const { selection } = useOpenTraceRouting();
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/TraceConversation.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.tsx
similarity index 96%
rename from ui/litellm-dashboard/src/components/lens/traces/detail/TraceConversation.tsx
rename to ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.tsx
index bd872ed8a57..860cb091d18 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/TraceConversation.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.tsx
@@ -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,
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation.test.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts
similarity index 98%
rename from ui/litellm-dashboard/src/components/lens/traces/detail/conversation.test.ts
rename to ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts
index 729fdaffaf4..2a42089a739 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation.test.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts
@@ -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" };
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts
similarity index 96%
rename from ui/litellm-dashboard/src/components/lens/traces/detail/conversation.ts
rename to ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts
index af80191f698..1adbee35570 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts
@@ -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);
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/PagingBanner.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/PagingBanner.tsx
new file mode 100644
index 00000000000..6c515cb7dce
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/PagingBanner.tsx
@@ -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 (
+
+
+ {failure
+ ? pageFailureMessage(failure)
+ : `Showing ${loaded.toLocaleString()} of ${total.toLocaleString()} steps`}
+
+
+ {failure && (
+
+ Refresh trace
+
+ )}
+ {(!failure || retryable) && (
+
+ {busy ? "Loading…" : nextLabel}
+
+ )}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunBody.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunBody.tsx
new file mode 100644
index 00000000000..84f6e199072
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunBody.tsx
@@ -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("tree");
+ const splitRef = useRef(null);
+ const { width = SIDE_BY_SIDE_MIN_PX } = useResizeObserver({ ref: splitRef as RefObject });
+ 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 (
+
+ {
+ select(id);
+ setView("steps");
+ }}
+ />
+
+ );
+
+ return (
+
+
+
+ setDetailOpen(true)}
+ query={stepQuery}
+ onQueryChange={setStepQuery}
+ errorsOnly={errorsOnly}
+ onErrorsOnlyChange={setErrorsOnly}
+ filtering={tree.filtering}
+ onClearFilters={() => {
+ setStepQuery("");
+ setErrorsOnly(false);
+ }}
+ onCollapseAll={tree.collapseAll}
+ />
+
+ {detailOpen && (
+ <>
+
+
+ setDetailOpen(false)}
+ />
+
+ >
+ )}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx
new file mode 100644
index 00000000000..4eac48e9d36
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx
@@ -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 (
+ setCopied(await copyToClipboard(handoff.text, handoff.copied))}
+ >
+ {copied ? : }
+ {copied ? "Copied" : "Copy for agent"}
+
+ );
+}
+
+function Stat({ label, value, error = false }: { label: string; value: string; error?: boolean }) {
+ return (
+
+ {label} {value}
+
+ );
+}
+
+function StatusPill({ failed }: { failed: boolean }) {
+ return (
+
+
+ {failed ? "Failed" : "Completed"}
+
+ );
+}
+
+function RunIcon({ summary, failed }: { summary: Trace["summary"]; failed: boolean }) {
+ const framework = traceFramework(summary);
+ if (!framework) return ;
+ return (
+
+
+ {traceAgentNames(summary).join(", ") || framework.label}
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/TraceDrawer.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx
similarity index 92%
rename from ui/litellm-dashboard/src/components/lens/traces/detail/TraceDrawer.test.tsx
rename to ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx
index 036659366ed..9136d5e1cf9 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/TraceDrawer.test.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx
@@ -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 }) => (
@@ -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);
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx
new file mode 100644
index 00000000000..3680cf8073d
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx
@@ -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 (
+
+ {embedded ? (
+ [72, 48, 88, 60, 80].map((w) => (
+
+ ))
+ ) : (
+
+ )}
+
+ );
+}
+
+function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
+ return (
+
+
+ Back to traces
+
+
Could not load trace
+
{error instanceof Error ? error.message : "Unknown error"}
+
+ Retry
+
+
+ );
+}
+
+/** 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 (
+
+ {({ reset }) => (
+ (
+
+ )}
+ >
+ }>
+
+
+
+ )}
+
+ );
+}
+
+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 (
+ 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"
+ >
+
+ {(traceQuery.hasNextPage || failure) && (
+ void traceQuery.fetchNextPage()}
+ onRefresh={() => void refreshTrace()}
+ />
+ )}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/useRunTree.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/run/useRunTree.ts
new file mode 100644
index 00000000000..d9eaf9a18de
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/useRunTree.ts
@@ -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, id: string): Set => {
+ 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(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(
+ () =>
+ 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,
+ };
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/span/AttributesTab.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/AttributesTab.tsx
new file mode 100644
index 00000000000..32378f4e331
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/AttributesTab.tsx
@@ -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 | 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 (
+
+
+
+
+ {attributeEntries.length > 0 && (
+
+
+
+ )}
+ {isLoading &&
Loading attributes…
}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailGroup.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailGroup.tsx
new file mode 100644
index 00000000000..a6688b4375f
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailGroup.tsx
@@ -0,0 +1,8 @@
+export function DetailGroup({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx
similarity index 82%
rename from ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.integration.test.tsx
rename to ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx
index 907a5a67129..a950d68aa78 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/DetailPane.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx
@@ -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 & Pick;
@@ -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));
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.tsx
new file mode 100644
index 00000000000..e115acdd0e6
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.tsx
@@ -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 (
+
+ Select a span to inspect it.
+
+ );
+ }
+ if (row.kind === "group")
+ return ;
+ return (
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/span/GroupPane.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/GroupPane.tsx
new file mode 100644
index 00000000000..caa1c2d76ec
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/GroupPane.tsx
@@ -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 (
+
+ );
+}
+
+/** ×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 (
+
+ 0}
+ title={
+ <>
+ {row.name} ×{row.members.length}
+ >
+ }
+ facts={[]}
+ actions={
+
+ }
+ onClose={onClose}
+ />
+
+
+
+
+
+
+
+
+
+ {firstFailure?.error && (
+
+ Failure pattern
+
+ {errorHeadline(firstFailure.error)}
+
+
+ )}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/span/PaneHeader.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/PaneHeader.tsx
new file mode 100644
index 00000000000..4757c55ab80
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/PaneHeader.tsx
@@ -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 (
+
+
+
+
{title}
+ {idValue &&
}
+
+
+ {facts.length > 0 && (
+
+ {facts.map(([label, value]) => (
+
+
{label}
+ {value}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/span/RequestTab.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/RequestTab.tsx
new file mode 100644
index 00000000000..855a7060336
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/RequestTab.tsx
@@ -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 This span is not a model request.
;
+ }
+
+ 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 (
+
+
+
+
+
+ {span.litellm_request_id ? (
+
+
+
setDrawerOpen(true)}>
+ Open request log
+
+ {logNotFound &&
No request log found for this call.
}
+
+ ) : (
+ Not linked to a LiteLLM request
+ )}
+
+
setDrawerOpen(false)}
+ logEntry={logQuery.data ?? null}
+ accessToken={accessToken}
+ />
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/span/SpanPane.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/SpanPane.tsx
new file mode 100644
index 00000000000..cfa92f67577
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/SpanPane.tsx
@@ -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 = { 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 (
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/SpanHoverCard.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/SpanHoverCard.tsx
similarity index 95%
rename from ui/litellm-dashboard/src/components/lens/traces/detail/SpanHoverCard.tsx
rename to ui/litellm-dashboard/src/components/lens/traces/detail/tree/SpanHoverCard.tsx
index 6d16a8fa713..3fa449577bf 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/detail/SpanHoverCard.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/SpanHoverCard.tsx
@@ -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;
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/tree/SpanTree.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/SpanTree.tsx
new file mode 100644
index 00000000000..e97444548f8
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/SpanTree.tsx
@@ -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 (
+
+ {LAYOUTS.map(({ id, label, icon: Icon }) => (
+ 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",
+ )}
+ >
+
+
+ ))}
+
+ );
+}
+
+function FilterChip({
+ pressed,
+ onClick,
+ children,
+}: {
+ pressed: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function TimeAxis({ totalMs }: { totalMs: number }) {
+ return (
+
+
+
+ {timeTicks(totalMs, 4).map((tick) => (
+ 0 ? (tick / totalMs) * 100 : 0}%` }}
+ >
+ {tickLabel(tick)}
+
+ ))}
+
+
+ );
+}
+
+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(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 (
+
+
+
+
+ Steps{" "}
+
+ {filtering && `${rows.length.toLocaleString()} of `}
+ {summary.span_count.toLocaleString()}
+
+
+
+
+
+ }>
+
+
+
+
+ Hide framework spans
+
+
+ Collapse branches
+
+
+ {onOpenDetails && (
+
+
+
+ )}
+
+
+
+
+
+ onQueryChange(event.target.value)}
+ className="h-7 pr-7 pl-7 text-xs shadow-none md:text-xs"
+ />
+ {query && (
+ onQueryChange("")}
+ >
+
+
+ )}
+
+
onErrorsOnlyChange(false)}>
+ All
+
+
onErrorsOnlyChange(true)}>
+ Errors
+ 0 ? "text-destructive" : "text-muted-foreground")}
+ >
+ {summary.error_count}
+
+
+ {filtering && (
+
+ {rows.length} found
+
+ )}
+
+
+
+ {layout === "waterfall" && rows.length > 0 &&
}
+ {rows.length === 0 && (
+
+
No matching steps
+
+ Clear filters
+
+
+ )}
+
+ {rows.map((row, i) => {
+ if (row.kind === "load-more")
+ return ;
+ const ctx: RowContext = {
+ guide: guides[i],
+ selected: selectedId === row.id,
+ layout,
+ traceStartMs,
+ totalMs: summary.duration_ms,
+ filtering,
+ onSelect,
+ onToggleSpan,
+ onToggleGroup,
+ };
+ return row.kind === "group" ? (
+
+ ) : (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx
new file mode 100644
index 00000000000..931b53a4259
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx
@@ -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 (
+
+ {rail && }
+ {parentColumn && (
+ <>
+
+
+ >
+ )}
+
+ );
+ });
+}
+
+function Caret({ open, onToggle }: { open: boolean; onToggle: () => void }) {
+ return (
+ {
+ event.stopPropagation();
+ onToggle();
+ }}
+ >
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
+
+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 (
+
+
+ {children}
+
+ );
+}
+
+function Label({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
+
+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 (
+
+ ctx.onSelect(row.id)}
+ onDoubleClick={() => row.hasChildren && ctx.onToggleSpan(row.id)}
+ >
+
+
+ {span.name}
+ {modelChip && (
+
+ {modelChip}
+
+ )}
+ {ctx.layout === "tree" && {fmtMs(span.duration_ms)} }
+ {hint && (
+
+ {hint}
+
+ )}
+
+ {ctx.layout === "waterfall" && (
+
+ )}
+
+ {row.hasChildren && ctx.onToggleSpan(row.id)} />}
+
+
+
+ );
+}
+
+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 (
+
+ {
+ ctx.onSelect(row.id);
+ ctx.onToggleGroup(row.id);
+ }}
+ >
+ 0} size="md" />
+
+
+ {row.name}
+
+
+ ×{row.members.length}
+
+ {row.failedCount > 0 && {row.failedCount} failed }
+
+ {ctx.layout === "waterfall" && (
+
+ )}
+
+ ctx.onToggleGroup(row.id)} />
+
+
+
+ );
+}
+
+export function LoadMoreRow({
+ row,
+ guide,
+ onLoadMore,
+}: {
+ row: LoadMoreRowData;
+ guide: TreeGuide;
+ onLoadMore: (groupId: string) => void;
+}) {
+ return (
+ onLoadMore(row.groupId)}
+ className={cn(ROW, "text-xs text-muted-foreground hover:bg-trace-row-hover hover:text-foreground")}
+ >
+
+
+ Load 20 more
+ ({row.remaining} remaining)
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/tree/timeline.test.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/timeline.test.ts
new file mode 100644
index 00000000000..2fdb6f25692
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/timeline.test.ts
@@ -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]);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/tree/timeline.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/timeline.ts
new file mode 100644
index 00000000000..9fd57c177d5
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/timeline.ts
@@ -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);
+}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx
index be295533651..dd7631ba703 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx
@@ -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 }) => (
run {traceId}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx
index 04fce64b551..ffdb3a959a5 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx
@@ -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";
diff --git a/ui/litellm-dashboard/src/components/lens/traces/ui/PaneBar.tsx b/ui/litellm-dashboard/src/components/lens/traces/ui/PaneBar.tsx
deleted file mode 100644
index c47562084e4..00000000000
--- a/ui/litellm-dashboard/src/components/lens/traces/ui/PaneBar.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { cn } from "@/lib/cva.config";
-
-export function PaneBar({ children, className }: { children: React.ReactNode; className?: string }) {
- return
{children}
;
-}
diff --git a/ui/litellm-dashboard/src/components/lens/traces/ui/SpanIcon.tsx b/ui/litellm-dashboard/src/components/lens/traces/ui/SpanIcon.tsx
index 4374e535802..467ddb73a0f 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/ui/SpanIcon.tsx
+++ b/ui/litellm-dashboard/src/components/lens/traces/ui/SpanIcon.tsx
@@ -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
= {
+ 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 = {
+ 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 = {
+ 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 = {
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 (
diff --git a/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts b/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts
index 1bb5f9ec3c1..eb7cca4bcac 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts
@@ -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", () => {
diff --git a/ui/litellm-dashboard/src/components/lens/traces/utils.ts b/ui/litellm-dashboard/src/components/lens/traces/utils.ts
index a1706d11b9e..99a2a1194ec 100644
--- a/ui/litellm-dashboard/src/components/lens/traces/utils.ts
+++ b/ui/litellm-dashboard/src/components/lens/traces/utils.ts
@@ -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> = {
+ 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;
diff --git a/ui/litellm-dashboard/src/components/ui/Resizable.tsx b/ui/litellm-dashboard/src/components/ui/Resizable.tsx
new file mode 100644
index 00000000000..02ac90b4f84
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/ui/Resizable.tsx
@@ -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 (
+
+ );
+}
+
+function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
+ return ;
+}
+
+function ResizableHandle({
+ withHandle,
+ className,
+ ...props
+}: ResizablePrimitive.SeparatorProps & {
+ withHandle?: boolean;
+}) {
+ return (
+ div]:rotate-90",
+ className,
+ )}
+ {...props}
+ >
+ {withHandle &&
}
+
+ );
+}
+
+export { ResizableHandle, ResizablePanel, ResizablePanelGroup };