@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
@@ -3,17 +3,27 @@ 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
 
14
23
  import type { CapturedLog } from "../contracts";
15
24
  import { exportLogsAsZip, type ExportMode } from "../lib/export-logs";
16
25
  import { copyTextToClipboard } from "../lib/clipboard";
26
+ import type { InspectorGroupEvidence, InspectorGroupMember } from "../lib/groupContract";
17
27
  import type { CaptureMode, TimeDisplayFormat } from "../lib/runtimeConfig";
18
28
  import { formatTimestampRange } from "../lib/timeDisplay";
19
29
  import { cn, formatContextWindowTokens, formatTokens } from "../lib/utils";
@@ -86,6 +96,31 @@ export type SessionContextScope = {
86
96
  showBackLink: boolean;
87
97
  };
88
98
 
99
+ export type SessionMembershipEvidence = {
100
+ groupId: string;
101
+ groupTitle: string;
102
+ groupKind: string;
103
+ groupStatus: string;
104
+ groupTask: string | null;
105
+ groupProject: string | null;
106
+ groupEvidence: InspectorGroupEvidence | null;
107
+ member: InspectorGroupMember;
108
+ };
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
+
89
124
  function formatTimeRange(logs: CapturedLog[], timeDisplayFormat: TimeDisplayFormat): string | null {
90
125
  const first = logs[0];
91
126
  const last = logs[logs.length - 1];
@@ -102,6 +137,167 @@ function getFirstUserAgent(logs: CapturedLog[]): string | null {
102
137
  return null;
103
138
  }
104
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
+
105
301
  export function resolveSessionContextScope({
106
302
  pinnedSessionId,
107
303
  selectedSession,
@@ -194,6 +390,306 @@ function formatContextWindow(summary: SessionModelContextSummary): string {
194
390
  return `Context window ${formatContextWindowTokens(tokens)}`;
195
391
  }
196
392
 
393
+ const MEMBER_METADATA_KEYS: string[] = [
394
+ "score",
395
+ "decision",
396
+ "final_status",
397
+ "validation_pass_ratio",
398
+ "semantic_memory_score",
399
+ "opencode_exit",
400
+ "actual_model",
401
+ "run_dir",
402
+ "workspace",
403
+ "project_dir",
404
+ "work_dir",
405
+ ];
406
+
407
+ function formatMetadataValue(value: InspectorGroupMember["metadata"][string]): string | null {
408
+ if (typeof value === "string") return value.length > 0 ? value : null;
409
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
410
+ if (value === null) return null;
411
+ return JSON.stringify(value);
412
+ }
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
+
447
+ function getMemberMetadataRows(member: InspectorGroupMember): { key: string; value: string }[] {
448
+ const rows: { key: string; value: string }[] = [];
449
+ for (const key of MEMBER_METADATA_KEYS) {
450
+ const value = member.metadata[key];
451
+ if (value === undefined) continue;
452
+ const formatted = formatMetadataValue(value);
453
+ if (formatted !== null) rows.push({ key, value: formatted });
454
+ }
455
+ return rows;
456
+ }
457
+
458
+ function SessionMembershipPanel({
459
+ memberships,
460
+ defaultOpen = false,
461
+ }: {
462
+ memberships: SessionMembershipEvidence[];
463
+ defaultOpen?: boolean;
464
+ }): JSX.Element | null {
465
+ const [open, setOpen] = useState(defaultOpen);
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
+ );
483
+
484
+ return (
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) => (
507
+ <span
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
+ )}
513
+ >
514
+ {chip.label}
515
+ </span>
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}
539
+ </span>
540
+ <span className="font-mono text-[10px] uppercase text-muted-foreground">
541
+ {membership.groupStatus}
542
+ </span>
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) => (
565
+ <div
566
+ key={row.key}
567
+ className="min-w-0 rounded border border-border/60 px-2 py-1"
568
+ >
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>
576
+ </div>
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
+ />
688
+ </div>
689
+ </div>
690
+ );
691
+ }
692
+
197
693
  function formatRequestCount(count: number): string {
198
694
  return `${count} ${count === 1 ? "request" : "requests"}`;
199
695
  }
@@ -293,34 +789,7 @@ function CopyableCommand({ command }: { command: string }): JSX.Element {
293
789
  className="text-muted-foreground hover:text-foreground transition-colors shrink-0 cursor-pointer"
294
790
  aria-label="Copy command"
295
791
  >
296
- {copied ? (
297
- <svg
298
- width="16"
299
- height="16"
300
- viewBox="0 0 24 24"
301
- fill="none"
302
- stroke="currentColor"
303
- strokeWidth="2"
304
- strokeLinecap="round"
305
- strokeLinejoin="round"
306
- >
307
- <path d="M20 6 9 17l-5-5" />
308
- </svg>
309
- ) : (
310
- <svg
311
- width="16"
312
- height="16"
313
- viewBox="0 0 24 24"
314
- fill="none"
315
- stroke="currentColor"
316
- strokeWidth="2"
317
- strokeLinecap="round"
318
- strokeLinejoin="round"
319
- >
320
- <rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
321
- <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
322
- </svg>
323
- )}
792
+ {copied ? <Check className="size-4" /> : <Copy className="size-4" />}
324
793
  </button>
325
794
  </div>
326
795
  );
@@ -346,6 +815,125 @@ function McpReadyBadge(): JSX.Element {
346
815
  );
347
816
  }
348
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
+
349
937
  function SessionContextLogButton({ label, logId }: { label: string; logId: number }): JSX.Element {
350
938
  return (
351
939
  <button
@@ -534,6 +1122,18 @@ function SessionContextSummaryPanel({
534
1122
  );
535
1123
  }
536
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
+
537
1137
  function SessionContextBar({
538
1138
  label,
539
1139
  logs,
@@ -552,11 +1152,16 @@ function SessionContextBar({
552
1152
  showBackLink: boolean;
553
1153
  }): JSX.Element {
554
1154
  const [copied, setCopied] = useState(false);
1155
+ const [detailsOpen, setDetailsOpen] = useState(false);
555
1156
  const timeRange = useMemo(
556
1157
  () => formatTimeRange(logs, timeDisplayFormat),
557
1158
  [logs, timeDisplayFormat],
558
1159
  );
559
1160
  const userAgent = useMemo(() => getFirstUserAgent(logs), [logs]);
1161
+ const contextOverview = useMemo(
1162
+ () => formatSessionContextOverview(contextSummary),
1163
+ [contextSummary],
1164
+ );
560
1165
 
561
1166
  const handleCopyLink = useCallback(() => {
562
1167
  void copyTextToClipboard(window.location.href).then((success) => {
@@ -567,8 +1172,8 @@ function SessionContextBar({
567
1172
  }, []);
568
1173
 
569
1174
  return (
570
- <div className="mb-4 border border-border rounded-md bg-muted/20 px-3 py-2 text-xs">
571
- <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">
572
1177
  {showBackLink && (
573
1178
  <a
574
1179
  href="/"
@@ -579,7 +1184,7 @@ function SessionContextBar({
579
1184
  <ArrowLeft className="size-3.5" />
580
1185
  </a>
581
1186
  )}
582
- <div className="min-w-0 flex-1">
1187
+ <div className="min-w-[180px] flex-1">
583
1188
  <div className="flex min-w-0 items-center gap-2">
584
1189
  <span className="font-mono font-semibold text-purple-400/90 truncate" title={label}>
585
1190
  {truncateSessionId(label)}
@@ -605,6 +1210,22 @@ function SessionContextBar({
605
1210
  )}
606
1211
  </div>
607
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
+ )}
608
1229
  <button
609
1230
  type="button"
610
1231
  onClick={handleCopyLink}
@@ -615,7 +1236,7 @@ function SessionContextBar({
615
1236
  {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
616
1237
  </button>
617
1238
  </div>
618
- <SessionContextSummaryPanel summary={contextSummary} />
1239
+ {detailsOpen && <SessionContextSummaryPanel summary={contextSummary} />}
619
1240
  </div>
620
1241
  );
621
1242
  }
@@ -641,6 +1262,12 @@ export type ProxyViewerProps = {
641
1262
  slowResponseThresholdSeconds: number;
642
1263
  /** Controls whether timestamps render as compact local time or full ISO strings. */
643
1264
  timeDisplayFormat: TimeDisplayFormat;
1265
+ /** True while a pinned session page is waiting for its initial log scan. */
1266
+ isLoading?: boolean;
1267
+ /** Cursor controls for history pages. Undefined keeps the live stream UI compact. */
1268
+ pagination?: LogPaginationControls;
1269
+ /** Group/run member metadata attached to the pinned session id. */
1270
+ sessionMemberships?: SessionMembershipEvidence[];
644
1271
  /** Hide the session filter dropdown. Used on `/session/$id` routes where
645
1272
  * the session is already pinned by the URL and the dropdown would just
646
1273
  * fight the URL state. */
@@ -665,6 +1292,9 @@ export function ProxyViewer({
665
1292
  strip,
666
1293
  slowResponseThresholdSeconds,
667
1294
  timeDisplayFormat,
1295
+ isLoading = false,
1296
+ pagination,
1297
+ sessionMemberships = [],
668
1298
  hideSessionFilter = false,
669
1299
  pinnedSessionId,
670
1300
  }: ProxyViewerProps): JSX.Element {
@@ -744,6 +1374,23 @@ export function ProxyViewer({
744
1374
  const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
745
1375
  const comparisonPredecessors = useMemo(() => buildValidPredecessors(groups), [groups]);
746
1376
  const exportActionVisibility = getExportActionVisibility(captureMode, logs.length);
1377
+ const isPinnedSessionPage = pinnedSessionId !== undefined;
1378
+ const activeScopeLabel =
1379
+ hasSessionContext && sessionContextScope !== null
1380
+ ? truncateSessionId(sessionContextScope.label)
1381
+ : selectedSession === "__all__"
1382
+ ? "All sessions"
1383
+ : truncateSessionId(selectedSession);
1384
+ const slateStats = useMemo(
1385
+ () =>
1386
+ buildSessionSlateStats({
1387
+ logs,
1388
+ isLoading,
1389
+ timeDisplayFormat,
1390
+ slowResponseThresholdSeconds,
1391
+ }),
1392
+ [isLoading, logs, slowResponseThresholdSeconds, timeDisplayFormat],
1393
+ );
747
1394
  const handleCompareWithPrevious = useCallback(
748
1395
  (log: CapturedLog) => {
749
1396
  const predecessor = comparisonPredecessors.get(log.id);
@@ -753,284 +1400,282 @@ export function ProxyViewer({
753
1400
  );
754
1401
 
755
1402
  return (
756
- <div className="max-w-[1400px] xl:max-w-[1600px] 2xl:max-w-[1800px] mx-auto px-6 pb-6">
757
- {/* Sticky chrome: brand row, optional pinned-session context, and the
758
- filter/control bar stay visible while the user scrolls through the
759
- log list. `z-30` sits above the per-conversation sticky header
760
- (`z-10`) but below modal-level overlays (dialogs, dropdowns,
761
- tooltips: `z-50`). Background is fully opaque so logs don't bleed
762
- through. */}
763
- <div className="sticky top-0 z-30 bg-background pt-6">
764
- {/* Brand row */}
765
- <div className="grid grid-cols-[1fr_auto_1fr] items-start gap-3 pb-8">
766
- <div />
767
- <h1 className="flex min-w-0 flex-col items-center gap-2 text-center">
768
- <span className="flex max-w-[calc(100vw-7rem)] items-end gap-2 whitespace-nowrap">
769
- {/* Crab family: hover to animate together */}
770
- <span
771
- className="flex shrink-0 items-end gap-1 group cursor-default"
772
- aria-hidden="true"
773
- >
774
- <CrabLogo className="size-10 text-amber-500 transition-all duration-300 group-hover:scale-125 group-hover:-translate-y-1.5" />
775
- <span className="hidden items-end gap-0.5 sm:flex">
776
- {crabVariants.map((Crab, i) => {
777
- const color = [
778
- "text-amber-500",
779
- "text-rose-500",
780
- "text-sky-500",
781
- "text-emerald-500",
782
- "text-violet-500",
783
- "text-orange-500",
784
- "text-cyan-500",
785
- "text-pink-500",
786
- "text-lime-500",
787
- "text-blue-500",
788
- "text-yellow-500",
789
- "text-fuchsia-500",
790
- ][i];
791
- const entranceClass =
792
- crabEntrancePhase === "hidden"
793
- ? "opacity-0 scale-0"
794
- : crabEntrancePhase === "playing"
795
- ? "animate-crab-piano-pop"
796
- : "";
797
- return (
798
- <Crab
799
- key={i}
800
- className={`size-5 ${color} transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1 ${entranceClass}`}
801
- style={{
802
- transitionDelay: `${i * 50}ms`,
803
- ...(crabEntrancePhase === "playing"
804
- ? { animationDelay: `${i * 400}ms` }
805
- : {}),
806
- }}
807
- />
808
- );
809
- })}
810
- </span>
811
- </span>
812
- <span className="flex min-w-0 items-baseline gap-2 pl-1">
813
- <span className="truncate text-lg font-bold">Agent Inspector</span>
814
- <span className="shrink-0 font-mono text-xs font-semibold text-muted-foreground">
815
- v{packageJson.version}
816
- </span>
817
- </span>
818
- <Plus className="size-4 shrink-0 text-muted-foreground/70" aria-hidden="true" />
819
- <McpLogo className="size-10 shrink-0" />
820
- </span>
821
- <span className="max-w-[calc(100vw-7rem)] text-sm font-semibold text-muted-foreground">
822
- Local First. Evidence First.
823
- </span>
824
- <McpReadyBadge />
825
- </h1>
826
- <div className="justify-self-end flex items-center gap-1">
827
- <GroupsDialog />
828
- <SettingsDialog />
829
- </div>
830
- </div>
1403
+ <div className="min-h-screen bg-[#070809] text-foreground">
1404
+ <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)]">
1405
+ <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">
1406
+ <div className="space-y-4 p-4">
1407
+ <BrandHeader compact={true} crabEntrancePhase={crabEntrancePhase} />
831
1408
 
832
- {hasSessionContext && sessionContextScope !== null && (
833
- <SessionContextBar
834
- label={sessionContextScope.label}
835
- logs={sessionContextLogs}
836
- totalIn={sessionContextTotalIn}
837
- totalOut={sessionContextTotalOut}
838
- timeDisplayFormat={timeDisplayFormat}
839
- contextSummary={sessionContextSummary}
840
- showBackLink={sessionContextScope.showBackLink}
841
- />
842
- )}
843
-
844
- {/* Controls + Filters */}
845
- <div className="flex items-center gap-3 mb-4">
846
- {!hideSessionFilter && (
847
- <Select value={selectedSession} onValueChange={onSessionChange}>
848
- <SelectTrigger className="flex-1 max-w-[350px] text-xs">
849
- <SelectValue placeholder="All sessions" />
850
- </SelectTrigger>
851
- <SelectContent>
852
- <SelectItem value="__all__">All sessions</SelectItem>
853
- {sessions.map((s) => (
854
- <SelectItem key={s} value={s}>
855
- {truncateSessionId(s)}
856
- </SelectItem>
857
- ))}
858
- </SelectContent>
859
- </Select>
860
- )}
861
- <Select value={selectedModel} onValueChange={onModelChange}>
862
- <SelectTrigger className="flex-1 max-w-[250px] text-xs">
863
- <SelectValue placeholder="All models" />
864
- </SelectTrigger>
865
- <SelectContent>
866
- <SelectItem value="__all__">All models</SelectItem>
867
- {models.map((m) => (
868
- <SelectItem key={m} value={m}>
869
- {m}
870
- </SelectItem>
871
- ))}
872
- </SelectContent>
873
- </Select>
874
- {captureMode === "full" ? (
875
- <div className="flex items-center border border-border rounded-md overflow-hidden">
876
- <button
877
- type="button"
878
- onClick={() => onViewModeChange("simple")}
879
- className={`h-8 px-3 cursor-pointer transition-colors text-xs ${
880
- viewMode === "simple"
881
- ? "bg-muted text-foreground"
882
- : "text-muted-foreground hover:bg-muted/50"
883
- }`}
884
- >
885
- Simple
886
- </button>
887
- <button
888
- type="button"
889
- onClick={() => onViewModeChange("full")}
890
- className={`h-8 px-3 cursor-pointer transition-colors text-xs ${
891
- viewMode === "full"
892
- ? "bg-muted text-foreground"
893
- : "text-muted-foreground hover:bg-muted/50"
894
- }`}
895
- >
896
- Full
897
- </button>
1409
+ <div className="grid grid-cols-3 gap-2 border-y border-border/70 py-3">
1410
+ <div className="min-w-0">
1411
+ <div className="font-mono text-lg font-semibold text-foreground">{logs.length}</div>
1412
+ <div className="text-[10px] uppercase text-muted-foreground">Requests</div>
1413
+ </div>
1414
+ <div className="min-w-0">
1415
+ <div className="font-mono text-lg font-semibold text-foreground">
1416
+ {formatTokens(hasSessionContext ? sessionContextTotalIn : totalIn)}
1417
+ </div>
1418
+ <div className="text-[10px] uppercase text-muted-foreground">Input</div>
1419
+ </div>
1420
+ <div className="min-w-0">
1421
+ <div className="font-mono text-lg font-semibold text-foreground">
1422
+ {formatTokens(hasSessionContext ? sessionContextTotalOut : totalOut)}
1423
+ </div>
1424
+ <div className="text-[10px] uppercase text-muted-foreground">Output</div>
1425
+ </div>
898
1426
  </div>
899
- ) : (
900
- <div className="flex h-8 items-center rounded-md border border-border bg-muted px-3 text-xs text-foreground">
901
- Simple
1427
+
1428
+ <div className="space-y-2">
1429
+ <div className="text-[10px] font-semibold uppercase tracking-normal text-muted-foreground">
1430
+ Workspace
1431
+ </div>
1432
+ <div className="rounded-md border border-border bg-[#0c0d0f] px-3 py-2">
1433
+ <div
1434
+ className="font-mono text-xs font-semibold text-purple-300"
1435
+ title={activeScopeLabel}
1436
+ >
1437
+ {activeScopeLabel}
1438
+ </div>
1439
+ <div className="mt-1 text-[11px] text-muted-foreground">
1440
+ {isPinnedSessionPage ? "Session workspace" : "Live workspace"}
1441
+ </div>
1442
+ </div>
902
1443
  </div>
903
- )}
904
- <div className="flex-1" />
905
- {!hasSessionContext && (
906
- <span className="text-muted-foreground text-xs font-mono">
907
- {logs.length} request{logs.length !== 1 ? "s" : ""}
908
- {totalIn > 0 || totalOut > 0
909
- ? ` - ${formatTokens(totalIn)} in / ${formatTokens(totalOut)} out`
910
- : ""}
911
- </span>
912
- )}
913
- {exportActionVisibility.redacted && (
914
- <button
915
- type="button"
916
- onClick={() => {
917
- void handleExport("redacted");
918
- }}
919
- disabled={exporting !== null}
920
- 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"
921
- title="Export redacted logs as JSON ZIP"
922
- >
923
- {exporting === "redacted" ? (
924
- <span>Exporting...</span>
925
- ) : (
926
- <>
927
- <Download className="size-3.5" />
928
- <span>Export</span>
929
- </>
930
- )}
931
- </button>
932
- )}
933
- {exportActionVisibility.raw && (
934
- <button
935
- type="button"
936
- onClick={() => {
937
- void handleExport("raw");
938
- }}
939
- disabled={exporting !== null}
940
- 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"
941
- title="Export raw logs without redaction"
942
- >
943
- {exporting === "raw" ? (
944
- <span>Exporting...</span>
945
- ) : (
946
- <>
947
- <FileJson className="size-3.5" />
948
- <span>Raw</span>
949
- </>
1444
+
1445
+ {hasSessionContext && sessionContextScope !== null && (
1446
+ <SessionContextBar
1447
+ label={sessionContextScope.label}
1448
+ logs={sessionContextLogs}
1449
+ totalIn={sessionContextTotalIn}
1450
+ totalOut={sessionContextTotalOut}
1451
+ timeDisplayFormat={timeDisplayFormat}
1452
+ contextSummary={sessionContextSummary}
1453
+ showBackLink={sessionContextScope.showBackLink}
1454
+ />
1455
+ )}
1456
+
1457
+ {hasSessionContext &&
1458
+ pinnedSessionId !== undefined &&
1459
+ sessionMemberships.length > 0 && (
1460
+ <SessionMembershipPanel memberships={sessionMemberships} />
950
1461
  )}
951
- </button>
952
- )}
953
- <button
954
- type="button"
955
- onClick={onClearAll}
956
- 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"
957
- title="Clear all logs"
958
- >
959
- <Trash2 className="size-3.5" />
960
- <span>Clear</span>
961
- </button>
962
- </div>
963
- {exportError !== null && (
964
- <div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
965
- {exportError}
966
- </div>
967
- )}
968
- </div>
969
1462
 
970
- {/* Log list */}
971
- <div>
972
- {logs.length === 0 ? (
973
- selectedSession !== "__all__" ? (
974
- <div className="text-center text-muted-foreground py-16 space-y-4">
975
- <p className="text-sm font-medium">Session not found</p>
976
- <p className="text-xs font-mono bg-muted px-3 py-1 rounded inline-block max-w-[500px] break-all">
977
- {truncateSessionId(selectedSession)}
978
- </p>
979
- <p className="text-xs">
980
- This session may have been cleared or never existed.{" "}
981
- {hideSessionFilter ? (
982
- <a href="/" className="underline hover:text-foreground transition-colors">
983
- Back to all sessions
984
- </a>
985
- ) : (
1463
+ <div className="space-y-2">
1464
+ <div className="text-[10px] font-semibold uppercase tracking-normal text-muted-foreground">
1465
+ Filters
1466
+ </div>
1467
+ {!hideSessionFilter && (
1468
+ <Select value={selectedSession} onValueChange={onSessionChange}>
1469
+ <SelectTrigger className="h-9 w-full text-xs">
1470
+ <SelectValue placeholder="All sessions" />
1471
+ </SelectTrigger>
1472
+ <SelectContent>
1473
+ <SelectItem value="__all__">All sessions</SelectItem>
1474
+ {sessions.map((s) => (
1475
+ <SelectItem key={s} value={s}>
1476
+ {truncateSessionId(s)}
1477
+ </SelectItem>
1478
+ ))}
1479
+ </SelectContent>
1480
+ </Select>
1481
+ )}
1482
+ <Select value={selectedModel} onValueChange={onModelChange}>
1483
+ <SelectTrigger className="h-9 w-full text-xs">
1484
+ <SelectValue placeholder="All models" />
1485
+ </SelectTrigger>
1486
+ <SelectContent>
1487
+ <SelectItem value="__all__">All models</SelectItem>
1488
+ {models.map((m) => (
1489
+ <SelectItem key={m} value={m}>
1490
+ {m}
1491
+ </SelectItem>
1492
+ ))}
1493
+ </SelectContent>
1494
+ </Select>
1495
+ {captureMode === "full" ? (
1496
+ <div className="grid h-9 grid-cols-2 overflow-hidden rounded-md border border-border">
986
1497
  <button
987
1498
  type="button"
988
- onClick={() => onSessionChange("__all__")}
989
- className="underline hover:text-foreground transition-colors"
1499
+ onClick={() => onViewModeChange("simple")}
1500
+ className={cn(
1501
+ "text-xs transition-colors",
1502
+ viewMode === "simple"
1503
+ ? "bg-muted text-foreground"
1504
+ : "text-muted-foreground hover:bg-muted/50",
1505
+ )}
990
1506
  >
991
- Show all sessions
1507
+ Simple
992
1508
  </button>
993
- )}
994
- </p>
1509
+ <button
1510
+ type="button"
1511
+ onClick={() => onViewModeChange("full")}
1512
+ className={cn(
1513
+ "text-xs transition-colors",
1514
+ viewMode === "full"
1515
+ ? "bg-muted text-foreground"
1516
+ : "text-muted-foreground hover:bg-muted/50",
1517
+ )}
1518
+ >
1519
+ Full
1520
+ </button>
1521
+ </div>
1522
+ ) : (
1523
+ <div className="flex h-9 items-center rounded-md border border-border bg-muted px-3 text-xs text-foreground">
1524
+ Simple
1525
+ </div>
1526
+ )}
995
1527
  </div>
996
- ) : (
997
- <div className="text-center text-muted-foreground py-16 space-y-4">
998
- <p className="text-sm">No requests captured yet.</p>
999
- <p className="text-xs">Route AI coding tools through the proxy:</p>
1000
- <div className="flex flex-col items-center gap-2">
1001
- <CopyableCommand command="ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>" />
1002
- <CopyableCommand command="OPENAI_BASE_URL=http://localhost:25947/proxy <your-tool>" />
1528
+ </div>
1529
+ </aside>
1530
+
1531
+ <main className="min-w-0">
1532
+ <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">
1533
+ <div className="flex flex-wrap items-center gap-2">
1534
+ <div className="min-w-[180px] flex-1">
1535
+ <div className="text-sm font-semibold text-foreground">Logs</div>
1536
+ <div className="mt-0.5 font-mono text-[11px] text-muted-foreground">
1537
+ {activeScopeLabel}
1538
+ </div>
1003
1539
  </div>
1004
- <p className="mx-auto max-w-xl text-xs leading-relaxed text-muted-foreground">
1005
- Container note: if your AI tool runs in a different container or host than Agent
1006
- Inspector, replace localhost with an address reachable from that tool and verify the
1007
- network/firewall path.
1008
- </p>
1009
- </div>
1010
- )
1011
- ) : (
1012
- <div
1013
- ref={logListWrapperRef}
1014
- tabIndex={0}
1015
- className="flex flex-col gap-2 focus:outline-none"
1016
- >
1017
- <div ref={logListRef}>
1018
- <ConversationGroupList
1019
- groups={groups}
1020
- viewMode={viewMode}
1021
- strip={strip}
1022
- slowResponseThresholdSeconds={slowResponseThresholdSeconds}
1023
- cacheTrends={cacheTrends}
1024
- onCompareWithPrevious={handleCompareWithPrevious}
1025
- comparisonPredecessors={comparisonPredecessors}
1026
- onClearGroup={onClearGroup}
1027
- standalone={groups.length === 1}
1028
- hasPinnedSessionContext={hasSessionContext}
1029
- timeDisplayFormat={timeDisplayFormat}
1030
- />
1540
+ {exportActionVisibility.redacted && (
1541
+ <button
1542
+ type="button"
1543
+ onClick={() => {
1544
+ void handleExport("redacted");
1545
+ }}
1546
+ disabled={exporting !== null}
1547
+ 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"
1548
+ title="Export redacted logs as JSON ZIP"
1549
+ >
1550
+ {exporting === "redacted" ? (
1551
+ <span>Exporting...</span>
1552
+ ) : (
1553
+ <>
1554
+ <Download className="size-3.5" />
1555
+ <span>Export</span>
1556
+ </>
1557
+ )}
1558
+ </button>
1559
+ )}
1560
+ {exportActionVisibility.raw && (
1561
+ <button
1562
+ type="button"
1563
+ onClick={() => {
1564
+ void handleExport("raw");
1565
+ }}
1566
+ disabled={exporting !== null}
1567
+ 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"
1568
+ title="Export raw logs without redaction"
1569
+ >
1570
+ {exporting === "raw" ? (
1571
+ <span>Exporting...</span>
1572
+ ) : (
1573
+ <>
1574
+ <FileJson className="size-3.5" />
1575
+ <span>Raw</span>
1576
+ </>
1577
+ )}
1578
+ </button>
1579
+ )}
1580
+ <button
1581
+ type="button"
1582
+ onClick={onClearAll}
1583
+ 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"
1584
+ title="Clear all logs"
1585
+ >
1586
+ <Trash2 className="size-3.5" />
1587
+ <span>Clear</span>
1588
+ </button>
1031
1589
  </div>
1590
+ <LogPaginationBar logs={logs} pagination={pagination} embedded={true} />
1591
+ {exportError !== null && (
1592
+ <div className="mt-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
1593
+ {exportError}
1594
+ </div>
1595
+ )}
1032
1596
  </div>
1033
- )}
1597
+
1598
+ <div className="px-4 py-4 lg:px-5">
1599
+ <SessionOpeningSlate stats={slateStats} />
1600
+ {logs.length === 0 ? (
1601
+ selectedSession !== "__all__" ? (
1602
+ <div className="py-16 text-center text-muted-foreground">
1603
+ <div className="mx-auto max-w-xl space-y-4 rounded-md border border-border bg-muted/10 px-4 py-8">
1604
+ <p className="text-sm font-medium">
1605
+ {isLoading ? "Loading session data..." : "Session not found"}
1606
+ </p>
1607
+ <p className="inline-block max-w-full rounded bg-muted px-3 py-1 font-mono text-xs break-all">
1608
+ {truncateSessionId(selectedSession)}
1609
+ </p>
1610
+ {sessionMemberships.length > 0 && (
1611
+ <div className="text-left">
1612
+ <SessionMembershipPanel memberships={sessionMemberships} />
1613
+ </div>
1614
+ )}
1615
+ {!isLoading && (
1616
+ <p className="text-xs">
1617
+ This session may have been cleared or never existed.{" "}
1618
+ {hideSessionFilter ? (
1619
+ <a href="/" className="underline transition-colors hover:text-foreground">
1620
+ Back to all sessions
1621
+ </a>
1622
+ ) : (
1623
+ <button
1624
+ type="button"
1625
+ onClick={() => onSessionChange("__all__")}
1626
+ className="underline transition-colors hover:text-foreground"
1627
+ >
1628
+ Show all sessions
1629
+ </button>
1630
+ )}
1631
+ </p>
1632
+ )}
1633
+ </div>
1634
+ </div>
1635
+ ) : (
1636
+ <div className="py-16 text-center text-muted-foreground">
1637
+ <div className="mx-auto max-w-2xl space-y-4 rounded-md border border-border bg-muted/10 px-4 py-8">
1638
+ <p className="text-sm">No requests captured yet.</p>
1639
+ <div className="flex flex-col items-center gap-2">
1640
+ <CopyableCommand command="ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>" />
1641
+ <CopyableCommand command="OPENAI_BASE_URL=http://localhost:25947/proxy <your-tool>" />
1642
+ </div>
1643
+ <p className="mx-auto max-w-xl text-xs leading-relaxed text-muted-foreground">
1644
+ Container note: if your AI tool runs in a different container or host than
1645
+ Agent Inspector, replace localhost with an address reachable from that tool
1646
+ and verify the network/firewall path.
1647
+ </p>
1648
+ </div>
1649
+ </div>
1650
+ )
1651
+ ) : (
1652
+ <div
1653
+ ref={logListWrapperRef}
1654
+ tabIndex={0}
1655
+ className="flex flex-col gap-2 focus:outline-none"
1656
+ >
1657
+ <div ref={logListRef}>
1658
+ <ConversationGroupList
1659
+ groups={groups}
1660
+ viewMode={viewMode}
1661
+ strip={strip}
1662
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
1663
+ cacheTrends={cacheTrends}
1664
+ onCompareWithPrevious={handleCompareWithPrevious}
1665
+ comparisonPredecessors={comparisonPredecessors}
1666
+ onClearGroup={onClearGroup}
1667
+ standalone={groups.length === 1}
1668
+ hasPinnedSessionContext={hasSessionContext}
1669
+ timeDisplayFormat={timeDisplayFormat}
1670
+ />
1671
+ </div>
1672
+ <div className="mt-3">
1673
+ <LogPaginationBar logs={logs} pagination={pagination} />
1674
+ </div>
1675
+ </div>
1676
+ )}
1677
+ </div>
1678
+ </main>
1034
1679
  </div>
1035
1680
 
1036
1681
  {/* Compare drawer: sibling of the log list, not a route change. */}