@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.
@@ -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
+ }