@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.
@@ -1,287 +1,48 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import {
3
- ChevronRight,
4
3
  RefreshCw,
5
4
  Trash2,
6
- Copy,
7
- Check,
8
5
  Zap,
9
- Wrench,
10
6
  MessageSquare,
11
7
  AlertCircle,
12
- ChevronDown,
13
- Settings,
14
- Brain,
15
8
  Loader2,
16
- BarChart3,
9
+ GanttChart,
17
10
  } from 'lucide-react';
18
11
  import { AISDKLogo } from '@/components/icons';
19
12
  import { Button } from '@/components/ui/button';
20
13
  import { Badge } from '@/components/ui/badge';
21
- import { Card } from '@/components/ui/card';
22
14
  import { ScrollArea } from '@/components/ui/scroll-area';
23
- import {
24
- Collapsible,
25
- CollapsibleContent,
26
- CollapsibleTrigger,
27
- } from '@/components/ui/collapsible';
28
- import {
29
- Drawer,
30
- DrawerContent,
31
- DrawerHeader,
32
- DrawerTitle,
33
- DrawerTrigger,
34
- } from '@/components/ui/drawer';
35
15
  import {
36
16
  Tooltip,
37
17
  TooltipContent,
38
18
  TooltipTrigger,
39
19
  } from '@/components/ui/tooltip';
40
-
41
- interface Run {
42
- id: string;
43
- started_at: string;
44
- stepCount: number;
45
- firstMessage?: string;
46
- hasError?: boolean;
47
- isInProgress?: boolean;
48
- type?: 'generate' | 'stream';
49
- }
50
-
51
- interface Step {
52
- id: string;
53
- run_id: string;
54
- step_number: number;
55
- type: 'generate' | 'stream';
56
- model_id: string;
57
- provider: string | null;
58
- started_at: string;
59
- duration_ms: number | null;
60
- input: string;
61
- output: string | null;
62
- usage: string | null;
63
- error: string | null;
64
- raw_request: string | null;
65
- raw_response: string | null;
66
- raw_chunks: string | null;
67
- provider_options: string | null;
68
- }
69
-
70
- interface RunDetail {
71
- run: { id: string; started_at: string; isInProgress?: boolean };
72
- steps: Step[];
73
- }
74
-
75
- type StepType = 'tool-calls' | 'response' | 'error';
76
-
77
- interface StepSummary {
78
- type: StepType;
79
- icon: 'wrench' | 'message' | 'alert';
80
- label: string;
81
- toolDetails?: string;
82
- }
83
-
84
- function summarizeToolCalls(toolCalls: any[]): {
85
- label: string;
86
- details: string;
87
- } {
88
- // Count occurrences of each tool
89
- const counts = toolCalls.reduce((acc: Record<string, number>, call: any) => {
90
- acc[call.toolName] = (acc[call.toolName] || 0) + 1;
91
- return acc;
92
- }, {});
93
-
94
- const uniqueTools = Object.keys(counts);
95
-
96
- // Format tool name with count if > 1
97
- const formatTool = (name: string) => {
98
- const count = counts[name] ?? 0;
99
- return count > 1 ? `${name} (x${count})` : name;
100
- };
101
-
102
- const allToolsFormatted = uniqueTools.map(formatTool);
103
-
104
- // Single tool type
105
- if (uniqueTools.length === 1 && uniqueTools[0]) {
106
- return {
107
- label: formatTool(uniqueTools[0]),
108
- details: '', // No tooltip needed
109
- };
110
- }
111
-
112
- // Two tools - show both
113
- if (uniqueTools.length === 2) {
114
- return {
115
- label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}`,
116
- details: allToolsFormatted.join(', '),
117
- };
118
- }
119
-
120
- // 3+ tools: show first two + ellipsis
121
- return {
122
- label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}, ...`,
123
- details: allToolsFormatted.join(', '),
124
- };
125
- }
126
-
127
- interface StepInputSummary {
128
- type: 'user' | 'tool';
129
- label: string;
130
- fullText?: string; // For tooltip on user messages
131
- toolDetails?: string; // For tooltip on tool results
132
- }
133
-
134
- function getStepInputSummary(
135
- input: any,
136
- isFirstStep: boolean,
137
- ): StepInputSummary | null {
138
- const prompt = input?.prompt;
139
- if (!Array.isArray(prompt)) return null;
140
-
141
- // For first step: always show the last user message (what kicked off this run)
142
- if (isFirstStep) {
143
- const userMessages = prompt.filter((msg: any) => msg.role === 'user');
144
- const lastUserMessage = userMessages[userMessages.length - 1];
145
-
146
- if (lastUserMessage) {
147
- const content = lastUserMessage.content;
148
- let text: string | null = null;
149
-
150
- if (typeof content === 'string') {
151
- text = content;
152
- } else if (Array.isArray(content)) {
153
- const textPart = content.find((part: any) => part.type === 'text');
154
- if (textPart?.text) {
155
- text = textPart.text;
156
- }
157
- }
158
-
159
- if (text) {
160
- return {
161
- type: 'user',
162
- label: `"${truncateText(text)}"`,
163
- fullText: text,
164
- };
165
- }
166
- }
167
- return null;
168
- }
169
-
170
- // For subsequent steps: show the last tool results
171
- const toolMessages = prompt.filter((msg: any) => msg.role === 'tool');
172
- if (toolMessages.length > 0) {
173
- // Only get tool names from the last tool message (the most recent tool results)
174
- const lastToolMessage = toolMessages[toolMessages.length - 1];
175
- const toolCounts: Record<string, number> = {};
176
-
177
- const content = lastToolMessage.content;
178
- if (Array.isArray(content)) {
179
- for (const part of content) {
180
- if (part.type === 'tool-result' && part.toolName) {
181
- toolCounts[part.toolName] = (toolCounts[part.toolName] || 0) + 1;
182
- }
183
- }
184
- }
185
-
186
- const uniqueTools = Object.keys(toolCounts);
187
- if (uniqueTools.length === 0) {
188
- return { type: 'tool', label: 'tool result' };
189
- }
190
-
191
- // Format tool name with count if > 1
192
- const formatTool = (name: string) => {
193
- const count = toolCounts[name];
194
- return count > 1 ? `${name} (x${count})` : name;
195
- };
196
-
197
- const allToolsFormatted = uniqueTools.map(formatTool);
198
-
199
- if (uniqueTools.length === 1) {
200
- return { type: 'tool', label: formatTool(uniqueTools[0]) };
201
- }
202
-
203
- if (uniqueTools.length === 2) {
204
- return {
205
- type: 'tool',
206
- label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}`,
207
- toolDetails: allToolsFormatted.join(', '),
208
- };
209
- }
210
-
211
- // 3+ tools: show first two + ellipsis
212
- return {
213
- type: 'tool',
214
- label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}, ...`,
215
- toolDetails: allToolsFormatted.join(', '),
216
- };
217
- }
218
-
219
- // Fall back to last user message
220
- const userMessages = prompt.filter((msg: any) => msg.role === 'user');
221
- const lastUserMessage = userMessages[userMessages.length - 1];
222
-
223
- if (!lastUserMessage) return null;
224
-
225
- // Extract text content from the message
226
- const content = lastUserMessage.content;
227
- let text: string | null = null;
228
-
229
- if (typeof content === 'string') {
230
- text = content;
231
- } else if (Array.isArray(content)) {
232
- const textPart = content.find((part: any) => part.type === 'text');
233
- if (textPart?.text) {
234
- text = textPart.text;
235
- }
236
- }
237
-
238
- if (!text) return null;
239
-
240
- return {
241
- type: 'user',
242
- label: `"${truncateText(text)}"`,
243
- fullText: text,
244
- };
245
- }
246
-
247
- function truncateText(text: string, maxLength: number = 30): string {
248
- if (text.length <= maxLength) return text;
249
- return text.slice(0, maxLength).trim() + '…';
250
- }
251
-
252
- function getStepSummary(output: any, error: string | null): StepSummary {
253
- if (error) {
254
- return { type: 'error', icon: 'alert', label: 'Error' };
255
- }
256
-
257
- // finishReason can be a string or an object with {unified, raw} properties
258
- const finishReason =
259
- typeof output?.finishReason === 'string'
260
- ? output.finishReason
261
- : output?.finishReason?.unified;
262
-
263
- if (finishReason === 'tool-calls') {
264
- const toolCalls =
265
- output?.toolCalls ||
266
- output?.content?.filter((p: any) => p.type === 'tool-call') ||
267
- [];
268
- const { label, details } = summarizeToolCalls(toolCalls);
269
- return {
270
- type: 'tool-calls',
271
- icon: 'wrench',
272
- label,
273
- toolDetails: details || undefined,
274
- };
275
- }
276
-
277
- return { type: 'response', icon: 'message', label: 'Response' };
278
- }
20
+ import type {
21
+ Run,
22
+ RunDetail,
23
+ Step,
24
+ ParsedInput,
25
+ ParsedUsage,
26
+ PromptMessage,
27
+ InputTokenBreakdown,
28
+ OutputTokenBreakdown,
29
+ } from './types';
30
+ import {
31
+ getInputTokenBreakdown,
32
+ getOutputTokenBreakdown,
33
+ formatInputTokens,
34
+ formatOutputTokens,
35
+ } from './utils';
36
+ import { TokenBreakdownTooltip } from './components/shared-components';
37
+ import { StepCard } from './components/step-card';
38
+ import { TraceTimeline } from './components/trace-timeline';
279
39
 
280
40
  function App() {
281
41
  const [runs, setRuns] = useState<Run[]>([]);
282
42
  const [selectedRun, setSelectedRun] = useState<RunDetail | null>(null);
283
43
  const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
284
44
  const [loading, setLoading] = useState(true);
45
+ const [showTimeline, setShowTimeline] = useState(false);
285
46
 
286
47
  const fetchRuns = async () => {
287
48
  try {
@@ -301,12 +62,10 @@ function App() {
301
62
  setSelectedRun(null);
302
63
  };
303
64
 
304
- // Initial fetch
305
65
  useEffect(() => {
306
66
  fetchRuns();
307
67
  }, []);
308
68
 
309
- // SSE connection for real-time updates
310
69
  useEffect(() => {
311
70
  let eventSource: EventSource | null = null;
312
71
  let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -319,9 +78,7 @@ function App() {
319
78
  });
320
79
 
321
80
  eventSource.addEventListener('update', () => {
322
- // Refresh the runs list when data changes
323
81
  fetchRuns();
324
- // Also refresh selected run if one is selected
325
82
  if (selectedRun) {
326
83
  fetch(`/api/runs/${selectedRun.run.id}`)
327
84
  .then(res => res.json())
@@ -334,14 +91,11 @@ function App() {
334
91
  }
335
92
  });
336
93
 
337
- eventSource.addEventListener('heartbeat', () => {
338
- // Connection is alive
339
- });
94
+ eventSource.addEventListener('heartbeat', () => {});
340
95
 
341
96
  eventSource.onerror = () => {
342
97
  console.log('[DevTools] SSE connection lost, reconnecting...');
343
98
  eventSource?.close();
344
- // Reconnect after 2 seconds
345
99
  reconnectTimeout = setTimeout(connect, 2000);
346
100
  };
347
101
  };
@@ -360,7 +114,6 @@ function App() {
360
114
  const res = await fetch(`/api/runs/${runId}`);
361
115
  const data = await res.json();
362
116
  setSelectedRun(data);
363
- // Start with all steps collapsed
364
117
  setExpandedSteps(new Set());
365
118
  };
366
119
 
@@ -382,7 +135,7 @@ function App() {
382
135
  return `${(ms / 1000).toFixed(2)}s`;
383
136
  };
384
137
 
385
- const parseJson = (str: string | null) => {
138
+ const parseJson = (str: string | null): unknown => {
386
139
  if (!str) return null;
387
140
  try {
388
141
  return JSON.parse(str);
@@ -394,13 +147,19 @@ function App() {
394
147
  const getFirstUserMessage = (steps: Step[]): string => {
395
148
  const firstStep = steps[0];
396
149
  if (!firstStep) return 'Empty run';
397
- const input = parseJson(firstStep.input);
398
- const userMsg = input?.prompt?.find((m: any) => m.role === 'user');
150
+ const input = parseJson(firstStep.input) as ParsedInput | null;
151
+ const userMsg = input?.prompt?.find(
152
+ (m: PromptMessage) => m.role === 'user',
153
+ );
399
154
  if (userMsg) {
400
155
  const content =
401
156
  typeof userMsg.content === 'string'
402
157
  ? userMsg.content
403
- : userMsg.content?.[0]?.text || '';
158
+ : Array.isArray(userMsg.content)
159
+ ? userMsg.content.find(
160
+ (p): p is { type: 'text'; text: string } => p.type === 'text',
161
+ )?.text || ''
162
+ : '';
404
163
  return content.slice(0, 50) + (content.length > 50 ? '...' : '');
405
164
  }
406
165
  return 'No user message';
@@ -419,7 +178,7 @@ function App() {
419
178
  ): { input: InputTokenBreakdown; output: OutputTokenBreakdown } => {
420
179
  return steps.reduce(
421
180
  (acc, s) => {
422
- const usage = parseJson(s.usage);
181
+ const usage = parseJson(s.usage) as ParsedUsage | null;
423
182
  const inputBreakdown = getInputTokenBreakdown(usage?.inputTokens);
424
183
  const outputBreakdown = getOutputTokenBreakdown(usage?.outputTokens);
425
184
 
@@ -427,7 +186,6 @@ function App() {
427
186
  total: acc.input.total + inputBreakdown.total,
428
187
  };
429
188
 
430
- // Only add breakdown properties if they exist in either accumulator or current breakdown
431
189
  if (
432
190
  acc.input.noCache !== undefined ||
433
191
  inputBreakdown.noCache !== undefined
@@ -454,7 +212,6 @@ function App() {
454
212
  total: acc.output.total + outputBreakdown.total,
455
213
  };
456
214
 
457
- // Only add breakdown properties if they exist in either accumulator or current breakdown
458
215
  if (
459
216
  acc.output.text !== undefined ||
460
217
  outputBreakdown.text !== undefined
@@ -542,9 +299,16 @@ function App() {
542
299
  ) : (
543
300
  <MessageSquare className="size-3.5 text-muted-foreground mt-0.5 shrink-0" />
544
301
  )}
545
- <span className="text-[13px] text-foreground leading-tight line-clamp-1 break-all">
546
- {run.firstMessage || 'Loading...'}
547
- </span>
302
+ <div className="min-w-0">
303
+ {run.function_id && (
304
+ <span className="text-[11px] font-mono text-sidebar-primary block truncate">
305
+ {run.function_id}
306
+ </span>
307
+ )}
308
+ <span className="text-[13px] text-foreground leading-tight line-clamp-1 break-all">
309
+ {run.firstMessage || 'Loading...'}
310
+ </span>
311
+ </div>
548
312
  </div>
549
313
  <div className="flex items-center gap-2 ml-5.5 text-[11px] text-muted-foreground">
550
314
  {run.type && (
@@ -643,246 +407,63 @@ function App() {
643
407
  <span>
644
408
  {new Date(selectedRun.run.started_at).toLocaleString()}
645
409
  </span>
410
+ <span className="px-3 text-muted-foreground/30">·</span>
411
+ <Tooltip>
412
+ <TooltipTrigger asChild>
413
+ <button
414
+ onClick={() => setShowTimeline(prev => !prev)}
415
+ className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium transition-colors ${
416
+ showTimeline
417
+ ? 'bg-accent text-foreground'
418
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
419
+ }`}
420
+ >
421
+ <GanttChart className="size-3.5" />
422
+ Timeline
423
+ </button>
424
+ </TooltipTrigger>
425
+ <TooltipContent>
426
+ {showTimeline
427
+ ? 'Switch to steps view'
428
+ : 'Switch to timeline view'}
429
+ </TooltipContent>
430
+ </Tooltip>
646
431
  </div>
647
432
  </div>
648
433
 
649
- {/* Steps */}
650
- <div className="flex flex-col gap-3">
651
- {selectedRun.steps.map((step, index) => {
652
- const isExpanded = expandedSteps.has(step.id);
653
- const isLastStep = index === selectedRun.steps.length - 1;
654
- const isActiveStep =
655
- isLastStep && selectedRun.run.isInProgress;
656
- const input = parseJson(step.input);
657
- const output = parseJson(step.output);
658
- const usage = parseJson(step.usage);
659
-
660
- // Get tool results from next step's input
661
- const nextStep = selectedRun.steps[index + 1];
662
- const nextInput = nextStep
663
- ? parseJson(nextStep.input)
664
- : null;
665
- const toolResults =
666
- nextInput?.prompt
667
- ?.filter((msg: any) => msg.role === 'tool')
668
- ?.flatMap((msg: any) => msg.content) ?? [];
669
-
670
- const summary = getStepSummary(output, step.error);
671
- const isFirstStep = index === 0;
672
- const inputSummary = getStepInputSummary(
673
- input,
674
- isFirstStep,
675
- );
676
-
677
- return (
678
- <Collapsible
679
- key={step.id}
680
- open={isExpanded}
681
- onOpenChange={() => toggleStep(step.id)}
682
- >
683
- <Card
684
- className={`overflow-hidden py-0 gap-0 ${isActiveStep ? 'ring-2 ring-blue-500/50 ring-offset-1 ring-offset-background' : ''}`}
685
- >
686
- {/* Step Header */}
687
- <CollapsibleTrigger asChild>
688
- <button
689
- className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/50 ${
690
- isExpanded ? 'border-b border-border' : ''
691
- }`}
692
- >
693
- <div className="flex items-center gap-3">
694
- <span className="text-xs text-muted-foreground font-mono w-4">
695
- {step.step_number}
696
- </span>
697
- <div className="flex items-center gap-2">
698
- {/* Input side */}
699
- {inputSummary && (
700
- <>
701
- {inputSummary.type === 'user' ? (
702
- <MessageSquare className="size-3.5 text-muted-foreground" />
703
- ) : (
704
- <Wrench className="size-3.5 text-muted-foreground" />
705
- )}
706
- {inputSummary.fullText ||
707
- inputSummary.toolDetails ? (
708
- <Tooltip>
709
- <TooltipTrigger asChild>
710
- <span
711
- className={`text-sm text-muted-foreground ${inputSummary.type === 'tool' ? 'font-mono' : ''}`}
712
- >
713
- {inputSummary.label}
714
- </span>
715
- </TooltipTrigger>
716
- <TooltipContent
717
- side="bottom"
718
- className="max-w-sm max-h-40 overflow-hidden"
719
- >
720
- <span className="line-clamp-6">
721
- {inputSummary.fullText ||
722
- inputSummary.toolDetails}
723
- </span>
724
- </TooltipContent>
725
- </Tooltip>
726
- ) : (
727
- <span
728
- className={`text-sm text-muted-foreground ${inputSummary.type === 'tool' ? 'font-mono' : ''}`}
729
- >
730
- {inputSummary.label}
731
- </span>
732
- )}
733
- <span className="text-muted-foreground/50 mx-1">
734
-
735
- </span>
736
- </>
737
- )}
738
-
739
- {/* Output side */}
740
- {summary.icon === 'wrench' && (
741
- <Wrench className="size-3.5 text-muted-foreground" />
742
- )}
743
- {summary.icon === 'message' && (
744
- <MessageSquare className="size-3.5 text-muted-foreground" />
745
- )}
746
- {summary.icon === 'alert' && (
747
- <AlertCircle className="size-3.5 text-destructive" />
748
- )}
749
-
750
- {summary.toolDetails ? (
751
- <Tooltip>
752
- <TooltipTrigger asChild>
753
- <span className="text-sm font-medium font-mono">
754
- {summary.label}
755
- </span>
756
- </TooltipTrigger>
757
- <TooltipContent>
758
- {summary.toolDetails}
759
- </TooltipContent>
760
- </Tooltip>
761
- ) : (
762
- <span
763
- className={`text-sm font-medium ${
764
- summary.icon === 'wrench'
765
- ? 'font-mono'
766
- : ''
767
- }`}
768
- >
769
- {summary.label}
770
- </span>
771
- )}
772
- </div>
773
- </div>
774
-
775
- <div className="flex items-center gap-4">
776
- {isActiveStep ? (
777
- <span className="text-[11px] text-blue-400 font-medium flex items-center gap-1.5">
778
- <Loader2 className="size-3 animate-spin" />
779
- streaming
780
- </span>
781
- ) : (
782
- <span className="text-[11px] text-muted-foreground font-mono">
783
- {formatDuration(step.duration_ms)}
784
- </span>
785
- )}
786
- {usage && (
787
- <Tooltip>
788
- <TooltipTrigger asChild>
789
- <span className="text-[11px] font-mono text-muted-foreground cursor-help">
790
- {formatInputTokens(
791
- getInputTokenBreakdown(
792
- usage.inputTokens,
793
- ),
794
- )}{' '}
795
- <span className="text-muted-foreground/50">
796
-
797
- </span>{' '}
798
- {formatOutputTokens(
799
- getOutputTokenBreakdown(
800
- usage.outputTokens,
801
- ),
802
- )}
803
- </span>
804
- </TooltipTrigger>
805
- <TooltipContent>
806
- <TokenBreakdownTooltip
807
- input={getInputTokenBreakdown(
808
- usage.inputTokens,
809
- )}
810
- output={getOutputTokenBreakdown(
811
- usage.outputTokens,
812
- )}
813
- raw={usage.raw}
814
- />
815
- </TooltipContent>
816
- </Tooltip>
817
- )}
818
- <ChevronDown
819
- className={`size-4 text-muted-foreground transition-transform ${
820
- isExpanded ? 'rotate-180' : ''
821
- }`}
822
- />
823
- </div>
824
- </button>
825
- </CollapsibleTrigger>
826
-
827
- {/* Step Content */}
828
- <CollapsibleContent>
829
- {/* Config Bar */}
830
- <StepConfigBar
831
- modelId={step.model_id}
832
- provider={step.provider}
833
- input={input}
834
- providerOptions={parseJson(step.provider_options)}
835
- usage={usage}
836
- />
837
-
838
- {/* Two Panel Layout */}
839
- <div className="grid grid-cols-2 divide-x divide-border">
840
- {/* INPUT Panel */}
841
- <div className="bg-card/50">
842
- <InputPanel input={input} />
843
- </div>
844
-
845
- {/* OUTPUT Panel */}
846
- <div className="p-4 bg-background min-h-full">
847
- <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-3">
848
- Output
849
- </h3>
850
-
851
- {step.error ? (
852
- <div className="p-3 rounded-md bg-destructive/10 border border-destructive/30 text-sm text-destructive-foreground font-mono">
853
- {step.error}
854
- </div>
855
- ) : output ? (
856
- <OutputDisplay
857
- output={output}
858
- toolResults={toolResults}
859
- />
860
- ) : isActiveStep ? (
861
- <div className="flex items-center gap-2 text-sm text-blue-400">
862
- <Loader2 className="size-4 animate-spin" />
863
- <span>Waiting for response...</span>
864
- </div>
865
- ) : (
866
- <p className="text-sm text-muted-foreground">
867
- No output
868
- </p>
869
- )}
870
- </div>
871
- </div>
872
-
873
- {/* Raw Data Toggle */}
874
- <RawDataSection
875
- rawRequest={step.raw_request}
876
- rawResponse={step.raw_response}
877
- rawChunks={step.raw_chunks}
878
- isStream={step.type === 'stream'}
879
- />
880
- </CollapsibleContent>
881
- </Card>
882
- </Collapsible>
883
- );
884
- })}
885
- </div>
434
+ {showTimeline ? (
435
+ <TraceTimeline
436
+ runDetail={selectedRun}
437
+ parseJson={parseJson}
438
+ formatDuration={formatDuration}
439
+ />
440
+ ) : (
441
+ <div className="flex flex-col gap-3">
442
+ {selectedRun.steps.map((step, index) => {
443
+ const childRunsForStep = (
444
+ selectedRun.childRuns ?? []
445
+ ).filter(cr => cr.run.parent_step_id === step.id);
446
+
447
+ return (
448
+ <StepCard
449
+ key={step.id}
450
+ step={step}
451
+ index={index}
452
+ steps={selectedRun.steps}
453
+ isRunInProgress={
454
+ selectedRun.run.isInProgress ?? false
455
+ }
456
+ expandedSteps={expandedSteps}
457
+ toggleStep={toggleStep}
458
+ parseJson={parseJson}
459
+ formatDuration={formatDuration}
460
+ childRuns={childRunsForStep}
461
+ depth={0}
462
+ />
463
+ );
464
+ })}
465
+ </div>
466
+ )}
886
467
  </div>
887
468
  )}
888
469
  </ScrollArea>
@@ -892,1366 +473,4 @@ function App() {
892
473
  );
893
474
  }
894
475
 
895
- function StepConfigBar({
896
- modelId,
897
- provider,
898
- input,
899
- providerOptions,
900
- usage,
901
- }: {
902
- modelId?: string;
903
- provider?: string | null;
904
- input: any;
905
- providerOptions?: any;
906
- usage?: any;
907
- }) {
908
- const toolCount = input?.tools?.length ?? 0;
909
-
910
- // Collect parameters
911
- const params: { label: string; value: string }[] = [];
912
- if (input?.temperature != null)
913
- params.push({ label: 'temp', value: String(input.temperature) });
914
- if (input?.maxOutputTokens != null)
915
- params.push({ label: 'max tokens', value: String(input.maxOutputTokens) });
916
- if (input?.topP != null)
917
- params.push({ label: 'topP', value: String(input.topP) });
918
- if (input?.topK != null)
919
- params.push({ label: 'topK', value: String(input.topK) });
920
- if (input?.toolChoice != null) {
921
- const choice =
922
- typeof input.toolChoice === 'string'
923
- ? input.toolChoice
924
- : input.toolChoice.type;
925
- params.push({ label: 'tool choice', value: choice });
926
- }
927
-
928
- return (
929
- <div className="flex items-center gap-2 px-4 py-2 bg-muted/20 border-b border-border text-[11px] text-muted-foreground">
930
- {provider && (
931
- <span className="px-1.5 py-0.5 rounded bg-sidebar-primary/10 text-sidebar-primary text-[10px] font-medium">
932
- {provider}
933
- </span>
934
- )}
935
- <span className="font-mono">{modelId}</span>
936
-
937
- {params.length > 0 && (
938
- <>
939
- <span className="text-muted-foreground/30">·</span>
940
- {params.map((p, i) => (
941
- <span key={i}>
942
- {p.label}: <span className="text-foreground">{p.value}</span>
943
- {i < params.length - 1 && (
944
- <span className="text-muted-foreground/30 mx-1">·</span>
945
- )}
946
- </span>
947
- ))}
948
- </>
949
- )}
950
-
951
- {toolCount > 0 && (
952
- <>
953
- <span className="text-muted-foreground/30">·</span>
954
- <Drawer direction="right">
955
- <DrawerTrigger asChild>
956
- <button className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer">
957
- <Wrench className="size-3" />
958
- {toolCount} available {toolCount === 1 ? 'tool' : 'tools'}
959
- </button>
960
- </DrawerTrigger>
961
- <DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
962
- <DrawerHeader className="border-b border-border shrink-0">
963
- <DrawerTitle>Available Tools ({toolCount})</DrawerTitle>
964
- </DrawerHeader>
965
- <div className="flex-1 overflow-y-auto p-4">
966
- <div className="space-y-3">
967
- {input?.tools?.map((tool: any, i: number) => (
968
- <ToolItem key={i} tool={tool} />
969
- ))}
970
- </div>
971
- </div>
972
- </DrawerContent>
973
- </Drawer>
974
- </>
975
- )}
976
-
977
- {providerOptions && Object.keys(providerOptions).length > 0 && (
978
- <>
979
- <span className="text-muted-foreground/30">·</span>
980
- <Drawer direction="right">
981
- <DrawerTrigger asChild>
982
- <button className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer">
983
- <Settings className="size-3" />
984
- Provider options
985
- </button>
986
- </DrawerTrigger>
987
- <DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
988
- <DrawerHeader className="border-b border-border shrink-0">
989
- <DrawerTitle>Provider Options</DrawerTitle>
990
- </DrawerHeader>
991
- <div className="flex-1 overflow-y-auto p-4">
992
- <JsonBlock data={providerOptions} size="lg" />
993
- </div>
994
- </DrawerContent>
995
- </Drawer>
996
- </>
997
- )}
998
-
999
- {usage && (
1000
- <>
1001
- <span className="text-muted-foreground/30">·</span>
1002
- <Drawer direction="right">
1003
- <DrawerTrigger asChild>
1004
- <button className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer">
1005
- <BarChart3 className="size-3" />
1006
- Usage
1007
- </button>
1008
- </DrawerTrigger>
1009
- <DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
1010
- <DrawerHeader className="border-b border-border shrink-0">
1011
- <DrawerTitle>Token Usage</DrawerTitle>
1012
- </DrawerHeader>
1013
- <div className="flex-1 overflow-y-auto p-4">
1014
- <UsageDetails usage={usage} />
1015
- </div>
1016
- </DrawerContent>
1017
- </Drawer>
1018
- </>
1019
- )}
1020
- </div>
1021
- );
1022
- }
1023
-
1024
- function InputPanel({ input }: { input: any }) {
1025
- const messages = input?.prompt ?? [];
1026
- const messageCount = messages.length;
1027
-
1028
- // Get last two messages to display
1029
- const lastTwoMessages = messages.slice(-2);
1030
- const previousMessageCount = Math.max(0, messageCount - 2);
1031
-
1032
- return (
1033
- <Drawer direction="right">
1034
- <DrawerTrigger asChild>
1035
- <button className="w-full h-full text-left p-4 hover:bg-accent/30 transition-colors cursor-pointer flex flex-col justify-start">
1036
- <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-3">
1037
- Input
1038
- </h3>
1039
-
1040
- <div className="space-y-3">
1041
- {/* Previous messages indicator */}
1042
- {previousMessageCount > 0 && (
1043
- <div className="text-[11px] text-muted-foreground/60 text-center py-1.5 rounded-md bg-muted/30">
1044
- + {previousMessageCount} previous{' '}
1045
- {previousMessageCount === 1 ? 'message' : 'messages'}
1046
- </div>
1047
- )}
1048
-
1049
- {/* Last two messages displayed inline */}
1050
- {lastTwoMessages.map((msg: any, i: number) => (
1051
- <InputMessagePreview
1052
- key={i}
1053
- message={msg}
1054
- index={previousMessageCount + i + 1}
1055
- />
1056
- ))}
1057
-
1058
- {/* Empty state */}
1059
- {messageCount === 0 && (
1060
- <p className="text-sm text-muted-foreground">No messages</p>
1061
- )}
1062
- </div>
1063
- </button>
1064
- </DrawerTrigger>
1065
- <DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
1066
- <DrawerHeader className="border-b border-border shrink-0">
1067
- <DrawerTitle>All Messages ({messageCount})</DrawerTitle>
1068
- </DrawerHeader>
1069
- <div className="flex-1 overflow-y-auto p-4">
1070
- <div className="space-y-3">
1071
- {messages.map((msg: any, i: number) => (
1072
- <MessageBubble key={i} message={msg} index={i + 1} />
1073
- ))}
1074
- </div>
1075
- </div>
1076
- </DrawerContent>
1077
- </Drawer>
1078
- );
1079
- }
1080
-
1081
- function InputMessagePreview({
1082
- message,
1083
- index,
1084
- }: {
1085
- message: any;
1086
- index?: number;
1087
- }) {
1088
- const role = message.role;
1089
- const content = message.content;
1090
-
1091
- const roleLabels: Record<string, string> = {
1092
- user: 'User',
1093
- assistant: 'Assistant',
1094
- system: 'System',
1095
- tool: 'Tool',
1096
- };
1097
-
1098
- // Get text content
1099
- const getTextContent = (content: any): string => {
1100
- if (typeof content === 'string') return content;
1101
- if (Array.isArray(content)) {
1102
- return content
1103
- .filter(p => p.type === 'text')
1104
- .map(p => p.text)
1105
- .join('');
1106
- }
1107
- return '';
1108
- };
1109
-
1110
- // Get tool calls from assistant message
1111
- const getToolCalls = (content: any): any[] => {
1112
- if (Array.isArray(content)) {
1113
- return content.filter(p => p.type === 'tool-call');
1114
- }
1115
- return [];
1116
- };
1117
-
1118
- // Get tool results from tool message
1119
- const getToolResults = (content: any): any[] => {
1120
- if (Array.isArray(content)) {
1121
- return content.filter(p => p.type === 'tool-result');
1122
- }
1123
- return [];
1124
- };
1125
-
1126
- // Get reasoning/thinking content
1127
- const getReasoningContent = (content: any): string => {
1128
- if (Array.isArray(content)) {
1129
- return content
1130
- .filter(p => p.type === 'thinking' || p.type === 'reasoning')
1131
- .map(p => p.thinking || p.text || p.reasoning)
1132
- .join('');
1133
- }
1134
- return '';
1135
- };
1136
-
1137
- const textContent = getTextContent(content);
1138
- const toolCalls = getToolCalls(content);
1139
- const toolResults = getToolResults(content);
1140
- const reasoningContent = getReasoningContent(content);
1141
-
1142
- // Count total parts
1143
- const partCount =
1144
- (textContent ? 1 : 0) +
1145
- (reasoningContent ? 1 : 0) +
1146
- toolCalls.length +
1147
- toolResults.length;
1148
-
1149
- return (
1150
- <div className="rounded-md border border-border/50 bg-background/50 p-2.5 space-y-2">
1151
- <div className="flex items-center gap-2">
1152
- {index && (
1153
- <span className="text-[10px] font-mono text-muted-foreground/50">
1154
- {index}
1155
- </span>
1156
- )}
1157
- <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1158
- {roleLabels[role] || role}
1159
- </span>
1160
- {partCount > 1 && (
1161
- <span className="text-[10px] text-muted-foreground/60">
1162
- {partCount} parts
1163
- </span>
1164
- )}
1165
- </div>
1166
-
1167
- {/* Reasoning/Thinking content */}
1168
- {reasoningContent && (
1169
- <div className="text-xs text-amber-500/60">[thinking]</div>
1170
- )}
1171
-
1172
- {/* Text content */}
1173
- {textContent && (
1174
- <div className="text-xs text-foreground/90 line-clamp-3">
1175
- {textContent}
1176
- </div>
1177
- )}
1178
-
1179
- {/* Tool calls (from assistant) */}
1180
- {toolCalls.length > 0 && (
1181
- <div className="space-y-1">
1182
- {toolCalls.slice(0, 3).map((call: any, i: number) => {
1183
- const args = call.args ?? call.input;
1184
- const parsedArgs =
1185
- typeof args === 'string' ? safeParseJson(args) : args;
1186
- return (
1187
- <div
1188
- key={i}
1189
- className="text-[11px] font-mono text-muted-foreground truncate"
1190
- >
1191
- {call.toolName}({formatToolParamsInline(parsedArgs)})
1192
- </div>
1193
- );
1194
- })}
1195
- {toolCalls.length > 3 && (
1196
- <div className="text-[11px] text-muted-foreground/60">
1197
- +{toolCalls.length - 3} more tool{' '}
1198
- {toolCalls.length - 3 === 1 ? 'call' : 'calls'}
1199
- </div>
1200
- )}
1201
- </div>
1202
- )}
1203
-
1204
- {/* Tool results */}
1205
- {toolResults.length > 0 && (
1206
- <div className="space-y-1">
1207
- {toolResults.slice(0, 3).map((result: any, i: number) => {
1208
- const resultContent = result.result ?? result.output ?? result;
1209
- const resultPreview = formatResultPreview(resultContent);
1210
- return (
1211
- <div
1212
- key={i}
1213
- className="text-[11px] font-mono text-muted-foreground truncate"
1214
- >
1215
- {result.toolName || 'tool'}(…) =&gt; {resultPreview}
1216
- </div>
1217
- );
1218
- })}
1219
- {toolResults.length > 3 && (
1220
- <div className="text-[11px] text-muted-foreground/60">
1221
- +{toolResults.length - 3} more tool{' '}
1222
- {toolResults.length - 3 === 1 ? 'result' : 'results'}
1223
- </div>
1224
- )}
1225
- </div>
1226
- )}
1227
-
1228
- {/* Empty content fallback */}
1229
- {!textContent &&
1230
- !reasoningContent &&
1231
- toolCalls.length === 0 &&
1232
- toolResults.length === 0 && (
1233
- <div className="text-[11px] text-muted-foreground italic">
1234
- Empty message
1235
- </div>
1236
- )}
1237
- </div>
1238
- );
1239
- }
1240
-
1241
- function ToolItem({ tool }: { tool: any }) {
1242
- const [expanded, setExpanded] = useState(false);
1243
-
1244
- return (
1245
- <div className="rounded-md border border-border bg-background overflow-hidden">
1246
- <button
1247
- className="w-full flex items-center justify-between p-2.5 hover:bg-accent/50 transition-colors"
1248
- onClick={() => setExpanded(!expanded)}
1249
- >
1250
- <span className="text-sm font-mono text-purple">{tool.name}</span>
1251
- {tool.parameters && (
1252
- <ChevronRight
1253
- className={`size-3 text-muted-foreground transition-transform ${
1254
- expanded ? 'rotate-90' : ''
1255
- }`}
1256
- />
1257
- )}
1258
- </button>
1259
- {expanded && tool.parameters && (
1260
- <div className="px-2.5 pb-2.5 border-t border-border">
1261
- {tool.description && (
1262
- <p className="text-xs text-muted-foreground mb-2 pt-2">
1263
- {tool.description}
1264
- </p>
1265
- )}
1266
- <JsonBlock data={tool.parameters} compact />
1267
- </div>
1268
- )}
1269
- {!expanded && tool.description && (
1270
- <div className="px-2.5 pb-2 -mt-1">
1271
- <p className="text-[11px] text-muted-foreground truncate">
1272
- {tool.description}
1273
- </p>
1274
- </div>
1275
- )}
1276
- </div>
1277
- );
1278
- }
1279
-
1280
- function CollapsibleToolCall({
1281
- toolName,
1282
- toolCallId,
1283
- data,
1284
- }: {
1285
- toolName: string;
1286
- toolCallId?: string;
1287
- data: any;
1288
- }) {
1289
- const [expanded, setExpanded] = useState(false);
1290
- const parsedData = typeof data === 'string' ? safeParseJson(data) : data;
1291
-
1292
- return (
1293
- <div className="rounded-md border border-purple/30 overflow-hidden">
1294
- <button
1295
- className="w-full flex items-center gap-2 px-3 py-2 bg-purple/10 hover:bg-purple/20 transition-colors"
1296
- onClick={() => setExpanded(!expanded)}
1297
- >
1298
- <ChevronRight
1299
- className={`size-3 text-purple transition-transform shrink-0 ${
1300
- expanded ? 'rotate-90' : ''
1301
- }`}
1302
- />
1303
- <Wrench className="size-3 text-purple shrink-0" />
1304
- <span className="text-xs font-mono font-medium text-purple">
1305
- {toolName}
1306
- </span>
1307
- {!expanded && parsedData && (
1308
- <span className="text-[11px] font-mono text-purple/70 truncate">
1309
- {formatToolParams(parsedData)}
1310
- </span>
1311
- )}
1312
- {toolCallId && (
1313
- <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto shrink-0">
1314
- {toolCallId}
1315
- </span>
1316
- )}
1317
- </button>
1318
- {expanded && (
1319
- <div className="p-3 bg-card/50 border-t border-purple/30">
1320
- <JsonBlock data={parsedData} />
1321
- </div>
1322
- )}
1323
- </div>
1324
- );
1325
- }
1326
-
1327
- function CollapsibleToolResult({
1328
- toolName,
1329
- toolCallId,
1330
- data,
1331
- }: {
1332
- toolName?: string;
1333
- toolCallId?: string;
1334
- data: any;
1335
- }) {
1336
- const [expanded, setExpanded] = useState(false);
1337
-
1338
- return (
1339
- <div className="rounded-md border border-success/30 overflow-hidden">
1340
- <button
1341
- className="w-full flex items-center gap-2 px-3 py-2 bg-success/10 hover:bg-success/20 transition-colors"
1342
- onClick={() => setExpanded(!expanded)}
1343
- >
1344
- <ChevronRight
1345
- className={`size-3 text-success transition-transform shrink-0 ${
1346
- expanded ? 'rotate-90' : ''
1347
- }`}
1348
- />
1349
- <span className="text-xs font-medium text-success">Result</span>
1350
- {toolName && (
1351
- <span className="text-[11px] font-mono text-muted-foreground">
1352
- {toolName}
1353
- </span>
1354
- )}
1355
- {toolCallId && (
1356
- <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto shrink-0">
1357
- {toolCallId}
1358
- </span>
1359
- )}
1360
- </button>
1361
- {expanded && (
1362
- <div className="p-3 bg-card/50 border-t border-success/30">
1363
- <JsonBlock data={data} />
1364
- </div>
1365
- )}
1366
- </div>
1367
- );
1368
- }
1369
-
1370
- function MessageBubble({ message, index }: { message: any; index?: number }) {
1371
- const role = message.role;
1372
- const content = message.content;
1373
-
1374
- const roleLabels: Record<string, string> = {
1375
- user: 'User',
1376
- assistant: 'Assistant',
1377
- system: 'System',
1378
- tool: 'Tool',
1379
- };
1380
-
1381
- const getTextContent = (content: any): string => {
1382
- if (typeof content === 'string') return content;
1383
- if (Array.isArray(content)) {
1384
- return content
1385
- .filter(p => p.type === 'text')
1386
- .map(p => p.text)
1387
- .join('');
1388
- }
1389
- return '';
1390
- };
1391
-
1392
- const getToolCalls = (content: any): any[] => {
1393
- if (Array.isArray(content)) {
1394
- return content.filter(p => p.type === 'tool-call');
1395
- }
1396
- return [];
1397
- };
1398
-
1399
- const getToolResults = (content: any): any[] => {
1400
- if (Array.isArray(content)) {
1401
- return content.filter(p => p.type === 'tool-result');
1402
- }
1403
- return [];
1404
- };
1405
-
1406
- const getReasoningContent = (content: any): string => {
1407
- if (Array.isArray(content)) {
1408
- return content
1409
- .filter(p => p.type === 'thinking' || p.type === 'reasoning')
1410
- .map(p => p.thinking || p.text || p.reasoning)
1411
- .join('');
1412
- }
1413
- return '';
1414
- };
1415
-
1416
- const textContent = getTextContent(content);
1417
- const toolCalls = getToolCalls(content);
1418
- const toolResults = getToolResults(content);
1419
- const reasoningContent = getReasoningContent(content);
1420
-
1421
- // Count total parts
1422
- const partCount =
1423
- (textContent ? 1 : 0) +
1424
- (reasoningContent ? 1 : 0) +
1425
- toolCalls.length +
1426
- toolResults.length;
1427
-
1428
- return (
1429
- <div className="rounded-md border border-border/50 bg-background/50 p-3 space-y-2">
1430
- <div className="flex items-center gap-2">
1431
- {index && (
1432
- <span className="text-[10px] font-mono text-muted-foreground/50">
1433
- {index}
1434
- </span>
1435
- )}
1436
- <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1437
- {roleLabels[role] || role}
1438
- </span>
1439
- {partCount > 1 && (
1440
- <span className="text-[10px] text-muted-foreground/60">
1441
- {partCount} parts
1442
- </span>
1443
- )}
1444
- </div>
1445
-
1446
- {/* Reasoning/Thinking content - full display */}
1447
- {reasoningContent && <ReasoningBlock content={reasoningContent} />}
1448
-
1449
- {/* Full text content */}
1450
- {textContent && (
1451
- <TextBlock
1452
- content={textContent}
1453
- defaultExpanded={
1454
- !reasoningContent &&
1455
- toolCalls.length === 0 &&
1456
- toolResults.length === 0
1457
- }
1458
- isSystem={role === 'system'}
1459
- />
1460
- )}
1461
-
1462
- {/* Tool calls */}
1463
- {toolCalls.length > 0 && (
1464
- <div className="space-y-2">
1465
- {toolCalls.map((call: any, i: number) => (
1466
- <CollapsibleToolCall
1467
- key={i}
1468
- toolName={call.toolName}
1469
- toolCallId={call.toolCallId}
1470
- data={call.args ?? call.input}
1471
- />
1472
- ))}
1473
- </div>
1474
- )}
1475
-
1476
- {/* Tool results */}
1477
- {toolResults.length > 0 && (
1478
- <div className="space-y-2">
1479
- {toolResults.map((result: any, i: number) => (
1480
- <CollapsibleToolResult
1481
- key={i}
1482
- toolName={result.toolName}
1483
- toolCallId={result.toolCallId}
1484
- data={result.result ?? result.output ?? result}
1485
- />
1486
- ))}
1487
- </div>
1488
- )}
1489
-
1490
- {/* Empty content fallback */}
1491
- {!textContent &&
1492
- !reasoningContent &&
1493
- toolCalls.length === 0 &&
1494
- toolResults.length === 0 && (
1495
- <div className="text-[11px] text-muted-foreground italic">
1496
- Empty message
1497
- </div>
1498
- )}
1499
- </div>
1500
- );
1501
- }
1502
-
1503
- function OutputDisplay({
1504
- output,
1505
- toolResults = [],
1506
- }: {
1507
- output: any;
1508
- toolResults?: any[];
1509
- }) {
1510
- const getToolResult = (toolCallId: string) => {
1511
- return toolResults.find((r: any) => r.toolCallId === toolCallId);
1512
- };
1513
-
1514
- const toolCalls =
1515
- output?.toolCalls ||
1516
- output?.content?.filter((p: any) => p.type === 'tool-call') ||
1517
- [];
1518
-
1519
- const textParts =
1520
- output?.textParts ||
1521
- output?.content?.filter((p: any) => p.type === 'text') ||
1522
- [];
1523
-
1524
- // Get reasoning/thinking parts
1525
- const reasoningParts =
1526
- output?.reasoningParts ||
1527
- output?.content?.filter(
1528
- (p: any) => p.type === 'thinking' || p.type === 'reasoning',
1529
- ) ||
1530
- [];
1531
-
1532
- const textContent = textParts.map((p: any) => p.text).join('');
1533
- const reasoningContent = reasoningParts.map((p: any) => p.text).join('');
1534
-
1535
- // Check if text is the only content
1536
- const isTextOnly = textContent && !reasoningContent && toolCalls.length === 0;
1537
-
1538
- return (
1539
- <div className="space-y-3">
1540
- {/* Reasoning/Thinking */}
1541
- {reasoningContent && <ReasoningBlock content={reasoningContent} />}
1542
-
1543
- {/* Text Response */}
1544
- {textContent && (
1545
- <TextBlock content={textContent} defaultExpanded={isTextOnly} />
1546
- )}
1547
-
1548
- {/* Tool Calls with Results */}
1549
- {toolCalls.map((call: any, i: number) => {
1550
- const result = getToolResult(call.toolCallId);
1551
- return (
1552
- <ToolCallCard
1553
- key={i}
1554
- toolName={call.toolName}
1555
- args={call.args ?? call.input}
1556
- result={result?.output ?? result?.result}
1557
- />
1558
- );
1559
- })}
1560
- </div>
1561
- );
1562
- }
1563
-
1564
- function ToolCallCard({
1565
- toolName,
1566
- args,
1567
- result,
1568
- }: {
1569
- toolName: string;
1570
- args: any;
1571
- result?: any;
1572
- }) {
1573
- const [expanded, setExpanded] = useState(false);
1574
- const parsedArgs = typeof args === 'string' ? safeParseJson(args) : args;
1575
- const parsedResult =
1576
- typeof result === 'string' ? safeParseJson(result) : result;
1577
-
1578
- return (
1579
- <div className="rounded-md border border-purple/30 overflow-hidden">
1580
- {/* Tool Call Header */}
1581
- <button
1582
- className="w-full flex items-center gap-2 px-3 py-2 bg-purple/10 hover:bg-purple/20 transition-colors"
1583
- onClick={() => setExpanded(!expanded)}
1584
- >
1585
- <ChevronRight
1586
- className={`size-3 text-purple transition-transform shrink-0 ${
1587
- expanded ? 'rotate-90' : ''
1588
- }`}
1589
- />
1590
- <Wrench className="size-3 text-purple shrink-0" />
1591
- <span className="text-xs font-mono font-medium text-purple">
1592
- {toolName}
1593
- </span>
1594
- {!expanded && parsedArgs && (
1595
- <span className="text-[11px] font-mono text-purple/70 truncate">
1596
- {formatToolParams(parsedArgs)}
1597
- </span>
1598
- )}
1599
- </button>
1600
-
1601
- {expanded && (
1602
- <>
1603
- {/* Arguments */}
1604
- <div className="p-3 bg-card/50 border-t border-purple/30">
1605
- <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-2">
1606
- Input
1607
- </div>
1608
- <JsonBlock data={parsedArgs} />
1609
- </div>
1610
-
1611
- {/* Result */}
1612
- {parsedResult && (
1613
- <div className="p-3 border-t border-border bg-success/5">
1614
- <div className="text-[10px] font-medium uppercase tracking-wider text-success mb-2">
1615
- Output
1616
- </div>
1617
- <JsonBlock data={parsedResult} />
1618
- </div>
1619
- )}
1620
- </>
1621
- )}
1622
- </div>
1623
- );
1624
- }
1625
-
1626
- function ReasoningBlock({ content }: { content: string }) {
1627
- const [expanded, setExpanded] = useState(false);
1628
-
1629
- // Truncate preview to first 200 characters
1630
- const previewContent =
1631
- content.length > 200 ? content.slice(0, 200) + '…' : content;
1632
-
1633
- return (
1634
- <div className="rounded-md border border-amber-500/30 overflow-hidden">
1635
- <button
1636
- className="w-full flex items-center gap-2 px-3 py-2 bg-amber-500/10 hover:bg-amber-500/20 transition-colors"
1637
- onClick={() => setExpanded(!expanded)}
1638
- >
1639
- <ChevronRight
1640
- className={`size-3 text-amber-500 transition-transform shrink-0 ${
1641
- expanded ? 'rotate-90' : ''
1642
- }`}
1643
- />
1644
- <Brain className="size-3 text-amber-500 shrink-0" />
1645
- <span className="text-xs font-medium text-amber-500">Thinking</span>
1646
- {!expanded && (
1647
- <span className="text-[11px] text-amber-500/70 truncate ml-1">
1648
- {previewContent}
1649
- </span>
1650
- )}
1651
- </button>
1652
-
1653
- {expanded && (
1654
- <div className="p-3 bg-card/50 border-t border-amber-500/30">
1655
- <div className="text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
1656
- {content}
1657
- </div>
1658
- </div>
1659
- )}
1660
- </div>
1661
- );
1662
- }
1663
-
1664
- function TextBlock({
1665
- content,
1666
- defaultExpanded = false,
1667
- isSystem = false,
1668
- }: {
1669
- content: string;
1670
- defaultExpanded?: boolean;
1671
- isSystem?: boolean;
1672
- }) {
1673
- const [expanded, setExpanded] = useState(defaultExpanded);
1674
- const [copied, setCopied] = useState(false);
1675
-
1676
- // Truncate preview to first 200 characters
1677
- const previewContent =
1678
- content.length > 200 ? content.slice(0, 200) + '…' : content;
1679
-
1680
- const borderColor = isSystem ? 'border-blue-500/30' : 'border-border';
1681
- const bgColor = isSystem ? 'bg-blue-500/10' : 'bg-muted/30';
1682
- const hoverBgColor = isSystem ? 'hover:bg-blue-500/20' : 'hover:bg-muted/50';
1683
- const iconColor = isSystem ? 'text-blue-400' : 'text-muted-foreground';
1684
- const labelColor = isSystem ? 'text-blue-400' : 'text-foreground';
1685
-
1686
- const handleCopy = async (e: React.MouseEvent) => {
1687
- e.stopPropagation();
1688
- await navigator.clipboard.writeText(content);
1689
- setCopied(true);
1690
- setTimeout(() => setCopied(false), 2000);
1691
- };
1692
-
1693
- return (
1694
- <div className={`rounded-md border ${borderColor} overflow-hidden`}>
1695
- <button
1696
- className={`w-full flex items-center gap-2 px-3 py-2 ${bgColor} ${hoverBgColor} transition-colors`}
1697
- onClick={() => setExpanded(!expanded)}
1698
- >
1699
- <ChevronRight
1700
- className={`size-3 ${iconColor} transition-transform shrink-0 ${
1701
- expanded ? 'rotate-90' : ''
1702
- }`}
1703
- />
1704
- <MessageSquare className={`size-3 ${iconColor} shrink-0`} />
1705
- <span className={`text-xs font-medium ${labelColor}`}>Text</span>
1706
- {!expanded && (
1707
- <span
1708
- className={`text-[11px] ${isSystem ? 'text-blue-400/70' : 'text-muted-foreground'} truncate ml-1`}
1709
- >
1710
- {previewContent}
1711
- </span>
1712
- )}
1713
- </button>
1714
-
1715
- {expanded && (
1716
- <div
1717
- className={`p-3 bg-card/50 border-t ${borderColor} group relative`}
1718
- >
1719
- <button
1720
- onClick={handleCopy}
1721
- className="absolute top-1.5 right-1.5 p-1.5 rounded-md border border-border bg-background opacity-0 group-hover:opacity-100 transition-opacity z-10"
1722
- title="Copy to clipboard"
1723
- >
1724
- {copied ? (
1725
- <Check className="size-3 text-success" />
1726
- ) : (
1727
- <Copy className="size-3 text-muted-foreground" />
1728
- )}
1729
- </button>
1730
- <div className="text-xs text-foreground leading-relaxed whitespace-pre-wrap max-h-60 overflow-y-auto">
1731
- {content}
1732
- </div>
1733
- </div>
1734
- )}
1735
- </div>
1736
- );
1737
- }
1738
-
1739
- function JsonBlock({
1740
- data,
1741
- compact = false,
1742
- size = 'sm',
1743
- }: {
1744
- data: any;
1745
- compact?: boolean;
1746
- size?: 'sm' | 'base' | 'lg';
1747
- }) {
1748
- const [copied, setCopied] = useState(false);
1749
-
1750
- const jsonString = JSON.stringify(data, null, 2);
1751
- const displayString =
1752
- compact && jsonString.length > 200 ? JSON.stringify(data) : jsonString;
1753
-
1754
- const sizeClasses = {
1755
- sm: 'text-xs',
1756
- base: 'text-sm',
1757
- lg: 'text-base',
1758
- };
1759
-
1760
- const handleCopy = async () => {
1761
- await navigator.clipboard.writeText(jsonString);
1762
- setCopied(true);
1763
- setTimeout(() => setCopied(false), 2000);
1764
- };
1765
-
1766
- return (
1767
- <div className="relative group">
1768
- <button
1769
- onClick={handleCopy}
1770
- className="absolute top-1.5 right-1.5 p-1.5 rounded-md border border-border bg-background opacity-0 group-hover:opacity-100 transition-opacity z-10"
1771
- title="Copy to clipboard"
1772
- >
1773
- {copied ? (
1774
- <Check className="size-3 text-success" />
1775
- ) : (
1776
- <Copy className="size-3 text-muted-foreground" />
1777
- )}
1778
- </button>
1779
- <pre
1780
- className={`font-mono ${sizeClasses[size]} text-muted-foreground whitespace-pre-wrap wrap-break-words bg-background rounded p-2 ${
1781
- compact ? 'max-h-20 overflow-hidden' : ''
1782
- }`}
1783
- >
1784
- {displayString}
1785
- </pre>
1786
- </div>
1787
- );
1788
- }
1789
-
1790
- function RawDataSection({
1791
- rawRequest,
1792
- rawResponse,
1793
- rawChunks,
1794
- isStream,
1795
- }: {
1796
- rawRequest: string | null;
1797
- rawResponse: string | null;
1798
- rawChunks: string | null;
1799
- isStream: boolean;
1800
- }) {
1801
- const [expanded, setExpanded] = useState(false);
1802
- const [responseView, setResponseView] = useState<'parsed' | 'raw'>('parsed');
1803
-
1804
- if (!rawRequest && !rawResponse) return null;
1805
-
1806
- const hasRawChunks = isStream && rawChunks;
1807
-
1808
- return (
1809
- <div className="border-t border-border">
1810
- <button
1811
- className="w-full flex items-center gap-2 px-4 py-2.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
1812
- onClick={() => setExpanded(!expanded)}
1813
- >
1814
- <ChevronRight
1815
- className={`size-3 transition-transform ${expanded ? 'rotate-90' : ''}`}
1816
- />
1817
- <span className="font-medium uppercase tracking-wider">
1818
- Request / Response
1819
- </span>
1820
- </button>
1821
-
1822
- {expanded && (
1823
- <div className="px-4 pb-4 grid grid-cols-2 gap-4">
1824
- {rawRequest && (
1825
- <div className="flex flex-col">
1826
- <div className="h-7 flex items-end mb-2">
1827
- <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1828
- Request
1829
- </span>
1830
- </div>
1831
- <div className="max-h-[400px] overflow-auto rounded-md border border-border">
1832
- <JsonBlock data={safeParseJson(rawRequest)} />
1833
- </div>
1834
- </div>
1835
- )}
1836
- {(rawResponse || rawChunks) && (
1837
- <div className="flex flex-col">
1838
- <div className="h-7 flex items-end justify-between mb-2">
1839
- <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1840
- {isStream ? 'Stream' : 'Response'}
1841
- </span>
1842
- {hasRawChunks && (
1843
- <div className="inline-flex rounded-md border border-border bg-muted/30 p-0.5">
1844
- <button
1845
- onClick={() => setResponseView('parsed')}
1846
- className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors ${
1847
- responseView === 'parsed'
1848
- ? 'bg-background text-foreground shadow-sm'
1849
- : 'text-muted-foreground hover:text-foreground'
1850
- }`}
1851
- >
1852
- AI SDK
1853
- </button>
1854
- <button
1855
- onClick={() => setResponseView('raw')}
1856
- className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors ${
1857
- responseView === 'raw'
1858
- ? 'bg-background text-foreground shadow-sm'
1859
- : 'text-muted-foreground hover:text-foreground'
1860
- }`}
1861
- >
1862
- Provider
1863
- </button>
1864
- </div>
1865
- )}
1866
- </div>
1867
- <div className="max-h-[400px] overflow-auto rounded-md border border-border">
1868
- <JsonBlock
1869
- data={safeParseJson(
1870
- hasRawChunks && responseView === 'raw'
1871
- ? rawChunks
1872
- : rawResponse,
1873
- )}
1874
- />
1875
- </div>
1876
- </div>
1877
- )}
1878
- </div>
1879
- )}
1880
- </div>
1881
- );
1882
- }
1883
-
1884
- function UsageDetails({ usage }: { usage: any }) {
1885
- const inputBreakdown = getInputTokenBreakdown(usage?.inputTokens);
1886
- const outputBreakdown = getOutputTokenBreakdown(usage?.outputTokens);
1887
-
1888
- return (
1889
- <div className="space-y-6">
1890
- {/* Summary Cards */}
1891
- <div className="grid grid-cols-2 gap-4">
1892
- <div className="rounded-lg border border-border bg-card p-4">
1893
- <div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
1894
- Input Tokens
1895
- </div>
1896
- <div className="text-2xl font-semibold">{inputBreakdown.total}</div>
1897
- {(inputBreakdown.cacheRead !== undefined ||
1898
- inputBreakdown.cacheWrite !== undefined) && (
1899
- <div className="mt-3 space-y-1 text-sm text-muted-foreground">
1900
- {inputBreakdown.cacheRead !== undefined && (
1901
- <div className="flex justify-between">
1902
- <span>Cache read</span>
1903
- <span className="font-mono">{inputBreakdown.cacheRead}</span>
1904
- </div>
1905
- )}
1906
- {inputBreakdown.cacheWrite !== undefined && (
1907
- <div className="flex justify-between">
1908
- <span>Cache write</span>
1909
- <span className="font-mono">{inputBreakdown.cacheWrite}</span>
1910
- </div>
1911
- )}
1912
- {inputBreakdown.noCache !== undefined && (
1913
- <div className="flex justify-between">
1914
- <span>No cache</span>
1915
- <span className="font-mono">{inputBreakdown.noCache}</span>
1916
- </div>
1917
- )}
1918
- </div>
1919
- )}
1920
- </div>
1921
-
1922
- <div className="rounded-lg border border-border bg-card p-4">
1923
- <div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
1924
- Output Tokens
1925
- </div>
1926
- <div className="text-2xl font-semibold">{outputBreakdown.total}</div>
1927
- {(outputBreakdown.text !== undefined ||
1928
- outputBreakdown.reasoning !== undefined) && (
1929
- <div className="mt-3 space-y-1 text-sm text-muted-foreground">
1930
- {outputBreakdown.text !== undefined && (
1931
- <div className="flex justify-between">
1932
- <span>Text</span>
1933
- <span className="font-mono">{outputBreakdown.text}</span>
1934
- </div>
1935
- )}
1936
- {outputBreakdown.reasoning !== undefined && (
1937
- <div className="flex justify-between">
1938
- <span>Reasoning</span>
1939
- <span className="font-mono">{outputBreakdown.reasoning}</span>
1940
- </div>
1941
- )}
1942
- </div>
1943
- )}
1944
- </div>
1945
- </div>
1946
-
1947
- {/* Raw Usage Data */}
1948
- {usage?.raw && (
1949
- <div>
1950
- <div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
1951
- Raw Provider Usage
1952
- </div>
1953
- <JsonBlock data={usage.raw} size="sm" />
1954
- </div>
1955
- )}
1956
-
1957
- {/* Full Usage Object */}
1958
- <div>
1959
- <div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
1960
- Full Usage Object
1961
- </div>
1962
- <JsonBlock data={usage} size="sm" />
1963
- </div>
1964
- </div>
1965
- );
1966
- }
1967
-
1968
- function TokenBreakdownTooltip({
1969
- input,
1970
- output,
1971
- raw,
1972
- }: {
1973
- input: InputTokenBreakdown;
1974
- output: OutputTokenBreakdown;
1975
- raw?: unknown;
1976
- }) {
1977
- // Check if we have any breakdown metadata (even if values are 0)
1978
- const hasInputBreakdown =
1979
- input.noCache !== undefined ||
1980
- input.cacheRead !== undefined ||
1981
- input.cacheWrite !== undefined;
1982
- const hasOutputBreakdown =
1983
- output.text !== undefined || output.reasoning !== undefined;
1984
- const hasBreakdown = hasInputBreakdown || hasOutputBreakdown;
1985
-
1986
- if (!hasBreakdown) {
1987
- return (
1988
- <div className="text-xs">
1989
- <div>Input: {input.total}</div>
1990
- <div>Output: {output.total}</div>
1991
- </div>
1992
- );
1993
- }
1994
-
1995
- return (
1996
- <div className="text-xs space-y-2">
1997
- <div>
1998
- <div className="font-medium mb-1">Input: {input.total}</div>
1999
- {input.cacheRead !== undefined && (
2000
- <div className="text-muted-foreground ml-2">
2001
- Cache read: {input.cacheRead}
2002
- </div>
2003
- )}
2004
- {input.cacheWrite !== undefined && (
2005
- <div className="text-muted-foreground ml-2">
2006
- Cache write: {input.cacheWrite}
2007
- </div>
2008
- )}
2009
- </div>
2010
- <div>
2011
- <div className="font-medium mb-1">Output: {output.total}</div>
2012
- {output.text !== undefined && (
2013
- <div className="text-muted-foreground ml-2">Text: {output.text}</div>
2014
- )}
2015
- {output.reasoning !== undefined && (
2016
- <div className="text-muted-foreground ml-2">
2017
- Reasoning: {output.reasoning}
2018
- </div>
2019
- )}
2020
- </div>
2021
- {raw !== undefined && (
2022
- <div className="pt-1 border-t border-border mt-1">
2023
- <div className="text-muted-foreground/70 font-mono text-[10px] max-w-[200px] truncate">
2024
- Raw: {JSON.stringify(raw)}
2025
- </div>
2026
- </div>
2027
- )}
2028
- </div>
2029
- );
2030
- }
2031
-
2032
- function safeParseJson(value: any): any {
2033
- if (typeof value === 'string') {
2034
- try {
2035
- return JSON.parse(value);
2036
- } catch {
2037
- return value;
2038
- }
2039
- }
2040
- return value;
2041
- }
2042
-
2043
- /**
2044
- * Token usage can be either a number (old format) or an object with breakdown (ai@6.0.0-beta.139+).
2045
- * Old format: inputTokens: 4456
2046
- * New format for input: { total: 4456, noCache: 9, cacheRead: 4100, cacheWrite: 347 }
2047
- * New format for output: { total: 1262, text: 1262, reasoning: 0 }
2048
- */
2049
- interface InputTokenBreakdown {
2050
- total: number;
2051
- noCache?: number;
2052
- cacheRead?: number;
2053
- cacheWrite?: number;
2054
- }
2055
-
2056
- interface OutputTokenBreakdown {
2057
- total: number;
2058
- text?: number;
2059
- reasoning?: number;
2060
- }
2061
-
2062
- function getInputTokenBreakdown(
2063
- tokens: number | InputTokenBreakdown | null | undefined,
2064
- ): InputTokenBreakdown {
2065
- if (tokens == null) return { total: 0 };
2066
- if (typeof tokens === 'number') return { total: tokens };
2067
- if (typeof tokens === 'object') {
2068
- // Handle case where total might be missing or not a number
2069
- const total =
2070
- 'total' in tokens && typeof tokens.total === 'number' ? tokens.total : 0;
2071
- return {
2072
- total,
2073
- // Only include cache fields if they are actual numbers
2074
- ...(typeof tokens.noCache === 'number' && { noCache: tokens.noCache }),
2075
- ...(typeof tokens.cacheRead === 'number' && {
2076
- cacheRead: tokens.cacheRead,
2077
- }),
2078
- ...(typeof tokens.cacheWrite === 'number' && {
2079
- cacheWrite: tokens.cacheWrite,
2080
- }),
2081
- };
2082
- }
2083
- return { total: 0 };
2084
- }
2085
-
2086
- function getOutputTokenBreakdown(
2087
- tokens: number | OutputTokenBreakdown | null | undefined,
2088
- ): OutputTokenBreakdown {
2089
- if (tokens == null) return { total: 0 };
2090
- if (typeof tokens === 'number') return { total: tokens };
2091
- if (typeof tokens === 'object') {
2092
- // Handle case where total might be missing or not a number
2093
- const total =
2094
- 'total' in tokens && typeof tokens.total === 'number' ? tokens.total : 0;
2095
- return {
2096
- total,
2097
- // Only include text/reasoning if they are actual numbers
2098
- ...(typeof tokens.text === 'number' && { text: tokens.text }),
2099
- ...(typeof tokens.reasoning === 'number' && {
2100
- reasoning: tokens.reasoning,
2101
- }),
2102
- };
2103
- }
2104
- return { total: 0 };
2105
- }
2106
-
2107
- /**
2108
- * Formats input token count with cache info in parentheses if available.
2109
- * e.g., "4456" or "4456 (4100 cached)"
2110
- */
2111
- function formatInputTokens(breakdown: InputTokenBreakdown): string {
2112
- const { total, cacheRead } = breakdown;
2113
- if (cacheRead && cacheRead > 0) {
2114
- return `${total} (${cacheRead} cached)`;
2115
- }
2116
- return String(total);
2117
- }
2118
-
2119
- /**
2120
- * Formats output token count with reasoning info if available.
2121
- * e.g., "1054" or "1054 (500 reasoning)"
2122
- */
2123
- function formatOutputTokens(breakdown: OutputTokenBreakdown): string {
2124
- const { total, reasoning } = breakdown;
2125
- if (reasoning && reasoning > 0) {
2126
- return `${total} (${reasoning} reasoning)`;
2127
- }
2128
- return String(total);
2129
- }
2130
-
2131
- /**
2132
- * Truncates tool call parameters for preview display.
2133
- */
2134
- function formatToolParams(args: any, maxLength = 40): string {
2135
- if (!args || typeof args !== 'object') return '';
2136
-
2137
- const entries = Object.entries(args);
2138
- if (entries.length === 0) return '';
2139
-
2140
- const formatValue = (value: any, maxLen = 20): string => {
2141
- if (value === null) return 'null';
2142
- if (value === undefined) return 'undefined';
2143
- if (typeof value === 'object') {
2144
- if (Array.isArray(value)) {
2145
- return `[${value.length}]`;
2146
- }
2147
- return '{…}';
2148
- }
2149
- if (typeof value === 'string') {
2150
- if (value.length > maxLen) {
2151
- return `"${value.slice(0, maxLen)}…"`;
2152
- }
2153
- return `"${value}"`;
2154
- }
2155
- return String(value);
2156
- };
2157
-
2158
- const firstEntry = entries[0];
2159
- if (!firstEntry) return '';
2160
-
2161
- const [firstKey, firstValue] = firstEntry;
2162
- const firstFormatted = `${firstKey}: ${formatValue(firstValue)}`;
2163
-
2164
- if (entries.length === 1) {
2165
- return `{ ${firstFormatted} }`;
2166
- }
2167
-
2168
- return `{ ${firstFormatted}, … }`;
2169
- }
2170
-
2171
- /**
2172
- * Formats tool params inline for JS-like syntax: { key: "value", … }
2173
- */
2174
- function formatToolParamsInline(args: any): string {
2175
- if (!args || typeof args !== 'object') return '';
2176
-
2177
- const entries = Object.entries(args);
2178
- if (entries.length === 0) return '';
2179
-
2180
- const formatValue = (value: any, maxLen = 20): string => {
2181
- if (value === null) return 'null';
2182
- if (value === undefined) return 'undefined';
2183
- if (typeof value === 'object') {
2184
- if (Array.isArray(value)) {
2185
- return `[${value.length}]`;
2186
- }
2187
- return '{…}';
2188
- }
2189
- if (typeof value === 'string') {
2190
- if (value.length > maxLen) {
2191
- return `"${value.slice(0, maxLen)}…"`;
2192
- }
2193
- return `"${value}"`;
2194
- }
2195
- return String(value);
2196
- };
2197
-
2198
- const firstEntry = entries[0];
2199
- if (!firstEntry) return '';
2200
-
2201
- const [firstKey, firstValue] = firstEntry;
2202
- const firstFormatted = `${firstKey}: ${formatValue(firstValue)}`;
2203
-
2204
- if (entries.length === 1) {
2205
- return `{ ${firstFormatted} }`;
2206
- }
2207
-
2208
- return `{ ${firstFormatted}, … }`;
2209
- }
2210
-
2211
- /**
2212
- * Formats tool result for preview display in JS-like syntax
2213
- */
2214
- function formatResultPreview(result: any): string {
2215
- if (result === null) return 'null';
2216
- if (result === undefined) return 'undefined';
2217
-
2218
- if (typeof result === 'string') {
2219
- if (result.length > 30) {
2220
- return `"${result.slice(0, 30)}…"`;
2221
- }
2222
- return `"${result}"`;
2223
- }
2224
-
2225
- if (Array.isArray(result)) {
2226
- return `[${result.length}]`;
2227
- }
2228
-
2229
- if (typeof result === 'object') {
2230
- const entries = Object.entries(result);
2231
- if (entries.length === 0) return '{}';
2232
-
2233
- const firstEntry = entries[0];
2234
- if (!firstEntry) return '{…}';
2235
-
2236
- const [firstKey, firstValue] = firstEntry;
2237
- let valueStr: string;
2238
- if (typeof firstValue === 'string' && firstValue.length > 15) {
2239
- valueStr = `"${firstValue.slice(0, 15)}…"`;
2240
- } else if (typeof firstValue === 'string') {
2241
- valueStr = `"${firstValue}"`;
2242
- } else if (typeof firstValue === 'object') {
2243
- valueStr = Array.isArray(firstValue) ? `[${firstValue.length}]` : '{…}';
2244
- } else {
2245
- valueStr = String(firstValue);
2246
- }
2247
-
2248
- if (entries.length === 1) {
2249
- return `{ ${firstKey}: ${valueStr} }`;
2250
- }
2251
- return `{ ${firstKey}: ${valueStr}, … }`;
2252
- }
2253
-
2254
- return String(result);
2255
- }
2256
-
2257
476
  export default App;