@townco/debugger 0.1.5 → 0.1.7

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,371 @@
1
+ import * as Dialog from "@radix-ui/react-dialog";
2
+ import { X } from "lucide-react";
3
+ import { useState } from "react";
4
+ import { cn } from "@/lib/utils";
5
+ import { detectSpanType } from "../lib/spanTypeDetector";
6
+ import type { SpanNode } from "../types";
7
+ import { SpanIcon } from "./SpanIcon";
8
+
9
+ interface SpanDetailsPanelProps {
10
+ span: SpanNode | 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
+ export function SpanDetailsPanel({
152
+ span,
153
+ onClose,
154
+ allSpans,
155
+ }: SpanDetailsPanelProps) {
156
+ const [activeTab, setActiveTab] = useState<"run" | "logs" | "feedback">(
157
+ "run",
158
+ );
159
+
160
+ if (!span) return null;
161
+
162
+ const spanType = detectSpanType(span);
163
+ const displayName = getDisplayName(span);
164
+ const parentSpan = findParentSpan(span, allSpans);
165
+ const attrs = parseAttributes(span.attributes);
166
+ const resourceAttrs = parseAttributes(span.resource_attributes);
167
+ const { toolCount, callCount } = countToolUsage(span);
168
+ const logCount = countLogs(span);
169
+ const tokenCount = countTokens(span);
170
+
171
+ return (
172
+ <Dialog.Root open={!!span} onOpenChange={(open) => !open && onClose()}>
173
+ <Dialog.Portal>
174
+ <Dialog.Overlay className="fixed inset-0 bg-black/30 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
175
+ <Dialog.Content className="fixed right-0 top-0 h-full w-[600px] bg-background shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right duration-200 overflow-y-auto border-l border-border">
176
+ <div className="flex flex-col gap-6 px-6 py-8">
177
+ {/* Header */}
178
+ <div className="flex items-start gap-2">
179
+ <SpanIcon type={spanType} className="size-6" />
180
+ <h2 className="flex-1 text-xl font-semibold text-foreground tracking-[-0.02em] leading-tight overflow-hidden text-ellipsis">
181
+ {displayName}
182
+ </h2>
183
+ <Dialog.Close asChild>
184
+ <button
185
+ className="size-6 flex items-center justify-center rounded hover:bg-muted transition-colors"
186
+ aria-label="Close"
187
+ >
188
+ <X className="size-4 text-muted-foreground" />
189
+ </button>
190
+ </Dialog.Close>
191
+ </div>
192
+
193
+ {/* Tabs */}
194
+ <div className="flex items-center gap-2">
195
+ <button
196
+ onClick={() => setActiveTab("run")}
197
+ className={cn(
198
+ "px-3 py-1 text-sm font-medium rounded-lg transition-colors",
199
+ activeTab === "run"
200
+ ? "bg-muted text-foreground"
201
+ : "text-muted-foreground hover:text-foreground",
202
+ )}
203
+ >
204
+ Run
205
+ </button>
206
+ <button
207
+ onClick={() => setActiveTab("logs")}
208
+ className={cn(
209
+ "px-3 py-1 text-sm font-medium rounded-lg transition-colors",
210
+ activeTab === "logs"
211
+ ? "bg-muted text-foreground"
212
+ : "text-muted-foreground hover:text-foreground",
213
+ )}
214
+ >
215
+ Logs
216
+ </button>
217
+ <button
218
+ onClick={() => setActiveTab("feedback")}
219
+ className={cn(
220
+ "px-3 py-1 text-sm font-medium rounded-lg transition-colors",
221
+ activeTab === "feedback"
222
+ ? "bg-muted text-foreground"
223
+ : "text-muted-foreground hover:text-foreground",
224
+ )}
225
+ >
226
+ Feedback
227
+ </button>
228
+ </div>
229
+
230
+ {/* Content */}
231
+ {activeTab === "run" && (
232
+ <div className="flex flex-col gap-4">
233
+ {/* Details section */}
234
+ <div className="flex flex-col">
235
+ {/* Parent Span */}
236
+ {parentSpan && (
237
+ <div className="border-t border-border flex items-center gap-6 h-9">
238
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
239
+ Parent Span
240
+ </span>
241
+ <div className="flex items-center gap-2 flex-1 overflow-hidden">
242
+ <SpanIcon
243
+ type={detectSpanType(parentSpan)}
244
+ className="size-5 shrink-0"
245
+ />
246
+ <span className="text-sm text-foreground truncate">
247
+ {getDisplayName(parentSpan)}
248
+ </span>
249
+ </div>
250
+ </div>
251
+ )}
252
+
253
+ {/* Span ID */}
254
+ <div className="border-t border-border flex items-center gap-6 h-9">
255
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
256
+ Span ID
257
+ </span>
258
+ <span className="text-sm text-foreground truncate flex-1">
259
+ {span.span_id}
260
+ </span>
261
+ </div>
262
+
263
+ {/* Duration */}
264
+ <div className="border-t border-border flex items-center gap-6 h-9">
265
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
266
+ Duration
267
+ </span>
268
+ <span className="text-sm text-foreground">
269
+ {formatDuration(span.durationMs)}
270
+ </span>
271
+ </div>
272
+
273
+ {/* Started */}
274
+ <div className="border-t border-border flex items-center gap-6 h-9">
275
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
276
+ Started
277
+ </span>
278
+ <span className="text-sm text-foreground">
279
+ {formatTimestamp(span.start_time_unix_nano)}
280
+ </span>
281
+ </div>
282
+
283
+ {/* Tokens */}
284
+ {tokenCount > 0 && (
285
+ <div className="border-t border-border flex items-center gap-6 h-9">
286
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
287
+ Tokens
288
+ </span>
289
+ <span className="text-sm text-foreground">
290
+ {tokenCount.toLocaleString()}
291
+ </span>
292
+ </div>
293
+ )}
294
+
295
+ {/* Tool Usage */}
296
+ {callCount > 0 && (
297
+ <div className="border-t border-border flex items-center gap-6 h-9">
298
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
299
+ Tool Usage
300
+ </span>
301
+ <span className="text-sm text-foreground">
302
+ {toolCount} {toolCount === 1 ? "tool" : "tools"},{" "}
303
+ {callCount} tool {callCount === 1 ? "call" : "calls"}
304
+ </span>
305
+ </div>
306
+ )}
307
+
308
+ {/* Logs */}
309
+ {logCount > 0 && (
310
+ <div className="border-t border-border flex items-center gap-6 h-9">
311
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
312
+ Logs
313
+ </span>
314
+ <span className="text-sm text-foreground">
315
+ {logCount}
316
+ </span>
317
+ </div>
318
+ )}
319
+ </div>
320
+
321
+ {/* Attributes */}
322
+ {attrs && Object.keys(attrs).length > 0 && (
323
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
324
+ <div className="p-3 border-b border-border">
325
+ <h3 className="text-[8px] font-bold text-muted-foreground uppercase tracking-wider">
326
+ Attributes
327
+ </h3>
328
+ </div>
329
+ <div className="p-3">
330
+ <pre className="text-[11px] font-mono text-foreground leading-[14px] whitespace-pre-wrap break-all">
331
+ {JSON.stringify(attrs, null, 2)}
332
+ </pre>
333
+ </div>
334
+ </div>
335
+ )}
336
+
337
+ {/* Resource */}
338
+ {resourceAttrs && Object.keys(resourceAttrs).length > 0 && (
339
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
340
+ <div className="p-3 border-b border-border">
341
+ <h3 className="text-[8px] font-bold text-muted-foreground uppercase tracking-wider">
342
+ Resource
343
+ </h3>
344
+ </div>
345
+ <div className="p-3">
346
+ <pre className="text-[11px] font-mono text-foreground leading-[14px] whitespace-pre-wrap break-all">
347
+ {JSON.stringify(resourceAttrs, null, 2)}
348
+ </pre>
349
+ </div>
350
+ </div>
351
+ )}
352
+ </div>
353
+ )}
354
+
355
+ {activeTab === "logs" && (
356
+ <div className="text-sm text-muted-foreground">
357
+ Logs view - Coming soon
358
+ </div>
359
+ )}
360
+
361
+ {activeTab === "feedback" && (
362
+ <div className="text-sm text-muted-foreground">
363
+ Feedback view - Coming soon
364
+ </div>
365
+ )}
366
+ </div>
367
+ </Dialog.Content>
368
+ </Dialog.Portal>
369
+ </Dialog.Root>
370
+ );
371
+ }
@@ -0,0 +1,42 @@
1
+ import { ListChecks, Search, Settings, Sparkles } from "lucide-react";
2
+ import { cn } from "@/lib/utils";
3
+ import type { SpanType } from "../lib/spanTypeDetector";
4
+
5
+ interface SpanIconProps {
6
+ type: SpanType;
7
+ className?: string;
8
+ }
9
+
10
+ export function SpanIcon({ type, className }: SpanIconProps) {
11
+ // Background color for the icon box
12
+ const bgColor =
13
+ type === "chat"
14
+ ? "bg-orange-400"
15
+ : type === "tool_call"
16
+ ? "bg-blue-500"
17
+ : type === "subagent"
18
+ ? "bg-purple-500"
19
+ : "bg-blue-500";
20
+
21
+ // Choose icon based on type
22
+ const Icon =
23
+ type === "chat"
24
+ ? Sparkles
25
+ : type === "tool_call"
26
+ ? ListChecks
27
+ : type === "subagent"
28
+ ? Search
29
+ : Settings;
30
+
31
+ return (
32
+ <div
33
+ className={cn(
34
+ "size-5 rounded flex items-center justify-center",
35
+ bgColor,
36
+ className,
37
+ )}
38
+ >
39
+ <Icon className="size-3 text-white" />
40
+ </div>
41
+ );
42
+ }