@tonyclaw/agent-inspector 2.0.4 → 2.0.6

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.
Files changed (61) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BCH_fsLm.js → CompareDrawer-DDmqSAfl.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-Cxpdziwd.js +101 -0
  4. package/.output/public/assets/ReplayDialog-Bt5DGzlh.js +1 -0
  5. package/.output/public/assets/RequestAnatomy-BxX3_N9S.js +1 -0
  6. package/.output/public/assets/ResponseView-Bl_5S9gZ.js +1 -0
  7. package/.output/public/assets/StreamingChunkSequence-RJMwNf6F.js +1 -0
  8. package/.output/public/assets/_sessionId-b4isaoDp.js +1 -0
  9. package/.output/public/assets/index-BZ4x5UI6.js +1 -0
  10. package/.output/public/assets/{index-CobXD0yH.css → index-C624DUk9.css} +1 -1
  11. package/.output/public/assets/{json-viewer-BrzjD7qI.js → json-viewer-CRL_gWEZ.js} +1 -1
  12. package/.output/public/assets/{main-mgxeUdZQ.js → main-CKnTJ4-O.js} +6 -6
  13. package/.output/server/_libs/lucide-react.mjs +181 -114
  14. package/.output/server/{_sessionId-C4xsxIWm.mjs → _sessionId-B-x9fRY3.mjs} +3 -3
  15. package/.output/server/_ssr/{CompareDrawer-DuWEpqQ7.mjs → CompareDrawer-BQVNsAY2.mjs} +6 -6
  16. package/.output/server/_ssr/{ProxyViewerContainer-Cckz5qKu.mjs → ProxyViewerContainer-CYm2Dw19.mjs} +766 -122
  17. package/.output/server/_ssr/{ReplayDialog-BDRcr8E5.mjs → ReplayDialog-CaMQBc79.mjs} +240 -14
  18. package/.output/server/_ssr/{RequestAnatomy-BoO2_Ij0.mjs → RequestAnatomy--P5arRH2.mjs} +236 -66
  19. package/.output/server/_ssr/{ResponseView-DZiPBxvO.mjs → ResponseView-RtFwNvgD.mjs} +8 -8
  20. package/.output/server/_ssr/{StreamingChunkSequence-D-be7KEL.mjs → StreamingChunkSequence-B5HPkzab.mjs} +3 -3
  21. package/.output/server/_ssr/{index-5RImHKfu.mjs → index-CZIKZU43.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{json-viewer-aJhb93ZK.mjs → json-viewer-d4obyRaA.mjs} +3 -3
  24. package/.output/server/_ssr/{router-Dgkv5nKP.mjs → router-DGPt3MUc.mjs} +145 -71
  25. package/.output/server/_tanstack-start-manifest_v-BzH4pNaI.mjs +4 -0
  26. package/.output/server/index.mjs +64 -64
  27. package/package.json +1 -1
  28. package/src/components/OnboardingBanner.tsx +11 -19
  29. package/src/components/ProxyViewer.tsx +1 -1
  30. package/src/components/providers/ProviderCard.tsx +6 -20
  31. package/src/components/providers/SettingsDialog.tsx +95 -2
  32. package/src/components/proxy-viewer/AgentTraceSummary.tsx +639 -38
  33. package/src/components/proxy-viewer/CompareDrawer.tsx +4 -2
  34. package/src/components/proxy-viewer/LogEntry.tsx +4 -4
  35. package/src/components/proxy-viewer/LogEntryHeader.tsx +15 -25
  36. package/src/components/proxy-viewer/ReplayDialog.tsx +190 -8
  37. package/src/components/proxy-viewer/ResponseView.tsx +2 -2
  38. package/src/components/proxy-viewer/ToolTraceEvents.tsx +37 -16
  39. package/src/components/proxy-viewer/TurnGroup.tsx +14 -2
  40. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +196 -45
  41. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +92 -67
  42. package/src/components/proxy-viewer/anatomy/types.ts +15 -13
  43. package/src/components/proxy-viewer/formats/anthropic/ResponseView.tsx +2 -2
  44. package/src/components/proxy-viewer/log-formats/anthropic.ts +1 -1
  45. package/src/components/proxy-viewer/log-formats/openai.ts +1 -1
  46. package/src/components/proxy-viewer/log-formats/types.ts +1 -1
  47. package/src/components/proxy-viewer/replayComparison.ts +131 -0
  48. package/src/components/proxy-viewer/useKeyboardNavigation.ts +64 -22
  49. package/src/components/proxy-viewer/viewerState.ts +14 -2
  50. package/src/components/ui/json-viewer.tsx +1 -1
  51. package/src/knowledge/candidateStore.ts +32 -1
  52. package/src/routes/api/knowledge.candidates.$candidateId.ts +50 -0
  53. package/src/routes/api/knowledge.sessions.$sessionId.candidates.ts +12 -2
  54. package/.output/public/assets/ProxyViewerContainer-D85_UANk.js +0 -101
  55. package/.output/public/assets/ReplayDialog-DTeaHHit.js +0 -1
  56. package/.output/public/assets/RequestAnatomy-DZ8grAih.js +0 -1
  57. package/.output/public/assets/ResponseView-Cldm6RCi.js +0 -1
  58. package/.output/public/assets/StreamingChunkSequence-3x4p-yT7.js +0 -1
  59. package/.output/public/assets/_sessionId-YqWFBu6d.js +0 -1
  60. package/.output/public/assets/index-BIw2H6jO.js +0 -1
  61. package/.output/server/_tanstack-start-manifest_v-B8rrWXjr.mjs +0 -4
@@ -550,11 +550,13 @@ function SideSummary({ log, side }: { log: CapturedLog; side: "left" | "right" }
550
550
  <div className="flex items-center gap-3 text-muted-foreground font-mono">
551
551
  {log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
552
552
  <span className="text-emerald-400">
553
- Cache +{formatTokens(log.cacheCreationInputTokens)}
553
+ KV Cache +{formatTokens(log.cacheCreationInputTokens)}
554
554
  </span>
555
555
  )}
556
556
  {log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
557
- <span className="text-purple-400">Cache ~{formatTokens(log.cacheReadInputTokens)}</span>
557
+ <span className="text-purple-400">
558
+ KV Cache ~{formatTokens(log.cacheReadInputTokens)}
559
+ </span>
558
560
  )}
559
561
  <span className="truncate" title={log.timestamp}>
560
562
  {log.timestamp}
@@ -177,7 +177,7 @@ export const LogEntry = memo(function ({
177
177
  const responseCopy = useCopyFeedback(log.responseText);
178
178
 
179
179
  // Per-tab action bundles consumed by the header. The header renders the
180
- // entry whose key matches `activeTab`. Tabs without an entry (Anatomy,
180
+ // entry whose key matches `activeTab`. Tabs without an entry (Context,
181
181
  // Parsed Response) render no header buttons.
182
182
  const tabActions: HeaderTabActions = useMemo(
183
183
  () => ({
@@ -320,7 +320,7 @@ export const LogEntry = memo(function ({
320
320
  <TabsTrigger value="raw-request">Raw Request</TabsTrigger>
321
321
  )}
322
322
  <TabsTrigger value="request">Request</TabsTrigger>
323
- {anatomySegments !== null && <TabsTrigger value="anatomy">Anatomy</TabsTrigger>}
323
+ {anatomySegments !== null && <TabsTrigger value="anatomy">Context</TabsTrigger>}
324
324
  {viewMode === "full" && <TabsTrigger value="raw">Raw Response</TabsTrigger>}
325
325
  <TabsTrigger value="parsed">Response</TabsTrigger>
326
326
  </TabsList>
@@ -359,7 +359,7 @@ export const LogEntry = memo(function ({
359
359
  <RequestDiffContent
360
360
  rawBody={log.rawRequestBody}
361
361
  displayedBody={displayedRequestBody}
362
- emptyLabel="No transformation applied raw and sent request bodies are identical."
362
+ emptyLabel="No transformation applied; raw and sent request bodies are identical."
363
363
  />
364
364
  ) : (
365
365
  <div ref={requestJsonRef}>
@@ -424,7 +424,7 @@ export const LogEntry = memo(function ({
424
424
  <HeadersDiffContent
425
425
  rawHeaders={log.rawHeaders}
426
426
  headers={log.headers}
427
- emptyLabel="No transformation applied raw and processed headers are identical."
427
+ emptyLabel="No transformation applied; raw and processed headers are identical."
428
428
  />
429
429
  ) : log.headers && Object.keys(log.headers).length > 0 ? (
430
430
  <div className="space-y-1 font-mono text-xs">
@@ -92,7 +92,7 @@ export type HeaderTabAction = {
92
92
  };
93
93
 
94
94
  /**
95
- * Tab actions keyed by Tabs value. Tabs without an entry (Anatomy, Parsed
95
+ * Tab actions keyed by Tabs value. Tabs without an entry (Context, Parsed
96
96
  * Response) leave the corresponding key unset, so the header renders no
97
97
  * action buttons for them. Typed as a record (rather than a `Partial<...>`
98
98
  * union) so the per-tab lookup in the header is type-safe.
@@ -109,14 +109,14 @@ export type LogEntryHeaderProps = {
109
109
  onToggle: () => void;
110
110
  /** Per-log cache token trend (creation + read) relative to the previous log
111
111
  * in the same conversation group. When `undefined` or a field is `null`,
112
- * the corresponding cache span renders as it did before no arrow.
112
+ * the corresponding cache span renders as it did before: no arrow.
113
113
  */
114
114
  cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
115
115
  /** Currently-active tab value (matches the `Tabs` value prop). The header
116
116
  * uses this to pick the right entry from `tabActions`. */
117
117
  activeTab?: string;
118
118
  /** Per-tab Copy + Expand-all actions. Only tabs with an entry will show
119
- * buttons when active. Tabs without an entry (Anatomy, Parsed Response)
119
+ * buttons when active. Tabs without an entry (Context, Parsed Response)
120
120
  * render no header buttons. */
121
121
  tabActions?: HeaderTabActions;
122
122
  /** Re-send this request to the provider. Rendered in the header row when
@@ -264,30 +264,20 @@ export const LogEntryHeader = memo(function ({
264
264
  )}
265
265
  {/* Cache tokens */}
266
266
  {log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
267
- <Tooltip>
268
- <TooltipTrigger asChild>
269
- <span className="flex items-center gap-1 text-xs shrink-0">
270
- <CacheTrendIndicator trend={cacheTrend?.creation ?? null} />
271
- <span className="font-mono tabular-nums text-emerald-400">
272
- Cache +{formatTokens(log.cacheCreationInputTokens)}
273
- </span>
274
- </span>
275
- </TooltipTrigger>
276
- <TooltipContent>Tokens cached for reuse, reducing future API cost</TooltipContent>
277
- </Tooltip>
267
+ <span className="flex items-center gap-1 text-xs shrink-0">
268
+ <CacheTrendIndicator trend={cacheTrend?.creation ?? null} />
269
+ <span className="font-mono tabular-nums text-emerald-400">
270
+ KV Cache +{formatTokens(log.cacheCreationInputTokens)}
271
+ </span>
272
+ </span>
278
273
  )}
279
274
  {log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
280
- <Tooltip>
281
- <TooltipTrigger asChild>
282
- <span className="flex items-center gap-1 text-xs shrink-0">
283
- <CacheTrendIndicator trend={cacheTrend?.read ?? null} />
284
- <span className="font-mono tabular-nums text-purple-400">
285
- Cache ~{formatTokens(log.cacheReadInputTokens)}
286
- </span>
287
- </span>
288
- </TooltipTrigger>
289
- <TooltipContent>Tokens served from cache, reducing API cost</TooltipContent>
290
- </Tooltip>
275
+ <span className="flex items-center gap-1 text-xs shrink-0">
276
+ <CacheTrendIndicator trend={cacheTrend?.read ?? null} />
277
+ <span className="font-mono tabular-nums text-purple-400">
278
+ KV Cache ~{formatTokens(log.cacheReadInputTokens)}
279
+ </span>
280
+ </span>
291
281
  )}
292
282
 
293
283
  {/* Message count */}
@@ -1,22 +1,31 @@
1
1
  import { RotateCcw } from "lucide-react";
2
2
  import type { JSX } from "react";
3
- import { useState } from "react";
3
+ import { useMemo, useState } from "react";
4
4
  import { z } from "zod";
5
+ import { useProviders } from "../../lib/useProviders";
5
6
  import { Button } from "../ui/button";
6
7
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
7
8
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
8
9
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
9
10
  import { ResponseView } from "./ResponseView";
10
11
  import type { CapturedLog } from "../../proxy/schemas";
12
+ import {
13
+ buildReplayComparisons,
14
+ buildReplayMetrics,
15
+ readRequestModel,
16
+ replaceRequestModel,
17
+ type ReplayMetricComparison,
18
+ type ReplayMetricValue,
19
+ } from "./replayComparison";
11
20
 
12
21
  const ReplayResultSchema = z.object({
13
22
  success: z.boolean(),
14
23
  error: z.string().optional(),
15
- responseStatus: z.number().optional(),
16
- responseText: z.string().optional(),
17
- inputTokens: z.number().optional(),
18
- outputTokens: z.number().optional(),
19
- elapsedMs: z.number().optional(),
24
+ responseStatus: z.number().nullable().optional(),
25
+ responseText: z.string().nullable().optional(),
26
+ inputTokens: z.number().nullable().optional(),
27
+ outputTokens: z.number().nullable().optional(),
28
+ elapsedMs: z.number().nullable().optional(),
20
29
  streaming: z.boolean().optional(),
21
30
  });
22
31
 
@@ -28,7 +37,91 @@ type ReplayDialogProps = {
28
37
  onOpenChange: (open: boolean) => void;
29
38
  };
30
39
 
40
+ type ReplayModelOption = {
41
+ key: string;
42
+ providerName: string;
43
+ model: string;
44
+ };
45
+
46
+ function formatElapsed(ms: number): string {
47
+ if (ms < 1000) return `${String(ms)}ms`;
48
+ return `${(ms / 1000).toFixed(1)}s`;
49
+ }
50
+
51
+ function formatMetricValue(
52
+ value: ReplayMetricValue,
53
+ metricId: ReplayMetricComparison["id"],
54
+ ): string {
55
+ if (value === null) return "-";
56
+ if (typeof value === "boolean") return value ? "stream" : "non-stream";
57
+ switch (metricId) {
58
+ case "elapsed":
59
+ return formatElapsed(value);
60
+ case "input":
61
+ case "output":
62
+ case "bytes":
63
+ return value.toLocaleString();
64
+ case "status":
65
+ return String(value);
66
+ case "streaming":
67
+ return value ? "stream" : "non-stream";
68
+ }
69
+ }
70
+
71
+ function formatDelta(delta: number | null, metricId: ReplayMetricComparison["id"]): string {
72
+ if (delta === null) return "-";
73
+ const sign = delta > 0 ? "+" : "";
74
+ switch (metricId) {
75
+ case "elapsed":
76
+ return `${sign}${formatElapsed(delta)}`;
77
+ case "input":
78
+ case "output":
79
+ case "bytes":
80
+ case "status":
81
+ return `${sign}${delta.toLocaleString()}`;
82
+ case "streaming":
83
+ return "-";
84
+ }
85
+ }
86
+
87
+ function deltaToneClass(delta: number | null): string {
88
+ if (delta === null || delta === 0) return "text-muted-foreground";
89
+ if (delta > 0) return "text-amber-400";
90
+ return "text-emerald-400";
91
+ }
92
+
93
+ function ReplayComparisonTable({
94
+ comparisons,
95
+ }: {
96
+ comparisons: ReplayMetricComparison[];
97
+ }): JSX.Element {
98
+ return (
99
+ <div className="overflow-hidden rounded-md border border-border">
100
+ <div className="grid grid-cols-[1fr_1fr_1fr_1fr] bg-muted/40 px-3 py-2 text-[11px] font-medium text-muted-foreground">
101
+ <span>Metric</span>
102
+ <span>Original</span>
103
+ <span>Replay</span>
104
+ <span>Delta</span>
105
+ </div>
106
+ {comparisons.map((comparison) => (
107
+ <div
108
+ key={comparison.id}
109
+ className="grid grid-cols-[1fr_1fr_1fr_1fr] border-t border-border px-3 py-2 text-xs"
110
+ >
111
+ <span className="text-muted-foreground">{comparison.label}</span>
112
+ <span className="font-mono">{formatMetricValue(comparison.original, comparison.id)}</span>
113
+ <span className="font-mono">{formatMetricValue(comparison.replay, comparison.id)}</span>
114
+ <span className={`font-mono ${deltaToneClass(comparison.delta)}`}>
115
+ {formatDelta(comparison.delta, comparison.id)}
116
+ </span>
117
+ </div>
118
+ ))}
119
+ </div>
120
+ );
121
+ }
122
+
31
123
  export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JSX.Element {
124
+ const { providers } = useProviders();
32
125
  const [modifiedBody, setModifiedBody] = useState<string>(() => {
33
126
  return log.rawRequestBody ?? "{}";
34
127
  });
@@ -73,6 +166,55 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
73
166
  onOpenChange(false);
74
167
  }
75
168
 
169
+ const replayModelOptions = useMemo<ReplayModelOption[]>(() => {
170
+ const options: ReplayModelOption[] = [];
171
+ for (const provider of providers) {
172
+ for (const model of provider.models) {
173
+ if (model.trim().length === 0) continue;
174
+ options.push({
175
+ key: `${provider.id}:${model}`,
176
+ providerName: provider.name,
177
+ model,
178
+ });
179
+ }
180
+ }
181
+ return options;
182
+ }, [providers]);
183
+ const currentReplayModel = readRequestModel(modifiedBody) ?? log.model ?? "";
184
+
185
+ function handleReplayModelChange(model: string): void {
186
+ const result = replaceRequestModel(modifiedBody, model);
187
+ if (result.error !== null) {
188
+ setError(result.error);
189
+ return;
190
+ }
191
+ setModifiedBody(result.body);
192
+ setReplayResult(null);
193
+ setError(null);
194
+ }
195
+
196
+ const originalMetrics = buildReplayMetrics({
197
+ status: log.responseStatus,
198
+ elapsedMs: log.elapsedMs,
199
+ inputTokens: log.inputTokens,
200
+ outputTokens: log.outputTokens,
201
+ responseText: log.responseText,
202
+ streaming: log.streaming,
203
+ });
204
+ const replayMetrics =
205
+ replayResult === null
206
+ ? null
207
+ : buildReplayMetrics({
208
+ status: replayResult.responseStatus,
209
+ elapsedMs: replayResult.elapsedMs,
210
+ inputTokens: replayResult.inputTokens,
211
+ outputTokens: replayResult.outputTokens,
212
+ responseText: replayResult.responseText,
213
+ streaming: replayResult.streaming,
214
+ });
215
+ const replayComparisons =
216
+ replayMetrics === null ? [] : buildReplayComparisons(originalMetrics, replayMetrics);
217
+
76
218
  return (
77
219
  <Dialog open={open} onOpenChange={handleClose}>
78
220
  <DialogContent className="max-w-4xl max-h-[85vh] overflow-auto">
@@ -88,9 +230,34 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
88
230
  <TabsTrigger value="modified">Modified Request</TabsTrigger>
89
231
  <TabsTrigger value="original">Original Response</TabsTrigger>
90
232
  {replayResult && <TabsTrigger value="replay">Replay Response</TabsTrigger>}
233
+ {replayResult && <TabsTrigger value="compare">Compare</TabsTrigger>}
91
234
  </TabsList>
92
235
 
93
236
  <TabsContent value="modified" className="space-y-4">
237
+ {replayModelOptions.length > 0 && (
238
+ <div className="grid gap-1.5">
239
+ <label htmlFor={`replay-model-${String(log.id)}`} className="text-sm font-medium">
240
+ Replay target
241
+ </label>
242
+ <select
243
+ id={`replay-model-${String(log.id)}`}
244
+ value={currentReplayModel}
245
+ onChange={(event) => handleReplayModelChange(event.currentTarget.value)}
246
+ className="h-8 rounded-md border border-input bg-background px-2 text-sm"
247
+ >
248
+ {currentReplayModel === "" && <option value="">Select model</option>}
249
+ {currentReplayModel !== "" &&
250
+ !replayModelOptions.some((option) => option.model === currentReplayModel) && (
251
+ <option value={currentReplayModel}>{currentReplayModel}</option>
252
+ )}
253
+ {replayModelOptions.map((option) => (
254
+ <option key={option.key} value={option.model}>
255
+ {option.providerName} / {option.model}
256
+ </option>
257
+ ))}
258
+ </select>
259
+ </div>
260
+ )}
94
261
  <div>
95
262
  <label className="text-sm font-medium mb-2 block">Request Body (JSON)</label>
96
263
  <TooltipProvider>
@@ -127,6 +294,10 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
127
294
  </Button>
128
295
  </div>
129
296
 
297
+ {replayResult !== null && (
298
+ <ReplayComparisonTable comparisons={replayComparisons.slice(0, 4)} />
299
+ )}
300
+
130
301
  {replayResult && replayResult.success && (
131
302
  <Tabs defaultValue="parsed">
132
303
  <TabsList>
@@ -184,7 +355,7 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
184
355
 
185
356
  {replayResult && replayResult.success && (
186
357
  <TabsContent value="replay">
187
- {replayResult.responseText !== null ? (
358
+ {(replayResult.responseText ?? null) !== null ? (
188
359
  <Tabs defaultValue="parsed">
189
360
  <TabsList>
190
361
  <TabsTrigger value="parsed">Response</TabsTrigger>
@@ -202,7 +373,7 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
202
373
  </TabsContent>
203
374
  <TabsContent value="raw">
204
375
  <pre className="font-mono text-xs whitespace-pre-wrap bg-muted p-3 rounded-md max-h-96 overflow-auto">
205
- {replayResult.responseText}
376
+ {replayResult.responseText ?? ""}
206
377
  </pre>
207
378
  </TabsContent>
208
379
  </Tabs>
@@ -211,6 +382,17 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
211
382
  )}
212
383
  </TabsContent>
213
384
  )}
385
+
386
+ {replayResult && (
387
+ <TabsContent value="compare" className="space-y-3">
388
+ <ReplayComparisonTable comparisons={replayComparisons} />
389
+ {replayResult.success ? null : (
390
+ <div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
391
+ {replayResult.error ?? "Replay failed"}
392
+ </div>
393
+ )}
394
+ </TabsContent>
395
+ )}
214
396
  </Tabs>
215
397
  </DialogContent>
216
398
  </Dialog>
@@ -148,14 +148,14 @@ export const ResponseView = memo(function ResponseView({
148
148
  cacheCreationInputTokens !== undefined &&
149
149
  cacheCreationInputTokens > 0 && (
150
150
  <span className="font-mono tabular-nums text-emerald-400">
151
- Cache +{formatTokens(cacheCreationInputTokens)}
151
+ KV Cache +{formatTokens(cacheCreationInputTokens)}
152
152
  </span>
153
153
  )}
154
154
  {cacheReadInputTokens !== null &&
155
155
  cacheReadInputTokens !== undefined &&
156
156
  cacheReadInputTokens > 0 && (
157
157
  <span className="font-mono tabular-nums text-purple-400">
158
- Cache ~{formatTokens(cacheReadInputTokens)}
158
+ KV Cache ~{formatTokens(cacheReadInputTokens)}
159
159
  </span>
160
160
  )}
161
161
  </span>
@@ -1,31 +1,52 @@
1
1
  import { type JSX } from "react";
2
- import { ChevronRight, Wrench } from "lucide-react";
2
+ import { Check, ChevronRight, Copy, Wrench } from "lucide-react";
3
3
  import type { ToolTraceEvent } from "./viewerState";
4
+ import { useCopyFeedback } from "./useCopyFeedback";
4
5
 
5
6
  type ToolTraceEventsProps = {
6
7
  events: ToolTraceEvent[];
7
8
  };
8
9
 
10
+ function ToolTraceEventRow({ event }: { event: ToolTraceEvent }): JSX.Element {
11
+ const argumentCopy = useCopyFeedback(event.argumentsText);
12
+ const canCopyArguments = event.argumentsText !== null;
13
+
14
+ return (
15
+ <div
16
+ key={event.id}
17
+ className="group/tool-trace flex min-w-0 items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-2.5 py-1.5 text-xs"
18
+ >
19
+ <Wrench className="size-3.5 shrink-0 text-sky-400/70" />
20
+ <span className="font-mono font-semibold text-foreground/80">{event.name}</span>
21
+ {event.argumentsPreview !== null && (
22
+ <>
23
+ <ChevronRight className="size-3 shrink-0 text-muted-foreground/60" />
24
+ <span className="min-w-0 truncate font-mono text-muted-foreground">
25
+ {event.argumentsPreview}
26
+ </span>
27
+ </>
28
+ )}
29
+ {canCopyArguments && (
30
+ <button
31
+ type="button"
32
+ className="ml-auto inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-background/80 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/tool-trace:opacity-100 group-focus-within/tool-trace:opacity-100"
33
+ onClick={argumentCopy.copy}
34
+ aria-label={argumentCopy.copied ? "Copied tool arguments" : "Copy tool arguments"}
35
+ title={argumentCopy.copied ? "Copied tool arguments" : "Copy tool arguments"}
36
+ >
37
+ {argumentCopy.copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
38
+ </button>
39
+ )}
40
+ </div>
41
+ );
42
+ }
43
+
9
44
  export function ToolTraceEvents({ events }: ToolTraceEventsProps): JSX.Element | null {
10
45
  if (events.length === 0) return null;
11
46
  return (
12
47
  <div className="mx-3 mb-2 grid gap-1.5">
13
48
  {events.map((event) => (
14
- <div
15
- key={event.id}
16
- className="flex min-w-0 items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-2.5 py-1.5 text-xs"
17
- >
18
- <Wrench className="size-3.5 shrink-0 text-sky-400/70" />
19
- <span className="font-mono font-semibold text-foreground/80">{event.name}</span>
20
- {event.argumentsPreview !== null && (
21
- <>
22
- <ChevronRight className="size-3 shrink-0 text-muted-foreground/60" />
23
- <span className="min-w-0 truncate font-mono text-muted-foreground">
24
- {event.argumentsPreview}
25
- </span>
26
- </>
27
- )}
28
- </div>
49
+ <ToolTraceEventRow key={event.id} event={event} />
29
50
  ))}
30
51
  </div>
31
52
  );
@@ -137,15 +137,27 @@ export const TurnGroup = memo(function TurnGroup({
137
137
  };
138
138
  }, []);
139
139
 
140
+ const firstLogId = entries[0]?.log.id ?? turnIndex;
141
+ const turnLabel = `Turn ${String(turnIndex + 1)}`;
142
+
140
143
  return (
141
144
  <div
142
145
  ref={containerRef}
143
- className={cn("border rounded-lg", isPending ? "border-amber-500/10" : "border-transparent")}
146
+ tabIndex={collapsed ? undefined : 0}
147
+ role={collapsed ? undefined : "group"}
148
+ aria-label={collapsed ? undefined : turnLabel}
149
+ data-nav-id={collapsed ? undefined : `turn-${String(firstLogId)}`}
150
+ className={cn(
151
+ "border rounded-lg",
152
+ isPending ? "border-amber-500/10" : "border-transparent",
153
+ !collapsed &&
154
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none",
155
+ )}
144
156
  >
145
157
  {collapsed ? (
146
158
  /* ---- Collapsed: dual-crab (+ summary card for multi-log turns) ---- */
147
159
  <div
148
- data-nav-id={`turn-collapsed-${entries[0]?.log.id ?? turnIndex}`}
160
+ data-nav-id={`turn-collapsed-${String(firstLogId)}`}
149
161
  data-nav-action="expand"
150
162
  role="button"
151
163
  tabIndex={0}