@tonyclaw/agent-inspector 2.0.42 → 2.1.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.
Files changed (42) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-Cfqxlo-U.js → CompareDrawer-VDpcSNGM.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-fsXSjqtm.js +117 -0
  4. package/.output/public/assets/{ReplayDialog-daRidZo_.js → ReplayDialog-CoDgTzHR.js} +1 -1
  5. package/.output/public/assets/RequestAnatomy-t3plgkPE.js +1 -0
  6. package/.output/public/assets/ResponseView-BYuKiNeG.js +1 -0
  7. package/.output/public/assets/{StreamingChunkSequence-nK-0LpbM.js → StreamingChunkSequence-DOdU38Is.js} +1 -1
  8. package/.output/public/assets/_sessionId-ew5QlyZ2.js +1 -0
  9. package/.output/public/assets/index-DFICWD6o.js +1 -0
  10. package/.output/public/assets/index-zLLCkAnF.css +1 -0
  11. package/.output/public/assets/{main-DOy_Q96H.js → main-DmzLt6ti.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +219 -178
  13. package/.output/server/{_sessionId-DtYRZHlM.mjs → _sessionId-CCN1UoEf.mjs} +3 -3
  14. package/.output/server/_ssr/{CompareDrawer-fy1ARwtx.mjs → CompareDrawer-DkSY3vxP.mjs} +4 -4
  15. package/.output/server/_ssr/{ProxyViewerContainer-9GJtCWCq.mjs → ProxyViewerContainer-OTh1V_Kh.mjs} +1034 -448
  16. package/.output/server/_ssr/{ReplayDialog-BrS7syOE.mjs → ReplayDialog-DhI6yxrR.mjs} +5 -5
  17. package/.output/server/_ssr/{RequestAnatomy-BtWt1cWY.mjs → RequestAnatomy-DN1SJVVL.mjs} +4 -4
  18. package/.output/server/_ssr/{ResponseView-CkKGYE6m.mjs → ResponseView-DW-kpJL5.mjs} +4 -4
  19. package/.output/server/_ssr/{StreamingChunkSequence-C7rB3osr.mjs → StreamingChunkSequence-BAE57Fae.mjs} +4 -4
  20. package/.output/server/_ssr/{index-D24WforP.mjs → index-C9ryJEna.mjs} +3 -3
  21. package/.output/server/_ssr/index.mjs +2 -2
  22. package/.output/server/_ssr/{router-CVqVjBzJ.mjs → router-DIIJGyML.mjs} +458 -10
  23. package/.output/server/{_tanstack-start-manifest_v-DSikvwru.mjs → _tanstack-start-manifest_v-C_v9-E5d.mjs} +1 -1
  24. package/.output/server/index.mjs +63 -63
  25. package/package.json +1 -1
  26. package/src/components/OnboardingBanner.tsx +74 -68
  27. package/src/components/ProxyViewer.tsx +942 -297
  28. package/src/components/ProxyViewerContainer.tsx +199 -5
  29. package/src/components/groups/GroupsDialog.tsx +11 -10
  30. package/src/components/proxy-viewer/LogEntry.tsx +107 -56
  31. package/src/components/proxy-viewer/LogEntryHeader.tsx +12 -6
  32. package/src/components/ui/crab-logo.tsx +0 -50
  33. package/src/components/ui/dialog.tsx +1 -1
  34. package/src/proxy/logIndex.ts +188 -1
  35. package/src/proxy/store.ts +405 -3
  36. package/src/routes/api/logs.ts +36 -0
  37. package/.output/public/assets/ProxyViewerContainer-CE9pAX4c.js +0 -117
  38. package/.output/public/assets/RequestAnatomy-CJ7EPQGQ.js +0 -1
  39. package/.output/public/assets/ResponseView-xBmr54hB.js +0 -1
  40. package/.output/public/assets/_sessionId-CbWEG5ej.js +0 -1
  41. package/.output/public/assets/index-BfGJEb-2.css +0 -1
  42. package/.output/public/assets/index-D1MkoT4l.js +0 -1
@@ -1,9 +1,15 @@
1
1
  import { useState, useEffect, useCallback, useRef, useMemo, type JSX } from "react";
2
2
  import { z } from "zod";
3
3
  import { CapturedLogSchema, type CapturedLog } from "../contracts";
4
+ import { fetchJson } from "../lib/apiClient";
5
+ import { InspectorGroupsListResponseSchema, type InspectorGroup } from "../lib/groupContract";
4
6
  import { useStripConfig } from "../lib/useStripConfig";
5
7
  import { OnboardingBanner } from "./OnboardingBanner";
6
- import { ProxyViewer } from "./ProxyViewer";
8
+ import {
9
+ ProxyViewer,
10
+ type LogPaginationControls,
11
+ type SessionMembershipEvidence,
12
+ } from "./ProxyViewer";
7
13
  import { dispatchLogFocusRequest } from "./proxy-viewer/logFocus";
8
14
 
9
15
  type SSEUpdate =
@@ -27,6 +33,47 @@ const SSEUpdateSchema = z.union([
27
33
  }),
28
34
  ]);
29
35
 
36
+ const LogCursorPageSchema = z.object({
37
+ logs: z.array(CapturedLogSchema),
38
+ total: z.number().int().nonnegative(),
39
+ limit: z.number().int().positive(),
40
+ hasOlder: z.boolean(),
41
+ hasNewer: z.boolean(),
42
+ oldestLogId: z.number().int().positive().nullable(),
43
+ newestLogId: z.number().int().positive().nullable(),
44
+ });
45
+
46
+ type LogCursorPage = z.infer<typeof LogCursorPageSchema>;
47
+
48
+ type SessionPageRequest =
49
+ | { kind: "newest" }
50
+ | { kind: "oldest" }
51
+ | { kind: "older"; beforeLogId: number }
52
+ | { kind: "newer"; afterLogId: number };
53
+
54
+ function buildSessionMembershipEvidence(
55
+ groups: readonly InspectorGroup[],
56
+ sessionId: string,
57
+ ): SessionMembershipEvidence[] {
58
+ const memberships: SessionMembershipEvidence[] = [];
59
+ for (const group of groups) {
60
+ for (const member of group.members) {
61
+ if (member.sessionId !== sessionId) continue;
62
+ memberships.push({
63
+ groupId: group.id,
64
+ groupTitle: group.title,
65
+ groupKind: group.kind,
66
+ groupStatus: group.status,
67
+ groupTask: group.task,
68
+ groupProject: group.project,
69
+ groupEvidence: group.evidence,
70
+ member,
71
+ });
72
+ }
73
+ }
74
+ return memberships;
75
+ }
76
+
30
77
  function extractSessions(logs: CapturedLog[]): string[] {
31
78
  const set = new Set<string>();
32
79
  for (const l of logs) {
@@ -65,11 +112,41 @@ const DEBOUNCE_MS = 50;
65
112
  const HASH_SCROLL_ATTEMPTS = 12;
66
113
  const HASH_HIGHLIGHT_MS = 1800;
67
114
  const MAX_CLIENT_LOGS = 500;
115
+ const SESSION_PAGE_LIMIT = 100;
68
116
 
69
117
  function buildLogsStreamUrl(sessionId: string | undefined): string {
70
- if (sessionId === undefined) return "/api/logs/stream";
71
- const params = new URLSearchParams({ sessionId });
72
- return `/api/logs/stream?${params.toString()}`;
118
+ const params = new URLSearchParams();
119
+ if (sessionId !== undefined) {
120
+ params.set("sessionId", sessionId);
121
+ params.set("compact", "1");
122
+ }
123
+ const query = params.toString();
124
+ return query.length > 0 ? `/api/logs/stream?${query}` : "/api/logs/stream";
125
+ }
126
+
127
+ function buildSessionLogsPageUrl(sessionId: string, request: SessionPageRequest): string {
128
+ const params = new URLSearchParams({
129
+ cursor: "1",
130
+ compact: "1",
131
+ limit: String(SESSION_PAGE_LIMIT),
132
+ sessionId,
133
+ });
134
+
135
+ switch (request.kind) {
136
+ case "oldest":
137
+ params.set("anchor", "oldest");
138
+ break;
139
+ case "older":
140
+ params.set("beforeLogId", String(request.beforeLogId));
141
+ break;
142
+ case "newer":
143
+ params.set("afterLogId", String(request.afterLogId));
144
+ break;
145
+ case "newest":
146
+ break;
147
+ }
148
+
149
+ return `/api/logs?${params.toString()}`;
73
150
  }
74
151
 
75
152
  function buildLogIndex(logs: readonly CapturedLog[]): Map<number, number> {
@@ -109,9 +186,14 @@ export function ProxyViewerContainer({
109
186
  const [selectedModel, setSelectedModel] = useState("__all__");
110
187
  const [viewMode, setViewMode] = useState<"simple" | "full">("simple");
111
188
  const [error, setError] = useState<string | null>(null);
189
+ const [streamInitialized, setStreamInitialized] = useState(initialSessionId === undefined);
190
+ const [logPage, setLogPage] = useState<LogCursorPage | null>(null);
191
+ const [sessionPageLoading, setSessionPageLoading] = useState(initialSessionId !== undefined);
192
+ const [sessionMemberships, setSessionMemberships] = useState<SessionMembershipEvidence[]>([]);
112
193
  const eventSourceRef = useRef<EventSource | null>(null);
113
194
  const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
114
195
  const handledHashRef = useRef<string | null>(null);
196
+ const sessionPageRequestIdRef = useRef(0);
115
197
 
116
198
  // O(1) log lookup by id
117
199
  const logIndexRef = useRef<Map<number, number>>(new Map());
@@ -169,6 +251,7 @@ export function ProxyViewerContainer({
169
251
  eventSourceRef.current.close();
170
252
  }
171
253
 
254
+ setStreamInitialized(false);
172
255
  const es = new EventSource(buildLogsStreamUrl(initialSessionId));
173
256
  eventSourceRef.current = es;
174
257
 
@@ -193,6 +276,7 @@ export function ProxyViewerContainer({
193
276
  const nextLogs = trimClientLogs(update.logs);
194
277
  logIndexRef.current = buildLogIndex(nextLogs);
195
278
  setAllLogs(nextLogs);
279
+ setStreamInitialized(true);
196
280
  setError(null);
197
281
  } else if (update.type === "update") {
198
282
  scheduleUpdate(update.log);
@@ -204,6 +288,7 @@ export function ProxyViewerContainer({
204
288
 
205
289
  es.onerror = () => {
206
290
  setError("SSE connection lost, reconnecting...");
291
+ setStreamInitialized(true);
207
292
  es.close();
208
293
  if (reconnectTimeoutRef.current !== null) {
209
294
  clearTimeout(reconnectTimeoutRef.current);
@@ -212,7 +297,71 @@ export function ProxyViewerContainer({
212
297
  };
213
298
  }, [initialSessionId, scheduleUpdate]);
214
299
 
300
+ const loadSessionPage = useCallback(
301
+ (request: SessionPageRequest) => {
302
+ if (initialSessionId === undefined) return;
303
+
304
+ const requestId = sessionPageRequestIdRef.current + 1;
305
+ sessionPageRequestIdRef.current = requestId;
306
+ setSessionPageLoading(true);
307
+ setError(null);
308
+
309
+ void fetchJson(buildSessionLogsPageUrl(initialSessionId, request), LogCursorPageSchema)
310
+ .then((page) => {
311
+ if (sessionPageRequestIdRef.current !== requestId) return;
312
+ const nextLogs = page.logs;
313
+ logIndexRef.current = buildLogIndex(nextLogs);
314
+ setAllLogs(nextLogs);
315
+ setLogPage(page);
316
+ setSelectedSession(initialSessionId);
317
+ setSessionPageLoading(false);
318
+ setError(null);
319
+ })
320
+ .catch((err: unknown) => {
321
+ if (sessionPageRequestIdRef.current !== requestId) return;
322
+ setAllLogs([]);
323
+ setLogPage(null);
324
+ setSessionPageLoading(false);
325
+ setError(err instanceof Error ? err.message : "Failed to load session logs");
326
+ });
327
+ },
328
+ [initialSessionId],
329
+ );
330
+
215
331
  useEffect(() => {
332
+ if (initialSessionId === undefined) {
333
+ setSessionMemberships([]);
334
+ return;
335
+ }
336
+
337
+ let cancelled = false;
338
+ void fetch("/api/groups")
339
+ .then(async (response) => {
340
+ if (!response.ok) return null;
341
+ const raw: unknown = await response.json();
342
+ const parsed = InspectorGroupsListResponseSchema.safeParse(raw);
343
+ return parsed.success ? parsed.data.groups : null;
344
+ })
345
+ .then((groups) => {
346
+ if (cancelled || groups === null) return;
347
+ setSessionMemberships(buildSessionMembershipEvidence(groups, initialSessionId));
348
+ })
349
+ .catch(() => {
350
+ if (!cancelled) setSessionMemberships([]);
351
+ });
352
+
353
+ return () => {
354
+ cancelled = true;
355
+ };
356
+ }, [initialSessionId]);
357
+
358
+ useEffect(() => {
359
+ if (initialSessionId === undefined) return;
360
+ loadSessionPage({ kind: "newest" });
361
+ }, [initialSessionId, loadSessionPage]);
362
+
363
+ useEffect(() => {
364
+ if (initialSessionId !== undefined) return undefined;
216
365
  connectSSE();
217
366
  return () => {
218
367
  if (eventSourceRef.current) {
@@ -228,7 +377,7 @@ export function ProxyViewerContainer({
228
377
  flushTimerRef.current = null;
229
378
  }
230
379
  };
231
- }, [connectSSE]);
380
+ }, [connectSSE, initialSessionId]);
232
381
 
233
382
  useEffect(() => {
234
383
  const hash = window.location.hash;
@@ -342,6 +491,48 @@ export function ProxyViewerContainer({
342
491
  // Read the strip config once at the container so the virtualized list does
343
492
  // not need N independent SWR subscriptions per row.
344
493
  const { strip, captureMode, slowResponseThresholdSeconds, timeDisplayFormat } = useStripConfig();
494
+ const loadOldestPage = useCallback(() => {
495
+ loadSessionPage({ kind: "oldest" });
496
+ }, [loadSessionPage]);
497
+ const loadNewestPage = useCallback(() => {
498
+ loadSessionPage({ kind: "newest" });
499
+ }, [loadSessionPage]);
500
+ const loadOlderPage = useCallback(() => {
501
+ const firstLog = allLogs[0];
502
+ const beforeLogId = logPage?.oldestLogId ?? firstLog?.id ?? null;
503
+ if (beforeLogId === null) return;
504
+ loadSessionPage({ kind: "older", beforeLogId });
505
+ }, [allLogs, loadSessionPage, logPage]);
506
+ const loadNewerPage = useCallback(() => {
507
+ const lastLog = allLogs[allLogs.length - 1];
508
+ const afterLogId = logPage?.newestLogId ?? lastLog?.id ?? null;
509
+ if (afterLogId === null) return;
510
+ loadSessionPage({ kind: "newer", afterLogId });
511
+ }, [allLogs, loadSessionPage, logPage]);
512
+ const pagination = useMemo<LogPaginationControls | undefined>(() => {
513
+ if (initialSessionId === undefined) return undefined;
514
+ return {
515
+ isLoading: sessionPageLoading,
516
+ total: logPage?.total ?? null,
517
+ pageSize: logPage?.limit ?? SESSION_PAGE_LIMIT,
518
+ hasOlder: logPage?.hasOlder ?? false,
519
+ hasNewer: logPage?.hasNewer ?? false,
520
+ oldestLogId: logPage?.oldestLogId ?? null,
521
+ newestLogId: logPage?.newestLogId ?? null,
522
+ onOldest: loadOldestPage,
523
+ onOlder: loadOlderPage,
524
+ onNewer: loadNewerPage,
525
+ onNewest: loadNewestPage,
526
+ };
527
+ }, [
528
+ initialSessionId,
529
+ loadNewestPage,
530
+ loadNewerPage,
531
+ loadOlderPage,
532
+ loadOldestPage,
533
+ logPage,
534
+ sessionPageLoading,
535
+ ]);
345
536
 
346
537
  return (
347
538
  <>
@@ -361,6 +552,9 @@ export function ProxyViewerContainer({
361
552
  onModelChange={setSelectedModel}
362
553
  onClearAll={handleClearAll}
363
554
  onClearGroup={handleClearGroup}
555
+ isLoading={initialSessionId === undefined ? !streamInitialized : sessionPageLoading}
556
+ pagination={pagination}
557
+ sessionMemberships={sessionMemberships}
364
558
  viewMode={viewMode}
365
559
  onViewModeChange={setViewMode}
366
560
  captureMode={captureMode}
@@ -920,21 +920,22 @@ function MemberRow({ row }: { row: GroupMemberRow }): JSX.Element {
920
920
  const errors = session?.errorCount ?? null;
921
921
  const tokens = session?.tokenUsage.total ?? null;
922
922
  const firstChunkMs = log?.firstChunkMs ?? null;
923
+ const sessionLabel = firstNonEmpty(member.label, member.sessionId);
923
924
 
924
925
  return (
925
926
  <tr className="border-b border-border last:border-0">
926
927
  <td className="max-w-56 px-3 py-2">
927
- <div className="flex min-w-0 items-center gap-2">
928
- <a
929
- href={sessionPath}
930
- target="_blank"
931
- rel="noreferrer"
932
- className="min-w-0 truncate font-mono text-xs text-primary hover:underline"
933
- >
934
- {firstNonEmpty(member.label, member.sessionId)}
935
- </a>
928
+ <a
929
+ href={sessionPath}
930
+ target="_blank"
931
+ rel="noreferrer"
932
+ className="inline-flex max-w-full min-w-0 items-center gap-2 rounded-sm font-mono text-xs text-primary outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
933
+ aria-label={`Open session ${sessionLabel}`}
934
+ title={`Open session ${sessionLabel}`}
935
+ >
936
+ <span className="min-w-0 truncate">{sessionLabel}</span>
936
937
  <ExternalLink className="size-3 shrink-0 text-muted-foreground" />
937
- </div>
938
+ </a>
938
939
  <div className="mt-0.5 truncate text-[11px] text-muted-foreground">
939
940
  {displayText(member.agent)}
940
941
  </div>
@@ -1,7 +1,8 @@
1
1
  import { AlertTriangle, GitCompareArrows } from "lucide-react";
2
2
  import { Suspense, type JSX } from "react";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
4
- import type { CapturedLog } from "../../contracts";
4
+ import { CapturedLogSchema, type CapturedLog } from "../../contracts";
5
+ import { fetchJson } from "../../lib/apiClient";
5
6
  import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
6
7
  import { stripClaudeCodeBillingHeader } from "../../proxy/claudeCodeStrip";
7
8
  import { Button } from "../ui/button";
@@ -50,6 +51,12 @@ function resolveFocusedTab(tab: LogFocusTab, anatomySegments: AnatomySegment[] |
50
51
  }
51
52
  }
52
53
 
54
+ type BodyHydrationState = "idle" | "loading" | "failed";
55
+
56
+ function shouldHydrateLogBody(log: CapturedLog): boolean {
57
+ return log.bodyContentMode === "compact" || log.bodyContentMode === "truncated";
58
+ }
59
+
53
60
  export type LogEntryProps = {
54
61
  log: CapturedLog;
55
62
  viewMode?: "simple" | "full";
@@ -144,53 +151,56 @@ export const LogEntry = memo(function ({
144
151
  const [requestDiff, setRequestDiff] = useState<boolean>(false);
145
152
  const [activeTab, setActiveTab] = useState("request");
146
153
  const [expandToPath, setExpandToPath] = useState<string | null>(null);
154
+ const [hydratedLog, setHydratedLog] = useState<CapturedLog | null>(null);
155
+ const [bodyHydrationState, setBodyHydrationState] = useState<BodyHydrationState>("idle");
147
156
  const requestJsonRef = useRef<HTMLDivElement | null>(null);
148
- const resolvedFormat = resolveLogFormat(log);
157
+ const displayLog = hydratedLog ?? log;
158
+ const resolvedFormat = resolveLogFormat(displayLog);
149
159
  const adapter = getLogFormatAdapter(resolvedFormat);
150
160
  const requestAnalysis = useMemo(
151
- () => adapter.analyzeRequest(log.rawRequestBody),
152
- [adapter, log.rawRequestBody],
161
+ () => adapter.analyzeRequest(displayLog.rawRequestBody),
162
+ [adapter, displayLog.rawRequestBody],
153
163
  );
154
164
  const responseAnalysis = useMemo(
155
- () => adapter.analyzeResponse(log.responseText),
156
- [adapter, log.responseText],
165
+ () => adapter.analyzeResponse(displayLog.responseText),
166
+ [adapter, displayLog.responseText],
157
167
  );
158
168
  const strippedRequestBody = useMemo(() => {
159
- if (!strip || resolvedFormat !== "anthropic" || log.rawRequestBody === null) {
169
+ if (!strip || resolvedFormat !== "anthropic" || displayLog.rawRequestBody === null) {
160
170
  return null;
161
171
  }
162
- return stripClaudeCodeBillingHeader(log.rawRequestBody).body;
163
- }, [log.rawRequestBody, resolvedFormat, strip]);
164
- const displayedRequestBody = strippedRequestBody ?? log.rawRequestBody;
172
+ return stripClaudeCodeBillingHeader(displayLog.rawRequestBody).body;
173
+ }, [displayLog.rawRequestBody, resolvedFormat, strip]);
174
+ const displayedRequestBody = strippedRequestBody ?? displayLog.rawRequestBody;
165
175
  const requestExpansion = useJsonBulkExpansion(displayedRequestBody);
166
- const rawRequestExpansion = useJsonBulkExpansion(log.rawRequestBody);
167
- const responseExpansion = useJsonBulkExpansion(log.responseText);
176
+ const rawRequestExpansion = useJsonBulkExpansion(displayLog.rawRequestBody);
177
+ const responseExpansion = useJsonBulkExpansion(displayLog.responseText);
168
178
 
169
179
  // Headers are rendered as a flat list, so we copy them as pretty-printed JSON.
170
180
  // Only build the string when there's at least one entry — empty headers would
171
181
  // otherwise copy "{}", which is misleading.
172
182
  const headersText = useMemo(
173
183
  () =>
174
- log.headers && Object.keys(log.headers).length > 0
175
- ? JSON.stringify(log.headers, null, 2)
184
+ displayLog.headers && Object.keys(displayLog.headers).length > 0
185
+ ? JSON.stringify(displayLog.headers, null, 2)
176
186
  : null,
177
- [log.headers],
187
+ [displayLog.headers],
178
188
  );
179
189
  const rawHeadersText = useMemo(
180
190
  () =>
181
- log.rawHeaders && Object.keys(log.rawHeaders).length > 0
182
- ? JSON.stringify(log.rawHeaders, null, 2)
191
+ displayLog.rawHeaders && Object.keys(displayLog.rawHeaders).length > 0
192
+ ? JSON.stringify(displayLog.rawHeaders, null, 2)
183
193
  : null,
184
- [log.rawHeaders],
194
+ [displayLog.rawHeaders],
185
195
  );
186
196
 
187
197
  // One copy-feedback hook per JSON-bearing tab, so each tab can surface its
188
198
  // own Copy button in the header with independent "Copied!" feedback.
189
199
  const requestCopy = useCopyFeedback(displayedRequestBody);
190
- const rawRequestCopy = useCopyFeedback(log.rawRequestBody);
200
+ const rawRequestCopy = useCopyFeedback(displayLog.rawRequestBody);
191
201
  const headersCopy = useCopyFeedback(headersText);
192
202
  const rawHeadersCopy = useCopyFeedback(rawHeadersText);
193
- const responseCopy = useCopyFeedback(log.responseText);
203
+ const responseCopy = useCopyFeedback(displayLog.responseText);
194
204
 
195
205
  // Per-tab action bundles consumed by the header. The header renders the
196
206
  // entry whose key matches `activeTab`. Tabs without an entry (Context,
@@ -214,7 +224,7 @@ export const LogEntry = memo(function ({
214
224
  resolvedFormat,
215
225
  viewMode,
216
226
  strip,
217
- log.rawRequestBody !== null,
227
+ displayLog.rawRequestBody !== null,
218
228
  )
219
229
  ? { active: requestDiff, onToggle: () => setRequestDiff(!requestDiff) }
220
230
  : undefined,
@@ -222,12 +232,12 @@ export const LogEntry = memo(function ({
222
232
  onCompareWithPrevious === undefined
223
233
  ? undefined
224
234
  : () => {
225
- onCompareWithPrevious(log);
235
+ onCompareWithPrevious(displayLog);
226
236
  },
227
237
  },
228
238
  "raw-request": {
229
239
  copyLabel: "Copy raw request",
230
- copyText: log.rawRequestBody,
240
+ copyText: displayLog.rawRequestBody,
231
241
  copyCopied: rawRequestCopy.copied,
232
242
  onCopy: rawRequestCopy.copy,
233
243
  expansion: {
@@ -253,7 +263,7 @@ export const LogEntry = memo(function ({
253
263
  },
254
264
  raw: {
255
265
  copyLabel: "Copy response",
256
- copyText: log.responseText,
266
+ copyText: displayLog.responseText,
257
267
  copyCopied: responseCopy.copied,
258
268
  onCopy: responseCopy.copy,
259
269
  expansion: {
@@ -268,14 +278,15 @@ export const LogEntry = memo(function ({
268
278
  requestCopy,
269
279
  requestExpansion,
270
280
  requestDiff,
271
- log.rawRequestBody,
281
+ displayLog,
282
+ displayLog.rawRequestBody,
272
283
  rawRequestCopy,
273
284
  rawRequestExpansion,
274
285
  headersText,
275
286
  headersCopy,
276
287
  rawHeadersText,
277
288
  rawHeadersCopy,
278
- log.responseText,
289
+ displayLog.responseText,
279
290
  responseCopy,
280
291
  responseExpansion,
281
292
  resolvedFormat,
@@ -295,7 +306,7 @@ export const LogEntry = memo(function ({
295
306
  () => parseRequestTools(requestExpansion.parsedData),
296
307
  [requestExpansion.parsedData],
297
308
  );
298
- const warnings = log.warnings ?? [];
309
+ const warnings = displayLog.warnings ?? [];
299
310
  const anatomyPaths = useMemo(() => {
300
311
  if (anatomySegments === null) return undefined;
301
312
  return new Set(anatomySegments.map((s) => s.path));
@@ -316,6 +327,34 @@ export const LogEntry = memo(function ({
316
327
  setExpanded(nextExpanded);
317
328
  }, [anatomySegments, expanded]);
318
329
 
330
+ useEffect(() => {
331
+ setHydratedLog(null);
332
+ setBodyHydrationState("idle");
333
+ }, [log.id]);
334
+
335
+ useEffect(() => {
336
+ if (!expanded) return;
337
+ if (!shouldHydrateLogBody(log)) return;
338
+ if (hydratedLog !== null || bodyHydrationState !== "idle") return;
339
+
340
+ let cancelled = false;
341
+ setBodyHydrationState("loading");
342
+ void fetchJson(`/api/logs/${String(log.id)}`, CapturedLogSchema)
343
+ .then((fullLog) => {
344
+ if (cancelled) return;
345
+ setHydratedLog(fullLog);
346
+ setBodyHydrationState("idle");
347
+ })
348
+ .catch(() => {
349
+ if (cancelled) return;
350
+ setBodyHydrationState("failed");
351
+ });
352
+
353
+ return () => {
354
+ cancelled = true;
355
+ };
356
+ }, [bodyHydrationState, expanded, hydratedLog, log.bodyContentMode, log.id]);
357
+
319
358
  useEffect(() => {
320
359
  const handleLogFocusRequest = (event: Event): void => {
321
360
  const request = readLogFocusRequest(event);
@@ -335,7 +374,7 @@ export const LogEntry = memo(function ({
335
374
  <TooltipProvider>
336
375
  <div className="border border-border rounded-lg mb-1 overflow-hidden">
337
376
  <LogEntryHeader
338
- log={log}
377
+ log={displayLog}
339
378
  messageCount={requestAnalysis.messageCount}
340
379
  toolCount={requestAnalysis.toolCount}
341
380
  expanded={expanded}
@@ -373,7 +412,7 @@ export const LogEntry = memo(function ({
373
412
  <TabsContent value="raw-request">
374
413
  {activeTab === "raw-request" && (
375
414
  <div className="px-4 pt-1 pb-3">
376
- {log.rawRequestBody === null ? (
415
+ {displayLog.rawRequestBody === null ? (
377
416
  <p className="text-xs text-muted-foreground italic">No request body</p>
378
417
  ) : rawRequestExpansion.parsedData !== null ? (
379
418
  <Suspense fallback={<TabFallback />}>
@@ -385,7 +424,7 @@ export const LogEntry = memo(function ({
385
424
  </Suspense>
386
425
  ) : (
387
426
  <pre className="font-mono text-xs whitespace-pre-wrap break-words">
388
- {log.rawRequestBody}
427
+ {displayLog.rawRequestBody}
389
428
  </pre>
390
429
  )}
391
430
  </div>
@@ -396,6 +435,17 @@ export const LogEntry = memo(function ({
396
435
  <TabsContent value="request">
397
436
  {activeTab === "request" && (
398
437
  <div className="px-4 pt-1 pb-3">
438
+ {bodyHydrationState === "loading" && (
439
+ <div className="mb-3 rounded border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
440
+ Loading full request and response bodies...
441
+ </div>
442
+ )}
443
+ {bodyHydrationState === "failed" && (
444
+ <div className="mb-3 rounded border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
445
+ Full request and response bodies could not be loaded. Compact metadata is
446
+ still available.
447
+ </div>
448
+ )}
399
449
  {warnings.length > 0 && (
400
450
  <div className="mb-3 rounded border border-amber-500/30 bg-amber-500/10 p-3 text-xs">
401
451
  <div className="flex items-center gap-2 font-semibold text-amber-300">
@@ -418,7 +468,7 @@ export const LogEntry = memo(function ({
418
468
  the diff or JSON view, depending on toggle state. */}
419
469
  {requestDiff ? (
420
470
  <RequestDiffContent
421
- rawBody={log.rawRequestBody}
471
+ rawBody={displayLog.rawRequestBody}
422
472
  displayedBody={displayedRequestBody}
423
473
  emptyLabel="No transformation applied; raw and sent request bodies are identical."
424
474
  />
@@ -453,8 +503,8 @@ export const LogEntry = memo(function ({
453
503
  <Suspense fallback={<TabFallback />}>
454
504
  <LazyRequestAnatomy
455
505
  parsed={requestExpansion.parsedData}
456
- inputTokens={log.inputTokens ?? null}
457
- model={log.model}
506
+ inputTokens={displayLog.inputTokens ?? null}
507
+ model={displayLog.model}
458
508
  segments={anatomySegments}
459
509
  onSegmentActivate={jumpToAnatomySegment}
460
510
  />
@@ -471,7 +521,8 @@ export const LogEntry = memo(function ({
471
521
  <div className="flex justify-end gap-2 mb-2">
472
522
  {shouldShowHeadersDiffButton(
473
523
  viewMode,
474
- log.rawHeaders !== undefined && Object.keys(log.rawHeaders).length > 0,
524
+ displayLog.rawHeaders !== undefined &&
525
+ Object.keys(displayLog.rawHeaders).length > 0,
475
526
  ) && (
476
527
  <DiffToggleButton
477
528
  active={headersDiff}
@@ -484,13 +535,13 @@ export const LogEntry = memo(function ({
484
535
  </div>
485
536
  {headersDiff ? (
486
537
  <HeadersDiffContent
487
- rawHeaders={log.rawHeaders}
488
- headers={log.headers}
538
+ rawHeaders={displayLog.rawHeaders}
539
+ headers={displayLog.headers}
489
540
  emptyLabel="No transformation applied; raw and processed headers are identical."
490
541
  />
491
- ) : log.headers && Object.keys(log.headers).length > 0 ? (
542
+ ) : displayLog.headers && Object.keys(displayLog.headers).length > 0 ? (
492
543
  <div className="space-y-1 font-mono text-xs">
493
- {Object.entries(log.headers)
544
+ {Object.entries(displayLog.headers)
494
545
  .sort(([a], [b]) => a.localeCompare(b))
495
546
  .map(([key, value]) => (
496
547
  <div key={key} className="flex gap-2">
@@ -516,9 +567,9 @@ export const LogEntry = memo(function ({
516
567
  {activeTab === "raw-headers" && (
517
568
  <div className="px-4 pt-1 pb-3">
518
569
  {/* Copy lives in the log header. */}
519
- {log.rawHeaders && Object.keys(log.rawHeaders).length > 0 ? (
570
+ {displayLog.rawHeaders && Object.keys(displayLog.rawHeaders).length > 0 ? (
520
571
  <div className="space-y-1 font-mono text-xs">
521
- {Object.entries(log.rawHeaders)
572
+ {Object.entries(displayLog.rawHeaders)
522
573
  .sort(([a], [b]) => a.localeCompare(b))
523
574
  .map(([key, value]) => (
524
575
  <div key={key} className="flex gap-2">
@@ -544,16 +595,16 @@ export const LogEntry = memo(function ({
544
595
  <TabsContent value="raw">
545
596
  {activeTab === "raw" && (
546
597
  <div className="px-4 pt-1 pb-3 space-y-3">
547
- {log.error !== undefined && log.error !== null && (
598
+ {displayLog.error !== undefined && displayLog.error !== null && (
548
599
  <div className="rounded border border-destructive/50 bg-destructive/10 p-3 text-xs">
549
600
  <div className="font-semibold text-destructive mb-1">SSE Error</div>
550
- <div className="text-muted-foreground font-mono">{log.error}</div>
601
+ <div className="text-muted-foreground font-mono">{displayLog.error}</div>
551
602
  </div>
552
603
  )}
553
- {log.responseText !== null ? (
604
+ {displayLog.responseText !== null ? (
554
605
  <Suspense fallback={<TabFallback />}>
555
606
  <LazyJsonViewerFromString
556
- text={log.responseText}
607
+ text={displayLog.responseText}
557
608
  defaultExpandDepth={0}
558
609
  bulkDepth={responseExpansion.bulkDepth}
559
610
  bulkRevision={responseExpansion.bulkRevision}
@@ -562,11 +613,11 @@ export const LogEntry = memo(function ({
562
613
  ) : (
563
614
  <p className="text-xs text-muted-foreground italic">No response</p>
564
615
  )}
565
- {log.streaming === true && (
616
+ {displayLog.streaming === true && (
566
617
  <Suspense fallback={<TabFallback />}>
567
618
  <LazyStreamingChunkSequence
568
- logId={log.id}
569
- truncated={log.streamingChunksPath !== null}
619
+ logId={displayLog.id}
620
+ truncated={displayLog.streamingChunksPath !== null}
570
621
  />
571
622
  </Suspense>
572
623
  )}
@@ -579,15 +630,15 @@ export const LogEntry = memo(function ({
579
630
  <div className="px-4 pt-1 pb-3">
580
631
  <Suspense fallback={<TabFallback />}>
581
632
  <LazyResponseView
582
- responseText={log.responseText}
583
- responseStatus={log.responseStatus}
584
- streaming={log.streaming}
585
- inputTokens={log.inputTokens}
586
- outputTokens={log.outputTokens}
587
- cacheCreationInputTokens={log.cacheCreationInputTokens}
588
- cacheReadInputTokens={log.cacheReadInputTokens}
633
+ responseText={displayLog.responseText}
634
+ responseStatus={displayLog.responseStatus}
635
+ streaming={displayLog.streaming}
636
+ inputTokens={displayLog.inputTokens}
637
+ outputTokens={displayLog.outputTokens}
638
+ cacheCreationInputTokens={displayLog.cacheCreationInputTokens}
639
+ cacheReadInputTokens={displayLog.cacheReadInputTokens}
589
640
  apiFormat={resolvedFormat}
590
- error={log.error}
641
+ error={displayLog.error}
591
642
  />
592
643
  </Suspense>
593
644
  </div>
@@ -598,7 +649,7 @@ export const LogEntry = memo(function ({
598
649
  )}
599
650
  </div>
600
651
  <Suspense fallback={null}>
601
- <LazyReplayDialog log={log} open={replayOpen} onOpenChange={setReplayOpen} />
652
+ <LazyReplayDialog log={displayLog} open={replayOpen} onOpenChange={setReplayOpen} />
602
653
  </Suspense>
603
654
  </TooltipProvider>
604
655
  );