@tonyclaw/llm-inspector 1.11.8 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import type { JSX } from "react";
3
+ import { ChevronRight, X } from "lucide-react";
4
+ import { cn, formatTokens } from "../../lib/utils";
5
+ import type { CapturedLog } from "../../proxy/schemas";
6
+ import { parseRequest } from "../../proxy/schemas";
7
+ import {
8
+ type DiffOp,
9
+ type JsonNode,
10
+ diffTrees,
11
+ normalizeRequest,
12
+ previewNode,
13
+ } from "./requestDiff";
14
+ import { getConversationId } from "./ConversationHeader";
15
+ import { JsonViewerFromString } from "../ui/json-viewer";
16
+ import { Badge } from "../ui/badge";
17
+
18
+ export type CompareDrawerProps = {
19
+ /** Log selected first (shown on the left). */
20
+ left: CapturedLog;
21
+ /** Log selected second (shown on the right). */
22
+ right: CapturedLog;
23
+ onClose: () => void;
24
+ };
25
+
26
+ type EqualOp = Extract<DiffOp, { kind: "equal" }>;
27
+ type AddedOp = Extract<DiffOp, { kind: "added" }>;
28
+ type RemovedOp = Extract<DiffOp, { kind: "removed" }>;
29
+ type ChangedOp = Extract<DiffOp, { kind: "changed" }>;
30
+
31
+ /** Walk the JsonNode tree and pretty-print it back to a JSON string for the
32
+ * expanded-equal-subtree view. The node is a plain object structure so
33
+ * `JSON.stringify` produces correct output. */
34
+ function nodeToJsonString(node: JsonNode, indent = 2): string {
35
+ return JSON.stringify(nodeToJsonValue(node), null, indent);
36
+ }
37
+
38
+ function nodeToJsonValue(node: JsonNode): unknown {
39
+ switch (node.kind) {
40
+ case "primitive":
41
+ return node.value;
42
+ case "array":
43
+ return node.value.map(nodeToJsonValue);
44
+ case "object": {
45
+ const out: Record<string, unknown> = {};
46
+ for (const [k, v] of Object.entries(node.value)) {
47
+ out[k] = nodeToJsonValue(v);
48
+ }
49
+ return out;
50
+ }
51
+ }
52
+ }
53
+
54
+ /** The parent path of a JSON path string. E.g. `messages[3].content` →
55
+ * `messages[3]`, `messages[3]` → `messages`, `messages` → `""`. */
56
+ function parentPath(path: string): string {
57
+ if (path === "") return "";
58
+ for (let i = path.length - 1; i >= 0; i--) {
59
+ const ch = path[i];
60
+ if (ch === "." || ch === "[") {
61
+ return path.substring(0, i);
62
+ }
63
+ }
64
+ return "";
65
+ }
66
+
67
+ /** Group contiguous deep-equal ops (object/array, not primitive) that share
68
+ * a common parent into a single collapsed row, so an unchanged block of N
69
+ * sibling subtrees renders as one row instead of N. Primitive equals
70
+ * always render as their own row (they're 1 line each). */
71
+ type GroupedOp = { kind: "single"; op: DiffOp } | { kind: "equal-run"; ops: EqualOp[] };
72
+
73
+ function isDeepEqual(op: DiffOp): op is EqualOp {
74
+ return op.kind === "equal" && (op.value.kind === "object" || op.value.kind === "array");
75
+ }
76
+
77
+ function groupContiguousEquals(ops: DiffOp[]): GroupedOp[] {
78
+ const out: GroupedOp[] = [];
79
+ let i = 0;
80
+ while (i < ops.length) {
81
+ const op = ops[i];
82
+ if (op !== undefined && isDeepEqual(op)) {
83
+ const startParent = parentPath(op.path);
84
+ let j = i + 1;
85
+ while (j < ops.length) {
86
+ const next = ops[j];
87
+ if (next === undefined) break;
88
+ if (!isDeepEqual(next)) break;
89
+ if (parentPath(next.path) !== startParent) break;
90
+ j++;
91
+ }
92
+ if (j - i > 1) {
93
+ const equalOps: EqualOp[] = [];
94
+ for (let k = i; k < j; k++) {
95
+ const eop = ops[k];
96
+ if (eop !== undefined && eop.kind === "equal") {
97
+ equalOps.push(eop);
98
+ }
99
+ }
100
+ out.push({ kind: "equal-run", ops: equalOps });
101
+ i = j;
102
+ continue;
103
+ }
104
+ }
105
+ if (op !== undefined) {
106
+ out.push({ kind: "single", op });
107
+ }
108
+ i++;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ function EqualRunRow({
114
+ ops,
115
+ expanded,
116
+ onToggle,
117
+ }: {
118
+ ops: EqualOp[];
119
+ expanded: boolean;
120
+ onToggle: () => void;
121
+ }): JSX.Element {
122
+ const first = ops[0];
123
+ const last = ops[ops.length - 1];
124
+ if (first === undefined || last === undefined) {
125
+ return <div className="col-span-3 text-muted-foreground/40 text-xs">—</div>;
126
+ }
127
+ const firstPath = first.path;
128
+ const lastPath = last.path;
129
+ const label = ops.length === 1 ? firstPath : `${firstPath} … ${lastPath}`;
130
+ const summary =
131
+ first.value.kind === "array"
132
+ ? `${ops.length} equal arrays`
133
+ : first.value.kind === "object"
134
+ ? `${ops.length} equal objects`
135
+ : "equal";
136
+
137
+ return (
138
+ <div className="col-span-3">
139
+ <button
140
+ type="button"
141
+ onClick={onToggle}
142
+ className="w-full text-left flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground hover:bg-muted/40 rounded cursor-pointer"
143
+ >
144
+ <ChevronRight
145
+ className={cn("size-3 transition-transform shrink-0", expanded && "rotate-90")}
146
+ />
147
+ <span className="font-mono truncate flex-1">{label}</span>
148
+ <span className="text-muted-foreground/60 shrink-0">({summary})</span>
149
+ </button>
150
+ {expanded && (
151
+ <div className="ml-5 mt-1 mb-2 space-y-2">
152
+ {ops.map((op) => (
153
+ <div key={op.path} className="border border-border/50 rounded p-2 bg-muted/20">
154
+ <div className="font-mono text-xs text-muted-foreground mb-1">{op.path}</div>
155
+ <JsonViewerFromString text={nodeToJsonString(op.value)} defaultExpandDepth={2} />
156
+ </div>
157
+ ))}
158
+ </div>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ function AddOrRemoveRow({
165
+ op,
166
+ kind,
167
+ }: {
168
+ op: AddedOp | RemovedOp;
169
+ kind: "added" | "removed";
170
+ }): JSX.Element {
171
+ const accent =
172
+ kind === "added"
173
+ ? "border-l-2 border-l-emerald-400/70 bg-emerald-500/5"
174
+ : "border-l-2 border-l-rose-400/70 bg-rose-500/5";
175
+ return (
176
+ <div className={cn("col-span-3 px-2 py-1 rounded text-xs", accent)}>
177
+ <div className="font-mono text-xs text-muted-foreground mb-0.5">{op.path}</div>
178
+ <div className="font-mono break-all">
179
+ {kind === "added" ? (
180
+ <span className="text-emerald-300/90">+ {previewNode(op.value, 400)}</span>
181
+ ) : (
182
+ <span className="text-rose-300/90 line-through">- {previewNode(op.value, 400)}</span>
183
+ )}
184
+ </div>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ function ChangedRow({ op }: { op: ChangedOp }): JSX.Element {
190
+ return (
191
+ <div className="col-span-3 px-2 py-1 rounded text-xs border-l-2 border-l-amber-400/70 bg-amber-500/5">
192
+ <div className="font-mono text-xs text-muted-foreground mb-1">{op.path}</div>
193
+ <div className="grid grid-cols-2 gap-2">
194
+ <div className="font-mono text-rose-300/90 break-all line-through">
195
+ {previewNode(op.left, 400)}
196
+ </div>
197
+ <div className="font-mono text-emerald-300/90 break-all">{previewNode(op.right, 400)}</div>
198
+ </div>
199
+ </div>
200
+ );
201
+ }
202
+
203
+ function SideSummary({ log, side }: { log: CapturedLog; side: "left" | "right" }): JSX.Element {
204
+ const conversationId = getConversationId(log);
205
+ return (
206
+ <div className="flex-1 min-w-0 space-y-1 text-xs">
207
+ <div className="flex items-center gap-2">
208
+ <Badge
209
+ variant="outline"
210
+ className={cn(
211
+ "text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
212
+ side === "left"
213
+ ? "border-rose-500/40 text-rose-400"
214
+ : "border-emerald-500/40 text-emerald-400",
215
+ )}
216
+ >
217
+ {side === "left" ? "← Left" : "Right →"}
218
+ </Badge>
219
+ <span className="font-mono text-blue-400/80">#{log.id}</span>
220
+ {log.model !== null && (
221
+ <span className="font-mono text-muted-foreground truncate">{log.model}</span>
222
+ )}
223
+ </div>
224
+ <div className="flex items-center gap-3 text-muted-foreground font-mono">
225
+ {log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
226
+ <span className="text-emerald-400">
227
+ Cache +{formatTokens(log.cacheCreationInputTokens)}
228
+ </span>
229
+ )}
230
+ {log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
231
+ <span className="text-purple-400">Cache ~{formatTokens(log.cacheReadInputTokens)}</span>
232
+ )}
233
+ <span className="truncate" title={log.timestamp}>
234
+ {log.timestamp}
235
+ </span>
236
+ </div>
237
+ <div className="text-muted-foreground/70 font-mono truncate" title={conversationId}>
238
+ session: {conversationId}
239
+ </div>
240
+ </div>
241
+ );
242
+ }
243
+
244
+ export function CompareDrawer({ left, right, onClose }: CompareDrawerProps): JSX.Element {
245
+ // Memoize the diff so re-renders (e.g. parent re-renders) don't recompute.
246
+ const ops = useMemo<DiffOp[]>(() => {
247
+ const l = normalizeRequest(parseRequest(left.rawRequestBody) ?? left.rawRequestBody);
248
+ const r = normalizeRequest(parseRequest(right.rawRequestBody) ?? right.rawRequestBody);
249
+ return diffTrees(l, r);
250
+ }, [left.rawRequestBody, right.rawRequestBody]);
251
+
252
+ const grouped = useMemo(() => groupContiguousEquals(ops), [ops]);
253
+
254
+ // Track which collapsed equal runs are expanded.
255
+ const [expandedRuns, setExpandedRuns] = useState<Set<number>>(new Set());
256
+ const toggleRun = (idx: number) => {
257
+ setExpandedRuns((prev) => {
258
+ const next = new Set(prev);
259
+ if (next.has(idx)) next.delete(idx);
260
+ else next.add(idx);
261
+ return next;
262
+ });
263
+ };
264
+
265
+ // Esc keybinding + body scroll lock while the drawer is open.
266
+ useEffect(() => {
267
+ const onKey = (e: KeyboardEvent) => {
268
+ if (e.key === "Escape") onClose();
269
+ };
270
+ document.addEventListener("keydown", onKey);
271
+ const prevOverflow = document.body.style.overflow;
272
+ document.body.style.overflow = "hidden";
273
+ return () => {
274
+ document.removeEventListener("keydown", onKey);
275
+ document.body.style.overflow = prevOverflow;
276
+ };
277
+ }, [onClose]);
278
+
279
+ const sameSession = getConversationId(left) === getConversationId(right);
280
+ const allEqual = ops.length === 1 && ops[0]?.kind === "equal";
281
+
282
+ return (
283
+ <div
284
+ className="fixed inset-0 z-50 flex justify-end"
285
+ role="dialog"
286
+ aria-modal="true"
287
+ aria-label="Compare two log requests"
288
+ >
289
+ {/* Backdrop */}
290
+ <button
291
+ type="button"
292
+ onClick={onClose}
293
+ aria-label="Close compare drawer"
294
+ className="absolute inset-0 bg-black/40 cursor-default"
295
+ tabIndex={-1}
296
+ />
297
+
298
+ {/* Drawer panel */}
299
+ <div
300
+ className={cn(
301
+ "relative bg-background border-l border-border shadow-xl",
302
+ "w-full md:w-[70vw] max-w-[1100px] flex flex-col h-full",
303
+ )}
304
+ onClick={(e) => e.stopPropagation()}
305
+ onKeyDown={(e) => e.stopPropagation()}
306
+ >
307
+ {/* Header */}
308
+ <div className="flex items-start gap-4 px-4 py-3 border-b border-border">
309
+ <div className="flex-1 flex gap-4 min-w-0">
310
+ <SideSummary log={left} side="left" />
311
+ <SideSummary log={right} side="right" />
312
+ </div>
313
+ <button
314
+ type="button"
315
+ onClick={onClose}
316
+ aria-label="Close"
317
+ className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer"
318
+ >
319
+ <X className="size-4" />
320
+ </button>
321
+ </div>
322
+
323
+ {!sameSession && (
324
+ <div className="px-4 py-1.5 text-xs text-amber-400 bg-amber-500/10 border-b border-border">
325
+ Heads up: the two selected logs are from different sessions.
326
+ </div>
327
+ )}
328
+
329
+ {/* Body: path-aligned two-pane diff */}
330
+ <div className="flex-1 min-h-0 overflow-y-auto">
331
+ {allEqual ? (
332
+ <div className="px-4 py-12 text-center text-muted-foreground text-sm">
333
+ The two Request payloads are identical.
334
+ </div>
335
+ ) : (
336
+ <div className="grid grid-cols-[200px_1fr_1fr] gap-x-2 gap-y-0.5 px-3 py-2 text-xs">
337
+ {/* Column headers */}
338
+ <div className="col-span-3 grid grid-cols-[200px_1fr_1fr] gap-x-2 pb-2 mb-2 border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
339
+ <span>Path</span>
340
+ <span>Left (Log #{left.id})</span>
341
+ <span>Right (Log #{right.id})</span>
342
+ </div>
343
+
344
+ {grouped.map((g, i) => {
345
+ if (g.kind === "equal-run") {
346
+ return (
347
+ <EqualRunRow
348
+ key={`r${i}`}
349
+ ops={g.ops}
350
+ expanded={expandedRuns.has(i)}
351
+ onToggle={() => toggleRun(i)}
352
+ />
353
+ );
354
+ }
355
+ const op = g.op;
356
+ if (op.kind === "equal") {
357
+ return (
358
+ <div
359
+ key={`e${i}`}
360
+ className="col-span-3 grid grid-cols-[200px_1fr_1fr] gap-x-2 px-2 py-0.5 text-muted-foreground"
361
+ >
362
+ <span className="font-mono text-xs truncate" title={op.path}>
363
+ {op.path}
364
+ </span>
365
+ <span className="font-mono text-xs break-all opacity-60">
366
+ {previewNode(op.value, 200)}
367
+ </span>
368
+ <span className="font-mono text-xs break-all opacity-60">
369
+ {previewNode(op.value, 200)}
370
+ </span>
371
+ </div>
372
+ );
373
+ }
374
+ if (op.kind === "added") {
375
+ return <AddOrRemoveRow key={`a${i}`} op={op} kind="added" />;
376
+ }
377
+ if (op.kind === "removed") {
378
+ return <AddOrRemoveRow key={`r${i}`} op={op} kind="removed" />;
379
+ }
380
+ return <ChangedRow key={`c${i}`} op={op} />;
381
+ })}
382
+ </div>
383
+ )}
384
+ </div>
385
+ </div>
386
+ </div>
387
+ );
388
+ }
@@ -9,12 +9,22 @@ import {
9
9
  type ConversationGroupData,
10
10
  } from "./ConversationHeader";
11
11
  import { LogEntry } from "./LogEntry";
12
+ import type { CacheTrendEntry } from "./cacheTrend";
12
13
 
13
14
  export type ConversationGroupProps = {
14
15
  group: ConversationGroupData;
15
16
  viewMode?: "simple" | "full";
16
17
  /** Live strip-Claude-Code-billing-header flag from the viewer container. */
17
18
  strip: boolean;
19
+ /**
20
+ * Pre-computed per-log cache token trend map (keyed by `log.id`) shared
21
+ * across the whole viewer. Each `LogEntry` looks up its own entry.
22
+ */
23
+ cacheTrends?: Map<number, CacheTrendEntry>;
24
+ /** Set of log ids currently marked for comparison. Forwarded to each `LogEntry`. */
25
+ selectedSet: Set<number>;
26
+ /** Toggle a log in/out of the comparison selection. */
27
+ onToggleSelect: (logId: number) => void;
18
28
  };
19
29
 
20
30
  function computeStats(logs: CapturedLog[]): {
@@ -34,6 +44,9 @@ export const ConversationGroup = memo(function ({
34
44
  group,
35
45
  viewMode = "simple",
36
46
  strip,
47
+ cacheTrends,
48
+ selectedSet,
49
+ onToggleSelect,
37
50
  }: ConversationGroupProps): JSX.Element {
38
51
  const [expanded, setExpanded] = useState(false);
39
52
 
@@ -73,6 +86,9 @@ export const ConversationGroup = memo(function ({
73
86
  viewMode={viewMode}
74
87
  suppressApiFormatBadge={!mixed}
75
88
  strip={strip}
89
+ cacheTrend={cacheTrends?.get(log.id) ?? null}
90
+ isSelected={selectedSet.has(log.id)}
91
+ onToggleSelect={onToggleSelect}
76
92
  />
77
93
  ))}
78
94
  </div>
@@ -12,6 +12,7 @@ import { LogEntryHeader } from "./LogEntryHeader";
12
12
  import { ReplayDialog } from "./ReplayDialog";
13
13
  import { ResponseView } from "./ResponseView";
14
14
  import { StreamingChunkSequence } from "./StreamingChunkSequence";
15
+ import type { CacheTrendEntry } from "./cacheTrend";
15
16
 
16
17
  export type LogEntryProps = {
17
18
  log: CapturedLog;
@@ -25,6 +26,15 @@ export type LogEntryProps = {
25
26
  * cost).
26
27
  */
27
28
  strip: boolean;
29
+ /**
30
+ * Per-log cache token trend, looked up in the viewer-level trend map.
31
+ * `null` (or absent) means the header should render with no arrows.
32
+ */
33
+ cacheTrend?: CacheTrendEntry | null;
34
+ /** Whether this log is currently marked for comparison. */
35
+ isSelected?: boolean;
36
+ /** Toggle this log in/out of the comparison selection. */
37
+ onToggleSelect?: (logId: number) => void;
28
38
  };
29
39
 
30
40
  /**
@@ -131,6 +141,9 @@ export const LogEntry = memo(function ({
131
141
  viewMode = "simple",
132
142
  suppressApiFormatBadge = false,
133
143
  strip,
144
+ cacheTrend = null,
145
+ isSelected = false,
146
+ onToggleSelect,
134
147
  }: LogEntryProps): JSX.Element {
135
148
  const [expanded, setExpanded] = useState<boolean>(false);
136
149
  const [requestCopied, setRequestCopied] = useState<boolean>(false);
@@ -186,13 +199,21 @@ export const LogEntry = memo(function ({
186
199
 
187
200
  return (
188
201
  <>
189
- <div className={cn("border border-border rounded-lg mb-3 overflow-hidden")}>
202
+ <div
203
+ className={cn(
204
+ "border border-border rounded-lg mb-3 overflow-hidden",
205
+ isSelected && "border-l-2 border-l-amber-400",
206
+ )}
207
+ >
190
208
  <LogEntryHeader
191
209
  log={log}
192
210
  parsedRequest={parsedRequest}
193
211
  expanded={expanded}
194
212
  onToggle={() => setExpanded(!expanded)}
195
213
  suppressApiFormatBadge={suppressApiFormatBadge}
214
+ cacheTrend={cacheTrend}
215
+ isSelected={isSelected}
216
+ onToggleSelect={onToggleSelect}
196
217
  />
197
218
 
198
219
  {expanded && (
@@ -1,4 +1,7 @@
1
1
  import {
2
+ ArrowDown,
3
+ ArrowUp,
4
+ Check,
2
5
  ChevronDown,
3
6
  ChevronRight,
4
7
  Clock,
@@ -17,6 +20,7 @@ import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../
17
20
  import type { CapturedLog, InspectorRequest } from "../../proxy/schemas";
18
21
  import { Badge } from "../ui/badge";
19
22
  import { ProviderLogo, detectProvider } from "../providers/ProviderLogo";
23
+ import type { CacheTrend } from "./cacheTrend";
20
24
 
21
25
  function formatElapsed(ms: number): string {
22
26
  if (ms < 1000) return `${ms}ms`;
@@ -30,6 +34,26 @@ const STATUS_BADGE_CLASSES: Record<StatusCategory, string> = {
30
34
  pending: "bg-muted text-muted-foreground border-border",
31
35
  };
32
36
 
37
+ /**
38
+ * Inline trend indicator: small arrow (green up / red down) plus the absolute
39
+ * delta in compact form. Returns `null` when there is no trend to display.
40
+ */
41
+ function CacheTrendIndicator({ trend }: { trend: CacheTrend | null }): JSX.Element | null {
42
+ if (trend === null) return null;
43
+ const isUp = trend.direction === "up";
44
+ const Icon = isUp ? ArrowUp : ArrowDown;
45
+ const sign = isUp ? "+" : "-";
46
+ return (
47
+ <span className="flex items-center gap-0.5 text-muted-foreground tabular-nums">
48
+ <Icon className={isUp ? "size-3 text-emerald-400" : "size-3 text-rose-400"} />
49
+ <span className="font-mono">
50
+ {sign}
51
+ {formatTokens(trend.delta)}
52
+ </span>
53
+ </span>
54
+ );
55
+ }
56
+
33
57
  export type LogEntryHeaderProps = {
34
58
  log: CapturedLog;
35
59
  parsedRequest: InspectorRequest | null;
@@ -37,6 +61,16 @@ export type LogEntryHeaderProps = {
37
61
  onToggle: () => void;
38
62
  /** Suppress the API format badge when log is displayed within a group */
39
63
  suppressApiFormatBadge?: boolean;
64
+ /**
65
+ * Per-log cache token trend (creation + read) relative to the previous log
66
+ * in the same conversation group. When `undefined` or a field is `null`,
67
+ * the corresponding cache span renders as it did before — no arrow.
68
+ */
69
+ cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
70
+ /** Whether this log is currently marked for comparison. */
71
+ isSelected?: boolean;
72
+ /** Toggle this log in/out of the comparison selection. */
73
+ onToggleSelect?: (logId: number) => void;
40
74
  };
41
75
 
42
76
  export const LogEntryHeader = memo(function ({
@@ -45,6 +79,9 @@ export const LogEntryHeader = memo(function ({
45
79
  expanded,
46
80
  onToggle,
47
81
  suppressApiFormatBadge = false,
82
+ cacheTrend = null,
83
+ isSelected = false,
84
+ onToggleSelect,
48
85
  }: LogEntryHeaderProps): JSX.Element {
49
86
  const statusCategory = getStatusCategory(log.responseStatus);
50
87
 
@@ -74,6 +111,27 @@ export const LogEntryHeader = memo(function ({
74
111
  }
75
112
  }}
76
113
  >
114
+ {/* Selection checkbox (for log-request comparison) */}
115
+ {onToggleSelect !== undefined && (
116
+ <button
117
+ type="button"
118
+ onClick={(e) => {
119
+ e.stopPropagation();
120
+ onToggleSelect(log.id);
121
+ }}
122
+ aria-label={isSelected ? "Deselect for comparison" : "Select for comparison"}
123
+ aria-pressed={isSelected}
124
+ className={cn(
125
+ "shrink-0 size-4 rounded-sm border flex items-center justify-center transition-colors cursor-pointer",
126
+ isSelected
127
+ ? "bg-amber-400 border-amber-400 text-amber-950"
128
+ : "border-muted-foreground/40 hover:border-amber-400 hover:bg-amber-400/10",
129
+ )}
130
+ >
131
+ {isSelected && <Check className="size-3" strokeWidth={3} />}
132
+ </button>
133
+ )}
134
+
77
135
  {/* Request ID */}
78
136
  <span className="text-blue-400/80 font-mono text-xs font-semibold tabular-nums shrink-0">
79
137
  #{log.id}
@@ -163,6 +221,7 @@ export const LogEntryHeader = memo(function ({
163
221
  {/* Cache tokens */}
164
222
  {log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
165
223
  <span className="flex items-center gap-1 text-xs shrink-0">
224
+ <CacheTrendIndicator trend={cacheTrend?.creation ?? null} />
166
225
  <span className="font-mono tabular-nums text-emerald-400">
167
226
  Cache +{formatTokens(log.cacheCreationInputTokens)}
168
227
  </span>
@@ -170,6 +229,7 @@ export const LogEntryHeader = memo(function ({
170
229
  )}
171
230
  {log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
172
231
  <span className="flex items-center gap-1 text-xs shrink-0">
232
+ <CacheTrendIndicator trend={cacheTrend?.read ?? null} />
173
233
  <span className="font-mono tabular-nums text-purple-400">
174
234
  Cache ~{formatTokens(log.cacheReadInputTokens)}
175
235
  </span>
@@ -0,0 +1,50 @@
1
+ import type { ConversationGroupData } from "./ConversationHeader";
2
+
3
+ /**
4
+ * Trend direction for a single cache token field, comparing the current log
5
+ * to the previous log in the same conversation group.
6
+ */
7
+ export type CacheTrend = { direction: "up" | "down"; delta: number };
8
+
9
+ /**
10
+ * Per-log trend entry. `creation` covers `cacheCreationInputTokens`,
11
+ * `read` covers `cacheReadInputTokens`. Each field is `null` when there is
12
+ * no previous log to compare against, when the current log has no value for
13
+ * the field, or when the values are equal (no visual change).
14
+ */
15
+ export type CacheTrendEntry = { creation: CacheTrend | null; read: CacheTrend | null };
16
+
17
+ /**
18
+ * Build a `logId -> CacheTrendEntry` map for every log that has a previous
19
+ * log in its conversation group. Pure: does not mutate `groups`. Walks each
20
+ * group in array order (which is the timestamp-sorted order produced by
21
+ * `groupLogsByConversation`), so "previous log" is the adjacent entry in
22
+ * that order.
23
+ */
24
+ export function computeCacheTrends(groups: ConversationGroupData[]): Map<number, CacheTrendEntry> {
25
+ const result = new Map<number, CacheTrendEntry>();
26
+
27
+ for (const group of groups) {
28
+ const logs = group.logs;
29
+ for (let i = 1; i < logs.length; i++) {
30
+ const prev = logs[i - 1];
31
+ const curr = logs[i];
32
+ if (prev === undefined || curr === undefined) continue;
33
+
34
+ result.set(curr.id, {
35
+ creation: compareField(prev.cacheCreationInputTokens, curr.cacheCreationInputTokens),
36
+ read: compareField(prev.cacheReadInputTokens, curr.cacheReadInputTokens),
37
+ });
38
+ }
39
+ }
40
+
41
+ return result;
42
+ }
43
+
44
+ function compareField(previous: number | null, current: number | null): CacheTrend | null {
45
+ if (current === null) return null;
46
+ if (previous === null) return null;
47
+ if (current > previous) return { direction: "up", delta: current - previous };
48
+ if (current < previous) return { direction: "down", delta: previous - current };
49
+ return null;
50
+ }