finchvox 0.0.1__py3-none-any.whl

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,1228 @@
1
+ function traceDetailApp() {
2
+ return {
3
+ // State
4
+ traceId: null,
5
+ serviceName: null, // Service name from first span with resource.attributes
6
+ spans: [], // Original spans from API
7
+ waterfallSpans: [], // Flat array in display order for waterfall view
8
+ expandedSpanIds: new Set(), // Set of span IDs that are expanded
9
+ expansionInitialized: false, // Flag to ensure we only auto-expand on first load
10
+ isWaterfallExpanded: false, // Global expand/collapse state for the waterfall
11
+ selectedSpan: null, // Span shown in the details panel
12
+ highlightedSpan: null, // Span highlighted in the waterfall (for keyboard navigation)
13
+ hoveredSpan: null, // Span being hovered over (for chunk highlighting)
14
+ chunkHoveredSpan: null, // Span hovered from chunk (applies .selected to waterfall)
15
+ isPanelOpen: false, // Controls panel visibility and transitions
16
+
17
+ // Audio state
18
+ wavesurfer: null,
19
+ playing: false,
20
+ currentTime: 0,
21
+ duration: 0,
22
+ audioError: false,
23
+
24
+ // Copy state
25
+ spanCopied: false,
26
+
27
+ // Timeline state
28
+ minTime: 0,
29
+ maxTime: 0,
30
+
31
+ // Real-time polling state
32
+ isPolling: false,
33
+ pollInterval: null,
34
+ lastSpanCount: 0,
35
+ consecutiveErrors: 0,
36
+
37
+ // Hover marker state
38
+ hoverMarker: {
39
+ visible: false,
40
+ time: 0,
41
+ source: null // 'waveform' or 'waterfall'
42
+ },
43
+
44
+ async init() {
45
+ // Extract trace_id from URL path: /traces/{trace_id}
46
+ const pathParts = window.location.pathname.split('/');
47
+ this.traceId = pathParts[pathParts.length - 1];
48
+
49
+ if (!this.traceId) {
50
+ console.error('No trace ID in URL');
51
+ return;
52
+ }
53
+
54
+ await this.loadTraceData();
55
+
56
+ // Start polling if trace appears to be active
57
+ const conversationSpan = this.spans.find(s => s.name === 'conversation');
58
+ const shouldPoll = !conversationSpan || // No conversation span yet - might be created later
59
+ (conversationSpan && !conversationSpan.end_time_unix_nano); // Conversation exists but not ended
60
+
61
+ if (shouldPoll) {
62
+ this.startPolling();
63
+ }
64
+
65
+ this.initAudioPlayer();
66
+ this.initKeyboardShortcuts();
67
+ this.initCleanup();
68
+ },
69
+
70
+ async loadTraceData() {
71
+ try {
72
+ const response = await fetch(`/api/trace/${this.traceId}`);
73
+ const data = await response.json();
74
+
75
+ // Parse and enrich spans
76
+ this.spans = data.spans.map(span => ({
77
+ ...span,
78
+ startMs: Number(span.start_time_unix_nano) / 1_000_000,
79
+ endMs: Number(span.end_time_unix_nano) / 1_000_000,
80
+ durationMs: (Number(span.end_time_unix_nano) - Number(span.start_time_unix_nano)) / 1_000_000
81
+ }));
82
+
83
+ // Extract service name from first span with resource attributes
84
+ for (const span of this.spans) {
85
+ if (span.resource && span.resource.attributes) {
86
+ const serviceAttr = span.resource.attributes.find(attr => attr.key === 'service.name');
87
+ if (serviceAttr && serviceAttr.value && serviceAttr.value.string_value) {
88
+ this.serviceName = serviceAttr.value.string_value;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+
94
+ // Calculate timeline bounds
95
+ this.minTime = Math.min(...this.spans.map(s => s.startMs));
96
+ this.maxTime = Math.max(...this.spans.map(s => s.endMs));
97
+
98
+ // Build waterfall tree structure
99
+ this.buildWaterfallTree();
100
+
101
+ } catch (error) {
102
+ console.error('Failed to load trace:', error);
103
+ }
104
+ },
105
+
106
+ buildWaterfallTree() {
107
+ // Step 1: Build parent-child map
108
+ const childrenMap = {}; // spanId -> [child spans]
109
+ const rootSpans = [];
110
+ const spanIds = new Set();
111
+
112
+ // Track all span IDs for orphan detection
113
+ this.spans.forEach(span => {
114
+ spanIds.add(span.span_id_hex);
115
+ });
116
+
117
+ this.spans.forEach(span => {
118
+ const parentId = span.parent_span_id_hex;
119
+
120
+ // Check if this is a root span or orphaned span
121
+ const isOrphan = parentId && !spanIds.has(parentId);
122
+ const isRoot = !parentId;
123
+
124
+ if (isRoot || isOrphan) {
125
+ rootSpans.push(span);
126
+ } else if (parentId) {
127
+ if (!childrenMap[parentId]) {
128
+ childrenMap[parentId] = [];
129
+ }
130
+ childrenMap[parentId].push(span);
131
+ }
132
+ });
133
+
134
+ // Step 2: Sort children by start time
135
+ Object.values(childrenMap).forEach(children => {
136
+ children.sort((a, b) => a.startMs - b.startMs);
137
+ });
138
+
139
+ // Step 3: Traverse tree depth-first and flatten to display order
140
+ this.waterfallSpans = [];
141
+
142
+ const traverse = (span, depth) => {
143
+ span.depth = depth;
144
+ span.children = childrenMap[span.span_id_hex] || [];
145
+ span.childCount = span.children.length;
146
+
147
+ // Add this span to the display list
148
+ this.waterfallSpans.push(span);
149
+
150
+ // If expanded, add children recursively
151
+ const isExpanded = this.expandedSpanIds.has(span.span_id_hex);
152
+ if (isExpanded && span.children.length > 0) {
153
+ span.children.forEach(child => traverse(child, depth + 1));
154
+ }
155
+ };
156
+
157
+ // Step 4: Start traversal from root spans (sorted by start time)
158
+ rootSpans.sort((a, b) => a.startMs - b.startMs);
159
+ rootSpans.forEach(span => traverse(span, 0));
160
+
161
+ // Step 5: Initialize expansion state (collapsed by default - only show conversation and turns)
162
+ // Only run this on first load, not on subsequent rebuilds
163
+ if (!this.expansionInitialized) {
164
+ let addedExpansions = false;
165
+
166
+ this.spans.forEach(span => {
167
+ // Only expand conversation spans by default
168
+ // This shows conversation and its children (turns), but not children of turns
169
+ if (span.name === 'conversation') {
170
+ const children = childrenMap[span.span_id_hex] || [];
171
+ if (children.length > 0 && !this.expandedSpanIds.has(span.span_id_hex)) {
172
+ this.expandedSpanIds.add(span.span_id_hex);
173
+ addedExpansions = true;
174
+ }
175
+ }
176
+ });
177
+
178
+ // If we just initialized spans as expanded, rebuild the tree
179
+ if (addedExpansions) {
180
+ this.waterfallSpans = [];
181
+ rootSpans.forEach(span => traverse(span, 0));
182
+ }
183
+
184
+ this.expansionInitialized = true;
185
+ this.isWaterfallExpanded = false; // Start in collapsed state
186
+ }
187
+ },
188
+
189
+ toggleSpanExpansion(span) {
190
+ if (span.childCount === 0) {
191
+ // No children, just select the span
192
+ this.selectSpan(span);
193
+ return;
194
+ }
195
+
196
+ // Toggle expansion state
197
+ if (this.expandedSpanIds.has(span.span_id_hex)) {
198
+ this.expandedSpanIds.delete(span.span_id_hex);
199
+ } else {
200
+ this.expandedSpanIds.add(span.span_id_hex);
201
+ }
202
+
203
+ // Rebuild the waterfall tree
204
+ this.buildWaterfallTree();
205
+ },
206
+
207
+ toggleWaterfallExpansion() {
208
+ if (this.isWaterfallExpanded) {
209
+ this.collapseAll();
210
+ } else {
211
+ this.expandAll();
212
+ }
213
+ },
214
+
215
+ expandAll() {
216
+ // Expand all spans with children
217
+ this.spans.forEach(span => {
218
+ // Find children for this span
219
+ const children = this.spans.filter(s => s.parent_span_id_hex === span.span_id_hex);
220
+ if (children.length > 0) {
221
+ this.expandedSpanIds.add(span.span_id_hex);
222
+ }
223
+ });
224
+
225
+ this.isWaterfallExpanded = true;
226
+ this.buildWaterfallTree();
227
+ },
228
+
229
+ collapseAll() {
230
+ // Collapse everything below turns (depth 2+)
231
+ // Only expand conversation to show turns, but don't expand turns
232
+ this.expandedSpanIds.clear();
233
+
234
+ // Find all conversation spans and expand them (this shows turns but not their children)
235
+ this.spans.forEach(span => {
236
+ if (span.name === 'conversation') {
237
+ const children = this.spans.filter(s => s.parent_span_id_hex === span.span_id_hex);
238
+ if (children.length > 0) {
239
+ this.expandedSpanIds.add(span.span_id_hex);
240
+ }
241
+ }
242
+ });
243
+
244
+ this.isWaterfallExpanded = false;
245
+ this.buildWaterfallTree();
246
+ },
247
+
248
+
249
+ getTimelineBarStyle(span) {
250
+ const totalDuration = this.maxTime - this.minTime;
251
+ const startPercent = ((span.startMs - this.minTime) / totalDuration) * 100;
252
+ const durationPercent = (span.durationMs / totalDuration) * 100;
253
+ const widthPercent = Math.max(durationPercent, 0.15); // Minimum 0.15% for visibility
254
+
255
+ return {
256
+ left: `${startPercent}%`,
257
+ width: `${widthPercent}%`,
258
+ isShort: durationPercent < 2
259
+ };
260
+ },
261
+
262
+ getTimelineBarClasses(span) {
263
+ const style = this.getTimelineBarStyle(span);
264
+ return {
265
+ [`bar-${span.name}`]: true,
266
+ 'short-bar': style.isShort
267
+ };
268
+ },
269
+
270
+ getExpandButtonStyle(span) {
271
+ const barStyle = this.getTimelineBarStyle(span);
272
+ const startPercent = parseFloat(barStyle.left);
273
+
274
+ // Position button 26px to the left of the bar (16px button + 10px gap)
275
+ // But ensure it doesn't go below 2px from the left edge
276
+ if (startPercent < 3) {
277
+ // For spans starting near 0%, position button at the start of the timeline (2px)
278
+ return {
279
+ left: '2px'
280
+ };
281
+ }
282
+
283
+ return {
284
+ left: `calc(${startPercent}% - 26px)`
285
+ };
286
+ },
287
+
288
+ handleRowClick(span) {
289
+ // Expand span children if not already expanded
290
+ if (span.childCount > 0 && !this.expandedSpanIds.has(span.span_id_hex)) {
291
+ this.expandedSpanIds.add(span.span_id_hex);
292
+ this.buildWaterfallTree();
293
+ }
294
+
295
+ this.selectSpan(span, true); // Always seek audio when clicking
296
+ },
297
+
298
+ initAudioPlayer() {
299
+ // Only initialize if not already created
300
+ if (this.wavesurfer) {
301
+ return;
302
+ }
303
+
304
+ this.wavesurfer = WaveSurfer.create({
305
+ container: '#waveform',
306
+ waveColor: '#a855f7', // Purple (fallback)
307
+ progressColor: '#7c3aed', // Darker purple (fallback)
308
+ cursorColor: '#ffffff',
309
+ height: 40,
310
+ barWidth: 2,
311
+ barGap: 1,
312
+ barRadius: 2,
313
+ normalize: true,
314
+ backend: 'WebAudio',
315
+ splitChannels: [
316
+ {
317
+ waveColor: getComputedStyle(document.documentElement).getPropertyValue('--span-stt').trim(),
318
+ progressColor: getComputedStyle(document.documentElement).getPropertyValue('--span-stt-progress').trim()
319
+ },
320
+ {
321
+ waveColor: '#a855f7', // Purple for channel 1 (bot)
322
+ progressColor: '#7c3aed' // Darker purple
323
+ }
324
+ ]
325
+ });
326
+
327
+ this.wavesurfer.load(`/api/audio/${this.traceId}`);
328
+
329
+ // Event listeners
330
+ this.wavesurfer.on('play', () => { this.playing = true; });
331
+ this.wavesurfer.on('pause', () => { this.playing = false; });
332
+ this.wavesurfer.on('audioprocess', (time) => { this.currentTime = time; });
333
+ this.wavesurfer.on('seek', (progress) => {
334
+ this.currentTime = progress * this.duration;
335
+ });
336
+ this.wavesurfer.on('ready', () => {
337
+ this.duration = this.wavesurfer.getDuration();
338
+ console.log('Audio ready, duration:', this.duration);
339
+
340
+ // Clear error state when audio loads successfully
341
+ if (this.audioError) {
342
+ console.log('Audio loaded successfully, clearing error state');
343
+ this.audioError = false;
344
+ }
345
+
346
+ // Generate timeline markers
347
+ this.generateTimeline();
348
+
349
+ // Setup hover marker listeners
350
+ this.initWaveformHover();
351
+ });
352
+ this.wavesurfer.on('error', (error) => {
353
+ console.error('Audio loading error:', error);
354
+ this.audioError = true;
355
+ });
356
+ },
357
+
358
+ generateTimeline() {
359
+ const timeline = document.getElementById('timeline');
360
+ if (!timeline || !this.duration) return;
361
+
362
+ timeline.innerHTML = ''; // Clear existing
363
+
364
+ // Show exactly 15 equally-spaced markers
365
+ const markerCount = 15;
366
+ const interval = this.duration / markerCount;
367
+
368
+ // Create timeline container with relative positioning
369
+ timeline.style.display = 'block';
370
+ timeline.style.position = 'relative';
371
+ timeline.style.width = '100%';
372
+ timeline.style.height = '20px';
373
+
374
+ for (let i = 0; i <= markerCount; i++) {
375
+ const time = i * interval;
376
+ const percent = (time / this.duration) * 100;
377
+
378
+ const marker = document.createElement('div');
379
+ marker.className = 'timeline-marker';
380
+ marker.style.position = 'absolute';
381
+ marker.style.left = `${percent}%`;
382
+ marker.style.height = '20px';
383
+
384
+ // Create tick mark
385
+ const tick = document.createElement('div');
386
+ tick.style.position = 'absolute';
387
+ tick.style.left = '0';
388
+ tick.style.bottom = '0';
389
+ tick.style.width = '1px';
390
+ tick.style.height = '6px';
391
+ tick.style.backgroundColor = '#6b7280';
392
+
393
+ // Create label
394
+ const label = document.createElement('span');
395
+ label.style.position = 'absolute';
396
+ label.style.left = '0';
397
+ label.style.top = '0';
398
+
399
+ // Align first label left, last label right, middle labels centered
400
+ if (i === 0) {
401
+ label.style.transform = 'translateX(0)';
402
+ } else if (i === markerCount) {
403
+ label.style.transform = 'translateX(-100%)';
404
+ } else {
405
+ label.style.transform = 'translateX(-50%)';
406
+ }
407
+
408
+ label.style.fontSize = '10px';
409
+ label.style.color = '#9ca3af';
410
+ label.style.fontFamily = 'monospace';
411
+ label.textContent = this.formatTimelineLabel(time);
412
+
413
+ marker.appendChild(tick);
414
+ marker.appendChild(label);
415
+ timeline.appendChild(marker);
416
+ }
417
+ },
418
+
419
+ formatTimelineLabel(seconds) {
420
+ // Convert seconds to milliseconds and use unified formatter with 0 decimals
421
+ return formatDuration(seconds * 1000, 0);
422
+ },
423
+
424
+ togglePlay() {
425
+ if (this.wavesurfer) {
426
+ this.wavesurfer.playPause();
427
+ }
428
+ },
429
+
430
+ skipBackward(seconds) {
431
+ if (!this.wavesurfer || !this.duration) return;
432
+
433
+ const currentTime = this.wavesurfer.getCurrentTime();
434
+ const newTime = Math.max(0, currentTime - seconds);
435
+ const progress = newTime / this.duration;
436
+
437
+ this.wavesurfer.seekTo(progress);
438
+ },
439
+
440
+ skipForward(seconds) {
441
+ if (!this.wavesurfer || !this.duration) return;
442
+
443
+ const currentTime = this.wavesurfer.getCurrentTime();
444
+ const newTime = Math.min(this.duration, currentTime + seconds);
445
+ const progress = newTime / this.duration;
446
+
447
+ this.wavesurfer.seekTo(progress);
448
+ },
449
+
450
+ initKeyboardShortcuts() {
451
+ // Prevent adding listener multiple times
452
+ if (window.__keyboardShortcutsInitialized) {
453
+ return;
454
+ }
455
+ window.__keyboardShortcutsInitialized = true;
456
+
457
+ // Store reference to component context for event listener
458
+ const self = this;
459
+
460
+ // Add global keyboard event listener for YouTube-style controls
461
+ document.addEventListener('keydown', (event) => {
462
+ // Check if user is typing in an input field
463
+ const activeElement = document.activeElement;
464
+ const isTyping = activeElement && (
465
+ activeElement.tagName === 'INPUT' ||
466
+ activeElement.tagName === 'TEXTAREA' ||
467
+ activeElement.contentEditable === 'true'
468
+ );
469
+
470
+ // Don't process shortcuts if user is typing
471
+ if (isTyping) return;
472
+
473
+ // Handle keyboard shortcuts
474
+ switch (event.key) {
475
+ case ' ': // Space - Play/Pause
476
+ event.preventDefault();
477
+ if (self.wavesurfer) {
478
+ self.wavesurfer.playPause();
479
+ }
480
+ break;
481
+
482
+ case 'ArrowLeft': // Left Arrow - Skip backward 5 seconds
483
+ event.preventDefault();
484
+ if (self.wavesurfer && self.duration) {
485
+ const currentTime = self.wavesurfer.getCurrentTime();
486
+ const newTime = Math.max(0, currentTime - 5);
487
+ const progress = newTime / self.duration;
488
+ self.wavesurfer.seekTo(progress);
489
+ }
490
+ break;
491
+
492
+ case 'ArrowRight': // Right Arrow - Skip forward 5 seconds
493
+ event.preventDefault();
494
+ if (self.wavesurfer && self.duration) {
495
+ const currentTime = self.wavesurfer.getCurrentTime();
496
+ const newTime = Math.min(self.duration, currentTime + 5);
497
+ const progress = newTime / self.duration;
498
+ self.wavesurfer.seekTo(progress);
499
+ }
500
+ break;
501
+
502
+ case 'ArrowUp': // Up Arrow - Navigate to previous span
503
+ event.preventDefault();
504
+ self.navigateToPreviousSpan();
505
+ break;
506
+
507
+ case 'ArrowDown': // Down Arrow - Navigate to next span
508
+ event.preventDefault();
509
+ self.navigateToNextSpan();
510
+ break;
511
+
512
+ case 'Escape': // Escape - Close details panel
513
+ if (self.selectedSpan) {
514
+ event.preventDefault();
515
+ self.closePanel();
516
+ }
517
+ break;
518
+
519
+ case 'Enter': // Enter - Select highlighted span (open panel and seek)
520
+ event.preventDefault();
521
+ if (self.highlightedSpan) {
522
+ self.selectSpan(self.highlightedSpan, true);
523
+ }
524
+ break;
525
+ }
526
+ });
527
+ },
528
+
529
+ initCleanup() {
530
+ // Stop polling when user navigates away
531
+ window.addEventListener('beforeunload', () => {
532
+ if (this.isPolling) {
533
+ this.stopPolling();
534
+ }
535
+ });
536
+ },
537
+
538
+ navigateToNextSpan() {
539
+ if (this.waterfallSpans.length === 0) return;
540
+
541
+ const panelWasOpen = this.selectedSpan !== null;
542
+ const currentSpan = this.highlightedSpan || this.selectedSpan;
543
+
544
+ if (!currentSpan) {
545
+ // No highlight, start at first span
546
+ const nextSpan = this.waterfallSpans[0];
547
+ this.highlightedSpan = nextSpan;
548
+ if (panelWasOpen) {
549
+ this.selectedSpan = nextSpan;
550
+ }
551
+ this.navigateToSpan(nextSpan); // Visual feedback only, no audio seek
552
+ } else {
553
+ // Find current index and move to next
554
+ const currentIndex = this.waterfallSpans.findIndex(
555
+ s => s.span_id_hex === currentSpan.span_id_hex
556
+ );
557
+ if (currentIndex !== -1 && currentIndex < this.waterfallSpans.length - 1) {
558
+ const nextSpan = this.waterfallSpans[currentIndex + 1];
559
+ this.highlightedSpan = nextSpan;
560
+ if (panelWasOpen) {
561
+ this.selectedSpan = nextSpan;
562
+ }
563
+ this.navigateToSpan(nextSpan); // Visual feedback only, no audio seek
564
+ }
565
+ }
566
+ },
567
+
568
+ navigateToPreviousSpan() {
569
+ if (this.waterfallSpans.length === 0) return;
570
+
571
+ const panelWasOpen = this.selectedSpan !== null;
572
+ const currentSpan = this.highlightedSpan || this.selectedSpan;
573
+
574
+ if (!currentSpan) {
575
+ // No highlight, start at last span
576
+ const prevSpan = this.waterfallSpans[this.waterfallSpans.length - 1];
577
+ this.highlightedSpan = prevSpan;
578
+ if (panelWasOpen) {
579
+ this.selectedSpan = prevSpan;
580
+ }
581
+ this.navigateToSpan(prevSpan); // Visual feedback only, no audio seek
582
+ } else {
583
+ // Find current index and move to previous
584
+ const currentIndex = this.waterfallSpans.findIndex(
585
+ s => s.span_id_hex === currentSpan.span_id_hex
586
+ );
587
+ if (currentIndex > 0) {
588
+ const prevSpan = this.waterfallSpans[currentIndex - 1];
589
+ this.highlightedSpan = prevSpan;
590
+ if (panelWasOpen) {
591
+ this.selectedSpan = prevSpan;
592
+ }
593
+ this.navigateToSpan(prevSpan); // Visual feedback only, no audio seek
594
+ }
595
+ }
596
+ },
597
+
598
+ navigateToSpan(span) {
599
+ // Show hover marker at span position (visual feedback only)
600
+ this.showMarkerAtSpan(span);
601
+
602
+ // Scroll the span into view
603
+ setTimeout(() => {
604
+ this.scrollSpanIntoView(span);
605
+ }, 0);
606
+ },
607
+
608
+ seekToSpan(span) {
609
+ // Show hover marker at span position
610
+ this.showMarkerAtSpan(span);
611
+
612
+ // Seek audio to span start time if audio is not playing
613
+ if (this.wavesurfer && this.duration) {
614
+ const isPlaying = this.wavesurfer.isPlaying();
615
+ if (!isPlaying) {
616
+ const audioTime = (span.startMs - this.minTime) / 1000;
617
+ const progress = audioTime / this.duration;
618
+ this.wavesurfer.seekTo(progress);
619
+ // Update currentTime directly for immediate UI feedback
620
+ this.currentTime = audioTime;
621
+ }
622
+ }
623
+
624
+ // Scroll the span into view
625
+ setTimeout(() => {
626
+ this.scrollSpanIntoView(span);
627
+ }, 0);
628
+ },
629
+
630
+ selectSpan(span, shouldSeekAudio = false) {
631
+ const panelWasOpen = this.isPanelOpen;
632
+
633
+ // Update selected span content (doesn't trigger transition)
634
+ this.selectedSpan = span;
635
+ this.highlightedSpan = span; // Keep highlight in sync when clicking
636
+
637
+ // Open panel if not already open (triggers transition only when opening)
638
+ if (!panelWasOpen && span) {
639
+ this.isPanelOpen = true;
640
+ }
641
+
642
+ // Seek audio to span start time if requested
643
+ if (shouldSeekAudio) {
644
+ this.seekToSpan(span);
645
+ }
646
+
647
+ // If panel state changed (opened), refresh marker position after transition
648
+ if (!panelWasOpen && span) {
649
+ setTimeout(() => {
650
+ this.refreshMarkerPosition();
651
+ }, 350); // Wait for CSS transition (0.3s) + small buffer
652
+ }
653
+ },
654
+
655
+ closePanel() {
656
+ this.isPanelOpen = false; // Triggers close transition
657
+ this.selectedSpan = null; // Clear panel content
658
+ // Refresh marker position after panel closes
659
+ setTimeout(() => {
660
+ this.refreshMarkerPosition();
661
+ }, 350); // Wait for CSS transition (0.3s) + small buffer
662
+ },
663
+
664
+ scrollSpanIntoView(span) {
665
+ // Find the DOM element for this span
666
+ const spanElement = document.querySelector(`[data-span-id="${span.span_id_hex}"]`);
667
+ if (spanElement) {
668
+ // Use native scrollIntoView with center alignment
669
+ // This automatically handles edge cases (top/bottom boundaries)
670
+ spanElement.scrollIntoView({
671
+ behavior: 'smooth',
672
+ // block: 'center',
673
+ inline: 'nearest'
674
+ });
675
+ }
676
+ },
677
+
678
+ handleSpanClick(span, event, clickedOnBadge = false) {
679
+ if (clickedOnBadge && span.childCount > 0) {
680
+ // Clicked on badge - toggle expansion
681
+ this.toggleSpanExpansion(span);
682
+ } else {
683
+ // Clicked on row - select span and seek audio if not playing
684
+ this.selectSpan(span, true);
685
+ }
686
+ },
687
+
688
+ formatTime(seconds) {
689
+ // Convert seconds to milliseconds and use unified formatter
690
+ if (!seconds) return formatDuration(0);
691
+ return formatDuration(seconds * 1000);
692
+ },
693
+
694
+ formatSpanDuration(span) {
695
+ // Format span duration using unified formatter
696
+ if (!span) return '';
697
+ return formatDuration(span.durationMs);
698
+ },
699
+
700
+ formatRelativeStartTime(span) {
701
+ // Format relative start time using unified formatter
702
+ if (!span) return '';
703
+ const relativeMs = span.startMs - this.minTime;
704
+ return formatDuration(relativeMs);
705
+ },
706
+
707
+ formatTimestamp(nanos) {
708
+ if (!nanos) return '';
709
+ const date = new Date(Number(nanos) / 1_000_000);
710
+ return date.toISOString().replace('T', ' ').substring(0, 23);
711
+ },
712
+
713
+ formatAttributes(span) {
714
+ if (!span || !span.attributes) return '{}';
715
+
716
+ // Flatten attributes array to object
717
+ const attrs = {};
718
+ span.attributes.forEach(attr => {
719
+ const value = attr.value.string_value ||
720
+ attr.value.int_value ||
721
+ attr.value.double_value ||
722
+ attr.value.bool_value;
723
+ attrs[attr.key] = value;
724
+ });
725
+
726
+ return JSON.stringify(attrs, null, 2);
727
+ },
728
+
729
+ formatResourceAttributes(span) {
730
+ if (!span || !span.resource || !span.resource.attributes) return '{}';
731
+
732
+ // Flatten resource attributes array to object
733
+ const attrs = {};
734
+ span.resource.attributes.forEach(attr => {
735
+ const value = attr.value.string_value ||
736
+ attr.value.int_value ||
737
+ attr.value.double_value ||
738
+ attr.value.bool_value;
739
+ attrs[attr.key] = value;
740
+ });
741
+
742
+ return JSON.stringify(attrs, null, 2);
743
+ },
744
+
745
+ getTranscriptText(span) {
746
+ if (!span || !span.attributes) return '';
747
+
748
+ const transcriptAttr = span.attributes.find(attr => attr.key === 'transcript');
749
+ if (!transcriptAttr) return '';
750
+
751
+ return transcriptAttr.value.string_value || '';
752
+ },
753
+
754
+ getOutputText(span) {
755
+ if (!span || !span.attributes) return '';
756
+
757
+ const outputAttr = span.attributes.find(attr => attr.key === 'output');
758
+ if (!outputAttr) return '';
759
+
760
+ return outputAttr.value.string_value || '';
761
+ },
762
+
763
+ // Get a specific attribute value
764
+ getAttribute(span, key) {
765
+ if (!span || !span.attributes) return null;
766
+
767
+ const attr = span.attributes.find(a => a.key === key);
768
+ if (!attr) return null;
769
+
770
+ return attr.value.string_value ||
771
+ attr.value.int_value ||
772
+ attr.value.double_value ||
773
+ attr.value.bool_value;
774
+ },
775
+
776
+ // Check if a specific attribute exists
777
+ hasAttribute(span, key) {
778
+ return this.getAttribute(span, key) !== null;
779
+ },
780
+
781
+ // Format a single attribute value as JSON or plain text
782
+ formatAttributeValue(span, key) {
783
+ const value = this.getAttribute(span, key);
784
+ if (value === null) return '';
785
+
786
+ // Try to parse as JSON for pretty printing
787
+ try {
788
+ const parsed = JSON.parse(value);
789
+ return JSON.stringify(parsed, null, 2);
790
+ } catch (e) {
791
+ // If not JSON, return as plain text
792
+ return value;
793
+ }
794
+ },
795
+
796
+ // Get TTFB (Time to First Byte) value from span attributes
797
+ getTTFB(span) {
798
+ if (!span || !span.attributes) return null;
799
+
800
+ const ttfbAttr = span.attributes.find(a => a.key === 'metrics.ttfb');
801
+ if (!ttfbAttr || !ttfbAttr.value.double_value) return null;
802
+
803
+ return ttfbAttr.value.double_value;
804
+ },
805
+
806
+ // Format TTFB value for display
807
+ formatTTFB(span) {
808
+ const ttfbSeconds = this.getTTFB(span);
809
+ if (ttfbSeconds === null) return '';
810
+
811
+ // Convert seconds to milliseconds and format
812
+ return formatDuration(ttfbSeconds * 1000);
813
+ },
814
+
815
+ // Get user-bot latency value from span attributes
816
+ getUserBotLatency(span) {
817
+ if (!span || !span.attributes) return null;
818
+
819
+ const latencyAttr = span.attributes.find(a => a.key === 'turn.user_bot_latency_seconds');
820
+ if (!latencyAttr || !latencyAttr.value.double_value) return null;
821
+
822
+ return latencyAttr.value.double_value;
823
+ },
824
+
825
+ // Check if latency is >= 2 seconds (slow response)
826
+ isSlowLatency(span) {
827
+ const latencySeconds = this.getUserBotLatency(span);
828
+ return latencySeconds !== null && latencySeconds >= 2.0;
829
+ },
830
+
831
+ // Format user-bot latency value for display with turtle icon if slow
832
+ formatUserBotLatency(span) {
833
+ const latencySeconds = this.getUserBotLatency(span);
834
+ if (latencySeconds === null) return '';
835
+
836
+ // Convert seconds to milliseconds and format
837
+ const formattedTime = formatDuration(latencySeconds * 1000);
838
+
839
+ // Add turtle icon if latency >= 2 seconds
840
+ if (latencySeconds >= 2.0) {
841
+ return `${formattedTime} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="width: 16px; height: 16px; display: inline-block; vertical-align: middle; margin-left: 4px; color: white; fill: currentColor;">
842
+ <path d="M511.325,275.018c-0.416-0.982-0.974-1.799-1.54-2.432c-1.117-1.241-2.199-1.891-3.157-2.382 c-1.808-0.892-3.391-1.274-5.107-1.633c-2.982-0.592-6.348-0.916-10.13-1.183c-5.64-0.4-12.13-0.633-18.419-1.016 c-3.166-0.192-6.29-0.433-9.18-0.734c0.3-1.449,0.474-2.932,0.467-4.432c0.008-3.732-0.975-7.447-2.725-10.896 c-1.757-3.458-4.24-6.698-7.372-9.831c-2.991-2.982-6.69-7.489-10.847-12.979c-7.289-9.613-16.045-22.243-26.233-35.738 c-15.311-20.252-33.847-42.503-56.24-59.93c-11.196-8.714-23.376-16.212-36.63-21.56c-13.246-5.339-27.574-8.505-42.853-8.505 c-23.292-0.008-44.302,7.356-62.796,18.544c-13.896,8.398-26.45,18.935-37.813,30.307c-17.036,17.045-31.44,35.955-43.486,52.45 c-6.023,8.239-11.454,15.878-16.27,22.326c-2.757,3.69-5.314,6.981-7.648,9.763c-0.783-0.741-1.549-1.475-2.283-2.208 c-3.582-3.599-6.489-7.139-8.672-12.03c-2.174-4.89-3.699-11.33-3.706-20.876c-0.009-8.781,1.332-20.143,4.673-34.872 c0.642-2.832,0.95-5.656,0.95-8.43c0-6.448-1.691-12.571-4.573-17.961c-4.323-8.114-11.205-14.653-19.318-19.235 c-8.139-4.574-17.578-7.214-27.316-7.223c-9.863-0.008-20.077,2.79-29.032,9.146c-8.181,5.824-13.979,11.18-17.953,16.495 c-1.974,2.658-3.491,5.315-4.531,8.023C0.542,148.685,0,151.442,0,154.141c-0.008,3.124,0.742,6.106,1.974,8.672 c1.075,2.258,2.491,4.216,4.057,5.906c2.741,2.966,5.94,5.182,9.139,6.998c4.816,2.691,9.722,4.449,13.496,5.599 c0.332,0.1,0.649,0.2,0.974,0.283c1.442,21.226,4.307,38.638,8.081,53.033c6.131,23.392,14.728,38.87,23.317,49.425 c4.282,5.274,8.547,9.305,12.346,12.462c3.799,3.158,7.156,5.474,9.464,7.215c5.465,4.098,10.696,7.047,15.687,8.996 c3.673,1.433,7.223,2.316,10.613,2.683v0.009c4.799,2.874,16.695,9.555,35.147,16.694c-0.183,0.666-0.5,1.491-0.925,2.4 c-1.124,2.432-2.99,5.464-5.123,8.463c-3.232,4.541-7.089,9.08-10.113,12.437c-1.516,1.675-2.808,3.058-3.724,4.024 c-0.467,0.484-0.816,0.85-1.075,1.084l-0.15,0.166c-0.016,0.017-0.091,0.1-0.2,0.208c-0.792,0.758-3.816,3.69-6.956,7.898 c-1.766,2.4-3.599,5.198-5.074,8.389c-1.458,3.199-2.616,6.798-2.64,10.888c-0.017,2.899,0.666,6.056,2.274,8.93 c0.883,1.608,2.007,2.933,3.224,4.041c2.124,1.958,4.54,3.357,7.09,4.482c3.857,1.699,8.097,2.824,12.546,3.582 c4.448,0.758,9.056,1.124,13.504,1.124c5.298-0.016,10.313-0.5,14.778-1.675c2.233-0.616,4.332-1.39,6.365-2.607 c1.016-0.608,2.008-1.342,2.949-2.308c0.925-0.933,1.808-2.133,2.441-3.599c0.366-0.883,1.1-2.466,2.049-4.44 c3.316-6.94,9.297-18.802,14.404-28.857c2.566-5.04,4.907-9.63,6.606-12.954c0.85-1.674,1.55-3.024,2.033-3.965 c0.475-0.924,0.733-1.442,0.733-1.442l0.016-0.033l0.042-0.042c0.033-0.067,0.075-0.142,0.092-0.217 c23.226,4.758,50.517,8.048,81.565,8.048c1.641,0,3.266,0,4.907-0.025h0.025c23.184-0.274,43.978-2.416,62.23-5.606 c2.25,4.39,7.597,14.812,12.804,25.15c2.657,5.256,5.274,10.497,7.414,14.87c1.092,2.174,2.05,4.148,2.824,5.79 c0.774,1.624,1.383,2.956,1.716,3.723c0.624,1.466,1.491,2.666,2.432,3.599c1.666,1.666,3.433,2.699,5.256,3.507 c2.75,1.2,5.69,1.9,8.84,2.383c3.157,0.475,6.514,0.7,9.98,0.7c6.814-0.016,13.937-0.833,20.318-2.64 c3.174-0.917,6.181-2.083,8.93-3.691c1.383-0.808,2.691-1.732,3.907-2.857c1.199-1.108,2.324-2.433,3.215-4.041 c1.625-2.874,2.283-6.031,2.266-8.93c0-4.09-1.158-7.689-2.616-10.888c-2.215-4.774-5.223-8.722-7.681-11.638 c-2.099-2.457-3.799-4.132-4.374-4.648v-0.016c-0.016-0.026-0.033-0.042-0.05-0.059c-0.024-0.016-0.024-0.033-0.042-0.033 c-0.033-0.042-0.05-0.058-0.091-0.1c-0.991-0.991-5.665-5.806-10.422-11.654c-2.641-3.232-5.274-6.772-7.306-10.039 c-0.7-1.107-1.308-2.199-1.832-3.215c20.868-7.689,33.806-15.295,38.438-18.227c0.883-0.05,1.848-0.125,2.907-0.225 c7.248-0.725,18.752-2.816,30.956-7.847c6.098-2.516,12.354-5.774,18.269-10.022c5.914-4.249,11.488-9.497,16.103-15.953 l0.166-0.242l0.158-0.258c0.341-0.575,0.666-1.241,0.916-2.024c0.241-0.776,0.408-1.683,0.408-2.641 C512,277.21,511.759,276.027,511.325,275.018z"/>
843
+ </svg>`;
844
+ }
845
+
846
+ return formattedTime;
847
+ },
848
+
849
+ // Get interruption status from span attributes
850
+ wasInterrupted(span) {
851
+ if (!span || !span.attributes) return null;
852
+
853
+ const interruptedAttr = span.attributes.find(a => a.key === 'turn.was_interrupted');
854
+ if (!interruptedAttr || interruptedAttr.value.bool_value === undefined) return null;
855
+
856
+ return interruptedAttr.value.bool_value;
857
+ },
858
+
859
+ // Format interruption status for display
860
+ formatInterrupted(span) {
861
+ const interrupted = this.wasInterrupted(span);
862
+ if (interrupted === null) return '';
863
+
864
+ if (interrupted) {
865
+ return `Yes <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width: 16px; height: 16px; display: inline-block; vertical-align: middle; margin-left: 4px; color: white;">
866
+ <path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 0 0 1.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06ZM17.78 9.22a.75.75 0 1 0-1.06 1.06L18.44 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 1 0 1.06-1.06L20.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-1.72 1.72-1.72-1.72Z" />
867
+ </svg>`;
868
+ } else {
869
+ return 'No';
870
+ }
871
+ },
872
+
873
+ // Format bot chunk text (interrupt icon removed for cleaner display)
874
+ formatBotChunkText(chunk) {
875
+ if (!chunk.botText) return '';
876
+ return chunk.botText;
877
+ },
878
+
879
+ // Format bar duration with interruption and slow latency icons if needed
880
+ formatBarDuration(span) {
881
+ if (!span) return '';
882
+
883
+ let result = formatDuration(span.durationMs);
884
+
885
+ // Add interrupt icon for interrupted turns
886
+ if (span.name === 'turn' && this.wasInterrupted(span)) {
887
+ result += ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width: 14px; height: 14px; display: inline-block; vertical-align: top; margin-left: 3px; color: white;">
888
+ <path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 0 0 1.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06ZM17.78 9.22a.75.75 0 1 0-1.06 1.06L18.44 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 1 0 1.06-1.06L20.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-1.72 1.72-1.72-1.72Z" />
889
+ </svg>`;
890
+ }
891
+
892
+ // Add turtle icon for slow latency (>= 2s)
893
+ if (span.name === 'turn' && this.isSlowLatency(span)) {
894
+ result += ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="width: 14px; height: 14px; display: inline-block; vertical-align: top; margin-left: 3px; color: white; fill: currentColor;">
895
+ <path d="M511.325,275.018c-0.416-0.982-0.974-1.799-1.54-2.432c-1.117-1.241-2.199-1.891-3.157-2.382 c-1.808-0.892-3.391-1.274-5.107-1.633c-2.982-0.592-6.348-0.916-10.13-1.183c-5.64-0.4-12.13-0.633-18.419-1.016 c-3.166-0.192-6.29-0.433-9.18-0.734c0.3-1.449,0.474-2.932,0.467-4.432c0.008-3.732-0.975-7.447-2.725-10.896 c-1.757-3.458-4.24-6.698-7.372-9.831c-2.991-2.982-6.69-7.489-10.847-12.979c-7.289-9.613-16.045-22.243-26.233-35.738 c-15.311-20.252-33.847-42.503-56.24-59.93c-11.196-8.714-23.376-16.212-36.63-21.56c-13.246-5.339-27.574-8.505-42.853-8.505 c-23.292-0.008-44.302,7.356-62.796,18.544c-13.896,8.398-26.45,18.935-37.813,30.307c-17.036,17.045-31.44,35.955-43.486,52.45 c-6.023,8.239-11.454,15.878-16.27,22.326c-2.757,3.69-5.314,6.981-7.648,9.763c-0.783-0.741-1.549-1.475-2.283-2.208 c-3.582-3.599-6.489-7.139-8.672-12.03c-2.174-4.89-3.699-11.33-3.706-20.876c-0.009-8.781,1.332-20.143,4.673-34.872 c0.642-2.832,0.95-5.656,0.95-8.43c0-6.448-1.691-12.571-4.573-17.961c-4.323-8.114-11.205-14.653-19.318-19.235 c-8.139-4.574-17.578-7.214-27.316-7.223c-9.863-0.008-20.077,2.79-29.032,9.146c-8.181,5.824-13.979,11.18-17.953,16.495 c-1.974,2.658-3.491,5.315-4.531,8.023C0.542,148.685,0,151.442,0,154.141c-0.008,3.124,0.742,6.106,1.974,8.672 c1.075,2.258,2.491,4.216,4.057,5.906c2.741,2.966,5.94,5.182,9.139,6.998c4.816,2.691,9.722,4.449,13.496,5.599 c0.332,0.1,0.649,0.2,0.974,0.283c1.442,21.226,4.307,38.638,8.081,53.033c6.131,23.392,14.728,38.87,23.317,49.425 c4.282,5.274,8.547,9.305,12.346,12.462c3.799,3.158,7.156,5.474,9.464,7.215c5.465,4.098,10.696,7.047,15.687,8.996 c3.673,1.433,7.223,2.316,10.613,2.683v0.009c4.799,2.874,16.695,9.555,35.147,16.694c-0.183,0.666-0.5,1.491-0.925,2.4 c-1.124,2.432-2.99,5.464-5.123,8.463c-3.232,4.541-7.089,9.08-10.113,12.437c-1.516,1.675-2.808,3.058-3.724,4.024 c-0.467,0.484-0.816,0.85-1.075,1.084l-0.15,0.166c-0.016,0.017-0.091,0.1-0.2,0.208c-0.792,0.758-3.816,3.69-6.956,7.898 c-1.766,2.4-3.599,5.198-5.074,8.389c-1.458,3.199-2.616,6.798-2.64,10.888c-0.017,2.899,0.666,6.056,2.274,8.93 c0.883,1.608,2.007,2.933,3.224,4.041c2.124,1.958,4.54,3.357,7.09,4.482c3.857,1.699,8.097,2.824,12.546,3.582 c4.448,0.758,9.056,1.124,13.504,1.124c5.298-0.016,10.313-0.5,14.778-1.675c2.233-0.616,4.332-1.39,6.365-2.607 c1.016-0.608,2.008-1.342,2.949-2.308c0.925-0.933,1.808-2.133,2.441-3.599c0.366-0.883,1.1-2.466,2.049-4.44 c3.316-6.94,9.297-18.802,14.404-28.857c2.566-5.04,4.907-9.63,6.606-12.954c0.85-1.674,1.55-3.024,2.033-3.965 c0.475-0.924,0.733-1.442,0.733-1.442l0.016-0.033l0.042-0.042c0.033-0.067,0.075-0.142,0.092-0.217 c23.226,4.758,50.517,8.048,81.565,8.048c1.641,0,3.266,0,4.907-0.025h0.025c23.184-0.274,43.978-2.416,62.23-5.606 c2.25,4.39,7.597,14.812,12.804,25.15c2.657,5.256,5.274,10.497,7.414,14.87c1.092,2.174,2.05,4.148,2.824,5.79 c0.774,1.624,1.383,2.956,1.716,3.723c0.624,1.466,1.491,2.666,2.432,3.599c1.666,1.666,3.433,2.699,5.256,3.507 c2.75,1.2,5.69,1.9,8.84,2.383c3.157,0.475,6.514,0.7,9.98,0.7c6.814-0.016,13.937-0.833,20.318-2.64 c3.174-0.917,6.181-2.083,8.93-3.691c1.383-0.808,2.691-1.732,3.907-2.857c1.199-1.108,2.324-2.433,3.215-4.041 c1.625-2.874,2.283-6.031,2.266-8.93c0-4.09-1.158-7.689-2.616-10.888c-2.215-4.774-5.223-8.722-7.681-11.638 c-2.099-2.457-3.799-4.132-4.374-4.648v-0.016c-0.016-0.026-0.033-0.042-0.05-0.059c-0.024-0.016-0.024-0.033-0.042-0.033 c-0.033-0.042-0.05-0.058-0.091-0.1c-0.991-0.991-5.665-5.806-10.422-11.654c-2.641-3.232-5.274-6.772-7.306-10.039 c-0.7-1.107-1.308-2.199-1.832-3.215c20.868-7.689,33.806-15.295,38.438-18.227c0.883-0.05,1.848-0.125,2.907-0.225 c7.248-0.725,18.752-2.816,30.956-7.847c6.098-2.516,12.354-5.774,18.269-10.022c5.914-4.249,11.488-9.497,16.103-15.953 l0.166-0.242l0.158-0.258c0.341-0.575,0.666-1.241,0.916-2.024c0.241-0.776,0.408-1.683,0.408-2.641 C512,277.21,511.759,276.027,511.325,275.018z"/>
896
+ </svg>`;
897
+ }
898
+
899
+ return result;
900
+ },
901
+
902
+ // Get all tool calls from the input attribute
903
+ getToolCalls(span) {
904
+ if (!span || span.name !== 'llm') return [];
905
+
906
+ const inputValue = this.getAttribute(span, 'input');
907
+ if (!inputValue) return [];
908
+
909
+ try {
910
+ const messages = JSON.parse(inputValue);
911
+ if (!Array.isArray(messages)) return [];
912
+
913
+ const toolCalls = [];
914
+ // Iterate through all messages and collect tool calls
915
+ messages.forEach(msg => {
916
+ if (msg.role === 'assistant' && msg.tool_calls && Array.isArray(msg.tool_calls)) {
917
+ toolCalls.push(...msg.tool_calls);
918
+ }
919
+ });
920
+
921
+ return toolCalls;
922
+ } catch (e) {
923
+ console.error('Error parsing tool calls:', e);
924
+ return [];
925
+ }
926
+ },
927
+
928
+ // Format tool calls as JSON
929
+ formatToolCalls(span) {
930
+ const toolCalls = this.getToolCalls(span);
931
+ if (toolCalls.length === 0) return '[]';
932
+
933
+ return JSON.stringify(toolCalls, null, 2);
934
+ },
935
+
936
+ // Get raw span JSON (excluding computed properties)
937
+ getRawSpanJSON(span) {
938
+ if (!span) return '{}';
939
+
940
+ // Exclude computed properties added by the frontend
941
+ const { startMs, endMs, durationMs, depth, children, childCount, ...rawSpan } = span;
942
+
943
+ return JSON.stringify(rawSpan, null, 2);
944
+ },
945
+
946
+ // Copy span JSON to clipboard with visual feedback
947
+ async copySpanToClipboard() {
948
+ if (!this.selectedSpan) return;
949
+
950
+ try {
951
+ const spanJSON = this.getRawSpanJSON(this.selectedSpan);
952
+ await navigator.clipboard.writeText(spanJSON);
953
+
954
+ // Visual feedback: set copied state
955
+ this.spanCopied = true;
956
+
957
+ // Reset after 1.5 seconds
958
+ setTimeout(() => {
959
+ this.spanCopied = false;
960
+ }, 1500);
961
+ } catch (err) {
962
+ console.error('Failed to copy span:', err);
963
+ }
964
+ },
965
+
966
+ // Initialize waveform hover listeners
967
+ initWaveformHover() {
968
+ const waveformContainer = document.getElementById('waveform');
969
+ if (!waveformContainer) return;
970
+
971
+ waveformContainer.addEventListener('mousemove', (e) => {
972
+ const rect = waveformContainer.getBoundingClientRect();
973
+ const x = e.clientX - rect.left;
974
+ const percent = x / rect.width;
975
+ const time = percent * this.duration;
976
+
977
+ this.hoverMarker.time = time;
978
+ this.hoverMarker.source = 'waveform';
979
+ this.hoverMarker.visible = true;
980
+ });
981
+
982
+ waveformContainer.addEventListener('mouseleave', () => {
983
+ if (this.hoverMarker.source === 'waveform') {
984
+ this.hoverMarker.visible = false;
985
+ }
986
+ });
987
+ },
988
+
989
+ // Show hover marker at span start time (called from waterfall row hover)
990
+ showMarkerAtSpan(span) {
991
+ // Calculate relative time from trace start (minTime is already in ms)
992
+ const relativeMs = span.startMs - this.minTime;
993
+ this.hoverMarker.time = relativeMs / 1000; // Convert to seconds
994
+ this.hoverMarker.source = 'waterfall';
995
+ this.hoverMarker.visible = true;
996
+ },
997
+
998
+ // Hide hover marker (called from waterfall row leave)
999
+ hideMarkerFromWaterfall() {
1000
+ if (this.hoverMarker.source === 'waterfall') {
1001
+ this.hoverMarker.visible = false;
1002
+ }
1003
+ },
1004
+
1005
+ // Refresh hover marker position (called when waveform width changes, e.g., panel open/close)
1006
+ refreshMarkerPosition() {
1007
+ if (this.hoverMarker.visible && this.hoverMarker.source === 'waterfall') {
1008
+ // Force Alpine to recalculate by triggering a reactive update
1009
+ // We temporarily store the time value, toggle visibility, and restore
1010
+ const savedTime = this.hoverMarker.time;
1011
+ this.hoverMarker.visible = false;
1012
+ this.$nextTick(() => {
1013
+ this.hoverMarker.time = savedTime;
1014
+ this.hoverMarker.visible = true;
1015
+ });
1016
+ }
1017
+ },
1018
+
1019
+ // Get hover marker position in pixels
1020
+ getMarkerPosition() {
1021
+ if (!this.duration || !this.hoverMarker.visible) return '32px'; // 2rem = 32px
1022
+
1023
+ const waveform = document.getElementById('waveform');
1024
+ if (!waveform) return '32px';
1025
+
1026
+ const waveformWidth = waveform.offsetWidth;
1027
+ const percent = this.hoverMarker.time / this.duration;
1028
+ const offsetInWaveform = percent * waveformWidth;
1029
+ const totalOffset = 32 + offsetInWaveform; // 32px = 2rem padding
1030
+
1031
+ return `${totalOffset}px`;
1032
+ },
1033
+
1034
+ // Format hover marker time label
1035
+ getMarkerTimeLabel() {
1036
+ if (!this.hoverMarker.visible) return '';
1037
+ return this.formatTime(this.hoverMarker.time);
1038
+ },
1039
+
1040
+ // Highlight span when hovering over chunk or waterfall row
1041
+ highlightSpan(span) {
1042
+ this.hoveredSpan = span;
1043
+ this.showMarkerAtSpan(span);
1044
+ },
1045
+
1046
+ // Unhighlight span when leaving chunk or waterfall row
1047
+ unhighlightSpan() {
1048
+ this.hoveredSpan = null;
1049
+ this.hideMarkerFromWaterfall();
1050
+ },
1051
+
1052
+ // Highlight span from chunk hover (also applies .selected to waterfall)
1053
+ highlightSpanFromChunk(span) {
1054
+ this.hoveredSpan = span;
1055
+ this.chunkHoveredSpan = span;
1056
+ this.showMarkerAtSpan(span);
1057
+ },
1058
+
1059
+ // Unhighlight span when leaving chunk
1060
+ unhighlightSpanFromChunk() {
1061
+ this.hoveredSpan = null;
1062
+ this.chunkHoveredSpan = null;
1063
+ this.hideMarkerFromWaterfall();
1064
+ },
1065
+
1066
+ // Handle chunk click - delegates to span click handler and expands children
1067
+ handleChunkClick(span) {
1068
+ // Expand span children if not already expanded
1069
+ if (span.childCount > 0 && !this.expandedSpanIds.has(span.span_id_hex)) {
1070
+ this.expandedSpanIds.add(span.span_id_hex);
1071
+ this.buildWaterfallTree();
1072
+ }
1073
+
1074
+ // Execute normal click behavior
1075
+ this.handleRowClick(span);
1076
+ },
1077
+
1078
+ getTurnChunks() {
1079
+ // Return empty array if no data or audio not ready
1080
+ if (!this.spans || this.spans.length === 0 || !this.duration) {
1081
+ return [];
1082
+ }
1083
+
1084
+ // Find all turn spans
1085
+ const turnSpans = this.spans.filter(s => s.name === 'turn');
1086
+
1087
+ // Map each turn to a chunk object with text and positioning
1088
+ return turnSpans.map(turn => {
1089
+ // Find all STT and LLM children
1090
+ const children = this.spans.filter(s => s.parent_span_id_hex === turn.span_id_hex);
1091
+ const sttChildren = children.filter(c => c.name === 'stt');
1092
+ const llmChildren = children.filter(c => c.name === 'llm');
1093
+
1094
+ // Concatenate text from all STT spans
1095
+ const humanText = sttChildren
1096
+ .map(child => this.getTranscriptText(child))
1097
+ .filter(text => text) // Remove empty strings
1098
+ .join(' ');
1099
+
1100
+ // Concatenate text from all LLM spans
1101
+ const botText = llmChildren
1102
+ .map(child => this.getOutputText(child))
1103
+ .filter(text => text) // Remove empty strings
1104
+ .join(' ');
1105
+
1106
+ // Calculate positioning (reuses existing method)
1107
+ const style = this.getTimelineBarStyle(turn);
1108
+
1109
+ return {
1110
+ span_id_hex: turn.span_id_hex,
1111
+ span: turn, // Store reference for hover marker
1112
+ humanText: humanText,
1113
+ botText: botText,
1114
+ style: style, // { left: "X%", width: "Y%" }
1115
+ wasInterrupted: this.wasInterrupted(turn)
1116
+ };
1117
+ });
1118
+ },
1119
+
1120
+ // Real-time polling methods
1121
+ startPolling() {
1122
+ if (this.isPolling) return;
1123
+
1124
+ this.isPolling = true;
1125
+ this.lastSpanCount = this.spans.length;
1126
+ console.log('Starting real-time polling for synchronized trace updates');
1127
+
1128
+ // Poll for new spans every 1 second (audio reloads when spans update)
1129
+ this.pollInterval = setInterval(() => {
1130
+ this.pollForSpans();
1131
+ }, 1000);
1132
+ },
1133
+
1134
+ stopPolling() {
1135
+ if (!this.isPolling) return;
1136
+
1137
+ console.log('Stopping real-time polling');
1138
+ this.isPolling = false;
1139
+ if (this.pollInterval) clearInterval(this.pollInterval);
1140
+ this.pollInterval = null;
1141
+ },
1142
+
1143
+ async reloadAudioIfNotPlaying() {
1144
+ // Only reload audio if it's not currently playing
1145
+ if (this.wavesurfer && !this.wavesurfer.isPlaying()) {
1146
+ console.log('Reloading audio waveform (synchronized with spans)');
1147
+ // Add cache-busting parameter to force reload
1148
+ const audioUrl = `/api/audio/${this.traceId}?t=${Date.now()}`;
1149
+ this.wavesurfer.load(audioUrl);
1150
+ } else if (this.wavesurfer) {
1151
+ console.log('Audio is playing, skipping reload');
1152
+ }
1153
+ },
1154
+
1155
+ async pollForSpans() {
1156
+ try {
1157
+ const response = await fetch(`/api/trace/${this.traceId}`);
1158
+ if (!response.ok) {
1159
+ throw new Error(`HTTP ${response.status}`);
1160
+ }
1161
+
1162
+ const data = await response.json();
1163
+ this.consecutiveErrors = 0;
1164
+
1165
+ // Check stopping condition 1: Conversation complete (if conversation span exists)
1166
+ const conversationSpan = data.spans.find(s => s.name === 'conversation');
1167
+ if (conversationSpan && conversationSpan.end_time_unix_nano) {
1168
+ console.log('Conversation complete, stopping polling');
1169
+ this.stopPolling();
1170
+ return;
1171
+ }
1172
+
1173
+ // Check stopping condition 2: Trace abandoned (10 minutes since last span)
1174
+ if (data.last_span_time) {
1175
+ const lastSpanMs = Number(data.last_span_time) / 1_000_000;
1176
+ const nowMs = Date.now();
1177
+ const tenMinutesMs = 10 * 60 * 1000;
1178
+
1179
+ if ((nowMs - lastSpanMs) > tenMinutesMs) {
1180
+ console.log('Trace abandoned (>10 min since last span), stopping polling');
1181
+ this.stopPolling();
1182
+ return;
1183
+ }
1184
+ }
1185
+
1186
+ // Check if new spans arrived
1187
+ if (data.spans.length > this.lastSpanCount) {
1188
+ console.log(`New spans detected: ${data.spans.length - this.lastSpanCount} new spans`);
1189
+
1190
+ // Get new spans by comparing span IDs
1191
+ const existingSpanIds = new Set(this.spans.map(s => s.span_id_hex));
1192
+ const newSpans = data.spans.filter(s => !existingSpanIds.has(s.span_id_hex));
1193
+
1194
+ // Add computed properties to new spans (same as loadTraceData)
1195
+ newSpans.forEach(span => {
1196
+ span.startMs = Number(span.start_time_unix_nano) / 1_000_000;
1197
+ span.endMs = Number(span.end_time_unix_nano) / 1_000_000;
1198
+ span.durationMs = (Number(span.end_time_unix_nano) - Number(span.start_time_unix_nano)) / 1_000_000;
1199
+ this.spans.push(span);
1200
+ });
1201
+
1202
+ // Update timeline bounds
1203
+ this.minTime = Math.min(...this.spans.map(s => s.startMs));
1204
+ this.maxTime = Math.max(...this.spans.map(s => s.endMs));
1205
+
1206
+ // Rebuild waterfall tree with new spans
1207
+ this.buildWaterfallTree();
1208
+
1209
+ // Reload audio waveform (synchronized with span update)
1210
+ await this.reloadAudioIfNotPlaying();
1211
+
1212
+ // Update count
1213
+ this.lastSpanCount = this.spans.length;
1214
+ }
1215
+
1216
+ } catch (error) {
1217
+ console.error('Error polling for spans:', error);
1218
+ this.consecutiveErrors++;
1219
+
1220
+ // Stop polling after 3 consecutive errors
1221
+ if (this.consecutiveErrors >= 3) {
1222
+ console.error('Too many consecutive errors, stopping polling');
1223
+ this.stopPolling();
1224
+ }
1225
+ }
1226
+ }
1227
+ };
1228
+ }