@townco/debugger 0.1.67 → 0.1.69

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,477 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import {
4
+ formatDuration,
5
+ getSpanDurationMs,
6
+ getSpanTokens,
7
+ type Segment,
8
+ } from "../lib/segmentation";
9
+ import { detectSpanType } from "../lib/spanTypeDetector";
10
+ import type { Log, Span, SpanNode } from "../types";
11
+ import { SpanIcon } from "./SpanIcon";
12
+
13
+ interface SpansLogsListProps {
14
+ segments: Segment[];
15
+ viewMode: "spans" | "logs";
16
+ highlightedSpanId: string | null;
17
+ /** Set of span IDs that should be highlighted (from selected conversation item) */
18
+ selectedSpanIds: Set<string>;
19
+ /** Set of log IDs that should be highlighted (from selected conversation item) */
20
+ selectedLogIds: Set<number>;
21
+ onSpanHover: (spanId: string | null) => void;
22
+ onSpanClick: (span: SpanNode) => void;
23
+ onLogClick: (log: Log) => void;
24
+ /** Ref to scroll to a specific span */
25
+ scrollToSpanId: string | null;
26
+ /** Ref to scroll to a specific log */
27
+ scrollToLogId: number | null;
28
+ /** The currently selected item ID (span_id or log id) for highlighting */
29
+ selectedItemId: string | null;
30
+ }
31
+
32
+ function parseAttributes(attrs: string | null): Record<string, unknown> {
33
+ if (!attrs) return {};
34
+ try {
35
+ return JSON.parse(attrs);
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Get a short identifier from a span_id (last 4 characters)
43
+ */
44
+ function getShortId(spanId: string): string {
45
+ return spanId.slice(-4);
46
+ }
47
+
48
+ /**
49
+ * Check if a span is a subagent (Task) span
50
+ */
51
+ function isSubagentSpan(span: Span): boolean {
52
+ if (span.name !== "agent.tool_call") return false;
53
+ const attrs = parseAttributes(span.attributes);
54
+ const toolName = attrs["tool.name"];
55
+
56
+ if (typeof toolName !== "string") return false;
57
+
58
+ // Check for Task or any tool name containing "subagent" (case-insensitive)
59
+ const isSubagent =
60
+ toolName === "Task" || toolName.toLowerCase().includes("subagent");
61
+
62
+ return isSubagent;
63
+ }
64
+
65
+ interface SubagentInfo {
66
+ spanId: string;
67
+ shortId: string;
68
+ }
69
+
70
+ /**
71
+ * Build a map of span_id -> subagent info for all spans that are descendants of a Task span
72
+ */
73
+ function buildSubagentMap(spans: Span[]): Map<string, SubagentInfo> {
74
+ const subagentMap = new Map<string, SubagentInfo>();
75
+
76
+ // Find all Task (subagent) spans
77
+ const taskSpans = spans.filter(isSubagentSpan);
78
+
79
+ // For each Task span, mark it and all its descendants
80
+ for (const taskSpan of taskSpans) {
81
+ const subagentInfo: SubagentInfo = {
82
+ spanId: taskSpan.span_id,
83
+ shortId: getShortId(taskSpan.span_id),
84
+ };
85
+
86
+ // Mark the Task span itself
87
+ subagentMap.set(taskSpan.span_id, subagentInfo);
88
+
89
+ // Find all descendants using BFS
90
+ const queue: string[] = [taskSpan.span_id];
91
+ while (queue.length > 0) {
92
+ const currentId = queue.shift();
93
+ if (!currentId) break;
94
+ // Find children of current span
95
+ for (const span of spans) {
96
+ if (span.parent_span_id === currentId) {
97
+ subagentMap.set(span.span_id, subagentInfo);
98
+ queue.push(span.span_id);
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ return subagentMap;
105
+ }
106
+
107
+ function getSpanDisplayName(span: Span, subagentInfo?: SubagentInfo): string {
108
+ const attrs = parseAttributes(span.attributes);
109
+ const spanType = detectSpanType(span);
110
+
111
+ let baseName = span.name;
112
+
113
+ if (spanType === "subagent") {
114
+ // Task/Subagent span - include the short span ID for identification
115
+ const shortId = getShortId(span.span_id);
116
+ try {
117
+ const toolInput = attrs["tool.input"];
118
+ const input =
119
+ typeof toolInput === "string" ? JSON.parse(toolInput) : toolInput;
120
+ if (input?.agentName) {
121
+ baseName = `Subagent .${shortId} (${input.agentName})`;
122
+ } else {
123
+ baseName = `Subagent .${shortId}`;
124
+ }
125
+ } catch {
126
+ baseName = `Subagent .${shortId}`;
127
+ }
128
+ } else if (spanType === "tool_call") {
129
+ const toolName = attrs["tool.name"] as string;
130
+ baseName = toolName || span.name;
131
+ } else if (spanType === "chat") {
132
+ baseName = (attrs["gen_ai.request.model"] as string) || span.name;
133
+ }
134
+
135
+ // If this span is part of a subagent (but not the Task span itself), prefix with subagent ID
136
+ if (subagentInfo && !isSubagentSpan(span)) {
137
+ return `[.${subagentInfo.shortId}] ${baseName}`;
138
+ }
139
+
140
+ return baseName;
141
+ }
142
+
143
+ /**
144
+ * Build a hierarchical span tree from flat spans
145
+ */
146
+ function buildSpanTree(spans: Span[]): SpanNode[] {
147
+ const spanMap = new Map<string, SpanNode>();
148
+ const roots: SpanNode[] = [];
149
+
150
+ // First pass: create SpanNode objects
151
+ for (const span of spans) {
152
+ spanMap.set(span.span_id, {
153
+ ...span,
154
+ children: [],
155
+ depth: 0,
156
+ durationMs: getSpanDurationMs(span),
157
+ });
158
+ }
159
+
160
+ // Second pass: build the tree
161
+ for (const span of spans) {
162
+ const node = spanMap.get(span.span_id);
163
+ if (!node) continue;
164
+
165
+ if (span.parent_span_id && spanMap.has(span.parent_span_id)) {
166
+ const parent = spanMap.get(span.parent_span_id);
167
+ if (parent) {
168
+ parent.children.push(node);
169
+ }
170
+ } else {
171
+ roots.push(node);
172
+ }
173
+ }
174
+
175
+ // Third pass: set depths
176
+ const setDepth = (nodes: SpanNode[], depth: number) => {
177
+ for (const node of nodes) {
178
+ node.depth = depth;
179
+ setDepth(node.children, depth + 1);
180
+ }
181
+ };
182
+ setDepth(roots, 0);
183
+
184
+ return roots;
185
+ }
186
+
187
+ /**
188
+ * Flatten span tree for rendering
189
+ */
190
+ function flattenSpanTree(
191
+ nodes: SpanNode[],
192
+ ): Array<{ node: SpanNode; depth: number }> {
193
+ const result: Array<{ node: SpanNode; depth: number }> = [];
194
+ for (const node of nodes) {
195
+ result.push({ node, depth: node.depth });
196
+ if (node.children.length > 0) {
197
+ result.push(...flattenSpanTree(node.children));
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+
203
+ interface SpanRowProps {
204
+ span: SpanNode;
205
+ depth: number;
206
+ isHighlighted: boolean;
207
+ isSelected: boolean;
208
+ onHover: (spanId: string | null) => void;
209
+ onClick: (span: SpanNode) => void;
210
+ spanRef?: (el: HTMLButtonElement | null) => void;
211
+ subagentInfo?: SubagentInfo | undefined;
212
+ }
213
+
214
+ function SpanRow({
215
+ span,
216
+ depth,
217
+ isHighlighted: _isHighlighted,
218
+ isSelected,
219
+ onHover,
220
+ onClick,
221
+ spanRef,
222
+ subagentInfo,
223
+ }: SpanRowProps) {
224
+ const displayName = getSpanDisplayName(span, subagentInfo);
225
+ const durationMs = getSpanDurationMs(span);
226
+ const tokens = getSpanTokens(span);
227
+
228
+ return (
229
+ <button
230
+ ref={spanRef}
231
+ type="button"
232
+ className={cn(
233
+ "flex items-center gap-2 h-[38px] w-full px-4 py-2 text-left border-b border-border hover:bg-accent",
234
+ isSelected && "bg-blue-50 hover:bg-blue-100/70",
235
+ )}
236
+ style={{ paddingLeft: `${16 + depth * 20}px` }}
237
+ onMouseEnter={() => onHover(span.span_id)}
238
+ onMouseLeave={() => onHover(null)}
239
+ onClick={() => onClick(span)}
240
+ >
241
+ {/* Span icon */}
242
+ <SpanIcon span={span} />
243
+
244
+ {/* Span name */}
245
+ <span className="flex-1 text-sm truncate">{displayName}</span>
246
+
247
+ {/* Duration */}
248
+ <span className="w-[120px] text-xs text-muted-foreground shrink-0">
249
+ {formatDuration(durationMs)}
250
+ </span>
251
+
252
+ {/* Tokens */}
253
+ <span className="w-[120px] text-xs text-muted-foreground shrink-0">
254
+ {tokens > 0 ? tokens.toLocaleString() : "-"}
255
+ </span>
256
+ </button>
257
+ );
258
+ }
259
+
260
+ interface LogRowProps {
261
+ log: Log;
262
+ isHighlighted: boolean;
263
+ isSelected: boolean;
264
+ onClick: (log: Log) => void;
265
+ logRef?: (el: HTMLButtonElement | null) => void;
266
+ }
267
+
268
+ function LogRow({
269
+ log,
270
+ isHighlighted,
271
+ isSelected,
272
+ onClick,
273
+ logRef,
274
+ }: LogRowProps) {
275
+ const timestamp = new Date(log.timestamp_unix_nano / 1_000_000);
276
+ const timeStr = timestamp.toLocaleTimeString("en-US", {
277
+ hour: "2-digit",
278
+ minute: "2-digit",
279
+ second: "2-digit",
280
+ hour12: false,
281
+ });
282
+
283
+ const severityColor =
284
+ log.severity_number >= 17
285
+ ? "text-red-500" // ERROR+
286
+ : log.severity_number >= 13
287
+ ? "text-yellow-500" // WARN
288
+ : "text-muted-foreground"; // INFO and below
289
+
290
+ return (
291
+ <button
292
+ ref={logRef}
293
+ type="button"
294
+ onClick={() => onClick(log)}
295
+ className={cn(
296
+ "flex items-center gap-2 h-[38px] w-full px-4 py-2 border-b border-border transition-colors text-left hover:bg-muted/50",
297
+ isHighlighted && "bg-accent",
298
+ isSelected && "bg-blue-50",
299
+ )}
300
+ >
301
+ {/* Timestamp */}
302
+ <span className="w-[100px] text-xs text-muted-foreground font-mono shrink-0">
303
+ {timeStr}
304
+ </span>
305
+
306
+ {/* Severity */}
307
+ <span
308
+ className={cn("w-[60px] text-xs font-medium shrink-0", severityColor)}
309
+ >
310
+ {log.severity_text || `LVL${log.severity_number}`}
311
+ </span>
312
+
313
+ {/* Body */}
314
+ <span className="flex-1 text-sm truncate">{log.body || "-"}</span>
315
+ </button>
316
+ );
317
+ }
318
+
319
+ export function SpansLogsList({
320
+ segments,
321
+ viewMode,
322
+ highlightedSpanId,
323
+ selectedSpanIds,
324
+ selectedLogIds,
325
+ onSpanHover,
326
+ onSpanClick,
327
+ onLogClick,
328
+ scrollToSpanId,
329
+ scrollToLogId,
330
+ selectedItemId,
331
+ }: SpansLogsListProps) {
332
+ const containerRef = useRef<HTMLDivElement>(null);
333
+ const spanRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
334
+ const logRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
335
+
336
+ // Scroll to selected span
337
+ useEffect(() => {
338
+ if (scrollToSpanId && spanRefs.current.has(scrollToSpanId)) {
339
+ const element = spanRefs.current.get(scrollToSpanId);
340
+ element?.scrollIntoView({ behavior: "smooth", block: "center" });
341
+ }
342
+ }, [scrollToSpanId]);
343
+
344
+ // Scroll to selected log
345
+ useEffect(() => {
346
+ if (scrollToLogId !== null && logRefs.current.has(scrollToLogId)) {
347
+ const element = logRefs.current.get(scrollToLogId);
348
+ element?.scrollIntoView({ behavior: "smooth", block: "center" });
349
+ }
350
+ }, [scrollToLogId]);
351
+
352
+ const setSpanRef = (spanId: string, el: HTMLButtonElement | null) => {
353
+ if (el) {
354
+ spanRefs.current.set(spanId, el);
355
+ } else {
356
+ spanRefs.current.delete(spanId);
357
+ }
358
+ };
359
+
360
+ const setLogRef = (logId: number, el: HTMLButtonElement | null) => {
361
+ if (el) {
362
+ logRefs.current.set(logId, el);
363
+ } else {
364
+ logRefs.current.delete(logId);
365
+ }
366
+ };
367
+
368
+ // Get all spans and logs from all segments
369
+ const getVisibleData = () => {
370
+ const allSpans: Span[] = [];
371
+ const allLogs: Log[] = [];
372
+ for (const segment of segments) {
373
+ allSpans.push(...segment.spans);
374
+ allLogs.push(...segment.logs);
375
+ }
376
+ return { spans: allSpans, logs: allLogs };
377
+ };
378
+
379
+ const { spans, logs } = getVisibleData();
380
+
381
+ if (viewMode === "logs") {
382
+ if (logs.length === 0) {
383
+ return (
384
+ <div className="flex-1 flex items-center justify-center text-muted-foreground bg-muted/30">
385
+ No logs for this segment
386
+ </div>
387
+ );
388
+ }
389
+
390
+ return (
391
+ <div ref={containerRef} className="flex-1 overflow-y-auto bg-muted/30">
392
+ {/* Header */}
393
+ <div className="flex items-center gap-2 h-[38px] px-4 py-2 border-b border-border bg-muted/50 sticky top-0">
394
+ <span className="w-[100px] text-xs font-medium text-muted-foreground shrink-0">
395
+ Time
396
+ </span>
397
+ <span className="w-[60px] text-xs font-medium text-muted-foreground shrink-0">
398
+ Level
399
+ </span>
400
+ <span className="flex-1 text-xs font-medium text-muted-foreground">
401
+ Message
402
+ </span>
403
+ </div>
404
+
405
+ {/* Log rows */}
406
+ {logs
407
+ .sort((a, b) => a.timestamp_unix_nano - b.timestamp_unix_nano)
408
+ .map((log) => (
409
+ <LogRow
410
+ key={`${log.trace_id}-${log.id}`}
411
+ log={log}
412
+ isHighlighted={selectedLogIds.has(log.id)}
413
+ isSelected={selectedItemId === String(log.id)}
414
+ onClick={onLogClick}
415
+ logRef={(el) => setLogRef(log.id, el)}
416
+ />
417
+ ))}
418
+ </div>
419
+ );
420
+ }
421
+
422
+ // Spans view
423
+ if (spans.length === 0) {
424
+ return (
425
+ <div className="flex-1 flex items-center justify-center text-muted-foreground bg-muted/30">
426
+ No spans for this segment
427
+ </div>
428
+ );
429
+ }
430
+
431
+ return (
432
+ <div ref={containerRef} className="flex-1 overflow-y-auto bg-muted/30">
433
+ {/* Header */}
434
+ <div className="flex items-center gap-2 h-[38px] px-4 py-2 border-b border-border bg-muted/50 sticky top-0">
435
+ <span className="flex-1 text-xs font-medium text-muted-foreground">
436
+ Process
437
+ </span>
438
+ <span className="w-[120px] text-xs font-medium text-muted-foreground shrink-0">
439
+ Duration
440
+ </span>
441
+ <span className="w-[120px] text-xs font-medium text-muted-foreground shrink-0">
442
+ Tokens
443
+ </span>
444
+ </div>
445
+
446
+ {/* Span rows grouped by segment */}
447
+ {segments.map((segment) => {
448
+ const segmentSpanTree = buildSpanTree(segment.spans);
449
+ const segmentFlatSpans = flattenSpanTree(segmentSpanTree);
450
+ const subagentMap = buildSubagentMap(segment.spans);
451
+
452
+ if (segmentFlatSpans.length === 0) return null;
453
+
454
+ return (
455
+ <div key={segment.id}>
456
+ {segmentFlatSpans.map(({ node, depth }) => (
457
+ <SpanRow
458
+ key={node.span_id}
459
+ span={node}
460
+ depth={depth}
461
+ isHighlighted={highlightedSpanId === node.span_id}
462
+ isSelected={
463
+ selectedSpanIds.has(node.span_id) ||
464
+ selectedItemId === node.span_id
465
+ }
466
+ onHover={onSpanHover}
467
+ onClick={onSpanClick}
468
+ spanRef={(el) => setSpanRef(node.span_id, el)}
469
+ subagentInfo={subagentMap.get(node.span_id)}
470
+ />
471
+ ))}
472
+ </div>
473
+ );
474
+ })}
475
+ </div>
476
+ );
477
+ }
@@ -238,7 +238,6 @@ export function UnifiedTimeline({
238
238
  }
239
239
 
240
240
  const attrs = parseAttributes(span.attributes);
241
- const spanType = detectSpanType(span);
242
241
 
243
242
  let displayName = span.name;
244
243
  if (span.name === "agent.tool_call") {
@@ -287,7 +286,7 @@ export function UnifiedTimeline({
287
286
  }
288
287
  }}
289
288
  >
290
- <SpanIcon type={spanType} className="w-4 h-4 shrink-0" />
289
+ <SpanIcon span={span} className="w-4 h-4 shrink-0" />
291
290
  <span className="text-xs truncate">{displayName}</span>
292
291
  </div>
293
292
  );
@@ -0,0 +1,79 @@
1
+ import { ArrowDown, ArrowUp, Settings } from "lucide-react";
2
+ import { Button } from "./ui/button";
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "./ui/select";
10
+
11
+ export type ViewMode = "spans" | "logs" | "timeline";
12
+
13
+ interface ViewControlBarProps {
14
+ viewMode: ViewMode;
15
+ onViewModeChange: (mode: ViewMode) => void;
16
+ onScrollUp?: () => void;
17
+ onScrollDown?: () => void;
18
+ onSettings?: () => void;
19
+ }
20
+
21
+ export function ViewControlBar({
22
+ viewMode,
23
+ onViewModeChange,
24
+ onScrollUp,
25
+ onScrollDown,
26
+ onSettings,
27
+ }: ViewControlBarProps) {
28
+ return (
29
+ <div className="h-[60px] border-b border-border bg-muted/50 flex items-center justify-between px-4">
30
+ {/* Left side - View mode dropdown */}
31
+ <div className="flex items-center gap-4">
32
+ <Select
33
+ value={viewMode}
34
+ onValueChange={(value) => onViewModeChange(value as ViewMode)}
35
+ >
36
+ <SelectTrigger className="w-[160px] bg-background">
37
+ <SelectValue placeholder="Select view" />
38
+ </SelectTrigger>
39
+ <SelectContent>
40
+ <SelectItem value="spans">Spans</SelectItem>
41
+ <SelectItem value="logs">Logs</SelectItem>
42
+ <SelectItem value="timeline">Timeline</SelectItem>
43
+ </SelectContent>
44
+ </Select>
45
+ </div>
46
+
47
+ {/* Right side - Icon buttons */}
48
+ <div className="flex items-center gap-2">
49
+ <Button
50
+ variant="outline"
51
+ size="icon"
52
+ className="h-9 w-9 shadow-sm"
53
+ onClick={onScrollUp}
54
+ title="Scroll up"
55
+ >
56
+ <ArrowUp className="size-4" />
57
+ </Button>
58
+ <Button
59
+ variant="outline"
60
+ size="icon"
61
+ className="h-9 w-9 shadow-sm"
62
+ onClick={onScrollDown}
63
+ title="Scroll down"
64
+ >
65
+ <ArrowDown className="size-4" />
66
+ </Button>
67
+ <Button
68
+ variant="outline"
69
+ size="icon"
70
+ className="h-9 w-9 shadow-sm"
71
+ onClick={onSettings}
72
+ title="Settings"
73
+ >
74
+ <Settings className="size-4" />
75
+ </Button>
76
+ </div>
77
+ </div>
78
+ );
79
+ }