Skip to content

Commit 7eb21dd

Browse files
JetoPistolaclaude
andauthored
[OPIK-5309] [FE] feat: add trace trajectory and span inspection to Agent Sandbox (#5978)
* [OPIK-5309] [FE] feat: add trace trajectory and span inspection to Agent Sandbox When a sandbox job runs and returns a trace_id, the execution panel now fetches the trace and its spans, builds a tree, and displays it in a vertically-split resizable layout (tree on top, data viewer on bottom). - AgentRunnerExecutionPanel fetches trace + spans with polling while job runs - AgentTraceTree renders a condensed virtualized tree with expand/collapse - Clicking a span shows its input/output/details in TraceDataViewer - Empty states for no-job, running, and loading Implements OPIK-5309 and OPIK-5310. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(agent-sandbox): use horizontal layout for trajectory panel Tree on left, detail viewer on right instead of stacked vertically. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: revert accidental config.yml change Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(agent-sandbox): use store selector and reset stale selectedSpanId - Use per-slice selector for useTreeDetailsStore to avoid unnecessary re-renders - Reset selectedSpanId when traceId changes to prevent stale span references Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2fcc3f0 commit 7eb21dd

3 files changed

Lines changed: 445 additions & 16 deletions

File tree

apps/opik-frontend/src/v2/pages/AgentRunnerPage/AgentRunnerContent.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import useSandboxPairCode from "@/api/agent-sandbox/useSandboxPairCode";
88
import useSandboxConnectionStatus from "@/api/agent-sandbox/useSandboxConnectionStatus";
99
import useSandboxCreateJobMutation from "@/api/agent-sandbox/useSandboxCreateJobMutation";
1010
import useSandboxJobStatus from "@/api/agent-sandbox/useSandboxJobStatus";
11-
import { SandboxConnectionStatus } from "@/types/agent-sandbox";
11+
import {
12+
SandboxConnectionStatus,
13+
SandboxJobStatus,
14+
} from "@/types/agent-sandbox";
1215
import AgentRunnerEmptyState from "./AgentRunnerEmptyState";
1316
import AgentRunnerConnectedState from "./AgentRunnerConnectedState";
1417
import AgentRunnerResult from "./AgentRunnerResult";
@@ -158,8 +161,16 @@ const AgentRunnerContent: React.FC<AgentRunnerContentProps> = ({
158161
</div>
159162

160163
{/* Right panel - Trajectory */}
161-
<div className="w-2/5 shrink-0 overflow-y-auto border-l p-6">
162-
<AgentRunnerExecutionPanel jobId={activeJobId} />
164+
<div className="w-2/5 shrink-0 overflow-y-auto border-l">
165+
<AgentRunnerExecutionPanel
166+
traceId={jobData?.trace_id ?? null}
167+
projectId={projectId}
168+
isJobRunning={
169+
jobData?.status === SandboxJobStatus.RUNNING ||
170+
jobData?.status === SandboxJobStatus.PENDING
171+
}
172+
hasJob={!!activeJobId}
173+
/>
163174
</div>
164175
</div>
165176
) : (

apps/opik-frontend/src/v2/pages/AgentRunnerPage/AgentRunnerExecutionPanel.tsx

Lines changed: 223 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,172 @@
1-
import React from "react";
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useMemo,
5+
useRef,
6+
useState,
7+
} from "react";
8+
import { keepPreviousData } from "@tanstack/react-query";
29
import { ListTree } from "lucide-react";
310

11+
import useTraceById from "@/api/traces/useTraceById";
12+
import useSpansList from "@/api/traces/useSpansList";
13+
import Loader from "@/shared/Loader/Loader";
14+
import AgentTraceTree from "./AgentTraceTree";
15+
import TraceDataViewer from "@/v2/pages-shared/traces/TraceDetailsPanel/TraceDataViewer/TraceDataViewer";
16+
import { useDetailsActionSectionState } from "@/v2/pages-shared/traces/DetailsActionSection";
17+
import { Span, Trace } from "@/types/traces";
18+
import { SPANS_COLORS_MAP, TRACE_TYPE_FOR_TREE } from "@/constants/traces";
19+
import useTreeDetailsStore, {
20+
TreeNode,
21+
} from "@/v2/pages-shared/traces/TraceDetailsPanel/TreeDetailsStore";
22+
import {
23+
ResizableHandle,
24+
ResizablePanel,
25+
ResizablePanelGroup,
26+
} from "@/ui/resizable";
27+
import find from "lodash/find";
28+
29+
const MAX_SPANS_LOAD_SIZE = 15000;
30+
const SPANS_POLL_INTERVAL = 3000;
31+
432
type AgentRunnerExecutionPanelProps = {
5-
jobId: string | null;
33+
traceId: string | null;
34+
projectId: string;
35+
isJobRunning: boolean;
36+
hasJob: boolean;
37+
};
38+
39+
const buildTree = (trace: Trace, spans: Span[]): TreeNode[] => {
40+
const sharedData = {
41+
maxStartTime: new Date(trace.start_time).getTime(),
42+
maxEndTime: new Date(trace.end_time).getTime(),
43+
maxDuration: trace.duration,
44+
};
45+
46+
const lookup: Record<string, TreeNode> = {
47+
[trace.id]: {
48+
id: trace.id,
49+
name: trace.name,
50+
data: {
51+
...trace,
52+
...sharedData,
53+
spanColor: SPANS_COLORS_MAP[TRACE_TYPE_FOR_TREE],
54+
parent_span_id: "",
55+
trace_id: trace.id,
56+
type: TRACE_TYPE_FOR_TREE,
57+
tokens: trace.usage?.total_tokens,
58+
duration: trace.duration,
59+
startTimestamp: new Date(trace.start_time).getTime(),
60+
name: trace.name,
61+
hasError: Boolean(trace.error_info),
62+
},
63+
children: [],
64+
},
65+
};
66+
67+
const sortedSpans = [...spans]
68+
.filter((span) => span.trace_id === trace.id)
69+
.sort((s1, s2) => s1.start_time.localeCompare(s2.start_time));
70+
71+
sortedSpans.forEach((span) => {
72+
lookup[span.id] = {
73+
id: span.id,
74+
name: span.name,
75+
data: {
76+
...span,
77+
...sharedData,
78+
spanColor: SPANS_COLORS_MAP[span.type],
79+
tokens: span.usage?.total_tokens,
80+
duration: span.duration,
81+
startTimestamp: new Date(span.start_time).getTime(),
82+
hasError: Boolean(span.error_info),
83+
},
84+
children: [],
85+
};
86+
});
87+
88+
sortedSpans.forEach((span) => {
89+
const parentKey = span.parent_span_id;
90+
if (!parentKey) {
91+
lookup[trace.id].children?.push(lookup[span.id]);
92+
} else if (lookup[parentKey]) {
93+
lookup[parentKey].children?.push(lookup[span.id]);
94+
}
95+
});
96+
97+
return [lookup[trace.id]];
698
};
799

8100
const AgentRunnerExecutionPanel: React.FC<AgentRunnerExecutionPanelProps> = ({
9-
jobId,
101+
traceId,
102+
projectId,
103+
isJobRunning,
104+
hasJob,
10105
}) => {
11-
return (
12-
<div className="flex h-full flex-col">
13-
<span className="comet-body-s-accented mb-4">Trajectory</span>
106+
const [selectedSpanId, setSelectedSpanId] = useState<string>("");
107+
const [activeSection, setActiveSection] = useDetailsActionSectionState(
108+
"agentSandboxSection",
109+
);
110+
const scrollRef = useRef<HTMLDivElement>(null);
111+
const setTree = useTreeDetailsStore((s) => s.setTree);
112+
113+
useEffect(() => {
114+
setSelectedSpanId("");
115+
}, [traceId]);
116+
117+
const { data: trace } = useTraceById(
118+
{ traceId: traceId ?? "", stripAttachments: true },
119+
{
120+
placeholderData: keepPreviousData,
121+
enabled: Boolean(traceId),
122+
refetchInterval: isJobRunning ? SPANS_POLL_INTERVAL : false,
123+
},
124+
);
125+
126+
const { data: spansData } = useSpansList(
127+
{
128+
traceId: traceId ?? "",
129+
projectId,
130+
page: 1,
131+
size: MAX_SPANS_LOAD_SIZE,
132+
stripAttachments: true,
133+
},
134+
{
135+
placeholderData: keepPreviousData,
136+
enabled: Boolean(traceId) && Boolean(projectId),
137+
refetchInterval: isJobRunning ? SPANS_POLL_INTERVAL : false,
138+
},
139+
);
140+
141+
const spans = useMemo(() => spansData?.content ?? [], [spansData?.content]);
142+
143+
useEffect(() => {
144+
if (!trace) {
145+
setTree([]);
146+
return;
147+
}
148+
setTree(buildTree(trace, spans));
149+
}, [trace, spans, setTree]);
150+
151+
const dataToView = useMemo(() => {
152+
if (!trace) return null;
153+
if (selectedSpanId) {
154+
return find(spans, (span: Span) => span.id === selectedSpanId) ?? trace;
155+
}
156+
return trace;
157+
}, [selectedSpanId, spans, trace]);
14158

15-
{!jobId && (
159+
const handleSelectRow = useCallback(
160+
(id: string) => {
161+
setSelectedSpanId(id === traceId ? "" : id);
162+
},
163+
[traceId],
164+
);
165+
166+
if (!hasJob) {
167+
return (
168+
<div className="flex h-full flex-col p-6">
169+
<span className="comet-body-s-accented mb-4">Trajectory</span>
16170
<div className="flex flex-1 flex-col items-center justify-center text-muted-slate">
17171
<ListTree className="mb-2 size-5" />
18172
<p className="comet-body-s font-medium">No run trace yet</p>
@@ -22,13 +176,69 @@ const AgentRunnerExecutionPanel: React.FC<AgentRunnerExecutionPanelProps> = ({
22176
executes step by step
23177
</p>
24178
</div>
25-
)}
179+
</div>
180+
);
181+
}
182+
183+
if (isJobRunning && !trace) {
184+
return (
185+
<div className="flex h-full flex-col p-6">
186+
<span className="comet-body-s-accented mb-4">Trajectory</span>
187+
<div className="flex flex-1 flex-col items-center justify-center text-muted-slate">
188+
<Loader className="mb-2 size-5" />
189+
<p className="comet-body-s font-medium">Running agent...</p>
190+
<p className="comet-body-xs mt-1 text-center">
191+
Collecting trace data as your agent executes
192+
</p>
193+
</div>
194+
</div>
195+
);
196+
}
197+
198+
if (!trace) {
199+
return (
200+
<div className="flex h-full flex-col p-6">
201+
<span className="comet-body-s-accented mb-4">Trajectory</span>
202+
<div className="flex flex-1 flex-col items-center justify-center text-muted-slate">
203+
<Loader className="mb-2 size-5" />
204+
<p className="comet-body-xs">Loading trace...</p>
205+
</div>
206+
</div>
207+
);
208+
}
26209

27-
{jobId && (
28-
<p className="comet-body-xs text-muted-slate">
29-
Execution trace will appear here once the agent completes.
30-
</p>
31-
)}
210+
return (
211+
<div className="flex h-full flex-col">
212+
<ResizablePanelGroup
213+
direction="horizontal"
214+
autoSaveId="agent-sandbox-trajectory"
215+
>
216+
<ResizablePanel id="agent-tree" defaultSize={50} minSize={20}>
217+
<div className="size-full overflow-auto" ref={scrollRef}>
218+
<AgentTraceTree
219+
scrollRef={scrollRef}
220+
spanCount={spans.length}
221+
rowId={selectedSpanId || trace.id}
222+
onSelectRow={handleSelectRow}
223+
isJobRunning={isJobRunning}
224+
/>
225+
</div>
226+
</ResizablePanel>
227+
<ResizableHandle />
228+
<ResizablePanel id="agent-data" defaultSize={50} minSize={20}>
229+
{dataToView && (
230+
<TraceDataViewer
231+
data={dataToView}
232+
projectId={projectId}
233+
spanId={selectedSpanId}
234+
traceId={trace.id}
235+
activeSection={activeSection}
236+
setActiveSection={setActiveSection}
237+
isSpansLazyLoading={false}
238+
/>
239+
)}
240+
</ResizablePanel>
241+
</ResizablePanelGroup>
32242
</div>
33243
);
34244
};

0 commit comments

Comments
 (0)