@townco/debugger 0.1.66 → 0.1.68

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,1095 @@
1
+ import { X } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { cn } from "@/lib/utils";
4
+ import { detectSpanType } from "../lib/spanTypeDetector";
5
+ import type { Log, SpanNode } from "../types";
6
+ import { SpanIcon } from "./SpanIcon";
7
+
8
+ interface DiagnosticDetailsPanelProps {
9
+ span: SpanNode | null;
10
+ log: Log | null;
11
+ onClose: () => void;
12
+ allSpans: SpanNode[];
13
+ }
14
+
15
+ function parseAttributes(attrs: string | null): Record<string, unknown> {
16
+ if (!attrs) return {};
17
+ try {
18
+ return JSON.parse(attrs);
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ function getDisplayName(span: SpanNode): string {
25
+ const attrs = parseAttributes(span.attributes);
26
+ const spanType = detectSpanType(span);
27
+
28
+ if (spanType === "tool_call") {
29
+ const toolName = attrs["tool.name"] as string;
30
+ if (toolName !== "Task") return toolName || span.name;
31
+
32
+ // Parse tool.input to extract agentName for subagent spans
33
+ try {
34
+ const toolInput = attrs["tool.input"];
35
+ const input =
36
+ typeof toolInput === "string" ? JSON.parse(toolInput) : toolInput;
37
+ if (input?.agentName) {
38
+ return `Subagent (${input.agentName})`;
39
+ }
40
+ } catch {
41
+ // Fall back to "Task" if parsing fails
42
+ }
43
+ return toolName || span.name;
44
+ }
45
+
46
+ if (spanType === "chat") {
47
+ return (attrs["gen_ai.request.model"] as string) || span.name;
48
+ }
49
+
50
+ return span.name;
51
+ }
52
+
53
+ function findParentSpan(span: SpanNode, allSpans: SpanNode[]): SpanNode | null {
54
+ if (!span.parent_span_id) return null;
55
+
56
+ const findSpan = (spans: SpanNode[]): SpanNode | null => {
57
+ for (const s of spans) {
58
+ if (s.span_id === span.parent_span_id) return s;
59
+ const found = findSpan(s.children);
60
+ if (found) return found;
61
+ }
62
+ return null;
63
+ };
64
+
65
+ return findSpan(allSpans);
66
+ }
67
+
68
+ function formatDuration(durationMs: number): string {
69
+ if (durationMs < 1) {
70
+ return `${(durationMs * 1000).toFixed(2)}μs`;
71
+ }
72
+ if (durationMs < 1000) {
73
+ return `${durationMs.toFixed(2)}ms`;
74
+ }
75
+ return `${(durationMs / 1000).toFixed(2)}s`;
76
+ }
77
+
78
+ function formatTimestamp(timestampNano: number): string {
79
+ const date = new Date(timestampNano / 1_000_000);
80
+ return date.toLocaleString("en-US", {
81
+ month: "numeric",
82
+ day: "numeric",
83
+ year: "numeric",
84
+ hour: "numeric",
85
+ minute: "numeric",
86
+ second: "numeric",
87
+ hour12: true,
88
+ });
89
+ }
90
+
91
+ function countToolUsage(span: SpanNode): {
92
+ toolCount: number;
93
+ callCount: number;
94
+ } {
95
+ const uniqueTools = new Set<string>();
96
+ let totalCalls = 0;
97
+
98
+ const traverse = (node: SpanNode) => {
99
+ const attrs = parseAttributes(node.attributes);
100
+ if (node.name === "agent.tool_call") {
101
+ const toolName = attrs["tool.name"] as string;
102
+ if (toolName) {
103
+ uniqueTools.add(toolName);
104
+ totalCalls++;
105
+ }
106
+ }
107
+ for (const child of node.children) {
108
+ traverse(child);
109
+ }
110
+ };
111
+
112
+ traverse(span);
113
+ return { toolCount: uniqueTools.size, callCount: totalCalls };
114
+ }
115
+
116
+ function countLogs(span: SpanNode): number {
117
+ let count = 0;
118
+ const traverse = (node: SpanNode) => {
119
+ if (node.events) {
120
+ try {
121
+ const events = JSON.parse(node.events);
122
+ count += Array.isArray(events) ? events.length : 0;
123
+ } catch {
124
+ // Ignore parse errors
125
+ }
126
+ }
127
+ for (const child of node.children) {
128
+ traverse(child);
129
+ }
130
+ };
131
+ traverse(span);
132
+ return count;
133
+ }
134
+
135
+ function countTokens(span: SpanNode): number {
136
+ let tokens = 0;
137
+ const traverse = (node: SpanNode) => {
138
+ const attrs = parseAttributes(node.attributes);
139
+ const inputTokens = attrs["gen_ai.usage.input_tokens"] as number;
140
+ const outputTokens = attrs["gen_ai.usage.output_tokens"] as number;
141
+ if (inputTokens) tokens += inputTokens;
142
+ if (outputTokens) tokens += outputTokens;
143
+ for (const child of node.children) {
144
+ traverse(child);
145
+ }
146
+ };
147
+ traverse(span);
148
+ return tokens;
149
+ }
150
+
151
+ const JsonSection = ({ title, data }: { title: string; data: unknown }) => {
152
+ if (!data) return null;
153
+
154
+ try {
155
+ const jsonString = typeof data === "string" ? data : JSON.stringify(data);
156
+ const parsed = JSON.parse(jsonString);
157
+
158
+ return (
159
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
160
+ <div className="p-2 border-b border-border">
161
+ <h3 className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">
162
+ {title}
163
+ </h3>
164
+ </div>
165
+ <div className="p-3">
166
+ <pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-all">
167
+ {JSON.stringify(parsed, null, 2)}
168
+ </pre>
169
+ </div>
170
+ </div>
171
+ );
172
+ } catch {
173
+ // If parse fails, show raw data
174
+ return (
175
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
176
+ <div className="p-2 border-b border-border">
177
+ <h3 className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">
178
+ {title}
179
+ </h3>
180
+ </div>
181
+ <div className="p-3">
182
+ <pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-all">
183
+ {String(data)}
184
+ </pre>
185
+ </div>
186
+ </div>
187
+ );
188
+ }
189
+ };
190
+
191
+ const CollapsibleText = ({ text }: { text: string }) => {
192
+ const [expanded, setExpanded] = useState(false);
193
+ const shouldCollapse = text.length > 300;
194
+ const preview = shouldCollapse ? `${text.slice(0, 300)}...` : text;
195
+
196
+ if (!shouldCollapse) {
197
+ return (
198
+ <div className="text-xs text-foreground whitespace-pre-wrap break-words">
199
+ {text}
200
+ </div>
201
+ );
202
+ }
203
+
204
+ return (
205
+ <div>
206
+ <div className="text-xs text-foreground whitespace-pre-wrap break-words">
207
+ {expanded ? text : preview}
208
+ </div>
209
+ <button
210
+ type="button"
211
+ onClick={() => setExpanded(!expanded)}
212
+ className="text-[10px] text-purple-600 hover:text-purple-700 mt-1 font-medium"
213
+ >
214
+ {expanded ? "Show less" : "Show more"}
215
+ </button>
216
+ </div>
217
+ );
218
+ };
219
+
220
+ const CollapsibleToolBlock = ({
221
+ title,
222
+ subtitle,
223
+ content,
224
+ borderColor,
225
+ bgColor,
226
+ badgeColor,
227
+ }: {
228
+ title: string;
229
+ subtitle?: string;
230
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
231
+ content: any;
232
+ borderColor: string;
233
+ bgColor: string;
234
+ badgeColor: string;
235
+ }) => {
236
+ const [expanded, setExpanded] = useState(false);
237
+
238
+ // Parse content as JSON if it's a string
239
+ let displayContent = content;
240
+ if (typeof content === "string") {
241
+ try {
242
+ displayContent = JSON.parse(content);
243
+ } catch {
244
+ // Keep as string if parsing fails
245
+ }
246
+ }
247
+
248
+ const contentString =
249
+ typeof displayContent === "string"
250
+ ? displayContent
251
+ : JSON.stringify(displayContent, null, 2);
252
+
253
+ return (
254
+ <div className={`border-l-2 ${borderColor} pl-2 ${bgColor} rounded py-1`}>
255
+ <button
256
+ type="button"
257
+ onClick={() => setExpanded(!expanded)}
258
+ className="flex items-center gap-2 mb-1 w-full hover:opacity-80 transition-opacity"
259
+ >
260
+ <span className={`text-[9px] font-semibold ${badgeColor} uppercase`}>
261
+ {title}
262
+ </span>
263
+ {subtitle && (
264
+ <span className="text-[10px] text-foreground font-mono">
265
+ {subtitle}
266
+ </span>
267
+ )}
268
+ <span className="text-[10px] text-muted-foreground ml-auto">
269
+ {expanded ? "▼" : "▶"}
270
+ </span>
271
+ </button>
272
+ {expanded && (
273
+ <pre className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all mt-1">
274
+ {contentString}
275
+ </pre>
276
+ )}
277
+ </div>
278
+ );
279
+ };
280
+
281
+ const ToolCallWithResult = ({
282
+ toolName,
283
+ input,
284
+ result,
285
+ }: {
286
+ toolName: string;
287
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
288
+ input: any;
289
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
290
+ result?: any;
291
+ }) => {
292
+ const [expanded, setExpanded] = useState(false);
293
+
294
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
295
+ const parseContent = (content: any) => {
296
+ if (typeof content === "string") {
297
+ try {
298
+ return JSON.parse(content);
299
+ } catch {
300
+ return content;
301
+ }
302
+ }
303
+ return content;
304
+ };
305
+
306
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
307
+ const formatContent = (content: any) => {
308
+ const parsed = parseContent(content);
309
+ return typeof parsed === "string"
310
+ ? parsed
311
+ : JSON.stringify(parsed, null, 2);
312
+ };
313
+
314
+ return (
315
+ <div className="border-l-2 border-blue-500 pl-2 bg-blue-500/5 rounded py-1">
316
+ <button
317
+ type="button"
318
+ onClick={() => setExpanded(!expanded)}
319
+ className="flex items-center gap-2 mb-1 w-full hover:opacity-80 transition-opacity"
320
+ >
321
+ <span className="text-[9px] font-semibold text-blue-600 uppercase">
322
+ Tool Call
323
+ </span>
324
+ <span className="text-[10px] text-foreground font-mono">
325
+ {toolName}
326
+ </span>
327
+ <span className="text-[10px] text-muted-foreground ml-auto">
328
+ {expanded ? "▼" : "▶"}
329
+ </span>
330
+ </button>
331
+ {expanded && (
332
+ <div className="flex flex-col gap-2 mt-1">
333
+ {/* Input section */}
334
+ <div>
335
+ <div className="text-[9px] font-semibold text-purple-600 uppercase mb-1">
336
+ Input
337
+ </div>
338
+ <pre className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all bg-background/50 rounded p-2">
339
+ {formatContent(input)}
340
+ </pre>
341
+ </div>
342
+ {/* Result section */}
343
+ {result !== undefined && (
344
+ <div>
345
+ <div className="text-[9px] font-semibold text-green-600 uppercase mb-1">
346
+ Result
347
+ </div>
348
+ <pre className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all bg-background/50 rounded p-2">
349
+ {formatContent(result)}
350
+ </pre>
351
+ </div>
352
+ )}
353
+ </div>
354
+ )}
355
+ </div>
356
+ );
357
+ };
358
+
359
+ const MessageSection = ({ title, data }: { title: string; data: unknown }) => {
360
+ if (!data) return null;
361
+
362
+ try {
363
+ const jsonString = typeof data === "string" ? data : JSON.stringify(data);
364
+ const messages = JSON.parse(jsonString);
365
+
366
+ if (!Array.isArray(messages) || messages.length === 0) return null;
367
+
368
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
369
+ const parseToolInput = (input: any): any => {
370
+ if (typeof input === "string") {
371
+ try {
372
+ return JSON.parse(input);
373
+ } catch {
374
+ return input;
375
+ }
376
+ }
377
+ return input;
378
+ };
379
+
380
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
381
+ const renderContent = (content: any) => {
382
+ // Handle string content - parse if it looks like JSON array
383
+ if (typeof content === "string") {
384
+ // Try to parse if it looks like a JSON array
385
+ if (content.trim().startsWith("[")) {
386
+ try {
387
+ const parsed = JSON.parse(content);
388
+ if (Array.isArray(parsed)) {
389
+ return renderContent(parsed);
390
+ }
391
+ } catch {
392
+ // Not valid JSON, fall through to text display
393
+ }
394
+ }
395
+
396
+ return <CollapsibleText text={content} />;
397
+ }
398
+
399
+ // Handle array content (can contain text and tool_use blocks)
400
+ if (Array.isArray(content)) {
401
+ // First, group tool_use with their corresponding tool_result
402
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
403
+ const processedBlocks: any[] = [];
404
+ const usedResultIndices = new Set<number>();
405
+
406
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
407
+ content.forEach((block: any, blockIndex: number) => {
408
+ if (block.type === "text") {
409
+ processedBlocks.push({ type: "text", block, index: blockIndex });
410
+ } else if (block.type === "tool_use") {
411
+ // Find the corresponding tool_result
412
+ const resultIndex = content.findIndex(
413
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
414
+ (b: any, idx: number) =>
415
+ b.type === "tool_result" &&
416
+ b.tool_use_id === block.id &&
417
+ !usedResultIndices.has(idx),
418
+ );
419
+
420
+ if (resultIndex !== -1) {
421
+ usedResultIndices.add(resultIndex);
422
+ processedBlocks.push({
423
+ type: "tool_call_with_result",
424
+ toolUse: block,
425
+ toolResult: content[resultIndex],
426
+ index: blockIndex,
427
+ });
428
+ } else {
429
+ // No result found, show just the tool_use
430
+ processedBlocks.push({
431
+ type: "tool_use",
432
+ block,
433
+ index: blockIndex,
434
+ });
435
+ }
436
+ } else if (
437
+ block.type === "tool_result" &&
438
+ !usedResultIndices.has(blockIndex)
439
+ ) {
440
+ // Orphaned tool_result (no matching tool_use)
441
+ processedBlocks.push({
442
+ type: "tool_result",
443
+ block,
444
+ index: blockIndex,
445
+ });
446
+ }
447
+ });
448
+
449
+ return (
450
+ <div className="flex flex-col gap-2">
451
+ {/* biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content */}
452
+ {processedBlocks.map((item: any) => {
453
+ if (item.type === "text") {
454
+ return (
455
+ <CollapsibleText key={item.index} text={item.block.text} />
456
+ );
457
+ }
458
+
459
+ if (item.type === "tool_call_with_result") {
460
+ const parsedInput = parseToolInput(item.toolUse.input);
461
+ return (
462
+ <ToolCallWithResult
463
+ key={item.index}
464
+ toolName={item.toolUse.name}
465
+ input={parsedInput}
466
+ result={item.toolResult.content}
467
+ />
468
+ );
469
+ }
470
+
471
+ if (item.type === "tool_use") {
472
+ const parsedInput = parseToolInput(item.block.input);
473
+ return (
474
+ <CollapsibleToolBlock
475
+ key={item.index}
476
+ title="Tool Use"
477
+ subtitle={item.block.name}
478
+ content={parsedInput}
479
+ borderColor="border-purple-500"
480
+ bgColor="bg-purple-500/5"
481
+ badgeColor="text-purple-600"
482
+ />
483
+ );
484
+ }
485
+
486
+ if (item.type === "tool_result") {
487
+ return (
488
+ <CollapsibleToolBlock
489
+ key={item.index}
490
+ title="Tool Result"
491
+ subtitle=""
492
+ content={item.block.content}
493
+ borderColor="border-green-500"
494
+ bgColor="bg-green-500/5"
495
+ badgeColor="text-green-600"
496
+ />
497
+ );
498
+ }
499
+
500
+ // Unknown block type - show as JSON
501
+ return (
502
+ <pre
503
+ key={item.index}
504
+ className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all"
505
+ >
506
+ {JSON.stringify(item.block, null, 2)}
507
+ </pre>
508
+ );
509
+ })}
510
+ </div>
511
+ );
512
+ }
513
+
514
+ // Fallback for other content types
515
+ return (
516
+ <pre className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all">
517
+ {JSON.stringify(content, null, 2)}
518
+ </pre>
519
+ );
520
+ };
521
+
522
+ return (
523
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
524
+ <div className="p-2 border-b border-border">
525
+ <h3 className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">
526
+ {title}
527
+ </h3>
528
+ </div>
529
+ <div className="p-3 flex flex-col gap-3">
530
+ {(() => {
531
+ // Group tool_use (from AI messages) with tool results (from tool messages)
532
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
533
+ const processedMessages: any[] = [];
534
+ const usedToolMessageIndices = new Set<number>();
535
+
536
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
537
+ messages.forEach((message: any, msgIndex: number) => {
538
+ const role = message.role || "unknown";
539
+
540
+ // Handle AI messages - check for tool_use blocks
541
+ if (role === "ai") {
542
+ const content = message.content;
543
+ let parsedContent = content;
544
+
545
+ if (
546
+ typeof content === "string" &&
547
+ content.trim().startsWith("[")
548
+ ) {
549
+ try {
550
+ parsedContent = JSON.parse(content);
551
+ } catch {}
552
+ }
553
+
554
+ if (Array.isArray(parsedContent)) {
555
+ // Extract tool_use blocks
556
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
557
+ const toolUses: any[] = [];
558
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic OTEL content
559
+ const nonToolBlocks: any[] = [];
560
+
561
+ for (const block of parsedContent) {
562
+ if (block.type === "tool_use") {
563
+ toolUses.push(block);
564
+ } else {
565
+ nonToolBlocks.push(block);
566
+ }
567
+ }
568
+
569
+ // Add non-tool content if any
570
+ if (nonToolBlocks.length > 0) {
571
+ processedMessages.push({
572
+ type: "content",
573
+ role,
574
+ content: nonToolBlocks,
575
+ index: msgIndex,
576
+ });
577
+ }
578
+
579
+ // For each tool_use, find the matching tool message
580
+ for (const toolUse of toolUses) {
581
+ // Find next unused tool message
582
+ let foundToolMessage = false;
583
+ for (let i = msgIndex + 1; i < messages.length; i++) {
584
+ if (
585
+ messages[i].role === "tool" &&
586
+ !usedToolMessageIndices.has(i)
587
+ ) {
588
+ usedToolMessageIndices.add(i);
589
+ processedMessages.push({
590
+ type: "tool_call_with_result",
591
+ toolUse: toolUse,
592
+ toolResult: messages[i].content,
593
+ index: msgIndex,
594
+ });
595
+ foundToolMessage = true;
596
+ break;
597
+ }
598
+ }
599
+
600
+ // If no tool message found, show just the tool_use
601
+ if (!foundToolMessage) {
602
+ processedMessages.push({
603
+ type: "tool_use_only",
604
+ toolUse: toolUse,
605
+ index: msgIndex,
606
+ });
607
+ }
608
+ }
609
+ } else {
610
+ // Non-array content, render normally
611
+ processedMessages.push({
612
+ type: "content",
613
+ role,
614
+ content: parsedContent,
615
+ index: msgIndex,
616
+ });
617
+ }
618
+ } else if (role === "tool") {
619
+ // Skip if already matched, otherwise show as orphan result
620
+ if (!usedToolMessageIndices.has(msgIndex)) {
621
+ processedMessages.push({
622
+ type: "tool_result_only",
623
+ content: message.content,
624
+ index: msgIndex,
625
+ });
626
+ }
627
+ } else {
628
+ // Other roles (system, human, user, etc.)
629
+ processedMessages.push({
630
+ type: "content",
631
+ role,
632
+ content: message.content,
633
+ index: msgIndex,
634
+ });
635
+ }
636
+ });
637
+
638
+ // Render processed messages
639
+ return processedMessages.map((item, idx) => {
640
+ if (item.type === "tool_call_with_result") {
641
+ return (
642
+ <ToolCallWithResult
643
+ key={`${item.index}-${idx}`}
644
+ toolName={item.toolUse.name}
645
+ input={parseToolInput(item.toolUse.input)}
646
+ result={item.toolResult}
647
+ />
648
+ );
649
+ }
650
+
651
+ if (item.type === "tool_use_only") {
652
+ return (
653
+ <CollapsibleToolBlock
654
+ key={`${item.index}-${idx}`}
655
+ title="Tool Use"
656
+ subtitle={item.toolUse.name}
657
+ content={parseToolInput(item.toolUse.input)}
658
+ borderColor="border-purple-500"
659
+ bgColor="bg-purple-500/5"
660
+ badgeColor="text-purple-600"
661
+ />
662
+ );
663
+ }
664
+
665
+ if (item.type === "tool_result_only") {
666
+ return (
667
+ <CollapsibleToolBlock
668
+ key={`${item.index}-${idx}`}
669
+ title="Tool Result"
670
+ subtitle=""
671
+ content={item.content}
672
+ borderColor="border-green-500"
673
+ bgColor="bg-green-500/5"
674
+ badgeColor="text-green-600"
675
+ />
676
+ );
677
+ }
678
+
679
+ // Regular content
680
+ return (
681
+ <div
682
+ key={`${item.index}-${idx}`}
683
+ className="border border-border rounded p-2 bg-background"
684
+ >
685
+ <div className="flex items-center gap-2 mb-2">
686
+ <span className="text-[10px] font-semibold text-muted-foreground uppercase">
687
+ {item.role}
688
+ </span>
689
+ </div>
690
+ {renderContent(item.content)}
691
+ </div>
692
+ );
693
+ });
694
+ })()}
695
+ </div>
696
+ </div>
697
+ );
698
+ } catch {
699
+ // If parse fails, fall back to JSON display
700
+ return <JsonSection title={title} data={data} />;
701
+ }
702
+ };
703
+
704
+ function SpanDetails({
705
+ span,
706
+ allSpans,
707
+ }: {
708
+ span: SpanNode;
709
+ allSpans: SpanNode[];
710
+ }) {
711
+ const [activeTab, setActiveTab] = useState<"run" | "logs" | "feedback">(
712
+ "run",
713
+ );
714
+
715
+ const spanType = detectSpanType(span);
716
+ const parentSpan = findParentSpan(span, allSpans);
717
+ const attrs = parseAttributes(span.attributes);
718
+ const resourceAttrs = parseAttributes(span.resource_attributes);
719
+ const { toolCount, callCount } = countToolUsage(span);
720
+ const logCount = countLogs(span);
721
+ const tokenCount = countTokens(span);
722
+
723
+ return (
724
+ <div className="flex flex-col gap-4">
725
+ {/* Tabs */}
726
+ <div className="flex items-center gap-2">
727
+ <button
728
+ type="button"
729
+ onClick={() => setActiveTab("run")}
730
+ className={cn(
731
+ "px-3 py-1 text-sm font-medium rounded-lg transition-colors",
732
+ activeTab === "run"
733
+ ? "bg-muted text-foreground"
734
+ : "text-muted-foreground hover:text-foreground",
735
+ )}
736
+ >
737
+ Run
738
+ </button>
739
+ <button
740
+ type="button"
741
+ onClick={() => setActiveTab("logs")}
742
+ className={cn(
743
+ "px-3 py-1 text-sm font-medium rounded-lg transition-colors",
744
+ activeTab === "logs"
745
+ ? "bg-muted text-foreground"
746
+ : "text-muted-foreground hover:text-foreground",
747
+ )}
748
+ >
749
+ Logs
750
+ </button>
751
+ <button
752
+ type="button"
753
+ onClick={() => setActiveTab("feedback")}
754
+ className={cn(
755
+ "px-3 py-1 text-sm font-medium rounded-lg transition-colors",
756
+ activeTab === "feedback"
757
+ ? "bg-muted text-foreground"
758
+ : "text-muted-foreground hover:text-foreground",
759
+ )}
760
+ >
761
+ Feedback
762
+ </button>
763
+ </div>
764
+
765
+ {/* Content */}
766
+ {activeTab === "run" && (
767
+ <div className="flex flex-col gap-4">
768
+ {/* Details section */}
769
+ <div className="flex flex-col">
770
+ {/* Parent Span */}
771
+ {parentSpan && (
772
+ <div className="border-t border-border flex items-center gap-6 h-9">
773
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
774
+ Parent Span
775
+ </span>
776
+ <div className="flex items-center gap-2 flex-1 overflow-hidden">
777
+ <SpanIcon span={parentSpan} className="size-5 shrink-0" />
778
+ <span className="text-sm text-foreground truncate">
779
+ {getDisplayName(parentSpan)}
780
+ </span>
781
+ </div>
782
+ </div>
783
+ )}
784
+
785
+ {/* Span ID */}
786
+ <div className="border-t border-border flex items-center gap-6 h-9">
787
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
788
+ Span ID
789
+ </span>
790
+ <span className="text-sm text-foreground truncate flex-1">
791
+ {span.span_id}
792
+ </span>
793
+ </div>
794
+
795
+ {/* Duration */}
796
+ <div className="border-t border-border flex items-center gap-6 h-9">
797
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
798
+ Duration
799
+ </span>
800
+ <span className="text-sm text-foreground">
801
+ {formatDuration(span.durationMs)}
802
+ </span>
803
+ </div>
804
+
805
+ {/* Started */}
806
+ <div className="border-t border-border flex items-center gap-6 h-9">
807
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
808
+ Started
809
+ </span>
810
+ <span className="text-sm text-foreground">
811
+ {formatTimestamp(span.start_time_unix_nano)}
812
+ </span>
813
+ </div>
814
+
815
+ {/* Tokens */}
816
+ {tokenCount > 0 && (
817
+ <div className="border-t border-border flex items-center gap-6 h-9">
818
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
819
+ Tokens
820
+ </span>
821
+ <span className="text-sm text-foreground">
822
+ {tokenCount.toLocaleString()}
823
+ </span>
824
+ </div>
825
+ )}
826
+
827
+ {/* Tool Usage - only show for parent spans, not individual tool calls */}
828
+ {callCount > 0 &&
829
+ spanType !== "tool_call" &&
830
+ spanType !== "subagent" && (
831
+ <div className="border-t border-border flex items-center gap-6 h-9">
832
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
833
+ Tool Usage
834
+ </span>
835
+ <span className="text-sm text-foreground">
836
+ {toolCount} {toolCount === 1 ? "tool" : "tools"},{" "}
837
+ {callCount} tool {callCount === 1 ? "call" : "calls"}
838
+ </span>
839
+ </div>
840
+ )}
841
+
842
+ {/* Logs */}
843
+ {logCount > 0 && (
844
+ <div className="border-t border-border flex items-center gap-6 h-9">
845
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
846
+ Logs
847
+ </span>
848
+ <span className="text-sm text-foreground">{logCount}</span>
849
+ </div>
850
+ )}
851
+ </div>
852
+
853
+ {/* Tool Call Input/Output - only show for tool_call and subagent spans */}
854
+ {(spanType === "tool_call" || spanType === "subagent") && (
855
+ <>
856
+ <JsonSection title="Input" data={attrs["tool.input"]} />
857
+ <JsonSection title="Output" data={attrs["tool.output"]} />
858
+ </>
859
+ )}
860
+
861
+ {/* Chat Messages - only show for chat spans */}
862
+ {spanType === "chat" && (
863
+ <>
864
+ <MessageSection
865
+ title="Input Messages"
866
+ data={attrs["gen_ai.input.messages"]}
867
+ />
868
+ <MessageSection
869
+ title="Output Messages"
870
+ data={attrs["gen_ai.output.messages"]}
871
+ />
872
+ </>
873
+ )}
874
+
875
+ {/* Attributes */}
876
+ {(() => {
877
+ // Filter out span-specific keys
878
+ let keysToExclude: string[] = [];
879
+
880
+ if (spanType === "tool_call" || spanType === "subagent") {
881
+ keysToExclude = ["tool.name", "tool.input", "tool.output"];
882
+ } else if (spanType === "chat") {
883
+ keysToExclude = [
884
+ "gen_ai.input.messages",
885
+ "gen_ai.output.messages",
886
+ ];
887
+ }
888
+
889
+ const filteredAttrs = { ...attrs };
890
+ for (const key of keysToExclude) {
891
+ delete filteredAttrs[key];
892
+ }
893
+
894
+ // Only show if there are attributes remaining
895
+ return Object.keys(filteredAttrs).length > 0 ? (
896
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
897
+ <div className="p-2 border-b border-border">
898
+ <h3 className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">
899
+ Attributes
900
+ </h3>
901
+ </div>
902
+ <div className="p-3">
903
+ <pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-all">
904
+ {JSON.stringify(filteredAttrs, null, 2)}
905
+ </pre>
906
+ </div>
907
+ </div>
908
+ ) : null;
909
+ })()}
910
+
911
+ {/* Resource */}
912
+ {resourceAttrs && Object.keys(resourceAttrs).length > 0 && (
913
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
914
+ <div className="p-2 border-b border-border">
915
+ <h3 className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">
916
+ Resource
917
+ </h3>
918
+ </div>
919
+ <div className="p-3">
920
+ <pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-all">
921
+ {JSON.stringify(resourceAttrs, null, 2)}
922
+ </pre>
923
+ </div>
924
+ </div>
925
+ )}
926
+ </div>
927
+ )}
928
+
929
+ {activeTab === "logs" && (
930
+ <div className="text-sm text-muted-foreground">
931
+ Logs view - Coming soon
932
+ </div>
933
+ )}
934
+
935
+ {activeTab === "feedback" && (
936
+ <div className="text-sm text-muted-foreground">
937
+ Feedback view - Coming soon
938
+ </div>
939
+ )}
940
+ </div>
941
+ );
942
+ }
943
+
944
+ function LogDetails({ log }: { log: Log }) {
945
+ const attrs = parseAttributes(log.attributes);
946
+ const resourceAttrs = parseAttributes(log.resource_attributes);
947
+
948
+ const severityColor =
949
+ log.severity_number >= 17
950
+ ? "text-red-500"
951
+ : log.severity_number >= 13
952
+ ? "text-yellow-500"
953
+ : "text-muted-foreground";
954
+
955
+ return (
956
+ <div className="flex flex-col gap-4">
957
+ {/* Details section */}
958
+ <div className="flex flex-col">
959
+ {/* Timestamp */}
960
+ <div className="border-t border-border flex items-center gap-6 h-9">
961
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
962
+ Timestamp
963
+ </span>
964
+ <span className="text-sm text-foreground">
965
+ {formatTimestamp(log.timestamp_unix_nano)}
966
+ </span>
967
+ </div>
968
+
969
+ {/* Severity */}
970
+ <div className="border-t border-border flex items-center gap-6 h-9">
971
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
972
+ Severity
973
+ </span>
974
+ <span className={cn("text-sm font-medium", severityColor)}>
975
+ {log.severity_text || `Level ${log.severity_number}`}
976
+ </span>
977
+ </div>
978
+
979
+ {/* Trace ID */}
980
+ {log.trace_id && (
981
+ <div className="border-t border-border flex items-center gap-6 h-9">
982
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
983
+ Trace ID
984
+ </span>
985
+ <span className="text-sm text-foreground truncate flex-1 font-mono">
986
+ {log.trace_id}
987
+ </span>
988
+ </div>
989
+ )}
990
+
991
+ {/* Span ID */}
992
+ {log.span_id && (
993
+ <div className="border-t border-border flex items-center gap-6 h-9">
994
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
995
+ Span ID
996
+ </span>
997
+ <span className="text-sm text-foreground truncate flex-1 font-mono">
998
+ {log.span_id}
999
+ </span>
1000
+ </div>
1001
+ )}
1002
+ </div>
1003
+
1004
+ {/* Body */}
1005
+ {log.body && (
1006
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
1007
+ <div className="p-2 border-b border-border">
1008
+ <h3 className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">
1009
+ Message
1010
+ </h3>
1011
+ </div>
1012
+ <div className="p-3">
1013
+ <pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-all">
1014
+ {log.body}
1015
+ </pre>
1016
+ </div>
1017
+ </div>
1018
+ )}
1019
+
1020
+ {/* Attributes */}
1021
+ {attrs && Object.keys(attrs).length > 0 && (
1022
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
1023
+ <div className="p-2 border-b border-border">
1024
+ <h3 className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">
1025
+ Attributes
1026
+ </h3>
1027
+ </div>
1028
+ <div className="p-3">
1029
+ <pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-all">
1030
+ {JSON.stringify(attrs, null, 2)}
1031
+ </pre>
1032
+ </div>
1033
+ </div>
1034
+ )}
1035
+
1036
+ {/* Resource Attributes */}
1037
+ {resourceAttrs && Object.keys(resourceAttrs).length > 0 && (
1038
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
1039
+ <div className="p-2 border-b border-border">
1040
+ <h3 className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">
1041
+ Resource
1042
+ </h3>
1043
+ </div>
1044
+ <div className="p-3">
1045
+ <pre className="text-xs font-mono text-foreground whitespace-pre-wrap break-all">
1046
+ {JSON.stringify(resourceAttrs, null, 2)}
1047
+ </pre>
1048
+ </div>
1049
+ </div>
1050
+ )}
1051
+ </div>
1052
+ );
1053
+ }
1054
+
1055
+ export function DiagnosticDetailsPanel({
1056
+ span,
1057
+ log,
1058
+ onClose,
1059
+ allSpans,
1060
+ }: DiagnosticDetailsPanelProps) {
1061
+ // Determine which type of detail to show
1062
+ const hasSelection = span !== null || log !== null;
1063
+
1064
+ if (!hasSelection) return null;
1065
+
1066
+ const title = span ? getDisplayName(span) : "Log Details";
1067
+
1068
+ return (
1069
+ <div className="flex flex-col h-full border-l border-border bg-background">
1070
+ {/* Header */}
1071
+ <div className="h-12 px-4 flex items-center justify-between border-b border-border bg-muted/30 shrink-0">
1072
+ <div className="flex items-center gap-2 min-w-0 flex-1">
1073
+ {span && <SpanIcon span={span} className="size-5 shrink-0" />}
1074
+ <h2 className="text-sm font-medium text-foreground truncate">
1075
+ {title}
1076
+ </h2>
1077
+ </div>
1078
+ <button
1079
+ type="button"
1080
+ onClick={onClose}
1081
+ className="size-7 flex items-center justify-center rounded hover:bg-muted transition-colors shrink-0"
1082
+ aria-label="Close"
1083
+ >
1084
+ <X className="size-4 text-muted-foreground" />
1085
+ </button>
1086
+ </div>
1087
+
1088
+ {/* Content */}
1089
+ <div className="flex-1 overflow-y-auto p-4">
1090
+ {span && <SpanDetails span={span} allSpans={allSpans} />}
1091
+ {log && !span && <LogDetails log={log} />}
1092
+ </div>
1093
+ </div>
1094
+ );
1095
+ }