@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,585 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { ConversationPanel } from "../components/ConversationPanel";
3
+ import { DiagnosticDetailsPanel } from "../components/DiagnosticDetailsPanel";
4
+ import {
5
+ DiagnosticPanel,
6
+ type DiagnosticViewMode,
7
+ } from "../components/DiagnosticPanel";
8
+ import { NavBar } from "../components/NavBar";
9
+ import { buildSpanTree } from "../components/SpanTimeline";
10
+ import { segmentByTurn, type TraceData } from "../lib/segmentation";
11
+ import type { ConversationTrace, Log, Span, SpanNode } from "../types";
12
+
13
+ interface SessionLogsViewProps {
14
+ sessionId: string;
15
+ }
16
+
17
+ /**
18
+ * Build a hierarchical span tree from flat spans (duplicated from SpansLogsList for navigation)
19
+ */
20
+ function buildSpanTreeLocal(spans: Span[]): SpanNode[] {
21
+ const spanMap = new Map<string, SpanNode>();
22
+ const roots: SpanNode[] = [];
23
+
24
+ // First pass: create SpanNode objects
25
+ for (const span of spans) {
26
+ spanMap.set(span.span_id, {
27
+ ...span,
28
+ children: [],
29
+ depth: 0,
30
+ durationMs:
31
+ (span.end_time_unix_nano - span.start_time_unix_nano) / 1_000_000,
32
+ });
33
+ }
34
+
35
+ // Second pass: build the tree
36
+ for (const span of spans) {
37
+ const node = spanMap.get(span.span_id);
38
+ if (!node) continue;
39
+
40
+ if (span.parent_span_id && spanMap.has(span.parent_span_id)) {
41
+ const parent = spanMap.get(span.parent_span_id);
42
+ if (parent) {
43
+ parent.children.push(node);
44
+ }
45
+ } else {
46
+ roots.push(node);
47
+ }
48
+ }
49
+
50
+ // Third pass: set depths
51
+ const setDepth = (nodes: SpanNode[], depth: number) => {
52
+ for (const node of nodes) {
53
+ node.depth = depth;
54
+ setDepth(node.children, depth + 1);
55
+ }
56
+ };
57
+ setDepth(roots, 0);
58
+
59
+ return roots;
60
+ }
61
+
62
+ /**
63
+ * Flatten span tree for navigation (same order as rendered)
64
+ */
65
+ function flattenSpanTree(nodes: SpanNode[]): SpanNode[] {
66
+ const result: SpanNode[] = [];
67
+ for (const node of nodes) {
68
+ result.push(node);
69
+ if (node.children.length > 0) {
70
+ result.push(...flattenSpanTree(node.children));
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+
76
+ export function SessionLogsView({ sessionId }: SessionLogsViewProps) {
77
+ // Data state
78
+ const [traces, setTraces] = useState<ConversationTrace[]>([]);
79
+ const [traceDataMap, setTraceDataMap] = useState<Map<string, TraceData>>(
80
+ new Map(),
81
+ );
82
+ const [loading, setLoading] = useState(true);
83
+ const [error, setError] = useState<string | null>(null);
84
+
85
+ // UI state
86
+ const [viewMode, setViewMode] = useState<DiagnosticViewMode>("spans");
87
+ const [highlightedItemId, setHighlightedItemId] = useState<string | null>(
88
+ null,
89
+ );
90
+ const [highlightedSpanId, setHighlightedSpanId] = useState<string | null>(
91
+ null,
92
+ );
93
+
94
+ // Selected span/log for the details panel
95
+ const [selectedSpan, setSelectedSpan] = useState<SpanNode | null>(null);
96
+ const [selectedLog, setSelectedLog] = useState<Log | null>(null);
97
+
98
+ // State for conversation item <-> span linking
99
+ const [selectedConversationItemId, setSelectedConversationItemId] = useState<
100
+ string | null
101
+ >(null);
102
+ const [selectedConversationSpanId, setSelectedConversationSpanId] = useState<
103
+ string | null
104
+ >(null);
105
+
106
+ // Scroll targets - only set when selection comes from ConversationPanel
107
+ const [scrollToSpanId, setScrollToSpanId] = useState<string | null>(null);
108
+ const [scrollToLogId, setScrollToLogId] = useState<number | null>(null);
109
+
110
+ // Refs for scroll sync
111
+ const conversationRef = useRef<HTMLDivElement>(null);
112
+
113
+ // Fetch conversation traces
114
+ useEffect(() => {
115
+ const params = new URLSearchParams();
116
+ params.set("sessionId", sessionId);
117
+ fetch(`/api/session-conversation?${params}`)
118
+ .then((res) => {
119
+ if (!res.ok) throw new Error("Failed to fetch conversation");
120
+ return res.json();
121
+ })
122
+ .then((data: ConversationTrace[]) => {
123
+ setTraces(data);
124
+ setLoading(false);
125
+ })
126
+ .catch((err) => {
127
+ setError(err.message);
128
+ setLoading(false);
129
+ });
130
+ }, [sessionId]);
131
+
132
+ // Fetch detailed trace data
133
+ useEffect(() => {
134
+ if (traces.length === 0) return;
135
+
136
+ const fetchAllTraceData = async () => {
137
+ const dataMap = new Map<string, TraceData>();
138
+
139
+ await Promise.all(
140
+ traces.map(async (trace) => {
141
+ try {
142
+ const res = await fetch(`/api/traces/${trace.trace_id}`);
143
+ if (!res.ok) return;
144
+ const data = await res.json();
145
+ const updatedTrace: ConversationTrace = {
146
+ trace_id: trace.trace_id,
147
+ start_time_unix_nano: trace.start_time_unix_nano,
148
+ userInput: data.messages?.userInput ?? trace.userInput,
149
+ llmOutput: data.messages?.llmOutput ?? trace.llmOutput,
150
+ agentMessages:
151
+ data.messages?.agentMessages ?? trace.agentMessages,
152
+ };
153
+ dataMap.set(trace.trace_id, {
154
+ trace: updatedTrace,
155
+ spans: data.spans || [],
156
+ logs: data.logs || [],
157
+ });
158
+ } catch (err) {
159
+ console.error(`Failed to fetch trace ${trace.trace_id}:`, err);
160
+ }
161
+ }),
162
+ );
163
+
164
+ setTraceDataMap(dataMap);
165
+ };
166
+
167
+ fetchAllTraceData();
168
+ }, [traces]);
169
+
170
+ // Compute segments using per-turn mode
171
+ const segments = useMemo(() => {
172
+ if (traces.length === 0 || traceDataMap.size === 0) return [];
173
+ return segmentByTurn(traces, traceDataMap);
174
+ }, [traces, traceDataMap]);
175
+
176
+ // Combine all spans for timeline view and span tree
177
+ const allSpans = useMemo(() => {
178
+ const spans: Array<Span & { traceId: string; turnIndex: number }> = [];
179
+ traces.forEach((trace, index) => {
180
+ const data = traceDataMap.get(trace.trace_id);
181
+ if (data) {
182
+ data.spans.forEach((span) => {
183
+ spans.push({
184
+ ...span,
185
+ traceId: trace.trace_id,
186
+ turnIndex: index,
187
+ });
188
+ });
189
+ }
190
+ });
191
+ return spans;
192
+ }, [traces, traceDataMap]);
193
+
194
+ // All logs combined
195
+ const allLogs = useMemo(() => {
196
+ const logs: Log[] = [];
197
+ for (const segment of segments) {
198
+ logs.push(...segment.logs);
199
+ }
200
+ return logs.sort((a, b) => a.timestamp_unix_nano - b.timestamp_unix_nano);
201
+ }, [segments]);
202
+
203
+ // Build span tree for the details panel
204
+ const spanTree = useMemo(() => {
205
+ return buildSpanTree(allSpans);
206
+ }, [allSpans]);
207
+
208
+ // Flattened span list for navigation (in render order)
209
+ const flattenedSpans = useMemo(() => {
210
+ const result: SpanNode[] = [];
211
+ for (const segment of segments) {
212
+ const segmentTree = buildSpanTreeLocal(segment.spans);
213
+ result.push(...flattenSpanTree(segmentTree));
214
+ }
215
+ return result;
216
+ }, [segments]);
217
+
218
+ // Compute the set of span IDs to highlight (selected span + all its children)
219
+ const selectedSpanIds = useMemo(() => {
220
+ const ids = new Set<string>();
221
+ if (!selectedConversationSpanId) return ids;
222
+
223
+ // Build a map of parent -> children
224
+ const childrenMap = new Map<string, string[]>();
225
+ for (const span of allSpans) {
226
+ if (span.parent_span_id) {
227
+ const children = childrenMap.get(span.parent_span_id) || [];
228
+ children.push(span.span_id);
229
+ childrenMap.set(span.parent_span_id, children);
230
+ }
231
+ }
232
+
233
+ // Add the target span
234
+ ids.add(selectedConversationSpanId);
235
+
236
+ // Recursively add all children
237
+ const addChildren = (spanId: string) => {
238
+ const children = childrenMap.get(spanId) || [];
239
+ for (const childId of children) {
240
+ ids.add(childId);
241
+ addChildren(childId);
242
+ }
243
+ };
244
+ addChildren(selectedConversationSpanId);
245
+
246
+ return ids;
247
+ }, [allSpans, selectedConversationSpanId]);
248
+
249
+ // Compute the set of log IDs that correspond to the selected conversation item
250
+ const selectedLogIds = useMemo(() => {
251
+ const ids = new Set<number>();
252
+ if (selectedSpanIds.size === 0) return ids;
253
+
254
+ // Find logs that belong to any of the selected spans
255
+ for (const log of allLogs) {
256
+ if (log.span_id && selectedSpanIds.has(log.span_id)) {
257
+ ids.add(log.id);
258
+ }
259
+ }
260
+
261
+ return ids;
262
+ }, [allLogs, selectedSpanIds]);
263
+
264
+ // Handle conversation item selection
265
+ const handleConversationItemSelect = useCallback(
266
+ (itemId: string | null, spanId: string | null) => {
267
+ if (spanId === null) {
268
+ // Deselecting
269
+ setSelectedConversationItemId(null);
270
+ setSelectedConversationSpanId(null);
271
+ setSelectedSpan(null);
272
+ setSelectedLog(null);
273
+ setScrollToSpanId(null);
274
+ setScrollToLogId(null);
275
+ } else {
276
+ setSelectedConversationItemId(itemId);
277
+ setSelectedConversationSpanId(spanId);
278
+
279
+ // Auto-open the first span in the details panel and scroll to it
280
+ const span = flattenedSpans.find((s) => s.span_id === spanId);
281
+ if (span) {
282
+ setSelectedSpan(span);
283
+ setSelectedLog(null);
284
+ // Only scroll when selection comes from ConversationPanel
285
+ setScrollToSpanId(spanId);
286
+ setScrollToLogId(null);
287
+ }
288
+ }
289
+ },
290
+ [flattenedSpans],
291
+ );
292
+
293
+ // Handle span click from DiagnosticPanel
294
+ const handleSpanClick = useCallback(
295
+ (span: SpanNode) => {
296
+ setSelectedSpan(span);
297
+ setSelectedLog(null);
298
+ // Clear scroll targets - no auto-scroll for direct clicks
299
+ setScrollToSpanId(null);
300
+ setScrollToLogId(null);
301
+
302
+ // Check if this span is in the highlighted set from conversation selection
303
+ if (selectedSpanIds.size > 0 && !selectedSpanIds.has(span.span_id)) {
304
+ // Clear conversation selection since user selected a different span
305
+ setSelectedConversationItemId(null);
306
+ setSelectedConversationSpanId(null);
307
+ }
308
+ },
309
+ [selectedSpanIds],
310
+ );
311
+
312
+ // Handle log click from DiagnosticPanel
313
+ const handleLogClick = useCallback(
314
+ (log: Log) => {
315
+ setSelectedLog(log);
316
+ setSelectedSpan(null);
317
+ // Clear scroll targets - no auto-scroll for direct clicks
318
+ setScrollToSpanId(null);
319
+ setScrollToLogId(null);
320
+
321
+ // Check if this log is in the highlighted set from conversation selection
322
+ if (selectedLogIds.size > 0 && !selectedLogIds.has(log.id)) {
323
+ // Clear conversation selection since user selected a different log
324
+ setSelectedConversationItemId(null);
325
+ setSelectedConversationSpanId(null);
326
+ }
327
+ },
328
+ [selectedLogIds],
329
+ );
330
+
331
+ // Handle closing the details panel
332
+ const handleCloseDetails = useCallback(() => {
333
+ setSelectedSpan(null);
334
+ setSelectedLog(null);
335
+ setScrollToSpanId(null);
336
+ setScrollToLogId(null);
337
+ }, []);
338
+
339
+ // Handle view mode change with selection sync
340
+ const handleViewModeChange = useCallback(
341
+ (newMode: DiagnosticViewMode) => {
342
+ setViewMode(newMode);
343
+
344
+ // Sync selection when switching views
345
+ if (newMode === "logs" && selectedSpan) {
346
+ // Switching to logs view - find a corresponding log
347
+ const correspondingLog = allLogs.find(
348
+ (log) =>
349
+ log.span_id === selectedSpan.span_id ||
350
+ (selectedSpanIds.size > 0 &&
351
+ log.span_id &&
352
+ selectedSpanIds.has(log.span_id)),
353
+ );
354
+ if (correspondingLog) {
355
+ setSelectedLog(correspondingLog);
356
+ setSelectedSpan(null);
357
+ // Scroll to the corresponding log in the new view
358
+ setScrollToLogId(correspondingLog.id);
359
+ setScrollToSpanId(null);
360
+ }
361
+ } else if (newMode === "spans" && selectedLog) {
362
+ // Switching to spans view - find a corresponding span
363
+ if (selectedLog.span_id) {
364
+ const correspondingSpan = flattenedSpans.find(
365
+ (s) => s.span_id === selectedLog.span_id,
366
+ );
367
+ if (correspondingSpan) {
368
+ setSelectedSpan(correspondingSpan);
369
+ setSelectedLog(null);
370
+ // Scroll to the corresponding span in the new view
371
+ setScrollToSpanId(correspondingSpan.span_id);
372
+ setScrollToLogId(null);
373
+ }
374
+ }
375
+ }
376
+ },
377
+ [selectedSpan, selectedLog, allLogs, flattenedSpans, selectedSpanIds],
378
+ );
379
+
380
+ // Handle keyboard navigation
381
+ const handleNavigate = useCallback(
382
+ (direction: "up" | "down") => {
383
+ if (viewMode === "spans") {
384
+ // Navigate spans
385
+ if (!selectedSpan) {
386
+ // No span selected, select first or last
387
+ if (flattenedSpans.length > 0) {
388
+ const span =
389
+ direction === "down"
390
+ ? flattenedSpans[0]
391
+ : flattenedSpans[flattenedSpans.length - 1];
392
+ if (span) {
393
+ setSelectedSpan(span);
394
+ setSelectedLog(null);
395
+ // Scroll to the newly selected span
396
+ setScrollToSpanId(span.span_id);
397
+ setScrollToLogId(null);
398
+ }
399
+ }
400
+ return;
401
+ }
402
+
403
+ const currentIndex = flattenedSpans.findIndex(
404
+ (s) => s.span_id === selectedSpan.span_id,
405
+ );
406
+ if (currentIndex === -1) return;
407
+
408
+ const newIndex =
409
+ direction === "down"
410
+ ? Math.min(currentIndex + 1, flattenedSpans.length - 1)
411
+ : Math.max(currentIndex - 1, 0);
412
+
413
+ if (newIndex !== currentIndex) {
414
+ const newSpan = flattenedSpans[newIndex];
415
+ if (newSpan) {
416
+ setSelectedSpan(newSpan);
417
+ setSelectedLog(null);
418
+ // Scroll to the newly selected span
419
+ setScrollToSpanId(newSpan.span_id);
420
+ setScrollToLogId(null);
421
+
422
+ // Check if leaving the highlighted set
423
+ if (
424
+ selectedSpanIds.size > 0 &&
425
+ !selectedSpanIds.has(newSpan.span_id)
426
+ ) {
427
+ setSelectedConversationItemId(null);
428
+ setSelectedConversationSpanId(null);
429
+ }
430
+ }
431
+ }
432
+ } else {
433
+ // Navigate logs
434
+ if (!selectedLog) {
435
+ // No log selected, select first or last
436
+ if (allLogs.length > 0) {
437
+ const log =
438
+ direction === "down" ? allLogs[0] : allLogs[allLogs.length - 1];
439
+ if (log) {
440
+ setSelectedLog(log);
441
+ setSelectedSpan(null);
442
+ // Scroll to the newly selected log
443
+ setScrollToLogId(log.id);
444
+ setScrollToSpanId(null);
445
+ }
446
+ }
447
+ return;
448
+ }
449
+
450
+ const currentIndex = allLogs.findIndex((l) => l.id === selectedLog.id);
451
+ if (currentIndex === -1) return;
452
+
453
+ const newIndex =
454
+ direction === "down"
455
+ ? Math.min(currentIndex + 1, allLogs.length - 1)
456
+ : Math.max(currentIndex - 1, 0);
457
+
458
+ if (newIndex !== currentIndex) {
459
+ const newLog = allLogs[newIndex];
460
+ if (newLog) {
461
+ setSelectedLog(newLog);
462
+ setSelectedSpan(null);
463
+ // Scroll to the newly selected log
464
+ setScrollToLogId(newLog.id);
465
+ setScrollToSpanId(null);
466
+
467
+ // Check if leaving the highlighted set
468
+ if (selectedLogIds.size > 0 && !selectedLogIds.has(newLog.id)) {
469
+ setSelectedConversationItemId(null);
470
+ setSelectedConversationSpanId(null);
471
+ }
472
+ }
473
+ }
474
+ }
475
+ },
476
+ [
477
+ viewMode,
478
+ selectedSpan,
479
+ selectedLog,
480
+ flattenedSpans,
481
+ allLogs,
482
+ selectedSpanIds,
483
+ selectedLogIds,
484
+ ],
485
+ );
486
+
487
+ // Handle Enter key to confirm selection (open details panel)
488
+ const handleEnterSelect = useCallback(() => {
489
+ // The current selection is already shown in the details panel
490
+ // This callback is here in case we need additional behavior on Enter
491
+ // Currently, selection already opens the details panel, so this is a no-op
492
+ }, []);
493
+
494
+ // Determine if details panel is open
495
+ const isDetailsPanelOpen = selectedSpan !== null || selectedLog !== null;
496
+
497
+ // Get the selected item ID for highlighting in the list
498
+ const selectedDiagnosticItemId =
499
+ selectedSpan?.span_id ?? (selectedLog ? String(selectedLog.id) : null);
500
+
501
+ if (loading) {
502
+ return (
503
+ <div className="h-screen flex flex-col bg-background">
504
+ <NavBar sessionId={sessionId} />
505
+ <div className="flex-1 flex items-center justify-center">
506
+ <div className="text-muted-foreground">Loading session...</div>
507
+ </div>
508
+ </div>
509
+ );
510
+ }
511
+
512
+ if (error) {
513
+ return (
514
+ <div className="h-screen flex flex-col bg-background">
515
+ <NavBar sessionId={sessionId} />
516
+ <div className="flex-1 flex items-center justify-center">
517
+ <div className="text-red-500">Error: {error}</div>
518
+ </div>
519
+ </div>
520
+ );
521
+ }
522
+
523
+ if (traces.length === 0) {
524
+ return (
525
+ <div className="h-screen flex flex-col bg-background">
526
+ <NavBar sessionId={sessionId} />
527
+ <div className="flex-1 flex items-center justify-center">
528
+ <div className="text-muted-foreground">No data for this session</div>
529
+ </div>
530
+ </div>
531
+ );
532
+ }
533
+
534
+ return (
535
+ <div className="h-screen flex flex-col bg-background">
536
+ <NavBar sessionId={sessionId} />
537
+
538
+ {/* Main content - 2 or 3 panel layout */}
539
+ <div className="flex-1 flex min-h-0">
540
+ {/* Left panel - Conversation */}
541
+ <div ref={conversationRef} className="flex-1 overflow-y-auto">
542
+ <ConversationPanel
543
+ segments={segments}
544
+ highlightedItemId={highlightedItemId}
545
+ onItemHover={setHighlightedItemId}
546
+ selectedItemId={selectedConversationItemId}
547
+ onItemSelect={handleConversationItemSelect}
548
+ />
549
+ </div>
550
+
551
+ {/* Middle panel - Diagnostic (Spans/Logs) */}
552
+ <div className="flex-1 min-h-0">
553
+ <DiagnosticPanel
554
+ segments={segments}
555
+ viewMode={viewMode}
556
+ onViewModeChange={handleViewModeChange}
557
+ highlightedSpanId={highlightedSpanId}
558
+ selectedSpanIds={selectedSpanIds}
559
+ selectedLogIds={selectedLogIds}
560
+ onSpanHover={setHighlightedSpanId}
561
+ onSpanClick={handleSpanClick}
562
+ onLogClick={handleLogClick}
563
+ scrollToSpanId={scrollToSpanId}
564
+ scrollToLogId={scrollToLogId}
565
+ selectedItemId={selectedDiagnosticItemId}
566
+ onNavigate={handleNavigate}
567
+ onEnterSelect={handleEnterSelect}
568
+ />
569
+ </div>
570
+
571
+ {/* Right panel - Details (conditional) */}
572
+ {isDetailsPanelOpen && (
573
+ <div className="flex-1 min-h-0">
574
+ <DiagnosticDetailsPanel
575
+ span={selectedSpan}
576
+ log={selectedLog}
577
+ onClose={handleCloseDetails}
578
+ allSpans={spanTree}
579
+ />
580
+ </div>
581
+ )}
582
+ </div>
583
+ </div>
584
+ );
585
+ }
@@ -1,8 +1,10 @@
1
+ import { List } from "lucide-react";
1
2
  import { useEffect, useState } from "react";
2
3
  import type { SessionAnalysis } from "../analysis/types";
3
- import { DebuggerLayout } from "../components/DebuggerLayout";
4
+ import { NavBar } from "../components/NavBar";
4
5
  import { SessionAnalysisButton } from "../components/SessionAnalysisButton";
5
6
  import { SessionTimelineView } from "../components/SessionTimelineView";
7
+ import { Button } from "../components/ui/button";
6
8
 
7
9
  interface SessionViewProps {
8
10
  sessionId: string;
@@ -30,19 +32,28 @@ export function SessionView({ sessionId }: SessionViewProps) {
30
32
  }, [sessionId]);
31
33
 
32
34
  return (
33
- <DebuggerLayout title={`Session: ${sessionId}`} showBackButton backHref="/">
34
- <div className="relative flex-1 flex flex-col overflow-hidden">
35
- <div className="absolute top-4 right-4 z-10">
36
- {!loadingAnalysis && (
37
- <SessionAnalysisButton
38
- sessionId={sessionId}
39
- existingAnalysis={existingAnalysis}
40
- onAnalysisComplete={(analysis) => setExistingAnalysis(analysis)}
41
- />
42
- )}
35
+ <div className="h-screen flex flex-col">
36
+ <NavBar sessionId={sessionId} />
37
+ <div className="flex-1 flex flex-col overflow-hidden">
38
+ <div className="relative flex-1 flex flex-col overflow-hidden">
39
+ <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
40
+ <Button variant="outline" size="sm" asChild>
41
+ <a href={`/sessions/${sessionId}/logs`}>
42
+ <List className="size-4 mr-2" />
43
+ Logs View
44
+ </a>
45
+ </Button>
46
+ {!loadingAnalysis && (
47
+ <SessionAnalysisButton
48
+ sessionId={sessionId}
49
+ existingAnalysis={existingAnalysis}
50
+ onAnalysisComplete={(analysis) => setExistingAnalysis(analysis)}
51
+ />
52
+ )}
53
+ </div>
54
+ <SessionTimelineView sessionId={sessionId} />
43
55
  </div>
44
- <SessionTimelineView sessionId={sessionId} />
45
56
  </div>
46
- </DebuggerLayout>
57
+ </div>
47
58
  );
48
59
  }