@ai-sdk/devtools 1.0.0-beta.8 → 1.0.0-canary.20

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,529 @@
1
+ import React, { useState, useMemo, useCallback, useRef } from 'react';
2
+ import { Wrench, MessageSquare, AlertCircle, Brain, Zap } from 'lucide-react';
3
+ import { Badge } from '@/components/ui/badge';
4
+ import { Card } from '@/components/ui/card';
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipTrigger,
9
+ } from '@/components/ui/tooltip';
10
+ import type {
11
+ RunDetail,
12
+ Step,
13
+ ChildRun,
14
+ SpanKind,
15
+ TraceSpan,
16
+ ParseJson,
17
+ ParsedOutput,
18
+ ContentPart,
19
+ ToolCallContentPart,
20
+ ToolResultContentPart,
21
+ } from '../types';
22
+ import {
23
+ buildTraceSpans,
24
+ safeParseJson,
25
+ SPAN_COLORS,
26
+ SPAN_COLORS_MUTED,
27
+ } from '../utils';
28
+ import { JsonBlock } from './shared-components';
29
+ import { StepDetailContent } from './step-card';
30
+
31
+ export function TraceTimeline({
32
+ runDetail,
33
+ parseJson,
34
+ formatDuration,
35
+ }: {
36
+ runDetail: RunDetail;
37
+ parseJson: ParseJson;
38
+ formatDuration: (ms: number | null) => string;
39
+ }) {
40
+ const [selectedSpanId, setSelectedSpanId] = useState<string | null>(null);
41
+ const [labelWidth, setLabelWidth] = useState(280);
42
+ const isResizingLabel = useRef(false);
43
+
44
+ const spans = useMemo(
45
+ () => buildTraceSpans(runDetail, parseJson),
46
+ [runDetail, parseJson],
47
+ );
48
+
49
+ const allSteps = useMemo(() => {
50
+ const steps: Step[] = [];
51
+ function collectSteps(runSteps: Step[], children: ChildRun[]) {
52
+ steps.push(...runSteps);
53
+ for (const cr of children) {
54
+ collectSteps(cr.steps, cr.childRuns ?? []);
55
+ }
56
+ }
57
+ collectSteps(runDetail.steps, runDetail.childRuns ?? []);
58
+ return steps;
59
+ }, [runDetail]);
60
+
61
+ const allChildRuns = useMemo(() => {
62
+ const runs: ChildRun[] = [];
63
+ function collectRuns(children: ChildRun[]) {
64
+ for (const cr of children) {
65
+ runs.push(cr);
66
+ collectRuns(cr.childRuns ?? []);
67
+ }
68
+ }
69
+ collectRuns(runDetail.childRuns ?? []);
70
+ return runs;
71
+ }, [runDetail]);
72
+
73
+ const handleLabelResizeStart = useCallback(
74
+ (e: React.MouseEvent) => {
75
+ e.preventDefault();
76
+ e.stopPropagation();
77
+ isResizingLabel.current = true;
78
+ const startX = e.clientX;
79
+ const startWidth = labelWidth;
80
+
81
+ const onMouseMove = (moveEvent: MouseEvent) => {
82
+ if (!isResizingLabel.current) return;
83
+ const newWidth = Math.min(
84
+ Math.max(startWidth + (moveEvent.clientX - startX), 120),
85
+ 600,
86
+ );
87
+ setLabelWidth(newWidth);
88
+ };
89
+
90
+ const onMouseUp = () => {
91
+ isResizingLabel.current = false;
92
+ document.removeEventListener('mousemove', onMouseMove);
93
+ document.removeEventListener('mouseup', onMouseUp);
94
+ document.body.style.cursor = '';
95
+ document.body.style.userSelect = '';
96
+ };
97
+
98
+ document.addEventListener('mousemove', onMouseMove);
99
+ document.addEventListener('mouseup', onMouseUp);
100
+ document.body.style.cursor = 'col-resize';
101
+ document.body.style.userSelect = 'none';
102
+ },
103
+ [labelWidth],
104
+ );
105
+
106
+ if (spans.length === 0) return null;
107
+
108
+ const totalDurationMs = Math.max(
109
+ ...spans.map(s => s.startMs + s.durationMs),
110
+ 1,
111
+ );
112
+
113
+ const tickCount = 5;
114
+ const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
115
+ const ms = (totalDurationMs / tickCount) * i;
116
+ return {
117
+ ms,
118
+ label: ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`,
119
+ };
120
+ });
121
+
122
+ const ROW_HEIGHT = 28;
123
+
124
+ const handleSpanClick = (spanId: string) => {
125
+ setSelectedSpanId(prev => (prev === spanId ? null : spanId));
126
+ };
127
+
128
+ const selectedSpan = selectedSpanId
129
+ ? spans.find(s => s.id === selectedSpanId)
130
+ : null;
131
+
132
+ const resolvedStepId = selectedSpan?.stepId ?? selectedSpanId;
133
+
134
+ const selectedStep = resolvedStepId
135
+ ? allSteps.find(s => s.id === resolvedStepId)
136
+ : null;
137
+
138
+ const selectedStepIndex = selectedStep
139
+ ? (() => {
140
+ if (runDetail.steps.find(s => s.id === selectedStep.id)) {
141
+ return runDetail.steps.findIndex(s => s.id === selectedStep.id);
142
+ }
143
+ const cr = allChildRuns.find(cr =>
144
+ cr.steps.some(s => s.id === selectedStep.id),
145
+ );
146
+ return cr?.steps.findIndex(s => s.id === selectedStep.id) ?? 0;
147
+ })()
148
+ : 0;
149
+
150
+ const selectedStepSiblings = selectedStep
151
+ ? (() => {
152
+ if (runDetail.steps.find(s => s.id === selectedStep.id)) {
153
+ return runDetail.steps;
154
+ }
155
+ const cr = allChildRuns.find(cr =>
156
+ cr.steps.some(s => s.id === selectedStep.id),
157
+ );
158
+ return cr?.steps ?? runDetail.steps;
159
+ })()
160
+ : runDetail.steps;
161
+
162
+ const selectedStepChildRuns = selectedStep
163
+ ? allChildRuns.filter(cr => cr.run.parent_step_id === selectedStep.id)
164
+ : [];
165
+
166
+ const spanIcon = (kind: SpanKind) => {
167
+ switch (kind) {
168
+ case 'child-run':
169
+ return <Brain className="size-3 text-cyan-400 shrink-0" />;
170
+ case 'thinking':
171
+ return <Brain className="size-3 text-amber-500 shrink-0" />;
172
+ case 'tool-call':
173
+ return <Wrench className="size-3 text-purple-400 shrink-0" />;
174
+ case 'text':
175
+ return <MessageSquare className="size-3 text-emerald-400 shrink-0" />;
176
+ case 'error':
177
+ return <AlertCircle className="size-3 text-red-400 shrink-0" />;
178
+ default:
179
+ return <Zap className="size-3 text-blue-400 shrink-0" />;
180
+ }
181
+ };
182
+
183
+ return (
184
+ <div className="space-y-4">
185
+ <div className="rounded-lg border border-border bg-card overflow-hidden">
186
+ <div className="px-4 py-2 border-b border-border bg-muted/20 flex items-center justify-between">
187
+ <span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
188
+ Trace Timeline
189
+ </span>
190
+ <span className="text-[11px] font-mono text-muted-foreground">
191
+ {formatDuration(totalDurationMs)}
192
+ </span>
193
+ </div>
194
+
195
+ <div className="overflow-x-auto">
196
+ <div style={{ minWidth: labelWidth + 400 }}>
197
+ {/* Time axis */}
198
+ <div
199
+ className="flex border-b border-border/50"
200
+ style={{ height: 22 }}
201
+ >
202
+ <div
203
+ style={{ width: labelWidth, minWidth: labelWidth }}
204
+ className="shrink-0"
205
+ />
206
+ <div
207
+ className="w-1 shrink-0 cursor-col-resize hover:bg-primary/30"
208
+ onMouseDown={handleLabelResizeStart}
209
+ />
210
+ <div className="flex-1 relative">
211
+ {ticks.map((tick, i) => (
212
+ <span
213
+ key={i}
214
+ className="absolute text-[9px] font-mono text-muted-foreground/60 -translate-x-1/2"
215
+ style={{
216
+ left: `${(tick.ms / totalDurationMs) * 100}%`,
217
+ top: 5,
218
+ }}
219
+ >
220
+ {tick.label}
221
+ </span>
222
+ ))}
223
+ </div>
224
+ </div>
225
+
226
+ {/* Span rows */}
227
+ {spans.map(span => {
228
+ const leftPct = (span.startMs / totalDurationMs) * 100;
229
+ const widthPct = Math.max(
230
+ (span.durationMs / totalDurationMs) * 100,
231
+ 0.3,
232
+ );
233
+ const isSelected = selectedSpanId === span.id;
234
+
235
+ return (
236
+ <div
237
+ key={span.id}
238
+ className={`flex items-center border-b border-border/30 transition-colors cursor-pointer ${
239
+ isSelected ? 'bg-accent' : 'hover:bg-accent/30'
240
+ }`}
241
+ style={{ height: ROW_HEIGHT }}
242
+ onClick={() => handleSpanClick(span.id)}
243
+ >
244
+ {/* Label column */}
245
+ <div
246
+ className="shrink-0 flex items-center gap-1.5 px-2 overflow-hidden"
247
+ style={{
248
+ width: labelWidth,
249
+ minWidth: labelWidth,
250
+ paddingLeft: 8 + span.depth * 16,
251
+ }}
252
+ >
253
+ {spanIcon(span.kind)}
254
+ <span className="text-[11px] font-mono text-foreground truncate">
255
+ {span.label}
256
+ </span>
257
+ {span.sublabel && (
258
+ <span className="text-[10px] text-muted-foreground truncate">
259
+ {span.sublabel}
260
+ </span>
261
+ )}
262
+ <span className="ml-auto text-[10px] font-mono text-muted-foreground shrink-0 pl-1">
263
+ {formatDuration(span.durationMs || null)}
264
+ </span>
265
+ {span.tokens && (
266
+ <span className="text-[9px] font-mono text-muted-foreground/60 shrink-0">
267
+ {span.tokens.input}→{span.tokens.output}
268
+ </span>
269
+ )}
270
+ </div>
271
+
272
+ {/* Resize handle */}
273
+ <div
274
+ className="w-1 shrink-0 cursor-col-resize hover:bg-primary/30 self-stretch"
275
+ onMouseDown={handleLabelResizeStart}
276
+ />
277
+
278
+ {/* Bar column */}
279
+ <div className="flex-1 relative h-full">
280
+ {ticks.map((tick, i) => (
281
+ <div
282
+ key={i}
283
+ className="absolute top-0 bottom-0 border-l border-border/20"
284
+ style={{
285
+ left: `${(tick.ms / totalDurationMs) * 100}%`,
286
+ }}
287
+ />
288
+ ))}
289
+ <Tooltip>
290
+ <TooltipTrigger asChild>
291
+ <div
292
+ className="absolute top-1 bottom-1 flex items-center cursor-pointer"
293
+ style={{
294
+ left: `${leftPct}%`,
295
+ width: `${widthPct}%`,
296
+ minWidth: 3,
297
+ }}
298
+ >
299
+ <div
300
+ className={`h-full w-full rounded-sm ${
301
+ isSelected
302
+ ? 'ring-2 ring-foreground/50 ring-offset-1 ring-offset-background'
303
+ : ''
304
+ } ${
305
+ span.isInProgress
306
+ ? `${SPAN_COLORS_MUTED[span.kind]} animate-pulse`
307
+ : SPAN_COLORS[span.kind]
308
+ }`}
309
+ />
310
+ </div>
311
+ </TooltipTrigger>
312
+ <TooltipContent side="top" className="max-w-xs">
313
+ <div className="text-xs space-y-1">
314
+ <div className="font-medium">{span.label}</div>
315
+ {span.sublabel && (
316
+ <div className="text-muted-foreground">
317
+ {span.sublabel}
318
+ </div>
319
+ )}
320
+ {span.modelId && span.modelId !== span.label && (
321
+ <div className="text-muted-foreground font-mono">
322
+ {span.modelId}
323
+ </div>
324
+ )}
325
+ <div className="text-muted-foreground font-mono">
326
+ {formatDuration(span.durationMs || null)}
327
+ {span.tokens && (
328
+ <span className="ml-2">
329
+ {span.tokens.input} in → {span.tokens.output}{' '}
330
+ out
331
+ </span>
332
+ )}
333
+ </div>
334
+ <div className="text-muted-foreground/60 pt-0.5">
335
+ Click to {isSelected ? 'deselect' : 'view details'}
336
+ </div>
337
+ </div>
338
+ </TooltipContent>
339
+ </Tooltip>
340
+ </div>
341
+ </div>
342
+ );
343
+ })}
344
+ </div>
345
+ </div>
346
+ </div>
347
+
348
+ {/* Selected span detail */}
349
+ {selectedStep && selectedSpan && (
350
+ <Card className="overflow-hidden py-0 gap-0">
351
+ <div className="flex items-center justify-between px-4 py-2.5 bg-muted/20 border-b border-border">
352
+ <div className="flex items-center gap-2">
353
+ {spanIcon(selectedSpan.kind)}
354
+ <span className="text-xs text-muted-foreground font-mono">
355
+ Step {selectedStepIndex + 1}
356
+ </span>
357
+ {selectedSpan.kind !== 'step' && (
358
+ <Badge
359
+ variant="secondary"
360
+ className="text-[10px] h-5 capitalize"
361
+ >
362
+ {selectedSpan.kind}
363
+ </Badge>
364
+ )}
365
+ <span className="text-xs font-medium font-mono">
366
+ {selectedSpan.label}
367
+ </span>
368
+ {selectedStep.provider && (
369
+ <span className="px-1.5 py-0.5 rounded bg-sidebar-primary/10 text-sidebar-primary text-[10px] font-medium">
370
+ {selectedStep.provider}
371
+ </span>
372
+ )}
373
+ </div>
374
+ <button
375
+ onClick={() => setSelectedSpanId(null)}
376
+ className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
377
+ >
378
+ Deselect
379
+ </button>
380
+ </div>
381
+ <SpanDetailPanel
382
+ span={selectedSpan}
383
+ step={selectedStep}
384
+ stepIndex={selectedStepIndex}
385
+ steps={selectedStepSiblings}
386
+ runDetail={runDetail}
387
+ parseJson={parseJson}
388
+ formatDuration={formatDuration}
389
+ childRuns={selectedStepChildRuns}
390
+ />
391
+ </Card>
392
+ )}
393
+ </div>
394
+ );
395
+ }
396
+
397
+ function SpanDetailPanel({
398
+ span,
399
+ step,
400
+ stepIndex,
401
+ steps,
402
+ runDetail,
403
+ parseJson,
404
+ formatDuration,
405
+ childRuns,
406
+ }: {
407
+ span: TraceSpan;
408
+ step: Step;
409
+ stepIndex: number;
410
+ steps: Step[];
411
+ runDetail: RunDetail;
412
+ parseJson: ParseJson;
413
+ formatDuration: (ms: number | null) => string;
414
+ childRuns: ChildRun[];
415
+ }) {
416
+ const output = parseJson(step.output) as ParsedOutput | null;
417
+ const contentParts: ContentPart[] =
418
+ (output?.content as ContentPart[] | undefined) ?? [];
419
+
420
+ if (span.kind === 'tool-call' && span.toolCallId) {
421
+ const toolCall = contentParts.find(
422
+ (p): p is ToolCallContentPart =>
423
+ p.type === 'tool-call' &&
424
+ 'toolCallId' in p &&
425
+ p.toolCallId === span.toolCallId,
426
+ );
427
+ const toolResult = contentParts.find(
428
+ (p): p is ToolResultContentPart =>
429
+ p.type === 'tool-result' &&
430
+ 'toolCallId' in p &&
431
+ p.toolCallId === span.toolCallId,
432
+ );
433
+
434
+ const args = toolCall?.input ?? toolCall?.args;
435
+ const parsedArgs = typeof args === 'string' ? safeParseJson(args) : args;
436
+ const resultData = toolResult?.output ?? toolResult?.result;
437
+ const parsedResult =
438
+ typeof resultData === 'string' ? safeParseJson(resultData) : resultData;
439
+
440
+ return (
441
+ <div className="p-4 space-y-4">
442
+ <div className="flex items-center gap-2 mb-1">
443
+ <Wrench className="size-4 text-purple-400" />
444
+ <span className="text-sm font-mono font-medium text-purple-400">
445
+ {span.label}
446
+ </span>
447
+ </div>
448
+ {parsedArgs != null && (
449
+ <div>
450
+ <h4 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
451
+ Input
452
+ </h4>
453
+ <JsonBlock data={parsedArgs} />
454
+ </div>
455
+ )}
456
+ {parsedResult != null && (
457
+ <div>
458
+ <h4 className="text-[11px] font-semibold uppercase tracking-wider text-success mb-2">
459
+ Output
460
+ </h4>
461
+ <JsonBlock data={parsedResult} />
462
+ </div>
463
+ )}
464
+ {!parsedArgs && !parsedResult && (
465
+ <p className="text-sm text-muted-foreground">
466
+ No data available for this tool call.
467
+ </p>
468
+ )}
469
+ </div>
470
+ );
471
+ }
472
+
473
+ if (span.kind === 'thinking' && span.thinkingText) {
474
+ return (
475
+ <div className="p-4 space-y-2">
476
+ <div className="flex items-center gap-2 mb-1">
477
+ <Brain className="size-4 text-amber-500" />
478
+ <span className="text-sm font-medium text-amber-500">Thinking</span>
479
+ </div>
480
+ <div className="text-sm text-foreground/80 leading-relaxed whitespace-pre-wrap rounded-md border border-amber-500/20 bg-amber-500/5 p-3">
481
+ {span.thinkingText}
482
+ </div>
483
+ </div>
484
+ );
485
+ }
486
+
487
+ if (span.kind === 'text' && span.textContent) {
488
+ return (
489
+ <div className="p-4 space-y-2">
490
+ <div className="flex items-center gap-2 mb-1">
491
+ <MessageSquare className="size-4 text-emerald-400" />
492
+ <span className="text-sm font-medium text-emerald-400">
493
+ Text Response
494
+ </span>
495
+ </div>
496
+ <div className="text-sm text-foreground/80 leading-relaxed whitespace-pre-wrap rounded-md border border-border bg-background p-3">
497
+ {span.textContent}
498
+ </div>
499
+ </div>
500
+ );
501
+ }
502
+
503
+ if (span.kind === 'error') {
504
+ return (
505
+ <div className="p-4 space-y-2">
506
+ <div className="flex items-center gap-2 mb-1">
507
+ <AlertCircle className="size-4 text-red-400" />
508
+ <span className="text-sm font-medium text-red-400">Error</span>
509
+ </div>
510
+ <div className="text-sm text-destructive-foreground font-mono whitespace-pre-wrap rounded-md border border-destructive/30 bg-destructive/10 p-3">
511
+ {step.error}
512
+ </div>
513
+ </div>
514
+ );
515
+ }
516
+
517
+ return (
518
+ <StepDetailContent
519
+ step={step}
520
+ index={stepIndex}
521
+ steps={steps}
522
+ isRunInProgress={runDetail.run.isInProgress ?? false}
523
+ parseJson={parseJson}
524
+ formatDuration={formatDuration}
525
+ childRuns={childRuns}
526
+ depth={0}
527
+ />
528
+ );
529
+ }
@@ -1,7 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import { Slot } from '@radix-ui/react-slot';
3
3
  import { cva, type VariantProps } from 'class-variance-authority';
4
-
5
4
  import { cn } from '@/lib/utils';
6
5
 
7
6
  const badgeVariants = cva(
@@ -1,7 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import { Slot } from '@radix-ui/react-slot';
3
3
  import { cva, type VariantProps } from 'class-variance-authority';
4
-
5
4
  import { cn } from '@/lib/utils';
6
5
 
7
6
  const buttonVariants = cva(