@tonyclaw/agent-inspector 2.0.43 → 2.1.1

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 (44) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-CxM1gCfL.js → CompareDrawer-DkDpl_Sg.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-D_-e1qn7.js +117 -0
  4. package/.output/public/assets/{ReplayDialog-UseUkucS.js → ReplayDialog-CKqYfD0A.js} +1 -1
  5. package/.output/public/assets/RequestAnatomy-CQZl-YIx.js +1 -0
  6. package/.output/public/assets/ResponseView-DW9tFPi0.js +1 -0
  7. package/.output/public/assets/{StreamingChunkSequence-JSQEPeNS.js → StreamingChunkSequence-m8zKc3L1.js} +1 -1
  8. package/.output/public/assets/_sessionId-DyfzgUXv.js +1 -0
  9. package/.output/public/assets/index-B6bobhTz.js +1 -0
  10. package/.output/public/assets/index-CLQKZ5A5.css +1 -0
  11. package/.output/public/assets/{main-BpGVJcpB.js → main-BjXd3DR-.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +219 -178
  13. package/.output/server/{_sessionId-DGn-TENM.mjs → _sessionId-LjzdyWF4.mjs} +3 -3
  14. package/.output/server/_ssr/{CompareDrawer-CFElCSYY.mjs → CompareDrawer-YX2uOwh9.mjs} +4 -4
  15. package/.output/server/_ssr/{ProxyViewerContainer-B7SR9mrD.mjs → ProxyViewerContainer-BcKHl2E3.mjs} +1016 -582
  16. package/.output/server/_ssr/{ReplayDialog-DfKapj0k.mjs → ReplayDialog-B9AxvV2j.mjs} +5 -5
  17. package/.output/server/_ssr/{RequestAnatomy-CUxTCg31.mjs → RequestAnatomy-DxmUOcz8.mjs} +4 -4
  18. package/.output/server/_ssr/{ResponseView-BXY0w197.mjs → ResponseView-B73PHDQ0.mjs} +4 -4
  19. package/.output/server/_ssr/{StreamingChunkSequence-CzMnES9H.mjs → StreamingChunkSequence-Bkwxu5Ev.mjs} +4 -4
  20. package/.output/server/_ssr/{index-CVlT-REW.mjs → index-Dfl6uxit.mjs} +3 -3
  21. package/.output/server/_ssr/index.mjs +2 -2
  22. package/.output/server/_ssr/{router-B2d1LUx6.mjs → router-B5cpU5Hi.mjs} +458 -10
  23. package/.output/server/{_tanstack-start-manifest_v-Drpi28BM.mjs → _tanstack-start-manifest_v-CHhxRQY_.mjs} +1 -1
  24. package/.output/server/index.mjs +62 -62
  25. package/package.json +1 -1
  26. package/src/components/OnboardingBanner.tsx +60 -70
  27. package/src/components/ProxyViewer.tsx +838 -366
  28. package/src/components/ProxyViewerContainer.tsx +136 -5
  29. package/src/components/providers/SettingsDialog.tsx +0 -13
  30. package/src/components/proxy-viewer/LogEntry.tsx +198 -89
  31. package/src/components/proxy-viewer/LogEntryHeader.tsx +23 -13
  32. package/src/components/proxy-viewer/RequestToolsPanel.tsx +8 -8
  33. package/src/components/ui/crab-logo.tsx +0 -50
  34. package/src/components/ui/json-viewer.tsx +6 -0
  35. package/src/proxy/logIndex.ts +188 -1
  36. package/src/proxy/store.ts +405 -3
  37. package/src/routes/api/logs.ts +36 -0
  38. package/styles/globals.css +53 -8
  39. package/.output/public/assets/ProxyViewerContainer-TtRG-0E7.js +0 -117
  40. package/.output/public/assets/RequestAnatomy-aPxgEJ2L.js +0 -1
  41. package/.output/public/assets/ResponseView-DA-F4F97.js +0 -1
  42. package/.output/public/assets/_sessionId-BEXuCWq5.js +0 -1
  43. package/.output/public/assets/index-Bhsa_2xX.js +0 -1
  44. package/.output/public/assets/index-DOWlRJ0W.css +0 -1
@@ -3,11 +3,20 @@ import {
3
3
  ArrowDownRight,
4
4
  ArrowLeft,
5
5
  ArrowUpRight,
6
+ Clapperboard,
6
7
  Check,
8
+ ChevronDown,
9
+ ChevronLeft,
10
+ ChevronRight,
11
+ ChevronUp,
12
+ ChevronsLeft,
13
+ ChevronsRight,
7
14
  Copy,
8
15
  Download,
9
16
  FileJson,
17
+ Gauge,
10
18
  Plus,
19
+ Siren,
11
20
  Trash2,
12
21
  } from "lucide-react";
13
22
 
@@ -98,6 +107,20 @@ export type SessionMembershipEvidence = {
98
107
  member: InspectorGroupMember;
99
108
  };
100
109
 
110
+ export type LogPaginationControls = {
111
+ isLoading: boolean;
112
+ total: number | null;
113
+ pageSize: number;
114
+ hasOlder: boolean;
115
+ hasNewer: boolean;
116
+ oldestLogId: number | null;
117
+ newestLogId: number | null;
118
+ onOldest: () => void;
119
+ onOlder: () => void;
120
+ onNewer: () => void;
121
+ onNewest: () => void;
122
+ };
123
+
101
124
  function formatTimeRange(logs: CapturedLog[], timeDisplayFormat: TimeDisplayFormat): string | null {
102
125
  const first = logs[0];
103
126
  const last = logs[logs.length - 1];
@@ -114,6 +137,167 @@ function getFirstUserAgent(logs: CapturedLog[]): string | null {
114
137
  return null;
115
138
  }
116
139
 
140
+ type SessionSlateStatus = "rolling" | "clear" | "watch" | "failed";
141
+
142
+ type SessionSlateStats = {
143
+ status: SessionSlateStatus;
144
+ statusLabel: string;
145
+ modelLabel: string;
146
+ requests: number;
147
+ failures: number;
148
+ slow: number;
149
+ timeRange: string | null;
150
+ tokenLabel: string;
151
+ };
152
+
153
+ function hasLogFailure(log: CapturedLog): boolean {
154
+ if (log.error !== null && log.error !== undefined && log.error.length > 0) return true;
155
+ return log.responseStatus !== null && log.responseStatus >= 400;
156
+ }
157
+
158
+ function getModelLabel(logs: readonly CapturedLog[]): string {
159
+ const models = new Set<string>();
160
+ for (const log of logs) {
161
+ if (log.model !== null && log.model.length > 0) {
162
+ models.add(log.model);
163
+ }
164
+ }
165
+ const sorted = [...models].sort();
166
+ if (sorted.length === 0) return "Model unknown";
167
+ if (sorted.length === 1) return sorted[0] ?? "Model unknown";
168
+ return `${sorted.length} models`;
169
+ }
170
+
171
+ function buildSessionSlateStats({
172
+ logs,
173
+ isLoading,
174
+ timeDisplayFormat,
175
+ slowResponseThresholdSeconds,
176
+ }: {
177
+ logs: CapturedLog[];
178
+ isLoading: boolean;
179
+ timeDisplayFormat: TimeDisplayFormat;
180
+ slowResponseThresholdSeconds: number;
181
+ }): SessionSlateStats {
182
+ let failures = 0;
183
+ let slow = 0;
184
+ let totalIn = 0;
185
+ let totalOut = 0;
186
+ const slowThresholdMs = slowResponseThresholdSeconds * 1000;
187
+
188
+ for (const log of logs) {
189
+ if (hasLogFailure(log)) failures += 1;
190
+ if (
191
+ slowResponseThresholdSeconds > 0 &&
192
+ log.elapsedMs !== null &&
193
+ log.elapsedMs >= slowThresholdMs
194
+ ) {
195
+ slow += 1;
196
+ }
197
+ if (log.inputTokens !== null) totalIn += log.inputTokens;
198
+ if (log.outputTokens !== null) totalOut += log.outputTokens;
199
+ }
200
+
201
+ const status: SessionSlateStatus = isLoading
202
+ ? "rolling"
203
+ : failures > 0
204
+ ? "failed"
205
+ : slow > 0
206
+ ? "watch"
207
+ : "clear";
208
+ const statusLabel =
209
+ status === "rolling"
210
+ ? "Rolling"
211
+ : status === "failed"
212
+ ? "Failure"
213
+ : status === "watch"
214
+ ? "Watch"
215
+ : "Clear";
216
+
217
+ return {
218
+ status,
219
+ statusLabel,
220
+ modelLabel: getModelLabel(logs),
221
+ requests: logs.length,
222
+ failures,
223
+ slow,
224
+ timeRange: formatTimeRange(logs, timeDisplayFormat),
225
+ tokenLabel: `${formatTokens(totalIn)} in / ${formatTokens(totalOut)} out`,
226
+ };
227
+ }
228
+
229
+ function slateStatusClass(status: SessionSlateStatus): string {
230
+ switch (status) {
231
+ case "rolling":
232
+ return "border-cyan-400/35 bg-cyan-500/10 text-cyan-200";
233
+ case "failed":
234
+ return "border-red-400/35 bg-red-500/10 text-red-100";
235
+ case "watch":
236
+ return "border-amber-300/35 bg-amber-400/10 text-amber-100";
237
+ case "clear":
238
+ return "border-emerald-400/30 bg-emerald-500/10 text-emerald-100";
239
+ }
240
+ }
241
+
242
+ function SessionOpeningSlate({ stats }: { stats: SessionSlateStats }): JSX.Element | null {
243
+ if (stats.requests === 0) return null;
244
+
245
+ return (
246
+ <div className="mb-4 rounded-md border border-border bg-[#0c0d0f] shadow-[0_14px_50px_rgba(0,0,0,0.25)]">
247
+ <div className="grid grid-cols-2 gap-0 md:grid-cols-[minmax(220px,1.2fr)_repeat(4,minmax(120px,1fr))]">
248
+ <div className="col-span-2 border-b border-border/70 px-4 py-3 md:col-span-1 md:border-b-0 md:border-r">
249
+ <div className="flex items-center gap-2 text-[10px] font-semibold uppercase text-muted-foreground">
250
+ <Clapperboard className="size-3.5 text-cyan-300" />
251
+ Opening Slate
252
+ </div>
253
+ <div className="mt-2 min-w-0 truncate font-mono text-sm font-semibold text-foreground">
254
+ {stats.modelLabel}
255
+ </div>
256
+ <div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
257
+ {stats.timeRange ?? "Timeline pending"}
258
+ </div>
259
+ </div>
260
+ <div className="border-r border-b border-border/70 px-4 py-3 md:border-b-0">
261
+ <div className="text-[10px] uppercase text-muted-foreground">Status</div>
262
+ <div
263
+ className={cn(
264
+ "mt-2 inline-flex h-7 items-center rounded-md border px-2.5 font-mono text-xs font-semibold",
265
+ slateStatusClass(stats.status),
266
+ )}
267
+ >
268
+ {stats.statusLabel}
269
+ </div>
270
+ </div>
271
+ <div className="border-b border-border/70 px-4 py-3 md:border-b-0 md:border-r">
272
+ <div className="text-[10px] uppercase text-muted-foreground">Requests</div>
273
+ <div className="mt-2 font-mono text-xl font-semibold text-foreground">
274
+ {stats.requests}
275
+ </div>
276
+ </div>
277
+ <div className="border-r border-border/70 px-4 py-3 md:border-r">
278
+ <div className="flex items-center gap-1 text-[10px] uppercase text-muted-foreground">
279
+ <Siren className="size-3 text-red-300" />
280
+ Failure
281
+ </div>
282
+ <div className="mt-2 font-mono text-xl font-semibold text-red-100">{stats.failures}</div>
283
+ </div>
284
+ <div className="px-4 py-3">
285
+ <div className="flex items-center gap-1 text-[10px] uppercase text-muted-foreground">
286
+ <Gauge className="size-3 text-amber-200" />
287
+ Tempo
288
+ </div>
289
+ <div className="mt-2 font-mono text-sm font-semibold text-foreground">
290
+ {stats.slow} slow
291
+ </div>
292
+ <div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
293
+ {stats.tokenLabel}
294
+ </div>
295
+ </div>
296
+ </div>
297
+ </div>
298
+ );
299
+ }
300
+
117
301
  export function resolveSessionContextScope({
118
302
  pinnedSessionId,
119
303
  selectedSession,
@@ -227,6 +411,39 @@ function formatMetadataValue(value: InspectorGroupMember["metadata"][string]): s
227
411
  return JSON.stringify(value);
228
412
  }
229
413
 
414
+ function formatMemberMetadata(member: InspectorGroupMember | null, key: string): string | null {
415
+ if (member === null) return null;
416
+ const value = member.metadata[key];
417
+ if (value === undefined) return null;
418
+ return formatMetadataValue(value);
419
+ }
420
+
421
+ function evidenceChipClass(value: string): string {
422
+ const normalized = value.toLowerCase();
423
+ if (
424
+ normalized.includes("reject") ||
425
+ normalized.includes("fail") ||
426
+ normalized.includes("error")
427
+ ) {
428
+ return "border-red-400/35 bg-red-500/10 text-red-100";
429
+ }
430
+ if (
431
+ normalized.includes("partial") ||
432
+ normalized.includes("pending") ||
433
+ normalized.includes("slow")
434
+ ) {
435
+ return "border-amber-300/35 bg-amber-400/10 text-amber-100";
436
+ }
437
+ if (
438
+ normalized.includes("pass") ||
439
+ normalized.includes("complete") ||
440
+ normalized.includes("accept")
441
+ ) {
442
+ return "border-emerald-400/30 bg-emerald-500/10 text-emerald-100";
443
+ }
444
+ return "border-border/70 bg-background/70 text-muted-foreground";
445
+ }
446
+
230
447
  function getMemberMetadataRows(member: InspectorGroupMember): { key: string; value: string }[] {
231
448
  const rows: { key: string; value: string }[] = [];
232
449
  for (const key of MEMBER_METADATA_KEYS) {
@@ -240,75 +457,234 @@ function getMemberMetadataRows(member: InspectorGroupMember): { key: string; val
240
457
 
241
458
  function SessionMembershipPanel({
242
459
  memberships,
460
+ defaultOpen = false,
243
461
  }: {
244
462
  memberships: SessionMembershipEvidence[];
463
+ defaultOpen?: boolean;
245
464
  }): JSX.Element | null {
465
+ const [open, setOpen] = useState(defaultOpen);
246
466
  if (memberships.length === 0) return null;
467
+ const primary = memberships[0];
468
+ const primaryMember = primary?.member ?? null;
469
+ const score = formatMemberMetadata(primaryMember, "score");
470
+ const decision = formatMemberMetadata(primaryMember, "decision");
471
+ const finalStatus = formatMemberMetadata(primaryMember, "final_status");
472
+ const summaryChips = [
473
+ score === null ? null : { label: `score ${score}`, value: score },
474
+ decision === null ? null : { label: decision, value: decision },
475
+ finalStatus === null ? null : { label: finalStatus, value: finalStatus },
476
+ primaryMember?.model === null || primaryMember?.model === undefined
477
+ ? null
478
+ : { label: primaryMember.model, value: primaryMember.model },
479
+ ].filter(
480
+ (value): value is { label: string; value: string } =>
481
+ value !== null && value.label.length > 0 && value.value.length > 0,
482
+ );
247
483
 
248
484
  return (
249
- <div className="mb-4 rounded-md border border-border bg-muted/10 px-3 py-2 text-xs">
250
- <div className="mb-2 flex items-center justify-between gap-2">
251
- <div className="font-semibold text-foreground">Group Member Evidence</div>
252
- <div className="font-mono text-[10px] text-muted-foreground">
253
- {memberships.length} group{memberships.length === 1 ? "" : "s"}
254
- </div>
255
- </div>
256
- <div className="space-y-2">
257
- {memberships.map((membership) => {
258
- const member = membership.member;
259
- const metadataRows = getMemberMetadataRows(member);
260
- return (
261
- <div key={`${membership.groupId}:${member.id}`} className="min-w-0">
262
- <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
485
+ <div className="mb-3 overflow-hidden rounded-md border border-border bg-[#0c0d0f] text-xs">
486
+ <button
487
+ type="button"
488
+ onClick={() => setOpen((value) => !value)}
489
+ className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/30"
490
+ aria-expanded={open}
491
+ >
492
+ <div className="min-w-0 flex-1">
493
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
494
+ <span className="font-semibold text-foreground">Case File</span>
495
+ {primary !== undefined && (
496
+ <span className="truncate text-muted-foreground" title={primary.groupTitle}>
497
+ {primary.groupTitle}
498
+ </span>
499
+ )}
500
+ <span className="font-mono text-[10px] uppercase text-muted-foreground">
501
+ {memberships.length} group{memberships.length === 1 ? "" : "s"}
502
+ </span>
503
+ </div>
504
+ {summaryChips.length > 0 && (
505
+ <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
506
+ {summaryChips.slice(0, 4).map((chip) => (
263
507
  <span
264
- className="truncate font-medium text-foreground"
265
- title={membership.groupTitle}
508
+ key={chip.label}
509
+ className={cn(
510
+ "rounded border px-1.5 py-0.5 font-mono text-[10px]",
511
+ evidenceChipClass(chip.value),
512
+ )}
266
513
  >
267
- {membership.groupTitle}
514
+ {chip.label}
268
515
  </span>
269
- <span className="font-mono text-[10px] uppercase text-muted-foreground">
270
- {membership.groupStatus}
271
- </span>
272
- {member.status !== null && (
273
- <span className="font-mono text-[10px] text-muted-foreground">
274
- member {member.status}
516
+ ))}
517
+ </div>
518
+ )}
519
+ </div>
520
+ {open ? (
521
+ <ChevronUp className="size-4 shrink-0 text-muted-foreground" />
522
+ ) : (
523
+ <ChevronDown className="size-4 shrink-0 text-muted-foreground" />
524
+ )}
525
+ </button>
526
+ {open && (
527
+ <div className="space-y-2 border-t border-border/70 px-3 py-2">
528
+ {memberships.map((membership) => {
529
+ const member = membership.member;
530
+ const metadataRows = getMemberMetadataRows(member);
531
+ return (
532
+ <div key={`${membership.groupId}:${member.id}`} className="min-w-0">
533
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
534
+ <span
535
+ className="truncate font-medium text-foreground"
536
+ title={membership.groupTitle}
537
+ >
538
+ {membership.groupTitle}
275
539
  </span>
276
- )}
277
- </div>
278
- <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
279
- <span className="font-mono" title={member.sessionId}>
280
- {truncateSessionId(member.sessionId)}
281
- </span>
282
- {member.label !== null && <span>{member.label}</span>}
283
- {member.provider !== null && <span>{member.provider}</span>}
284
- {member.model !== null && <span className="font-mono">{member.model}</span>}
285
- {member.runId !== null && (
286
- <span className="font-mono" title={member.runId}>
287
- run {truncateSessionId(member.runId)}
540
+ <span className="font-mono text-[10px] uppercase text-muted-foreground">
541
+ {membership.groupStatus}
288
542
  </span>
289
- )}
290
- </div>
291
- {metadataRows.length > 0 && (
292
- <div className="mt-2 grid gap-1 sm:grid-cols-2 xl:grid-cols-3">
293
- {metadataRows.map((row) => (
294
- <div
295
- key={row.key}
296
- className="min-w-0 rounded border border-border/60 px-2 py-1"
297
- >
298
- <div className="font-mono text-[10px] text-muted-foreground">{row.key}</div>
543
+ {member.status !== null && (
544
+ <span className="font-mono text-[10px] text-muted-foreground">
545
+ member {member.status}
546
+ </span>
547
+ )}
548
+ </div>
549
+ <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
550
+ <span className="font-mono" title={member.sessionId}>
551
+ {truncateSessionId(member.sessionId)}
552
+ </span>
553
+ {member.label !== null && <span>{member.label}</span>}
554
+ {member.provider !== null && <span>{member.provider}</span>}
555
+ {member.model !== null && <span className="font-mono">{member.model}</span>}
556
+ {member.runId !== null && (
557
+ <span className="font-mono" title={member.runId}>
558
+ run {truncateSessionId(member.runId)}
559
+ </span>
560
+ )}
561
+ </div>
562
+ {metadataRows.length > 0 && (
563
+ <div className="mt-2 grid gap-1 sm:grid-cols-2 xl:grid-cols-3">
564
+ {metadataRows.map((row) => (
299
565
  <div
300
- className="truncate font-mono text-[11px] text-foreground"
301
- title={row.value}
566
+ key={row.key}
567
+ className="min-w-0 rounded border border-border/60 px-2 py-1"
302
568
  >
303
- {row.value}
569
+ <div className="font-mono text-[10px] text-muted-foreground">{row.key}</div>
570
+ <div
571
+ className="truncate font-mono text-[11px] text-foreground"
572
+ title={row.value}
573
+ >
574
+ {row.value}
575
+ </div>
304
576
  </div>
305
- </div>
306
- ))}
307
- </div>
308
- )}
309
- </div>
310
- );
311
- })}
577
+ ))}
578
+ </div>
579
+ )}
580
+ </div>
581
+ );
582
+ })}
583
+ </div>
584
+ )}
585
+ </div>
586
+ );
587
+ }
588
+
589
+ export function formatPaginationStatus({
590
+ logs,
591
+ pagination,
592
+ }: {
593
+ logs: readonly CapturedLog[];
594
+ pagination: LogPaginationControls;
595
+ }): string {
596
+ if (pagination.isLoading && logs.length === 0) return "Loading logs...";
597
+ if (logs.length === 0) return "No logs on this page";
598
+
599
+ const first = logs[0];
600
+ const last = logs[logs.length - 1];
601
+ const range =
602
+ first === undefined || last === undefined ? "Current page" : `#${first.id}-#${last.id}`;
603
+ const total = pagination.total === null ? "logs" : `${pagination.total} logs`;
604
+ return `Showing ${range} of ${total}`;
605
+ }
606
+
607
+ function LogPaginationButton({
608
+ label,
609
+ title,
610
+ icon,
611
+ disabled,
612
+ onClick,
613
+ }: {
614
+ label: string;
615
+ title: string;
616
+ icon: JSX.Element;
617
+ disabled: boolean;
618
+ onClick: () => void;
619
+ }): JSX.Element {
620
+ return (
621
+ <button
622
+ type="button"
623
+ onClick={onClick}
624
+ disabled={disabled}
625
+ className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-45"
626
+ title={title}
627
+ >
628
+ {icon}
629
+ <span>{label}</span>
630
+ </button>
631
+ );
632
+ }
633
+
634
+ function LogPaginationBar({
635
+ logs,
636
+ pagination,
637
+ embedded = false,
638
+ }: {
639
+ logs: readonly CapturedLog[];
640
+ pagination: LogPaginationControls | undefined;
641
+ embedded?: boolean;
642
+ }): JSX.Element | null {
643
+ if (pagination === undefined) return null;
644
+ const disabled = pagination.isLoading;
645
+
646
+ return (
647
+ <div
648
+ className={cn(
649
+ "flex min-h-9 flex-col gap-2 px-1 py-1 sm:flex-row sm:items-center",
650
+ embedded
651
+ ? "border-t border-border/70 pt-2"
652
+ : "mb-3 rounded-md border border-border bg-muted/10 px-2",
653
+ )}
654
+ >
655
+ <div className="w-full min-w-0 px-1 font-mono text-[11px] text-muted-foreground sm:flex-1">
656
+ {formatPaginationStatus({ logs, pagination })}
657
+ {pagination.isLoading && logs.length > 0 && <span className="ml-2">Loading...</span>}
658
+ </div>
659
+ <div className="flex w-full items-center gap-1 overflow-x-auto sm:w-auto">
660
+ <LogPaginationButton
661
+ label="Oldest"
662
+ title="Load the oldest logs in this session"
663
+ icon={<ChevronsLeft className="size-3.5" />}
664
+ disabled={disabled || !pagination.hasOlder}
665
+ onClick={pagination.onOldest}
666
+ />
667
+ <LogPaginationButton
668
+ label="Older"
669
+ title="Load older logs"
670
+ icon={<ChevronLeft className="size-3.5" />}
671
+ disabled={disabled || !pagination.hasOlder}
672
+ onClick={pagination.onOlder}
673
+ />
674
+ <LogPaginationButton
675
+ label="Newer"
676
+ title="Load newer logs"
677
+ icon={<ChevronRight className="size-3.5" />}
678
+ disabled={disabled || !pagination.hasNewer}
679
+ onClick={pagination.onNewer}
680
+ />
681
+ <LogPaginationButton
682
+ label="Newest"
683
+ title="Load the newest logs in this session"
684
+ icon={<ChevronsRight className="size-3.5" />}
685
+ disabled={disabled || !pagination.hasNewer}
686
+ onClick={pagination.onNewest}
687
+ />
312
688
  </div>
313
689
  </div>
314
690
  );
@@ -413,34 +789,7 @@ function CopyableCommand({ command }: { command: string }): JSX.Element {
413
789
  className="text-muted-foreground hover:text-foreground transition-colors shrink-0 cursor-pointer"
414
790
  aria-label="Copy command"
415
791
  >
416
- {copied ? (
417
- <svg
418
- width="16"
419
- height="16"
420
- viewBox="0 0 24 24"
421
- fill="none"
422
- stroke="currentColor"
423
- strokeWidth="2"
424
- strokeLinecap="round"
425
- strokeLinejoin="round"
426
- >
427
- <path d="M20 6 9 17l-5-5" />
428
- </svg>
429
- ) : (
430
- <svg
431
- width="16"
432
- height="16"
433
- viewBox="0 0 24 24"
434
- fill="none"
435
- stroke="currentColor"
436
- strokeWidth="2"
437
- strokeLinecap="round"
438
- strokeLinejoin="round"
439
- >
440
- <rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
441
- <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
442
- </svg>
443
- )}
792
+ {copied ? <Check className="size-4" /> : <Copy className="size-4" />}
444
793
  </button>
445
794
  </div>
446
795
  );
@@ -466,6 +815,125 @@ function McpReadyBadge(): JSX.Element {
466
815
  );
467
816
  }
468
817
 
818
+ const CRAB_VARIANT_COLORS = [
819
+ "text-amber-500",
820
+ "text-rose-500",
821
+ "text-sky-500",
822
+ "text-emerald-500",
823
+ "text-violet-500",
824
+ "text-orange-500",
825
+ "text-cyan-500",
826
+ "text-pink-500",
827
+ "text-lime-500",
828
+ "text-blue-500",
829
+ "text-yellow-500",
830
+ "text-fuchsia-500",
831
+ ] as const;
832
+
833
+ function CrabFamily({
834
+ compact,
835
+ entrancePhase,
836
+ }: {
837
+ compact: boolean;
838
+ entrancePhase: "hidden" | "playing" | "done";
839
+ }): JSX.Element {
840
+ if (compact) {
841
+ return <CrabLogo className="size-7 text-amber-500" />;
842
+ }
843
+
844
+ return (
845
+ <span className="flex shrink-0 items-end gap-1 group cursor-default" aria-hidden="true">
846
+ <CrabLogo className="size-10 text-amber-500 transition-all duration-300 group-hover:scale-125 group-hover:-translate-y-1.5" />
847
+ <span className="hidden items-end gap-0.5 sm:flex">
848
+ {crabVariants.map((Crab, i) => {
849
+ const color = CRAB_VARIANT_COLORS[i] ?? "text-amber-500";
850
+ const entranceClass =
851
+ entrancePhase === "hidden"
852
+ ? "opacity-0 scale-0"
853
+ : entrancePhase === "playing"
854
+ ? "animate-crab-piano-pop"
855
+ : "";
856
+ return (
857
+ <Crab
858
+ key={i}
859
+ className={`size-5 ${color} transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1 ${entranceClass}`}
860
+ style={{
861
+ transitionDelay: `${i * 50}ms`,
862
+ ...(entrancePhase === "playing" ? { animationDelay: `${i * 400}ms` } : {}),
863
+ }}
864
+ />
865
+ );
866
+ })}
867
+ </span>
868
+ </span>
869
+ );
870
+ }
871
+
872
+ function BrandHeader({
873
+ compact,
874
+ crabEntrancePhase,
875
+ }: {
876
+ compact: boolean;
877
+ crabEntrancePhase: "hidden" | "playing" | "done";
878
+ }): JSX.Element {
879
+ if (compact) {
880
+ return (
881
+ <div className="pb-3">
882
+ <div className="flex items-start justify-between gap-3">
883
+ <div className="flex min-w-0 items-start gap-2">
884
+ <CrabFamily compact={true} entrancePhase={crabEntrancePhase} />
885
+ <div className="min-w-0">
886
+ <div className="flex min-w-0 items-baseline gap-2">
887
+ <span className="truncate text-base font-bold">Inspector</span>
888
+ <span className="hidden shrink-0 font-mono text-[11px] font-semibold text-muted-foreground sm:inline">
889
+ v{packageJson.version}
890
+ </span>
891
+ </div>
892
+ <div className="hidden text-[11px] font-semibold text-muted-foreground sm:block">
893
+ Local First. Evidence First.
894
+ </div>
895
+ </div>
896
+ </div>
897
+ <div className="flex shrink-0 items-center gap-1">
898
+ <GroupsDialog />
899
+ <SettingsDialog />
900
+ </div>
901
+ </div>
902
+ <div className="mt-2 hidden sm:block">
903
+ <McpReadyBadge />
904
+ </div>
905
+ </div>
906
+ );
907
+ }
908
+
909
+ return (
910
+ <div className="grid grid-cols-[1fr_auto_1fr] items-start gap-3 pb-6">
911
+ <div />
912
+ <h1 className="flex min-w-0 flex-col items-center gap-2 text-center">
913
+ <span className="flex max-w-[calc(100vw-7rem)] items-end gap-2 whitespace-nowrap">
914
+ <CrabFamily compact={false} entrancePhase={crabEntrancePhase} />
915
+ <span className="flex min-w-0 items-baseline gap-2 pl-1">
916
+ <span className="truncate text-lg font-bold">Agent Inspector</span>
917
+ <span className="shrink-0 font-mono text-xs font-semibold text-muted-foreground">
918
+ v{packageJson.version}
919
+ </span>
920
+ </span>
921
+ <Plus className="size-4 shrink-0 text-muted-foreground/70" aria-hidden="true" />
922
+ <McpLogo className="size-10 shrink-0" />
923
+ </span>
924
+ <span className="max-w-[calc(100vw-7rem)] text-sm font-semibold text-muted-foreground">
925
+ Local First. Evidence First.
926
+ </span>
927
+ <McpReadyBadge />
928
+ </h1>
929
+ <div className="justify-self-end flex items-center gap-1">
930
+ <GroupsDialog />
931
+ <SettingsDialog />
932
+ </div>
933
+ </div>
934
+ );
935
+ }
936
+
469
937
  function SessionContextLogButton({ label, logId }: { label: string; logId: number }): JSX.Element {
470
938
  return (
471
939
  <button
@@ -654,6 +1122,18 @@ function SessionContextSummaryPanel({
654
1122
  );
655
1123
  }
656
1124
 
1125
+ function formatSessionContextOverview(summary: SessionContextSummary): string {
1126
+ const riskCount = summary.models.filter((model) => model.latest.health.level === "risk").length;
1127
+ if (riskCount > 0) return `${riskCount} risk`;
1128
+
1129
+ const optimizableCount = summary.models.filter(
1130
+ (model) => model.latest.health.level === "optimizable",
1131
+ ).length;
1132
+ if (optimizableCount > 0) return `${optimizableCount} optimizable`;
1133
+
1134
+ return `${summary.models.length} model${summary.models.length === 1 ? "" : "s"}`;
1135
+ }
1136
+
657
1137
  function SessionContextBar({
658
1138
  label,
659
1139
  logs,
@@ -672,11 +1152,16 @@ function SessionContextBar({
672
1152
  showBackLink: boolean;
673
1153
  }): JSX.Element {
674
1154
  const [copied, setCopied] = useState(false);
1155
+ const [detailsOpen, setDetailsOpen] = useState(false);
675
1156
  const timeRange = useMemo(
676
1157
  () => formatTimeRange(logs, timeDisplayFormat),
677
1158
  [logs, timeDisplayFormat],
678
1159
  );
679
1160
  const userAgent = useMemo(() => getFirstUserAgent(logs), [logs]);
1161
+ const contextOverview = useMemo(
1162
+ () => formatSessionContextOverview(contextSummary),
1163
+ [contextSummary],
1164
+ );
680
1165
 
681
1166
  const handleCopyLink = useCallback(() => {
682
1167
  void copyTextToClipboard(window.location.href).then((success) => {
@@ -687,8 +1172,8 @@ function SessionContextBar({
687
1172
  }, []);
688
1173
 
689
1174
  return (
690
- <div className="mb-4 border border-border rounded-md bg-muted/20 px-3 py-2 text-xs">
691
- <div className="flex items-center gap-3">
1175
+ <div className="mb-3 rounded-md border border-border bg-muted/15 px-3 py-2 text-xs">
1176
+ <div className="flex flex-wrap items-center gap-2">
692
1177
  {showBackLink && (
693
1178
  <a
694
1179
  href="/"
@@ -699,7 +1184,7 @@ function SessionContextBar({
699
1184
  <ArrowLeft className="size-3.5" />
700
1185
  </a>
701
1186
  )}
702
- <div className="min-w-0 flex-1">
1187
+ <div className="min-w-[180px] flex-1">
703
1188
  <div className="flex min-w-0 items-center gap-2">
704
1189
  <span className="font-mono font-semibold text-purple-400/90 truncate" title={label}>
705
1190
  {truncateSessionId(label)}
@@ -725,6 +1210,22 @@ function SessionContextBar({
725
1210
  )}
726
1211
  </div>
727
1212
  </div>
1213
+ {contextSummary.models.length > 0 && (
1214
+ <button
1215
+ type="button"
1216
+ onClick={() => setDetailsOpen((value) => !value)}
1217
+ className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-border bg-background/60 px-2.5 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
1218
+ aria-expanded={detailsOpen}
1219
+ title={detailsOpen ? "Hide context details" : "Show context details"}
1220
+ >
1221
+ <span>{contextOverview}</span>
1222
+ {detailsOpen ? (
1223
+ <ChevronUp className="size-3.5" />
1224
+ ) : (
1225
+ <ChevronDown className="size-3.5" />
1226
+ )}
1227
+ </button>
1228
+ )}
728
1229
  <button
729
1230
  type="button"
730
1231
  onClick={handleCopyLink}
@@ -735,7 +1236,7 @@ function SessionContextBar({
735
1236
  {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
736
1237
  </button>
737
1238
  </div>
738
- <SessionContextSummaryPanel summary={contextSummary} />
1239
+ {detailsOpen && <SessionContextSummaryPanel summary={contextSummary} />}
739
1240
  </div>
740
1241
  );
741
1242
  }
@@ -753,7 +1254,6 @@ export type ProxyViewerProps = {
753
1254
  * Clear button on each conversation header. */
754
1255
  onClearGroup: (ids: number[]) => void;
755
1256
  viewMode: "simple" | "full";
756
- onViewModeChange: (mode: "simple" | "full") => void;
757
1257
  captureMode: CaptureMode;
758
1258
  /** Live strip-Claude-Code-billing-header flag, sourced once at the container. */
759
1259
  strip: boolean;
@@ -763,6 +1263,8 @@ export type ProxyViewerProps = {
763
1263
  timeDisplayFormat: TimeDisplayFormat;
764
1264
  /** True while a pinned session page is waiting for its initial log scan. */
765
1265
  isLoading?: boolean;
1266
+ /** Cursor controls for history pages. Undefined keeps the live stream UI compact. */
1267
+ pagination?: LogPaginationControls;
766
1268
  /** Group/run member metadata attached to the pinned session id. */
767
1269
  sessionMemberships?: SessionMembershipEvidence[];
768
1270
  /** Hide the session filter dropdown. Used on `/session/$id` routes where
@@ -784,12 +1286,12 @@ export function ProxyViewer({
784
1286
  onClearAll,
785
1287
  onClearGroup,
786
1288
  viewMode,
787
- onViewModeChange,
788
1289
  captureMode,
789
1290
  strip,
790
1291
  slowResponseThresholdSeconds,
791
1292
  timeDisplayFormat,
792
1293
  isLoading = false,
1294
+ pagination,
793
1295
  sessionMemberships = [],
794
1296
  hideSessionFilter = false,
795
1297
  pinnedSessionId,
@@ -870,6 +1372,23 @@ export function ProxyViewer({
870
1372
  const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
871
1373
  const comparisonPredecessors = useMemo(() => buildValidPredecessors(groups), [groups]);
872
1374
  const exportActionVisibility = getExportActionVisibility(captureMode, logs.length);
1375
+ const isPinnedSessionPage = pinnedSessionId !== undefined;
1376
+ const activeScopeLabel =
1377
+ hasSessionContext && sessionContextScope !== null
1378
+ ? truncateSessionId(sessionContextScope.label)
1379
+ : selectedSession === "__all__"
1380
+ ? "All sessions"
1381
+ : truncateSessionId(selectedSession);
1382
+ const slateStats = useMemo(
1383
+ () =>
1384
+ buildSessionSlateStats({
1385
+ logs,
1386
+ isLoading,
1387
+ timeDisplayFormat,
1388
+ slowResponseThresholdSeconds,
1389
+ }),
1390
+ [isLoading, logs, slowResponseThresholdSeconds, timeDisplayFormat],
1391
+ );
873
1392
  const handleCompareWithPrevious = useCallback(
874
1393
  (log: CapturedLog) => {
875
1394
  const predecessor = comparisonPredecessors.get(log.id);
@@ -879,297 +1398,250 @@ export function ProxyViewer({
879
1398
  );
880
1399
 
881
1400
  return (
882
- <div className="max-w-[1400px] xl:max-w-[1600px] 2xl:max-w-[1800px] mx-auto px-6 pb-6">
883
- {/* Sticky chrome: brand row, optional pinned-session context, and the
884
- filter/control bar stay visible while the user scrolls through the
885
- log list. `z-30` sits above the per-conversation sticky header
886
- (`z-10`) but below modal-level overlays (dialogs, dropdowns,
887
- tooltips: `z-50`). Background is fully opaque so logs don't bleed
888
- through. */}
889
- <div className="sticky top-0 z-30 bg-background pt-6">
890
- {/* Brand row */}
891
- <div className="grid grid-cols-[1fr_auto_1fr] items-start gap-3 pb-8">
892
- <div />
893
- <h1 className="flex min-w-0 flex-col items-center gap-2 text-center">
894
- <span className="flex max-w-[calc(100vw-7rem)] items-end gap-2 whitespace-nowrap">
895
- {/* Crab family: hover to animate together */}
896
- <span
897
- className="flex shrink-0 items-end gap-1 group cursor-default"
898
- aria-hidden="true"
899
- >
900
- <CrabLogo className="size-10 text-amber-500 transition-all duration-300 group-hover:scale-125 group-hover:-translate-y-1.5" />
901
- <span className="hidden items-end gap-0.5 sm:flex">
902
- {crabVariants.map((Crab, i) => {
903
- const color = [
904
- "text-amber-500",
905
- "text-rose-500",
906
- "text-sky-500",
907
- "text-emerald-500",
908
- "text-violet-500",
909
- "text-orange-500",
910
- "text-cyan-500",
911
- "text-pink-500",
912
- "text-lime-500",
913
- "text-blue-500",
914
- "text-yellow-500",
915
- "text-fuchsia-500",
916
- ][i];
917
- const entranceClass =
918
- crabEntrancePhase === "hidden"
919
- ? "opacity-0 scale-0"
920
- : crabEntrancePhase === "playing"
921
- ? "animate-crab-piano-pop"
922
- : "";
923
- return (
924
- <Crab
925
- key={i}
926
- className={`size-5 ${color} transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1 ${entranceClass}`}
927
- style={{
928
- transitionDelay: `${i * 50}ms`,
929
- ...(crabEntrancePhase === "playing"
930
- ? { animationDelay: `${i * 400}ms` }
931
- : {}),
932
- }}
933
- />
934
- );
935
- })}
936
- </span>
937
- </span>
938
- <span className="flex min-w-0 items-baseline gap-2 pl-1">
939
- <span className="truncate text-lg font-bold">Agent Inspector</span>
940
- <span className="shrink-0 font-mono text-xs font-semibold text-muted-foreground">
941
- v{packageJson.version}
942
- </span>
943
- </span>
944
- <Plus className="size-4 shrink-0 text-muted-foreground/70" aria-hidden="true" />
945
- <McpLogo className="size-10 shrink-0" />
946
- </span>
947
- <span className="max-w-[calc(100vw-7rem)] text-sm font-semibold text-muted-foreground">
948
- Local First. Evidence First.
949
- </span>
950
- <McpReadyBadge />
951
- </h1>
952
- <div className="justify-self-end flex items-center gap-1">
953
- <GroupsDialog />
954
- <SettingsDialog />
955
- </div>
956
- </div>
1401
+ <div className="min-h-screen bg-[#070809] text-foreground">
1402
+ <div className="mx-auto grid min-h-screen max-w-[1920px] grid-cols-1 lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[360px_minmax(0,1fr)]">
1403
+ <aside className="border-b border-border bg-[#090a0c] lg:sticky lg:top-0 lg:h-screen lg:overflow-y-auto lg:border-b-0 lg:border-r">
1404
+ <div className="space-y-4 p-4">
1405
+ <BrandHeader compact={true} crabEntrancePhase={crabEntrancePhase} />
1406
+
1407
+ <div className="grid grid-cols-3 gap-2 border-y border-border/70 py-3">
1408
+ <div className="min-w-0">
1409
+ <div className="font-mono text-lg font-semibold text-foreground">{logs.length}</div>
1410
+ <div className="text-[10px] uppercase text-muted-foreground">Requests</div>
1411
+ </div>
1412
+ <div className="min-w-0">
1413
+ <div className="font-mono text-lg font-semibold text-foreground">
1414
+ {formatTokens(hasSessionContext ? sessionContextTotalIn : totalIn)}
1415
+ </div>
1416
+ <div className="text-[10px] uppercase text-muted-foreground">Input</div>
1417
+ </div>
1418
+ <div className="min-w-0">
1419
+ <div className="font-mono text-lg font-semibold text-foreground">
1420
+ {formatTokens(hasSessionContext ? sessionContextTotalOut : totalOut)}
1421
+ </div>
1422
+ <div className="text-[10px] uppercase text-muted-foreground">Output</div>
1423
+ </div>
1424
+ </div>
957
1425
 
958
- {hasSessionContext && sessionContextScope !== null && (
959
- <SessionContextBar
960
- label={sessionContextScope.label}
961
- logs={sessionContextLogs}
962
- totalIn={sessionContextTotalIn}
963
- totalOut={sessionContextTotalOut}
964
- timeDisplayFormat={timeDisplayFormat}
965
- contextSummary={sessionContextSummary}
966
- showBackLink={sessionContextScope.showBackLink}
967
- />
968
- )}
1426
+ <div className="space-y-2">
1427
+ <div className="text-[10px] font-semibold uppercase tracking-normal text-muted-foreground">
1428
+ Workspace
1429
+ </div>
1430
+ <div className="rounded-md border border-border bg-[#0c0d0f] px-3 py-2">
1431
+ <div
1432
+ className="font-mono text-xs font-semibold text-purple-300"
1433
+ title={activeScopeLabel}
1434
+ >
1435
+ {activeScopeLabel}
1436
+ </div>
1437
+ <div className="mt-1 text-[11px] text-muted-foreground">
1438
+ {isPinnedSessionPage ? "Session workspace" : "Live workspace"}
1439
+ </div>
1440
+ </div>
1441
+ </div>
969
1442
 
970
- {hasSessionContext && pinnedSessionId !== undefined && sessionMemberships.length > 0 && (
971
- <SessionMembershipPanel memberships={sessionMemberships} />
972
- )}
1443
+ {hasSessionContext && sessionContextScope !== null && (
1444
+ <SessionContextBar
1445
+ label={sessionContextScope.label}
1446
+ logs={sessionContextLogs}
1447
+ totalIn={sessionContextTotalIn}
1448
+ totalOut={sessionContextTotalOut}
1449
+ timeDisplayFormat={timeDisplayFormat}
1450
+ contextSummary={sessionContextSummary}
1451
+ showBackLink={sessionContextScope.showBackLink}
1452
+ />
1453
+ )}
973
1454
 
974
- {/* Controls + Filters */}
975
- <div className="flex items-center gap-3 mb-4">
976
- {!hideSessionFilter && (
977
- <Select value={selectedSession} onValueChange={onSessionChange}>
978
- <SelectTrigger className="flex-1 max-w-[350px] text-xs">
979
- <SelectValue placeholder="All sessions" />
980
- </SelectTrigger>
981
- <SelectContent>
982
- <SelectItem value="__all__">All sessions</SelectItem>
983
- {sessions.map((s) => (
984
- <SelectItem key={s} value={s}>
985
- {truncateSessionId(s)}
986
- </SelectItem>
987
- ))}
988
- </SelectContent>
989
- </Select>
990
- )}
991
- <Select value={selectedModel} onValueChange={onModelChange}>
992
- <SelectTrigger className="flex-1 max-w-[250px] text-xs">
993
- <SelectValue placeholder="All models" />
994
- </SelectTrigger>
995
- <SelectContent>
996
- <SelectItem value="__all__">All models</SelectItem>
997
- {models.map((m) => (
998
- <SelectItem key={m} value={m}>
999
- {m}
1000
- </SelectItem>
1001
- ))}
1002
- </SelectContent>
1003
- </Select>
1004
- {captureMode === "full" ? (
1005
- <div className="flex items-center border border-border rounded-md overflow-hidden">
1006
- <button
1007
- type="button"
1008
- onClick={() => onViewModeChange("simple")}
1009
- className={`h-8 px-3 cursor-pointer transition-colors text-xs ${
1010
- viewMode === "simple"
1011
- ? "bg-muted text-foreground"
1012
- : "text-muted-foreground hover:bg-muted/50"
1013
- }`}
1014
- >
1015
- Simple
1016
- </button>
1017
- <button
1018
- type="button"
1019
- onClick={() => onViewModeChange("full")}
1020
- className={`h-8 px-3 cursor-pointer transition-colors text-xs ${
1021
- viewMode === "full"
1022
- ? "bg-muted text-foreground"
1023
- : "text-muted-foreground hover:bg-muted/50"
1024
- }`}
1025
- >
1026
- Full
1027
- </button>
1028
- </div>
1029
- ) : (
1030
- <div className="flex h-8 items-center rounded-md border border-border bg-muted px-3 text-xs text-foreground">
1031
- Simple
1032
- </div>
1033
- )}
1034
- <div className="flex-1" />
1035
- {!hasSessionContext && (
1036
- <span className="text-muted-foreground text-xs font-mono">
1037
- {logs.length} request{logs.length !== 1 ? "s" : ""}
1038
- {totalIn > 0 || totalOut > 0
1039
- ? ` - ${formatTokens(totalIn)} in / ${formatTokens(totalOut)} out`
1040
- : ""}
1041
- </span>
1042
- )}
1043
- {exportActionVisibility.redacted && (
1044
- <button
1045
- type="button"
1046
- onClick={() => {
1047
- void handleExport("redacted");
1048
- }}
1049
- disabled={exporting !== null}
1050
- className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5 rounded-md hover:bg-muted"
1051
- title="Export redacted logs as JSON ZIP"
1052
- >
1053
- {exporting === "redacted" ? (
1054
- <span>Exporting...</span>
1055
- ) : (
1056
- <>
1057
- <Download className="size-3.5" />
1058
- <span>Export</span>
1059
- </>
1455
+ {hasSessionContext &&
1456
+ pinnedSessionId !== undefined &&
1457
+ sessionMemberships.length > 0 && (
1458
+ <SessionMembershipPanel memberships={sessionMemberships} />
1060
1459
  )}
1061
- </button>
1062
- )}
1063
- {exportActionVisibility.raw && (
1064
- <button
1065
- type="button"
1066
- onClick={() => {
1067
- void handleExport("raw");
1068
- }}
1069
- disabled={exporting !== null}
1070
- className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5 rounded-md hover:bg-muted"
1071
- title="Export raw logs without redaction"
1072
- >
1073
- {exporting === "raw" ? (
1074
- <span>Exporting...</span>
1075
- ) : (
1076
- <>
1077
- <FileJson className="size-3.5" />
1078
- <span>Raw</span>
1079
- </>
1460
+
1461
+ <div className="space-y-2">
1462
+ <div className="text-[10px] font-semibold uppercase tracking-normal text-muted-foreground">
1463
+ Filters
1464
+ </div>
1465
+ {!hideSessionFilter && (
1466
+ <Select value={selectedSession} onValueChange={onSessionChange}>
1467
+ <SelectTrigger className="h-9 w-full text-xs">
1468
+ <SelectValue placeholder="All sessions" />
1469
+ </SelectTrigger>
1470
+ <SelectContent>
1471
+ <SelectItem value="__all__">All sessions</SelectItem>
1472
+ {sessions.map((s) => (
1473
+ <SelectItem key={s} value={s}>
1474
+ {truncateSessionId(s)}
1475
+ </SelectItem>
1476
+ ))}
1477
+ </SelectContent>
1478
+ </Select>
1080
1479
  )}
1081
- </button>
1082
- )}
1083
- <button
1084
- type="button"
1085
- onClick={onClearAll}
1086
- className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer inline-flex items-center gap-1.5 rounded-md hover:bg-muted"
1087
- title="Clear all logs"
1088
- >
1089
- <Trash2 className="size-3.5" />
1090
- <span>Clear</span>
1091
- </button>
1092
- </div>
1093
- {exportError !== null && (
1094
- <div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
1095
- {exportError}
1480
+ <Select value={selectedModel} onValueChange={onModelChange}>
1481
+ <SelectTrigger className="h-9 w-full text-xs">
1482
+ <SelectValue placeholder="All models" />
1483
+ </SelectTrigger>
1484
+ <SelectContent>
1485
+ <SelectItem value="__all__">All models</SelectItem>
1486
+ {models.map((m) => (
1487
+ <SelectItem key={m} value={m}>
1488
+ {m}
1489
+ </SelectItem>
1490
+ ))}
1491
+ </SelectContent>
1492
+ </Select>
1493
+ </div>
1096
1494
  </div>
1097
- )}
1098
- </div>
1099
-
1100
- {/* Log list */}
1101
- <div>
1102
- {logs.length === 0 ? (
1103
- selectedSession !== "__all__" ? (
1104
- <div className="text-center text-muted-foreground py-16 space-y-4">
1105
- <p className="text-sm font-medium">
1106
- {isLoading ? "Loading session data..." : "Session not found"}
1107
- </p>
1108
- <p className="text-xs font-mono bg-muted px-3 py-1 rounded inline-block max-w-[500px] break-all">
1109
- {truncateSessionId(selectedSession)}
1110
- </p>
1111
- {sessionMemberships.length > 0 && (
1112
- <div className="mx-auto max-w-3xl text-left">
1113
- <SessionMembershipPanel memberships={sessionMemberships} />
1495
+ </aside>
1496
+
1497
+ <main className="min-w-0">
1498
+ <div className="sticky top-0 z-30 border-b border-border bg-[#08090b]/95 px-4 py-3 shadow-[0_12px_40px_rgba(0,0,0,0.24)] backdrop-blur lg:px-5">
1499
+ <div className="flex flex-wrap items-center gap-2">
1500
+ <div className="min-w-[180px] flex-1">
1501
+ <div className="text-sm font-semibold text-foreground">Logs</div>
1502
+ <div className="mt-0.5 font-mono text-[11px] text-muted-foreground">
1503
+ {activeScopeLabel}
1114
1504
  </div>
1505
+ </div>
1506
+ {exportActionVisibility.redacted && (
1507
+ <button
1508
+ type="button"
1509
+ onClick={() => {
1510
+ void handleExport("redacted");
1511
+ }}
1512
+ disabled={exporting !== null}
1513
+ className="inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
1514
+ title="Export redacted logs as JSON ZIP"
1515
+ >
1516
+ {exporting === "redacted" ? (
1517
+ <span>Exporting...</span>
1518
+ ) : (
1519
+ <>
1520
+ <Download className="size-3.5" />
1521
+ <span>Export</span>
1522
+ </>
1523
+ )}
1524
+ </button>
1115
1525
  )}
1116
- {!isLoading && (
1117
- <p className="text-xs">
1118
- This session may have been cleared or never existed.{" "}
1119
- {hideSessionFilter ? (
1120
- <a href="/" className="underline hover:text-foreground transition-colors">
1121
- Back to all sessions
1122
- </a>
1526
+ {exportActionVisibility.raw && (
1527
+ <button
1528
+ type="button"
1529
+ onClick={() => {
1530
+ void handleExport("raw");
1531
+ }}
1532
+ disabled={exporting !== null}
1533
+ className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
1534
+ title="Export raw logs without redaction"
1535
+ >
1536
+ {exporting === "raw" ? (
1537
+ <span>Exporting...</span>
1123
1538
  ) : (
1124
- <button
1125
- type="button"
1126
- onClick={() => onSessionChange("__all__")}
1127
- className="underline hover:text-foreground transition-colors"
1128
- >
1129
- Show all sessions
1130
- </button>
1539
+ <>
1540
+ <FileJson className="size-3.5" />
1541
+ <span>Raw</span>
1542
+ </>
1131
1543
  )}
1132
- </p>
1544
+ </button>
1133
1545
  )}
1546
+ <button
1547
+ type="button"
1548
+ onClick={onClearAll}
1549
+ className="inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
1550
+ title="Clear all logs"
1551
+ >
1552
+ <Trash2 className="size-3.5" />
1553
+ <span>Clear</span>
1554
+ </button>
1134
1555
  </div>
1135
- ) : (
1136
- <div className="text-center text-muted-foreground py-16 space-y-4">
1137
- <p className="text-sm">No requests captured yet.</p>
1138
- <p className="text-xs">Route AI coding tools through the proxy:</p>
1139
- <div className="flex flex-col items-center gap-2">
1140
- <CopyableCommand command="ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>" />
1141
- <CopyableCommand command="OPENAI_BASE_URL=http://localhost:25947/proxy <your-tool>" />
1556
+ <LogPaginationBar logs={logs} pagination={pagination} embedded={true} />
1557
+ {exportError !== null && (
1558
+ <div className="mt-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
1559
+ {exportError}
1142
1560
  </div>
1143
- <p className="mx-auto max-w-xl text-xs leading-relaxed text-muted-foreground">
1144
- Container note: if your AI tool runs in a different container or host than Agent
1145
- Inspector, replace localhost with an address reachable from that tool and verify the
1146
- network/firewall path.
1147
- </p>
1148
- </div>
1149
- )
1150
- ) : (
1151
- <div
1152
- ref={logListWrapperRef}
1153
- tabIndex={0}
1154
- className="flex flex-col gap-2 focus:outline-none"
1155
- >
1156
- <div ref={logListRef}>
1157
- <ConversationGroupList
1158
- groups={groups}
1159
- viewMode={viewMode}
1160
- strip={strip}
1161
- slowResponseThresholdSeconds={slowResponseThresholdSeconds}
1162
- cacheTrends={cacheTrends}
1163
- onCompareWithPrevious={handleCompareWithPrevious}
1164
- comparisonPredecessors={comparisonPredecessors}
1165
- onClearGroup={onClearGroup}
1166
- standalone={groups.length === 1}
1167
- hasPinnedSessionContext={hasSessionContext}
1168
- timeDisplayFormat={timeDisplayFormat}
1169
- />
1170
- </div>
1561
+ )}
1171
1562
  </div>
1172
- )}
1563
+
1564
+ <div className="px-4 py-4 lg:px-5">
1565
+ <SessionOpeningSlate stats={slateStats} />
1566
+ {logs.length === 0 ? (
1567
+ selectedSession !== "__all__" ? (
1568
+ <div className="py-16 text-center text-muted-foreground">
1569
+ <div className="mx-auto max-w-xl space-y-4 rounded-md border border-border bg-muted/10 px-4 py-8">
1570
+ <p className="text-sm font-medium">
1571
+ {isLoading ? "Loading session data..." : "Session not found"}
1572
+ </p>
1573
+ <p className="inline-block max-w-full rounded bg-muted px-3 py-1 font-mono text-xs break-all">
1574
+ {truncateSessionId(selectedSession)}
1575
+ </p>
1576
+ {sessionMemberships.length > 0 && (
1577
+ <div className="text-left">
1578
+ <SessionMembershipPanel memberships={sessionMemberships} />
1579
+ </div>
1580
+ )}
1581
+ {!isLoading && (
1582
+ <p className="text-xs">
1583
+ This session may have been cleared or never existed.{" "}
1584
+ {hideSessionFilter ? (
1585
+ <a href="/" className="underline transition-colors hover:text-foreground">
1586
+ Back to all sessions
1587
+ </a>
1588
+ ) : (
1589
+ <button
1590
+ type="button"
1591
+ onClick={() => onSessionChange("__all__")}
1592
+ className="underline transition-colors hover:text-foreground"
1593
+ >
1594
+ Show all sessions
1595
+ </button>
1596
+ )}
1597
+ </p>
1598
+ )}
1599
+ </div>
1600
+ </div>
1601
+ ) : (
1602
+ <div className="py-16 text-center text-muted-foreground">
1603
+ <div className="mx-auto max-w-2xl space-y-4 rounded-md border border-border bg-muted/10 px-4 py-8">
1604
+ <p className="text-sm">No requests captured yet.</p>
1605
+ <div className="flex flex-col items-center gap-2">
1606
+ <CopyableCommand command="ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>" />
1607
+ <CopyableCommand command="OPENAI_BASE_URL=http://localhost:25947/proxy <your-tool>" />
1608
+ </div>
1609
+ <p className="mx-auto max-w-xl text-xs leading-relaxed text-muted-foreground">
1610
+ Container note: if your AI tool runs in a different container or host than
1611
+ Agent Inspector, replace localhost with an address reachable from that tool
1612
+ and verify the network/firewall path.
1613
+ </p>
1614
+ </div>
1615
+ </div>
1616
+ )
1617
+ ) : (
1618
+ <div
1619
+ ref={logListWrapperRef}
1620
+ tabIndex={0}
1621
+ className="flex flex-col gap-2 focus:outline-none"
1622
+ >
1623
+ <div ref={logListRef}>
1624
+ <ConversationGroupList
1625
+ groups={groups}
1626
+ viewMode={viewMode}
1627
+ strip={strip}
1628
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
1629
+ cacheTrends={cacheTrends}
1630
+ onCompareWithPrevious={handleCompareWithPrevious}
1631
+ comparisonPredecessors={comparisonPredecessors}
1632
+ onClearGroup={onClearGroup}
1633
+ standalone={groups.length === 1}
1634
+ hasPinnedSessionContext={hasSessionContext}
1635
+ timeDisplayFormat={timeDisplayFormat}
1636
+ />
1637
+ </div>
1638
+ <div className="mt-3">
1639
+ <LogPaginationBar logs={logs} pagination={pagination} />
1640
+ </div>
1641
+ </div>
1642
+ )}
1643
+ </div>
1644
+ </main>
1173
1645
  </div>
1174
1646
 
1175
1647
  {/* Compare drawer: sibling of the log list, not a route change. */}