@tonyclaw/agent-inspector 2.1.16 → 2.1.17

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 (51) hide show
  1. package/.output/cli.js +57 -13
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-Ccrrcx1j.js → CompareDrawer-B9sLBHw5.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-DbWjc_BE.js +106 -0
  5. package/.output/public/assets/{ReplayDialog-C7axhr-l.js → ReplayDialog-D9I9W9n3.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-WOptg8j_.js → RequestAnatomy-DGXK_Rii.js} +1 -1
  7. package/.output/public/assets/{ResponseView-CSAcxh_M.js → ResponseView-df_JzwsS.js} +2 -2
  8. package/.output/public/assets/{StreamingChunkSequence-DJS5KhPx.js → StreamingChunkSequence-i3DM5IKL.js} +1 -1
  9. package/.output/public/assets/_sessionId-CTpCHdFh.js +1 -0
  10. package/.output/public/assets/index-Blqvndcn.js +1 -0
  11. package/.output/public/assets/index-Cs79WRQj.css +1 -0
  12. package/.output/public/assets/{index-DMPNh46t.js → index-Dqc2r1ea.js} +1 -1
  13. package/.output/public/assets/{json-viewer-inX5QSa3.js → json-viewer-D3QWQlSB.js} +1 -1
  14. package/.output/public/assets/{main-aCAKYGSD.js → main-ZhxhOCmF.js} +2 -2
  15. package/.output/server/_libs/lucide-react.mjs +220 -202
  16. package/.output/server/{_sessionId-C0fhvQnn.mjs → _sessionId-YFrP0lZP.mjs} +2 -2
  17. package/.output/server/_ssr/{CompareDrawer-CO-Ti5sg.mjs → CompareDrawer-qC3adEeq.mjs} +3 -3
  18. package/.output/server/_ssr/{ProxyViewerContainer-N4J7uBCX.mjs → ProxyViewerContainer-DCZIJC2b.mjs} +1858 -1396
  19. package/.output/server/_ssr/{ReplayDialog-J1VxC0In.mjs → ReplayDialog-CvX4XYCi.mjs} +4 -4
  20. package/.output/server/_ssr/{RequestAnatomy-DBH-F3rq.mjs → RequestAnatomy-HQfrxjO1.mjs} +2 -2
  21. package/.output/server/_ssr/{ResponseView-DLFwV7Hd.mjs → ResponseView-QSlSD11e.mjs} +3 -3
  22. package/.output/server/_ssr/{StreamingChunkSequence-BywmuND0.mjs → StreamingChunkSequence-DTAiwlnI.mjs} +2 -2
  23. package/.output/server/_ssr/{index-CStymIc0.mjs → index-D_EzfXaN.mjs} +2 -2
  24. package/.output/server/_ssr/index.mjs +2 -2
  25. package/.output/server/_ssr/{json-viewer-DlQu0N1x.mjs → json-viewer-CrIX0e0Q.mjs} +3 -3
  26. package/.output/server/_ssr/{router-CQ4Zxhmr.mjs → router-__o2wrfO.mjs} +398 -95
  27. package/.output/server/{_tanstack-start-manifest_v-BHeJBGfo.mjs → _tanstack-start-manifest_v-DbqyLpsI.mjs} +1 -1
  28. package/.output/server/index.mjs +73 -73
  29. package/README.md +57 -2
  30. package/package.json +1 -1
  31. package/src/cli/startupOutput.ts +16 -0
  32. package/src/cli.ts +61 -15
  33. package/src/components/ProxyViewer.tsx +395 -64
  34. package/src/components/providers/ProviderCard.tsx +132 -31
  35. package/src/components/providers/ProviderForm.tsx +43 -11
  36. package/src/components/providers/ProvidersPanel.tsx +1 -1
  37. package/src/components/proxy-viewer/ConversationHeader.tsx +133 -112
  38. package/src/components/proxy-viewer/LogEntry.tsx +109 -26
  39. package/src/components/proxy-viewer/LogEntryHeader.tsx +339 -329
  40. package/src/components/proxy-viewer/ThreadConnector.tsx +102 -83
  41. package/src/components/proxy-viewer/TurnGroup.tsx +22 -63
  42. package/src/components/proxy-viewer/bodyHydration.ts +56 -0
  43. package/src/lib/providerTestContract.ts +56 -10
  44. package/src/lib/providerTestPrompt.ts +23 -1
  45. package/src/lib/upstreamUrl.ts +105 -1
  46. package/src/mcp/toolHandlers.ts +1 -1
  47. package/src/routes/api/providers.$providerId.test.log.ts +85 -10
  48. package/.output/public/assets/ProxyViewerContainer-DLqClc9A.js +0 -106
  49. package/.output/public/assets/_sessionId-DUSalzKH.js +0 -1
  50. package/.output/public/assets/index-BPpA21dY.css +0 -1
  51. package/.output/public/assets/index-Bt0Az2I2.js +0 -1
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from "react";
1
+ import { type ReactNode, useCallback, useState } from "react";
2
2
  import {
3
3
  ChevronDown,
4
4
  ChevronRight,
@@ -21,6 +21,34 @@ import { ClientLogo, clientAppLabel, detectClientApp } from "../clients/ClientLo
21
21
  import { ProviderLogoStack } from "./ProviderLogoStack";
22
22
  import { resolveLogFormat } from "./log-formats";
23
23
 
24
+ const HEADER_ACTION_BUTTON_CLASS =
25
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground";
26
+
27
+ function HeaderMetric({
28
+ icon,
29
+ children,
30
+ title,
31
+ className,
32
+ }: {
33
+ icon: ReactNode;
34
+ children: ReactNode;
35
+ title?: string;
36
+ className?: string;
37
+ }): JSX.Element {
38
+ return (
39
+ <span
40
+ className={cn(
41
+ "border border-border bg-muted/30 inline-flex h-7 min-w-0 items-center gap-1.5 rounded-md px-2 text-xs text-muted-foreground",
42
+ className,
43
+ )}
44
+ title={title}
45
+ >
46
+ {icon}
47
+ <span className="min-w-0 font-mono tabular-nums">{children}</span>
48
+ </span>
49
+ );
50
+ }
51
+
24
52
  export type ConversationHeaderProps = {
25
53
  conversationId: string;
26
54
  startTime: string;
@@ -112,6 +140,9 @@ export function ConversationHeader({
112
140
  ? conversationId.slice(0, 12) + "..." + conversationId.slice(-12)
113
141
  : conversationId;
114
142
 
143
+ const timeLabel = formatTimestampRange(startTime, endTime, timeDisplayFormat);
144
+ const callsLabel = `${totalCalls} call${totalCalls !== 1 ? "s" : ""}`;
145
+
115
146
  return (
116
147
  <div
117
148
  role="button"
@@ -119,11 +150,11 @@ export function ConversationHeader({
119
150
  data-nav-id={`conv-${conversationId}`}
120
151
  data-nav-action={expanded ? "collapse" : "expand"}
121
152
  className={cn(
122
- "border border-border bg-card shadow-sm flex cursor-pointer items-center gap-3 rounded-[8px] px-3 py-2 transition-colors",
153
+ "border border-border bg-card shadow-sm flex cursor-pointer items-start gap-3 rounded-[8px] px-3 py-2.5 transition-colors",
123
154
  "hover:border-cyan-200/20",
124
155
  "select-none",
125
156
  "sticky top-0 z-10 mb-2",
126
- "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none",
157
+ "focus-visible:ring-1 focus-visible:ring-cyan-300/35 focus-visible:outline-none",
127
158
  )}
128
159
  onClick={onToggle}
129
160
  onKeyDown={(e) => {
@@ -135,143 +166,133 @@ export function ConversationHeader({
135
166
  }}
136
167
  >
137
168
  {/* Provider logos: first visual element whenever model/provider logos are available. */}
138
- <ProviderLogoStack items={providerLogoItems} ariaLabel="Models used in this session" />
169
+ <ProviderLogoStack
170
+ items={providerLogoItems}
171
+ ariaLabel="Models used in this session"
172
+ className="mt-0.5"
173
+ />
139
174
 
140
- {/* Session identifier.
141
- - Real session id (e.g. `sess_abc123...`) - render the id with the
142
- standard ellipsis truncation; show `PID <n>` as a secondary pill
143
- when we know the originating process.
144
- - Fallback id (no sessionId was emitted) - the conversationId is
145
- already a synthesized `PID:<n>|<folder>` marker; render the PID
146
- as the primary identifier (purple, same weight as a real id)
147
- and the project folder as a secondary muted label. */}
148
- {isFallbackConversation && showProcessMetadata ? (
149
- <>
150
- {clientPid !== null && clientPid !== undefined ? (
175
+ <span className="min-w-0 flex-1">
176
+ <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
177
+ {isFallbackConversation && showProcessMetadata ? (
178
+ clientPid !== null && clientPid !== undefined ? (
179
+ <span
180
+ className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-violet-200"
181
+ title={`PID ${clientPid}`}
182
+ >
183
+ PID {clientPid}
184
+ </span>
185
+ ) : (
186
+ <span
187
+ className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-violet-200"
188
+ title={conversationId}
189
+ >
190
+ {conversationId}
191
+ </span>
192
+ )
193
+ ) : (
151
194
  <span
152
- className="shrink-0 font-mono text-xs font-semibold text-violet-200"
153
- title={`PID ${clientPid}`}
195
+ className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-violet-200"
196
+ title={conversationId}
154
197
  >
155
- PID {clientPid}
156
- </span>
157
- ) : (
158
- <span className="shrink-0 font-mono text-xs font-semibold text-violet-200">
159
- {conversationId}
198
+ {isFallbackConversation ? "sessionless" : compactSessionLabel}
160
199
  </span>
161
200
  )}
162
- {clientProjectFolder !== null &&
201
+
202
+ {!isFallbackConversation &&
203
+ showProcessMetadata &&
204
+ clientPid !== null &&
205
+ clientPid !== undefined && (
206
+ <span
207
+ className="border border-border bg-muted/40 shrink-0 rounded px-1.5 py-0.5 font-mono text-xs tabular-nums text-muted-foreground"
208
+ title={`Client process ID ${clientPid}`}
209
+ >
210
+ PID {clientPid}
211
+ </span>
212
+ )}
213
+
214
+ {isFallbackConversation &&
215
+ showProcessMetadata &&
216
+ clientProjectFolder !== null &&
163
217
  clientProjectFolder !== undefined &&
164
218
  clientProjectFolder !== "" && (
165
219
  <span
166
- className="text-muted-foreground text-xs font-mono truncate max-w-[200px] shrink-0"
220
+ className="min-w-0 max-w-[240px] truncate font-mono text-xs text-muted-foreground"
167
221
  title={clientProjectFolder}
168
222
  >
169
223
  {clientProjectFolder}
170
224
  </span>
171
225
  )}
172
- </>
173
- ) : (
174
- <>
175
- <span
176
- className="shrink-0 font-mono text-xs font-semibold text-violet-200"
177
- title={conversationId}
178
- >
179
- {isFallbackConversation ? "sessionless" : compactSessionLabel}
180
- </span>
181
- {showProcessMetadata && clientPid !== null && clientPid !== undefined && (
226
+
227
+ {showClientIdentity && clientDisplayLabel !== null && clientDisplayLabel !== "" && (
182
228
  <span
183
- className="border border-border bg-muted/40 shrink-0 rounded px-1.5 py-0.5 font-mono text-xs tabular-nums text-muted-foreground"
184
- title={`Client process ID ${clientPid}`}
229
+ className="border border-border bg-muted/30 inline-flex h-6 max-w-[180px] shrink-0 items-center gap-1 rounded-md px-1.5 text-xs text-muted-foreground"
230
+ title={clientTooltip ?? clientDisplayLabel}
185
231
  >
186
- PID {clientPid}
232
+ <ClientLogo client={clientApp} className="size-4 shrink-0" />
233
+ <span className="truncate font-mono tabular-nums">{clientDisplayLabel}</span>
187
234
  </span>
188
235
  )}
189
- </>
190
- )}
191
-
192
- {/* Client app / User-Agent */}
193
- {showClientIdentity && clientDisplayLabel !== null && clientDisplayLabel !== "" && (
194
- <span
195
- className="flex items-center gap-1 text-muted-foreground text-xs shrink-0"
196
- title={clientTooltip ?? clientDisplayLabel}
197
- >
198
- <ClientLogo client={clientApp} className="size-5" />
199
- <span className="font-mono tabular-nums truncate max-w-[120px]">
200
- {clientDisplayLabel}
201
- </span>
202
- </span>
203
- )}
204
-
205
- {/* Time range */}
206
- <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
207
- <Clock className="size-3" />
208
- <span className="font-mono tabular-nums">
209
- {formatTimestampRange(startTime, endTime, timeDisplayFormat)}
210
- </span>
211
- </span>
212
-
213
- {/* Total calls */}
214
- <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
215
- <MessageSquare className="size-3" />
216
- <span className="font-mono tabular-nums">
217
- {totalCalls} call{totalCalls !== 1 ? "s" : ""}
218
236
  </span>
219
- </span>
220
237
 
221
- {/* Token counts */}
222
- <span className="flex items-center gap-1 text-xs shrink-0">
223
- <Zap className="size-3 text-muted-foreground" />
224
- <span className="font-mono tabular-nums">
225
- <span className="text-blue-400">{formatTokens(totalInputTokens)}</span>
226
- {" / "}
227
- <span className="text-amber-400">{formatTokens(totalOutputTokens)}</span>
238
+ <span className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1.5">
239
+ <HeaderMetric icon={<Clock className="size-3 shrink-0" />} title={timeLabel}>
240
+ {timeLabel}
241
+ </HeaderMetric>
242
+ <HeaderMetric icon={<MessageSquare className="size-3 shrink-0" />}>
243
+ {callsLabel}
244
+ </HeaderMetric>
245
+ <HeaderMetric icon={<Zap className="size-3 shrink-0" />} className="max-w-full">
246
+ <span className="text-blue-400">{formatTokens(totalInputTokens)}</span>
247
+ {" / "}
248
+ <span className="text-amber-400">{formatTokens(totalOutputTokens)}</span>
249
+ </HeaderMetric>
228
250
  </span>
229
251
  </span>
230
252
 
231
- {/* Spacer */}
232
- <span className="flex-1 min-w-0" />
233
-
234
- {/* Open this session in a new tab - deep link to /session/$id */}
235
- <button
236
- type="button"
237
- onClick={handleOpenInNewTab}
238
- onKeyDown={(e) => {
239
- if (e.key === "Enter" || e.key === " ") {
240
- handleOpenInNewTab(e);
241
- }
242
- }}
243
- aria-label={`Open session ${conversationId} in a new tab`}
244
- title="Open this session in a new tab"
245
- className="border border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground"
246
- >
247
- <ExternalLink className="size-3.5" />
248
- </button>
249
-
250
- {/* Per-group Clear - does not toggle the group's expand state */}
251
- {onClear !== undefined && (
253
+ <span className="flex shrink-0 items-start gap-1.5">
254
+ {/* Open this session in a new tab - deep link to /session/$id */}
252
255
  <button
253
256
  type="button"
254
- onClick={handleClearClick}
255
- aria-label={`Clear group (${totalCalls} request${totalCalls !== 1 ? "s" : ""})`}
256
- title="Clear this group"
257
- className="border border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground"
257
+ onClick={handleOpenInNewTab}
258
+ onKeyDown={(e) => {
259
+ if (e.key === "Enter" || e.key === " ") {
260
+ handleOpenInNewTab(e);
261
+ }
262
+ }}
263
+ aria-label={`Open session ${conversationId} in a new tab`}
264
+ title="Open this session in a new tab"
265
+ className={HEADER_ACTION_BUTTON_CLASS}
258
266
  >
259
- <Trash2 className="size-3.5" />
267
+ <ExternalLink className="size-3.5" />
260
268
  </button>
261
- )}
262
269
 
263
- {/* Expand chevron - shows spinner when collapsed and group has pending logs */}
264
- <span
265
- className="border border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex size-8 shrink-0 items-center justify-center rounded-md text-slate-200"
266
- aria-hidden="true"
267
- >
268
- {expanded ? (
269
- <ChevronDown className="size-4 shrink-0" />
270
- ) : isLoading ? (
271
- <Loader2 className="size-4 shrink-0 animate-spin" />
272
- ) : (
273
- <ChevronRight className="size-4 shrink-0" />
270
+ {/* Per-group Clear - does not toggle the group's expand state */}
271
+ {onClear !== undefined && (
272
+ <button
273
+ type="button"
274
+ onClick={handleClearClick}
275
+ aria-label={`Clear group (${totalCalls} request${totalCalls !== 1 ? "s" : ""})`}
276
+ title="Clear this group"
277
+ className={HEADER_ACTION_BUTTON_CLASS}
278
+ >
279
+ <Trash2 className="size-3.5" />
280
+ </button>
274
281
  )}
282
+
283
+ {/* Expand chevron - shows spinner when collapsed and group has pending logs */}
284
+ <span
285
+ className="border border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex size-8 shrink-0 items-center justify-center rounded-md text-slate-200"
286
+ aria-hidden="true"
287
+ >
288
+ {expanded ? (
289
+ <ChevronDown className="size-4 shrink-0" />
290
+ ) : isLoading ? (
291
+ <Loader2 className="size-4 shrink-0 animate-spin" />
292
+ ) : (
293
+ <ChevronRight className="size-4 shrink-0" />
294
+ )}
295
+ </span>
275
296
  </span>
276
297
 
277
298
  <ConfirmDialog
@@ -1,4 +1,4 @@
1
- import { AlertTriangle, GitCompareArrows } from "lucide-react";
1
+ import { AlertTriangle, GitCompareArrows, Loader2, RefreshCw } from "lucide-react";
2
2
  import { Suspense, type JSX, type ReactNode } from "react";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
4
4
  import {
@@ -27,6 +27,12 @@ import {
27
27
  } from "./lazy";
28
28
  import type { AnatomySegment } from "./anatomy/types";
29
29
  import { useAnatomyJump } from "./anatomy/useAnatomyJump";
30
+ import {
31
+ describeFullBodyHydrationError,
32
+ formatBodyHydrationFootprint,
33
+ formatBytes,
34
+ type FullBodyHydrationError,
35
+ } from "./bodyHydration";
30
36
  import { computeHeadersDiff, computeRequestDiff, DiffView } from "./diff";
31
37
  import { LogEntryHeader } from "./LogEntryHeader";
32
38
  import { RequestTools } from "./RequestToolsPanel";
@@ -57,7 +63,7 @@ function resolveFocusedTab(tab: LogFocusTab, anatomySegments: AnatomySegment[] |
57
63
  }
58
64
  }
59
65
 
60
- type BodyHydrationState = "idle" | "loading" | "failed";
66
+ type BodyHydrationStatus = "idle" | "loading" | "failed";
61
67
  type BodyChunkState = "idle" | "loading" | "success" | "failed";
62
68
 
63
69
  type BodyPreviewState = {
@@ -108,13 +114,6 @@ function shouldHydrateLogBody(log: CapturedLog): boolean {
108
114
  return isCompactBody(log) && !shouldUseChunkedBodyLoading(log);
109
115
  }
110
116
 
111
- function formatBytes(bytes: number): string {
112
- if (bytes < 1024) return `${bytes.toLocaleString()} B`;
113
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
114
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
115
- return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
116
- }
117
-
118
117
  function bodyPreviewErrorMessage(error: unknown): string {
119
118
  if (error instanceof ApiTimeoutError) {
120
119
  return "Body preview timed out. The log is still available on disk; try loading again.";
@@ -123,6 +122,57 @@ function bodyPreviewErrorMessage(error: unknown): string {
123
122
  return "Body preview could not be loaded.";
124
123
  }
125
124
 
125
+ function FullBodyHydrationNotice({
126
+ status,
127
+ error,
128
+ footprint,
129
+ onRetry,
130
+ }: {
131
+ status: BodyHydrationStatus;
132
+ error: FullBodyHydrationError | null;
133
+ footprint: string | null;
134
+ onRetry: () => void;
135
+ }): JSX.Element | null {
136
+ switch (status) {
137
+ case "idle":
138
+ return null;
139
+ case "loading":
140
+ return (
141
+ <NoticeBlock tone="loading" title="Loading full bodies">
142
+ <div className="flex flex-wrap items-center justify-between gap-2">
143
+ <span className="flex min-w-0 items-center gap-2">
144
+ <Loader2 className="size-3.5 shrink-0 animate-spin text-cyan-200" />
145
+ <span>Loading full request and response bodies from the log store.</span>
146
+ </span>
147
+ {footprint !== null && (
148
+ <span className="rounded border border-cyan-300/15 bg-cyan-300/[0.06] px-2 py-0.5 font-mono text-[11px] text-cyan-100">
149
+ {footprint}
150
+ </span>
151
+ )}
152
+ </div>
153
+ </NoticeBlock>
154
+ );
155
+ case "failed":
156
+ return (
157
+ <NoticeBlock tone="warning" title={error?.title ?? "Full bodies could not be loaded"}>
158
+ <div className="flex flex-wrap items-center justify-between gap-2">
159
+ <span>{error?.detail ?? "Compact metadata is still available."}</span>
160
+ <Button
161
+ type="button"
162
+ variant="outline"
163
+ size="sm"
164
+ className="border border-input bg-background hover:bg-accent hover:text-accent-foreground h-7 gap-1.5 text-xs text-amber-100"
165
+ onClick={onRetry}
166
+ >
167
+ <RefreshCw className="size-3" />
168
+ Retry
169
+ </Button>
170
+ </div>
171
+ </NoticeBlock>
172
+ );
173
+ }
174
+ }
175
+
126
176
  function bodyPreviewTextForView(enabled: boolean, state: BodyPreviewState): string | null {
127
177
  if (!enabled) return null;
128
178
  if (state.text !== "") return state.text;
@@ -381,7 +431,9 @@ export const LogEntry = memo(function ({
381
431
  const [activeTab, setActiveTab] = useState("request");
382
432
  const [expandToPath, setExpandToPath] = useState<string | null>(null);
383
433
  const [hydratedLog, setHydratedLog] = useState<CapturedLog | null>(null);
384
- const [bodyHydrationState, setBodyHydrationState] = useState<BodyHydrationState>("idle");
434
+ const [bodyHydrationStatus, setBodyHydrationStatus] = useState<BodyHydrationStatus>("idle");
435
+ const [bodyHydrationError, setBodyHydrationError] = useState<FullBodyHydrationError | null>(null);
436
+ const [bodyHydrationRetryNonce, setBodyHydrationRetryNonce] = useState(0);
385
437
  const [requestPreview, setRequestPreview] = useState<BodyPreviewState>(() =>
386
438
  createEmptyBodyPreviewState(log.rawRequestBodyBytes ?? null),
387
439
  );
@@ -393,6 +445,10 @@ export const LogEntry = memo(function ({
393
445
  const bodyHydrationInFlightRef = useRef(false);
394
446
  const displayLog = hydratedLog ?? log;
395
447
  const useChunkedBody = hydratedLog === null && shouldUseChunkedBodyLoading(log);
448
+ const fullBodyHydrationFootprint = useMemo(
449
+ () => formatBodyHydrationFootprint(log.rawRequestBodyBytes, log.responseTextBytes),
450
+ [log.rawRequestBodyBytes, log.responseTextBytes],
451
+ );
396
452
  const requestPreviewBody = bodyPreviewTextForView(useChunkedBody, requestPreview);
397
453
  const responsePreviewBody = bodyPreviewTextForView(useChunkedBody, responsePreview);
398
454
  const rawRequestBodyForView = displayLog.rawRequestBody ?? requestPreviewBody;
@@ -631,6 +687,14 @@ export const LogEntry = memo(function ({
631
687
  [log.id],
632
688
  );
633
689
 
690
+ const retryFullBodyHydration = useCallback(() => {
691
+ if (bodyHydrationInFlightRef.current) return;
692
+ setHydratedLog(null);
693
+ setBodyHydrationStatus("idle");
694
+ setBodyHydrationError(null);
695
+ setBodyHydrationRetryNonce((current) => current + 1);
696
+ }, []);
697
+
634
698
  const handleToggleExpanded = useCallback(() => {
635
699
  const nextExpanded = !expanded;
636
700
  if (nextExpanded) {
@@ -643,7 +707,9 @@ export const LogEntry = memo(function ({
643
707
  logIdRef.current = log.id;
644
708
  bodyHydrationInFlightRef.current = false;
645
709
  setHydratedLog(null);
646
- setBodyHydrationState("idle");
710
+ setBodyHydrationStatus("idle");
711
+ setBodyHydrationError(null);
712
+ setBodyHydrationRetryNonce(0);
647
713
  setRequestPreview(createEmptyBodyPreviewState(log.rawRequestBodyBytes ?? null));
648
714
  setResponsePreview(createEmptyBodyPreviewState(log.responseTextBytes ?? null));
649
715
  }, [log.id, log.rawRequestBodyBytes, log.responseTextBytes]);
@@ -655,7 +721,8 @@ export const LogEntry = memo(function ({
655
721
 
656
722
  let cancelled = false;
657
723
  bodyHydrationInFlightRef.current = true;
658
- setBodyHydrationState("loading");
724
+ setBodyHydrationStatus("loading");
725
+ setBodyHydrationError(null);
659
726
  void fetchJsonWithTimeout(
660
727
  `/api/logs/${String(log.id)}`,
661
728
  CapturedLogSchema,
@@ -665,12 +732,14 @@ export const LogEntry = memo(function ({
665
732
  if (cancelled) return;
666
733
  bodyHydrationInFlightRef.current = false;
667
734
  setHydratedLog(fullLog);
668
- setBodyHydrationState("idle");
735
+ setBodyHydrationStatus("idle");
736
+ setBodyHydrationError(null);
669
737
  })
670
- .catch(() => {
738
+ .catch((error: unknown) => {
671
739
  if (cancelled) return;
672
740
  bodyHydrationInFlightRef.current = false;
673
- setBodyHydrationState("failed");
741
+ setBodyHydrationStatus("failed");
742
+ setBodyHydrationError(describeFullBodyHydrationError(error));
674
743
  });
675
744
 
676
745
  return () => {
@@ -684,6 +753,7 @@ export const LogEntry = memo(function ({
684
753
  log.id,
685
754
  log.rawRequestBodyBytes,
686
755
  log.responseTextBytes,
756
+ bodyHydrationRetryNonce,
687
757
  ]);
688
758
 
689
759
  useEffect(() => {
@@ -812,6 +882,12 @@ export const LogEntry = memo(function ({
812
882
  <TabsContent value="raw-request">
813
883
  {activeTab === "raw-request" && (
814
884
  <ExpandedPanel>
885
+ <FullBodyHydrationNotice
886
+ status={bodyHydrationStatus}
887
+ error={bodyHydrationError}
888
+ footprint={fullBodyHydrationFootprint}
889
+ onRetry={retryFullBodyHydration}
890
+ />
815
891
  {useChunkedBody && (
816
892
  <BodyPreviewNotice
817
893
  label="Request"
@@ -843,17 +919,12 @@ export const LogEntry = memo(function ({
843
919
  <TabsContent value="request">
844
920
  {activeTab === "request" && (
845
921
  <ExpandedPanel>
846
- {bodyHydrationState === "loading" && (
847
- <NoticeBlock tone="loading">
848
- Loading full request and response bodies...
849
- </NoticeBlock>
850
- )}
851
- {bodyHydrationState === "failed" && (
852
- <NoticeBlock tone="warning">
853
- Full request and response bodies could not be loaded. Compact metadata is
854
- still available.
855
- </NoticeBlock>
856
- )}
922
+ <FullBodyHydrationNotice
923
+ status={bodyHydrationStatus}
924
+ error={bodyHydrationError}
925
+ footprint={fullBodyHydrationFootprint}
926
+ onRetry={retryFullBodyHydration}
927
+ />
857
928
  {useChunkedBody && (
858
929
  <BodyPreviewNotice
859
930
  label="Request"
@@ -982,6 +1053,12 @@ export const LogEntry = memo(function ({
982
1053
  <TabsContent value="raw">
983
1054
  {activeTab === "raw" && (
984
1055
  <ExpandedPanel className="space-y-3">
1056
+ <FullBodyHydrationNotice
1057
+ status={bodyHydrationStatus}
1058
+ error={bodyHydrationError}
1059
+ footprint={fullBodyHydrationFootprint}
1060
+ onRetry={retryFullBodyHydration}
1061
+ />
985
1062
  {useChunkedBody && (
986
1063
  <BodyPreviewNotice
987
1064
  label="Response"
@@ -1026,6 +1103,12 @@ export const LogEntry = memo(function ({
1026
1103
  <TabsContent value="parsed">
1027
1104
  {activeTab === "parsed" && (
1028
1105
  <ExpandedPanel>
1106
+ <FullBodyHydrationNotice
1107
+ status={bodyHydrationStatus}
1108
+ error={bodyHydrationError}
1109
+ footprint={fullBodyHydrationFootprint}
1110
+ onRetry={retryFullBodyHydration}
1111
+ />
1029
1112
  {useChunkedBody && (
1030
1113
  <BodyPreviewNotice
1031
1114
  label="Response"