@tonyclaw/agent-inspector 2.0.4 → 2.0.5

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 (53) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BCH_fsLm.js → CompareDrawer-3nRwtk8J.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-CbW5VRER.js +101 -0
  4. package/.output/public/assets/ReplayDialog-Cl62N9PI.js +1 -0
  5. package/.output/public/assets/{RequestAnatomy-DZ8grAih.js → RequestAnatomy-DgQWGvjs.js} +1 -1
  6. package/.output/public/assets/ResponseView-Cvc-ct4E.js +1 -0
  7. package/.output/public/assets/StreamingChunkSequence-BCQaCAIe.js +1 -0
  8. package/.output/public/assets/_sessionId-CcD_aLGq.js +1 -0
  9. package/.output/public/assets/index-B_dffD3u.js +1 -0
  10. package/.output/public/assets/index-CX796gvi.css +1 -0
  11. package/.output/public/assets/{json-viewer-BrzjD7qI.js → json-viewer-IXejqXB0.js} +1 -1
  12. package/.output/public/assets/{main-mgxeUdZQ.js → main-2NlGzgOe.js} +2 -2
  13. package/.output/server/_libs/lucide-react.mjs +181 -114
  14. package/.output/server/{_sessionId-C4xsxIWm.mjs → _sessionId-DWCTasJU.mjs} +3 -3
  15. package/.output/server/_ssr/{CompareDrawer-DuWEpqQ7.mjs → CompareDrawer-DhrN1uC2.mjs} +6 -6
  16. package/.output/server/_ssr/{ProxyViewerContainer-Cckz5qKu.mjs → ProxyViewerContainer-DRl51s_n.mjs} +763 -119
  17. package/.output/server/_ssr/{ReplayDialog-BDRcr8E5.mjs → ReplayDialog-BQT_ygxC.mjs} +240 -14
  18. package/.output/server/_ssr/{RequestAnatomy-BoO2_Ij0.mjs → RequestAnatomy-DS2tZOgq.mjs} +3 -3
  19. package/.output/server/_ssr/{ResponseView-DZiPBxvO.mjs → ResponseView-e0kL2C3x.mjs} +8 -8
  20. package/.output/server/_ssr/{StreamingChunkSequence-D-be7KEL.mjs → StreamingChunkSequence-BJG-m7xs.mjs} +3 -3
  21. package/.output/server/_ssr/{index-5RImHKfu.mjs → index-Dea3OeRw.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{json-viewer-aJhb93ZK.mjs → json-viewer-DDU55MLK.mjs} +3 -3
  24. package/.output/server/_ssr/{router-Dgkv5nKP.mjs → router-Dl7oh0zx.mjs} +145 -71
  25. package/.output/server/_tanstack-start-manifest_v-m-FJNBVf.mjs +4 -0
  26. package/.output/server/index.mjs +69 -69
  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/LogEntryHeader.tsx +12 -22
  35. package/src/components/proxy-viewer/ReplayDialog.tsx +190 -8
  36. package/src/components/proxy-viewer/ResponseView.tsx +2 -2
  37. package/src/components/proxy-viewer/ToolTraceEvents.tsx +37 -16
  38. package/src/components/proxy-viewer/TurnGroup.tsx +14 -2
  39. package/src/components/proxy-viewer/formats/anthropic/ResponseView.tsx +2 -2
  40. package/src/components/proxy-viewer/replayComparison.ts +131 -0
  41. package/src/components/proxy-viewer/useKeyboardNavigation.ts +64 -22
  42. package/src/components/proxy-viewer/viewerState.ts +14 -2
  43. package/src/knowledge/candidateStore.ts +32 -1
  44. package/src/routes/api/knowledge.candidates.$candidateId.ts +50 -0
  45. package/src/routes/api/knowledge.sessions.$sessionId.candidates.ts +12 -2
  46. package/.output/public/assets/ProxyViewerContainer-D85_UANk.js +0 -101
  47. package/.output/public/assets/ReplayDialog-DTeaHHit.js +0 -1
  48. package/.output/public/assets/ResponseView-Cldm6RCi.js +0 -1
  49. package/.output/public/assets/StreamingChunkSequence-3x4p-yT7.js +0 -1
  50. package/.output/public/assets/_sessionId-YqWFBu6d.js +0 -1
  51. package/.output/public/assets/index-BIw2H6jO.js +0 -1
  52. package/.output/public/assets/index-CobXD0yH.css +0 -1
  53. 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}
@@ -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}
@@ -38,14 +38,14 @@ export const StructuredResponseViewAnthropic = memo(function StructuredResponseV
38
38
  response.usage.cache_creation_input_tokens !== null &&
39
39
  response.usage.cache_creation_input_tokens > 0 && (
40
40
  <span className="font-mono tabular-nums text-emerald-400">
41
- Cache +{formatTokens(response.usage.cache_creation_input_tokens)}
41
+ KV Cache +{formatTokens(response.usage.cache_creation_input_tokens)}
42
42
  </span>
43
43
  )}
44
44
  {response.usage.cache_read_input_tokens !== undefined &&
45
45
  response.usage.cache_read_input_tokens !== null &&
46
46
  response.usage.cache_read_input_tokens > 0 && (
47
47
  <span className="font-mono tabular-nums text-purple-400">
48
- Cache ~{formatTokens(response.usage.cache_read_input_tokens)}
48
+ KV Cache ~{formatTokens(response.usage.cache_read_input_tokens)}
49
49
  </span>
50
50
  )}
51
51
  </span>
@@ -0,0 +1,131 @@
1
+ export type ReplayMetricValue = number | boolean | null;
2
+
3
+ export type ReplayMetrics = {
4
+ status: number | null;
5
+ elapsedMs: number | null;
6
+ inputTokens: number | null;
7
+ outputTokens: number | null;
8
+ responseBytes: number | null;
9
+ streaming: boolean;
10
+ };
11
+
12
+ export type ReplayMetricComparison = {
13
+ id: "status" | "elapsed" | "input" | "output" | "bytes" | "streaming";
14
+ label: string;
15
+ original: ReplayMetricValue;
16
+ replay: ReplayMetricValue;
17
+ delta: number | null;
18
+ };
19
+
20
+ type JsonObject = Record<string, unknown>;
21
+
22
+ function isJsonObject(value: unknown): value is JsonObject {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+
26
+ function byteLength(value: string | null | undefined): number | null {
27
+ if (value === null || value === undefined) return null;
28
+ return new TextEncoder().encode(value).length;
29
+ }
30
+
31
+ export function readRequestModel(body: string): string | null {
32
+ try {
33
+ const parsed: unknown = JSON.parse(body);
34
+ if (!isJsonObject(parsed)) return null;
35
+ const model = parsed["model"];
36
+ return typeof model === "string" && model.length > 0 ? model : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function replaceRequestModel(
43
+ body: string,
44
+ model: string,
45
+ ): { body: string; error: string | null } {
46
+ try {
47
+ const parsed: unknown = JSON.parse(body);
48
+ if (!isJsonObject(parsed)) {
49
+ return { body, error: "Request body must be a JSON object." };
50
+ }
51
+ return {
52
+ body: JSON.stringify({ ...parsed, model }, null, 2),
53
+ error: null,
54
+ };
55
+ } catch {
56
+ return { body, error: "Request body must be valid JSON before changing the replay model." };
57
+ }
58
+ }
59
+
60
+ export function buildReplayMetrics(input: {
61
+ status: number | null | undefined;
62
+ elapsedMs: number | null | undefined;
63
+ inputTokens: number | null | undefined;
64
+ outputTokens: number | null | undefined;
65
+ responseText: string | null | undefined;
66
+ streaming: boolean | null | undefined;
67
+ }): ReplayMetrics {
68
+ return {
69
+ status: input.status ?? null,
70
+ elapsedMs: input.elapsedMs ?? null,
71
+ inputTokens: input.inputTokens ?? null,
72
+ outputTokens: input.outputTokens ?? null,
73
+ responseBytes: byteLength(input.responseText),
74
+ streaming: input.streaming === true,
75
+ };
76
+ }
77
+
78
+ function numericDelta(original: number | null, replay: number | null): number | null {
79
+ if (original === null || replay === null) return null;
80
+ return replay - original;
81
+ }
82
+
83
+ export function buildReplayComparisons(
84
+ original: ReplayMetrics,
85
+ replay: ReplayMetrics,
86
+ ): ReplayMetricComparison[] {
87
+ return [
88
+ {
89
+ id: "status",
90
+ label: "Status",
91
+ original: original.status,
92
+ replay: replay.status,
93
+ delta: numericDelta(original.status, replay.status),
94
+ },
95
+ {
96
+ id: "elapsed",
97
+ label: "Elapsed",
98
+ original: original.elapsedMs,
99
+ replay: replay.elapsedMs,
100
+ delta: numericDelta(original.elapsedMs, replay.elapsedMs),
101
+ },
102
+ {
103
+ id: "input",
104
+ label: "Input",
105
+ original: original.inputTokens,
106
+ replay: replay.inputTokens,
107
+ delta: numericDelta(original.inputTokens, replay.inputTokens),
108
+ },
109
+ {
110
+ id: "output",
111
+ label: "Output",
112
+ original: original.outputTokens,
113
+ replay: replay.outputTokens,
114
+ delta: numericDelta(original.outputTokens, replay.outputTokens),
115
+ },
116
+ {
117
+ id: "bytes",
118
+ label: "Bytes",
119
+ original: original.responseBytes,
120
+ replay: replay.responseBytes,
121
+ delta: numericDelta(original.responseBytes, replay.responseBytes),
122
+ },
123
+ {
124
+ id: "streaming",
125
+ label: "Stream",
126
+ original: original.streaming,
127
+ replay: replay.streaming,
128
+ delta: null,
129
+ },
130
+ ];
131
+ }