@townco/debugger 0.1.23 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -8
- package/src/App.tsx +13 -0
- package/src/comparison-db.test.ts +113 -0
- package/src/comparison-db.ts +332 -0
- package/src/components/DebuggerHeader.tsx +62 -2
- package/src/components/SessionTimelineView.tsx +173 -0
- package/src/components/SpanTimeline.tsx +6 -4
- package/src/components/UnifiedTimeline.tsx +691 -0
- package/src/db.ts +71 -0
- package/src/index.ts +2 -0
- package/src/lib/metrics.test.ts +51 -0
- package/src/lib/metrics.ts +136 -0
- package/src/lib/pricing.ts +23 -0
- package/src/lib/turnExtractor.ts +64 -23
- package/src/pages/ComparisonView.tsx +685 -0
- package/src/pages/SessionList.tsx +77 -56
- package/src/pages/SessionView.tsx +3 -64
- package/src/pages/TownHall.tsx +406 -0
- package/src/schemas.ts +15 -0
- package/src/server.ts +345 -12
- package/src/types.ts +87 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { detectSpanType } from "../lib/spanTypeDetector";
|
|
3
|
+
import type { ConversationTrace, Span, SpanNode } from "../types";
|
|
4
|
+
import { SpanDetailsPanel } from "./SpanDetailsPanel";
|
|
5
|
+
import { SpanIcon } from "./SpanIcon";
|
|
6
|
+
import { buildSpanTree } from "./SpanTimeline";
|
|
7
|
+
|
|
8
|
+
interface UnifiedTimelineProps {
|
|
9
|
+
spans: Array<Span & { traceId: string; turnIndex: number }>;
|
|
10
|
+
traces: ConversationTrace[];
|
|
11
|
+
traceData: Map<string, { trace: ConversationTrace; spans: Span[] }>;
|
|
12
|
+
timelineStart: number;
|
|
13
|
+
timelineEnd: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseAttributes(attrs: string | null): Record<string, unknown> {
|
|
17
|
+
if (!attrs) return {};
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(attrs);
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Generate distinct colors for turns
|
|
26
|
+
const TURN_COLORS = [
|
|
27
|
+
"bg-blue-500/10",
|
|
28
|
+
"bg-purple-500/10",
|
|
29
|
+
"bg-green-500/10",
|
|
30
|
+
"bg-orange-500/10",
|
|
31
|
+
"bg-pink-500/10",
|
|
32
|
+
"bg-yellow-500/10",
|
|
33
|
+
"bg-red-500/10",
|
|
34
|
+
"bg-indigo-500/10",
|
|
35
|
+
"bg-teal-500/10",
|
|
36
|
+
"bg-cyan-500/10",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function UnifiedTimeline({
|
|
40
|
+
spans,
|
|
41
|
+
traces,
|
|
42
|
+
traceData,
|
|
43
|
+
}: UnifiedTimelineProps) {
|
|
44
|
+
const [selectedSpan, setSelectedSpan] = useState<SpanNode | null>(null);
|
|
45
|
+
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
|
|
46
|
+
const [zoom, setZoom] = useState(1);
|
|
47
|
+
const timelineRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
|
|
49
|
+
// Build span tree for details panel
|
|
50
|
+
const spanTree = useMemo(() => {
|
|
51
|
+
return buildSpanTree(spans);
|
|
52
|
+
}, [spans]);
|
|
53
|
+
|
|
54
|
+
// Group spans by turn and calculate turn boundaries
|
|
55
|
+
const turnBoundaries = useMemo(() => {
|
|
56
|
+
return traces.map((trace, index) => {
|
|
57
|
+
const data = traceData.get(trace.trace_id);
|
|
58
|
+
if (!data || data.spans.length === 0) {
|
|
59
|
+
// Fallback if no spans: use trace start time for both start and end
|
|
60
|
+
return {
|
|
61
|
+
traceId: trace.trace_id,
|
|
62
|
+
turnIndex: index,
|
|
63
|
+
start: trace.start_time_unix_nano,
|
|
64
|
+
end: trace.start_time_unix_nano,
|
|
65
|
+
userInput: data?.trace.userInput ?? trace.userInput,
|
|
66
|
+
llmOutput: data?.trace.llmOutput ?? trace.llmOutput,
|
|
67
|
+
spans: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const startTimes = data.spans.map((s) => s.start_time_unix_nano);
|
|
72
|
+
const endTimes = data.spans.map((s) => s.end_time_unix_nano);
|
|
73
|
+
const start =
|
|
74
|
+
startTimes.length > 0
|
|
75
|
+
? Math.min(...startTimes)
|
|
76
|
+
: trace.start_time_unix_nano;
|
|
77
|
+
const end =
|
|
78
|
+
endTimes.length > 0
|
|
79
|
+
? Math.max(...endTimes)
|
|
80
|
+
: trace.start_time_unix_nano;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
traceId: trace.trace_id,
|
|
84
|
+
turnIndex: index,
|
|
85
|
+
start,
|
|
86
|
+
end,
|
|
87
|
+
userInput: data.trace.userInput,
|
|
88
|
+
llmOutput: data.trace.llmOutput,
|
|
89
|
+
spans: data.spans,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}, [traces, traceData]);
|
|
93
|
+
|
|
94
|
+
// Helper to find span node in tree
|
|
95
|
+
const findSpanNode = (nodes: SpanNode[], spanId: string): SpanNode | null => {
|
|
96
|
+
for (const node of nodes) {
|
|
97
|
+
if (node.span_id === spanId) return node;
|
|
98
|
+
const found = findSpanNode(node.children, spanId);
|
|
99
|
+
if (found) return found;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Calculate cumulative width for each turn
|
|
105
|
+
const turnLayouts = useMemo(() => {
|
|
106
|
+
let cumulativePercent = 0;
|
|
107
|
+
return turnBoundaries.map((turn) => {
|
|
108
|
+
const turnDurationMs = (turn.end - turn.start) / 1_000_000;
|
|
109
|
+
// Each turn gets proportional width based on its duration
|
|
110
|
+
const totalDuration = turnBoundaries.reduce(
|
|
111
|
+
(sum, t) => sum + (t.end - t.start),
|
|
112
|
+
0,
|
|
113
|
+
);
|
|
114
|
+
const widthPercent = ((turn.end - turn.start) / totalDuration) * 100;
|
|
115
|
+
|
|
116
|
+
const layout = {
|
|
117
|
+
traceId: turn.traceId,
|
|
118
|
+
leftPercent: cumulativePercent,
|
|
119
|
+
widthPercent,
|
|
120
|
+
start: turn.start,
|
|
121
|
+
end: turn.end,
|
|
122
|
+
turnDurationMs,
|
|
123
|
+
userInput: turn.userInput,
|
|
124
|
+
llmOutput: turn.llmOutput,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
cumulativePercent += widthPercent;
|
|
128
|
+
return layout;
|
|
129
|
+
});
|
|
130
|
+
}, [turnBoundaries]);
|
|
131
|
+
|
|
132
|
+
// Calculate global timeline bounds
|
|
133
|
+
const globalStart = useMemo(() => {
|
|
134
|
+
if (spans.length === 0) return 0;
|
|
135
|
+
return Math.min(...spans.map((s) => s.start_time_unix_nano));
|
|
136
|
+
}, [spans]);
|
|
137
|
+
|
|
138
|
+
const globalEnd = useMemo(() => {
|
|
139
|
+
if (spans.length === 0) return 0;
|
|
140
|
+
return Math.max(...spans.map((s) => s.end_time_unix_nano));
|
|
141
|
+
}, [spans]);
|
|
142
|
+
|
|
143
|
+
const totalDurationMs = (globalEnd - globalStart) / 1_000_000;
|
|
144
|
+
|
|
145
|
+
// Zoom handler (scroll is now native)
|
|
146
|
+
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
147
|
+
if (e.ctrlKey || e.metaKey) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
// Zoom
|
|
150
|
+
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
151
|
+
setZoom((prev) => Math.max(1, Math.min(prev * zoomDelta, 10)));
|
|
152
|
+
}
|
|
153
|
+
// Otherwise let browser handle natural scroll
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
// Flatten span tree while preserving hierarchy information
|
|
157
|
+
const flattenSpanTree = (
|
|
158
|
+
nodes: SpanNode[],
|
|
159
|
+
depth = 0,
|
|
160
|
+
): Array<{ node: SpanNode; depth: number }> => {
|
|
161
|
+
const result: Array<{ node: SpanNode; depth: number }> = [];
|
|
162
|
+
for (const node of nodes) {
|
|
163
|
+
result.push({ node, depth });
|
|
164
|
+
if (node.children.length > 0) {
|
|
165
|
+
result.push(...flattenSpanTree(node.children, depth + 1));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Get flattened spans with hierarchy information
|
|
172
|
+
const flatSpans = useMemo(() => {
|
|
173
|
+
const flattened = flattenSpanTree(spanTree);
|
|
174
|
+
|
|
175
|
+
// Filter to only include spans that:
|
|
176
|
+
// 1. Exist in the spans array
|
|
177
|
+
// 2. Have a valid turn in turnLayouts (so they can be rendered)
|
|
178
|
+
const filtered = flattened.filter(({ node }) => {
|
|
179
|
+
const span = spans.find((s) => s.span_id === node.span_id);
|
|
180
|
+
if (!span) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check if this span has a valid turn
|
|
185
|
+
const turn = turnLayouts[span.turnIndex];
|
|
186
|
+
return turn !== undefined;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return filtered;
|
|
190
|
+
}, [spanTree, spans, turnLayouts]);
|
|
191
|
+
|
|
192
|
+
if (spans.length === 0) {
|
|
193
|
+
return (
|
|
194
|
+
<div className="text-muted-foreground text-sm">
|
|
195
|
+
No spans in this session
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<>
|
|
202
|
+
<div className="space-y-4">
|
|
203
|
+
{/* Zoom controls */}
|
|
204
|
+
<div className="flex items-center gap-4 text-sm">
|
|
205
|
+
<button
|
|
206
|
+
onClick={() => setZoom((prev) => Math.max(1, prev * 0.8))}
|
|
207
|
+
className="px-3 py-1 border border-border rounded hover:bg-muted"
|
|
208
|
+
>
|
|
209
|
+
Zoom Out
|
|
210
|
+
</button>
|
|
211
|
+
<span className="text-muted-foreground">
|
|
212
|
+
{(zoom * 100).toFixed(0)}%
|
|
213
|
+
</span>
|
|
214
|
+
<button
|
|
215
|
+
onClick={() => setZoom((prev) => Math.min(10, prev * 1.2))}
|
|
216
|
+
className="px-3 py-1 border border-border rounded hover:bg-muted"
|
|
217
|
+
>
|
|
218
|
+
Zoom In
|
|
219
|
+
</button>
|
|
220
|
+
<button
|
|
221
|
+
onClick={() => setZoom(1)}
|
|
222
|
+
className="px-3 py-1 border border-border rounded hover:bg-muted"
|
|
223
|
+
>
|
|
224
|
+
Reset
|
|
225
|
+
</button>
|
|
226
|
+
<span className="text-muted-foreground text-xs ml-4">
|
|
227
|
+
Scroll horizontally to pan • Ctrl+Scroll to zoom
|
|
228
|
+
</span>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Timeline container */}
|
|
232
|
+
<div
|
|
233
|
+
ref={timelineRef}
|
|
234
|
+
className="border border-border rounded-lg overflow-hidden bg-card flex"
|
|
235
|
+
onWheel={handleWheel}
|
|
236
|
+
>
|
|
237
|
+
{/* Fixed left column - processes labels */}
|
|
238
|
+
<div className="w-[276px] flex-shrink-0 flex flex-col border-r border-border">
|
|
239
|
+
{/* Spacer to match message bubbles section height */}
|
|
240
|
+
<div className="h-40 border-b-2 border-border bg-white dark:bg-gray-900" />
|
|
241
|
+
|
|
242
|
+
{/* Processes label */}
|
|
243
|
+
<div className="h-6 border-b border-border bg-white dark:bg-gray-900 px-4 py-1 flex items-center">
|
|
244
|
+
<span className="text-xs font-semibold">Processes</span>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Span labels */}
|
|
248
|
+
<div className="flex-1 bg-white dark:bg-gray-900">
|
|
249
|
+
{flatSpans.map(({ node, depth }) => {
|
|
250
|
+
const span = spans.find((s) => s.span_id === node.span_id);
|
|
251
|
+
// This should never be null now due to filtering
|
|
252
|
+
if (!span) {
|
|
253
|
+
// Render empty placeholder to maintain alignment
|
|
254
|
+
return (
|
|
255
|
+
<div
|
|
256
|
+
key={`missing-${node.span_id}`}
|
|
257
|
+
className="h-[38px] border-b border-border"
|
|
258
|
+
/>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const attrs = parseAttributes(span.attributes);
|
|
263
|
+
const spanType = detectSpanType(span);
|
|
264
|
+
|
|
265
|
+
let displayName = span.name;
|
|
266
|
+
if (span.name === "agent.tool_call") {
|
|
267
|
+
const toolName = attrs["tool.name"] as string;
|
|
268
|
+
if (toolName === "Task") {
|
|
269
|
+
try {
|
|
270
|
+
const toolInput = attrs["tool.input"];
|
|
271
|
+
const input =
|
|
272
|
+
typeof toolInput === "string"
|
|
273
|
+
? JSON.parse(toolInput)
|
|
274
|
+
: toolInput;
|
|
275
|
+
if (input?.agentName) {
|
|
276
|
+
displayName = `Subagent (${input.agentName})`;
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
displayName = toolName || span.name;
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
displayName = toolName || span.name;
|
|
283
|
+
}
|
|
284
|
+
} else if (
|
|
285
|
+
span.name.startsWith("chat") &&
|
|
286
|
+
"gen_ai.input.messages" in attrs
|
|
287
|
+
) {
|
|
288
|
+
displayName =
|
|
289
|
+
(attrs["gen_ai.request.model"] as string) || span.name;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div
|
|
294
|
+
key={span.span_id}
|
|
295
|
+
className={`h-[38px] px-3 py-2 border-b border-border flex items-center gap-2 cursor-pointer transition-colors ${
|
|
296
|
+
hoveredSpanId === span.span_id ? "bg-muted" : ""
|
|
297
|
+
}`}
|
|
298
|
+
style={{ paddingLeft: `${12 + depth * 20}px` }}
|
|
299
|
+
onMouseEnter={() => setHoveredSpanId(span.span_id)}
|
|
300
|
+
onMouseLeave={() => setHoveredSpanId(null)}
|
|
301
|
+
onClick={() => setSelectedSpan(node)}
|
|
302
|
+
>
|
|
303
|
+
<SpanIcon type={spanType} className="w-4 h-4 shrink-0" />
|
|
304
|
+
<span className="text-xs truncate">{displayName}</span>
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
})}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
{/* Scrollable right side - message bubbles + timeline */}
|
|
312
|
+
<div
|
|
313
|
+
className="flex-1 overflow-x-auto overflow-y-hidden"
|
|
314
|
+
style={{ background: "#404040" }}
|
|
315
|
+
>
|
|
316
|
+
<div
|
|
317
|
+
className="relative"
|
|
318
|
+
style={{ width: `${zoom * 100}%`, minWidth: "100%" }}
|
|
319
|
+
>
|
|
320
|
+
{/* Message bubbles section */}
|
|
321
|
+
<div
|
|
322
|
+
className="h-40 relative border-b-2 border-border"
|
|
323
|
+
style={{
|
|
324
|
+
background: "#fafafa",
|
|
325
|
+
width: "100%",
|
|
326
|
+
paddingLeft: "16px",
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
{traces.map((trace, index) => {
|
|
330
|
+
const data = traceData.get(trace.trace_id);
|
|
331
|
+
if (!data) return null;
|
|
332
|
+
|
|
333
|
+
const turn = turnLayouts[index];
|
|
334
|
+
if (!turn) return null;
|
|
335
|
+
|
|
336
|
+
// User message positioned at start of turn (0ms)
|
|
337
|
+
const userPos = {
|
|
338
|
+
left: turn.leftPercent * zoom,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// Agent message positioned at end of turn (100% of turn width)
|
|
342
|
+
const agentPos = {
|
|
343
|
+
left: (turn.leftPercent + turn.widthPercent) * zoom,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<div key={trace.trace_id}>
|
|
348
|
+
{/* User message bubble - positioned at top, aligned to start of turn */}
|
|
349
|
+
<div
|
|
350
|
+
className="absolute top-4"
|
|
351
|
+
style={{ left: `calc(${userPos.left}% + 16px)` }}
|
|
352
|
+
>
|
|
353
|
+
<div className="relative">
|
|
354
|
+
<div className="bg-blue-100 dark:bg-blue-900 px-3 py-2 rounded-lg shadow-sm max-w-xs">
|
|
355
|
+
<div className="text-xs font-medium text-blue-900 dark:text-blue-100 truncate">
|
|
356
|
+
User
|
|
357
|
+
</div>
|
|
358
|
+
<div className="text-[11px] text-blue-800 dark:text-blue-200 truncate">
|
|
359
|
+
{data.trace.userInput}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
{/* Line extending all the way to timeline, positioned to connect with bubble */}
|
|
363
|
+
<div
|
|
364
|
+
className="absolute top-full w-0.5 bg-blue-400"
|
|
365
|
+
style={{ height: "94px", left: "24px" }}
|
|
366
|
+
/>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{/* Agent message bubbles - group chat messages with their tool calls */}
|
|
371
|
+
{(() => {
|
|
372
|
+
// Group messages: each chat message with its following tool calls
|
|
373
|
+
const messageGroups: Array<{
|
|
374
|
+
chatMessage: (typeof data.trace.agentMessages)[0];
|
|
375
|
+
chatIndex: number;
|
|
376
|
+
toolCalls: Array<{
|
|
377
|
+
message: (typeof data.trace.agentMessages)[0];
|
|
378
|
+
index: number;
|
|
379
|
+
}>;
|
|
380
|
+
}> = [];
|
|
381
|
+
|
|
382
|
+
let currentChatMessage:
|
|
383
|
+
| (typeof data.trace.agentMessages)[0]
|
|
384
|
+
| null = null;
|
|
385
|
+
let currentChatIndex = -1;
|
|
386
|
+
let currentToolCalls: Array<{
|
|
387
|
+
message: (typeof data.trace.agentMessages)[0];
|
|
388
|
+
index: number;
|
|
389
|
+
}> = [];
|
|
390
|
+
|
|
391
|
+
data.trace.agentMessages.forEach((msg, idx) => {
|
|
392
|
+
if (msg.type === "chat") {
|
|
393
|
+
// Save previous group if exists
|
|
394
|
+
if (currentChatMessage) {
|
|
395
|
+
messageGroups.push({
|
|
396
|
+
chatMessage: currentChatMessage,
|
|
397
|
+
chatIndex: currentChatIndex,
|
|
398
|
+
toolCalls: currentToolCalls,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// Start new group
|
|
402
|
+
currentChatMessage = msg;
|
|
403
|
+
currentChatIndex = idx;
|
|
404
|
+
currentToolCalls = [];
|
|
405
|
+
} else if (msg.type === "tool_call") {
|
|
406
|
+
// Add to current group
|
|
407
|
+
currentToolCalls.push({ message: msg, index: idx });
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Don't forget the last group
|
|
412
|
+
if (currentChatMessage) {
|
|
413
|
+
messageGroups.push({
|
|
414
|
+
chatMessage: currentChatMessage,
|
|
415
|
+
chatIndex: currentChatIndex,
|
|
416
|
+
toolCalls: currentToolCalls,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return messageGroups.map((group) => {
|
|
421
|
+
// Calculate position based on chat message timestamp
|
|
422
|
+
const messageRelativePos =
|
|
423
|
+
(group.chatMessage.timestamp - turn.start) /
|
|
424
|
+
(turn.end - turn.start);
|
|
425
|
+
const messagePosPercent =
|
|
426
|
+
(turn.leftPercent +
|
|
427
|
+
messageRelativePos * turn.widthPercent) *
|
|
428
|
+
zoom;
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<div
|
|
432
|
+
key={`${trace.trace_id}-agent-${group.chatIndex}`}
|
|
433
|
+
className="absolute bottom-4"
|
|
434
|
+
style={{
|
|
435
|
+
left: `calc(${messagePosPercent}% + 16px)`,
|
|
436
|
+
transform: "translateX(-100%)",
|
|
437
|
+
}}
|
|
438
|
+
title={group.chatMessage.content}
|
|
439
|
+
>
|
|
440
|
+
<div className="relative">
|
|
441
|
+
{/* Chat message bubble */}
|
|
442
|
+
<div className="bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded-lg shadow-sm max-w-xs">
|
|
443
|
+
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
|
|
444
|
+
Agent
|
|
445
|
+
</div>
|
|
446
|
+
<div className="text-[11px] text-gray-700 dark:text-gray-300 truncate">
|
|
447
|
+
{group.chatMessage.content}
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
{/* Tool call badges below */}
|
|
452
|
+
{group.toolCalls.length > 0 && (
|
|
453
|
+
<div className="mt-1 flex flex-wrap gap-1 max-w-xs">
|
|
454
|
+
{group.toolCalls.map((tool) => (
|
|
455
|
+
<div
|
|
456
|
+
key={`${trace.trace_id}-tool-${tool.index}`}
|
|
457
|
+
className="inline-flex items-center gap-1 bg-blue-100 dark:bg-blue-900 border border-blue-300 dark:border-blue-700 px-2 py-0.5 rounded text-[10px] font-mono text-blue-800 dark:text-blue-200"
|
|
458
|
+
title={`Tool: ${tool.message.toolName}`}
|
|
459
|
+
>
|
|
460
|
+
<span className="text-blue-600 dark:text-blue-400">
|
|
461
|
+
⚡
|
|
462
|
+
</span>
|
|
463
|
+
{tool.message.content}
|
|
464
|
+
</div>
|
|
465
|
+
))}
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{/* Line extending to timeline - positioned to the right */}
|
|
470
|
+
<div
|
|
471
|
+
className="absolute top-full w-0.5 bg-gray-400"
|
|
472
|
+
style={{
|
|
473
|
+
height: "94px",
|
|
474
|
+
right: "24px",
|
|
475
|
+
}}
|
|
476
|
+
/>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
})()}
|
|
482
|
+
</div>
|
|
483
|
+
);
|
|
484
|
+
})}
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
{/* Time markers */}
|
|
488
|
+
<div
|
|
489
|
+
className="h-6 relative border-b border-border w-full"
|
|
490
|
+
style={{ background: "#f5f5f5", paddingLeft: "16px" }}
|
|
491
|
+
>
|
|
492
|
+
{turnLayouts.map((turn, turnIndex) => {
|
|
493
|
+
const turnDurationMs = turn.turnDurationMs;
|
|
494
|
+
const isLastTurn = turnIndex === turnLayouts.length - 1;
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<div key={turn.traceId}>
|
|
498
|
+
{[0, 0.25, 0.5, 0.75, 1].map((fraction) => {
|
|
499
|
+
// Skip the end marker (1.0) for all turns except the last
|
|
500
|
+
// This prevents overlap with the next turn's 0ms marker
|
|
501
|
+
if (fraction === 1 && !isLastTurn) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const timeMs = turnDurationMs * fraction;
|
|
506
|
+
const label =
|
|
507
|
+
timeMs < 1000
|
|
508
|
+
? `${timeMs.toFixed(0)}ms`
|
|
509
|
+
: `${(timeMs / 1000).toFixed(1)}s`;
|
|
510
|
+
|
|
511
|
+
const leftPos =
|
|
512
|
+
(turn.leftPercent + fraction * turn.widthPercent) *
|
|
513
|
+
zoom;
|
|
514
|
+
|
|
515
|
+
return (
|
|
516
|
+
<div
|
|
517
|
+
key={`${turn.traceId}-${fraction}`}
|
|
518
|
+
className="absolute text-[10px] text-muted-foreground top-1"
|
|
519
|
+
style={{ left: `calc(${leftPos}% + 16px)` }}
|
|
520
|
+
>
|
|
521
|
+
{label}
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
})}
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
})}
|
|
528
|
+
</div>
|
|
529
|
+
|
|
530
|
+
{/* Timeline area with rows */}
|
|
531
|
+
<div
|
|
532
|
+
className="relative w-full"
|
|
533
|
+
style={{
|
|
534
|
+
minHeight: `${flatSpans.length * 38}px`,
|
|
535
|
+
background: "#404040",
|
|
536
|
+
}}
|
|
537
|
+
>
|
|
538
|
+
{/* Turn dividers - extending through timeline to turn labels */}
|
|
539
|
+
{turnLayouts.map((turn, turnIndex) => {
|
|
540
|
+
if (turnIndex === 0) return null; // No divider before first turn
|
|
541
|
+
const dividerPos = turn.leftPercent * zoom;
|
|
542
|
+
return (
|
|
543
|
+
<div
|
|
544
|
+
key={`divider-${turn.traceId}`}
|
|
545
|
+
className="absolute w-1 bg-gray-600"
|
|
546
|
+
style={{
|
|
547
|
+
left: `${dividerPos}%`,
|
|
548
|
+
marginLeft: "16px",
|
|
549
|
+
top: "0", // Start at top of timeline area
|
|
550
|
+
height: `calc(100% + 48px)`, // Extend through timeline + turn labels section (48px)
|
|
551
|
+
}}
|
|
552
|
+
/>
|
|
553
|
+
);
|
|
554
|
+
})}
|
|
555
|
+
|
|
556
|
+
{/* Span bars */}
|
|
557
|
+
{flatSpans.map(({ node }, visualIndex) => {
|
|
558
|
+
const span = spans.find((s) => s.span_id === node.span_id);
|
|
559
|
+
// This should never be null now due to filtering
|
|
560
|
+
if (!span) {
|
|
561
|
+
// Render empty placeholder to maintain alignment
|
|
562
|
+
return (
|
|
563
|
+
<div
|
|
564
|
+
key={`missing-${node.span_id}`}
|
|
565
|
+
className="absolute h-8"
|
|
566
|
+
style={{ top: `${visualIndex * 38 + 5}px` }}
|
|
567
|
+
/>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const attrs = parseAttributes(span.attributes);
|
|
572
|
+
const spanType = detectSpanType(span);
|
|
573
|
+
|
|
574
|
+
// Find which turn this span belongs to
|
|
575
|
+
const turn = turnLayouts[span.turnIndex];
|
|
576
|
+
if (!turn) {
|
|
577
|
+
// Render empty placeholder to maintain alignment
|
|
578
|
+
return (
|
|
579
|
+
<div
|
|
580
|
+
key={`no-turn-${span.span_id}`}
|
|
581
|
+
className="absolute h-8"
|
|
582
|
+
style={{ top: `${visualIndex * 38 + 5}px` }}
|
|
583
|
+
/>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Calculate position within turn
|
|
588
|
+
const relativeStart =
|
|
589
|
+
(span.start_time_unix_nano - turn.start) /
|
|
590
|
+
(turn.end - turn.start);
|
|
591
|
+
const relativeWidth =
|
|
592
|
+
(span.end_time_unix_nano - span.start_time_unix_nano) /
|
|
593
|
+
(turn.end - turn.start);
|
|
594
|
+
const leftPos =
|
|
595
|
+
(turn.leftPercent + relativeStart * turn.widthPercent) *
|
|
596
|
+
zoom;
|
|
597
|
+
const width = relativeWidth * turn.widthPercent * zoom;
|
|
598
|
+
|
|
599
|
+
const barColor =
|
|
600
|
+
spanType === "chat"
|
|
601
|
+
? "bg-orange-500"
|
|
602
|
+
: spanType === "tool_call"
|
|
603
|
+
? "bg-blue-500"
|
|
604
|
+
: spanType === "subagent"
|
|
605
|
+
? "bg-purple-500"
|
|
606
|
+
: "bg-blue-500";
|
|
607
|
+
|
|
608
|
+
const durationMs =
|
|
609
|
+
(span.end_time_unix_nano - span.start_time_unix_nano) /
|
|
610
|
+
1_000_000;
|
|
611
|
+
|
|
612
|
+
return (
|
|
613
|
+
<div
|
|
614
|
+
key={span.span_id}
|
|
615
|
+
className={`absolute h-6 ${barColor} rounded cursor-pointer transition-all flex items-center justify-start px-2 ${
|
|
616
|
+
hoveredSpanId === span.span_id ? "brightness-110" : ""
|
|
617
|
+
}`}
|
|
618
|
+
style={{
|
|
619
|
+
left: `calc(${leftPos}% + 16px)`,
|
|
620
|
+
width: `calc(${Math.max(width, 0.5)}% - 16px)`,
|
|
621
|
+
top: `${visualIndex * 38 + 7}px`,
|
|
622
|
+
}}
|
|
623
|
+
onMouseEnter={() => setHoveredSpanId(span.span_id)}
|
|
624
|
+
onMouseLeave={() => setHoveredSpanId(null)}
|
|
625
|
+
onClick={() => {
|
|
626
|
+
setSelectedSpan(node);
|
|
627
|
+
}}
|
|
628
|
+
>
|
|
629
|
+
<span className="text-[11px] text-white font-medium whitespace-nowrap">
|
|
630
|
+
{durationMs.toFixed(2)}ms
|
|
631
|
+
</span>
|
|
632
|
+
</div>
|
|
633
|
+
);
|
|
634
|
+
})}
|
|
635
|
+
</div>
|
|
636
|
+
|
|
637
|
+
{/* Turn labels at bottom */}
|
|
638
|
+
<div
|
|
639
|
+
className="h-12 relative w-full"
|
|
640
|
+
style={{ background: "#404040", paddingLeft: "16px" }}
|
|
641
|
+
>
|
|
642
|
+
{turnLayouts.map((turn, index) => {
|
|
643
|
+
const leftPos = turn.leftPercent * zoom;
|
|
644
|
+
|
|
645
|
+
return (
|
|
646
|
+
<div
|
|
647
|
+
key={turn.traceId}
|
|
648
|
+
className="absolute bottom-2"
|
|
649
|
+
style={{
|
|
650
|
+
left: `calc(${leftPos}% + 20px)`,
|
|
651
|
+
}}
|
|
652
|
+
>
|
|
653
|
+
<span
|
|
654
|
+
className="font-semibold uppercase whitespace-nowrap"
|
|
655
|
+
style={{
|
|
656
|
+
fontFamily: "Inter, sans-serif",
|
|
657
|
+
fontSize: "10px",
|
|
658
|
+
letterSpacing: "0.4px",
|
|
659
|
+
color: "#ffffff",
|
|
660
|
+
opacity: 0.2,
|
|
661
|
+
}}
|
|
662
|
+
>
|
|
663
|
+
Turn {index + 1}
|
|
664
|
+
</span>
|
|
665
|
+
</div>
|
|
666
|
+
);
|
|
667
|
+
})}
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
|
|
673
|
+
{/* Session metadata */}
|
|
674
|
+
<div className="text-sm text-muted-foreground space-y-1">
|
|
675
|
+
<div>
|
|
676
|
+
Total turns: <span className="font-medium">{traces.length}</span>
|
|
677
|
+
</div>
|
|
678
|
+
<div>
|
|
679
|
+
Total spans: <span className="font-medium">{spans.length}</span>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
</div>
|
|
683
|
+
|
|
684
|
+
<SpanDetailsPanel
|
|
685
|
+
span={selectedSpan}
|
|
686
|
+
onClose={() => setSelectedSpan(null)}
|
|
687
|
+
allSpans={spanTree}
|
|
688
|
+
/>
|
|
689
|
+
</>
|
|
690
|
+
);
|
|
691
|
+
}
|