@tonyclaw/agent-inspector 3.0.29 → 3.0.30

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 (152) hide show
  1. package/.output/cli.js +791 -191
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/CompareDrawer-B6cXJohL.js +1 -0
  4. package/.output/public/assets/InspectorPet-mAzeGmEE.js +4108 -0
  5. package/.output/public/assets/ProxyViewerContainer-CkFWv_Nm.js +126 -0
  6. package/.output/public/assets/{ReplayDialog-CctnITbt.js → ReplayDialog-DodiTL6E.js} +1 -1
  7. package/.output/public/assets/{RequestAnatomy-BrN0RBbL.js → RequestAnatomy-B3IauToI.js} +1 -1
  8. package/.output/public/assets/ResponseView-Hn-5ordK.js +3 -0
  9. package/.output/public/assets/StreamingChunkSequence-vLDOVQM_.js +1 -0
  10. package/.output/public/assets/_sessionId-BFH4TOaI.js +1 -0
  11. package/.output/public/assets/{_sessionId-DKM91udC.js → _sessionId-Cj_HIFZx.js} +1 -1
  12. package/.output/public/assets/{index-Bq-nqolG.js → index-Bmj2lcWE.js} +1 -1
  13. package/.output/public/assets/index-CgJj-HBC.css +1 -0
  14. package/.output/public/assets/index-Ch7uqnCT.js +1 -0
  15. package/.output/public/assets/index-DkDdKvLl.js +76 -0
  16. package/.output/public/assets/{index-DPiESBo2.js → index-ZlhqCrSg.js} +6 -6
  17. package/.output/public/assets/{json-viewer-CfaDJwx8.js → json-viewer-BdrnVQu-.js} +1 -1
  18. package/.output/public/assets/jszip.min-CS_nt_o1.js +2 -0
  19. package/.output/public/assets/qwen-mMn3f5ul.webp +0 -0
  20. package/.output/server/_libs/jszip.mjs +35 -9
  21. package/.output/server/_libs/lucide-react.mjs +86 -86
  22. package/.output/server/_libs/radix-ui__react-dialog.mjs +3 -3
  23. package/.output/server/_libs/tanstack__react-router.mjs +1 -1
  24. package/.output/server/{_sessionId-CwGXPD4C.mjs → _sessionId-C5S3pGZd.mjs} +15 -15
  25. package/.output/server/_sessionId-g6D69lKq.mjs +81 -0
  26. package/.output/server/_ssr/{CompareDrawer-R_P1-VNr.mjs → CompareDrawer-CqUmGHal.mjs} +138 -103
  27. package/.output/server/_ssr/InspectorPet-DI0UJd01.mjs +925 -0
  28. package/.output/server/_ssr/{ProxyViewerContainer-DUgxRNbj.mjs → ProxyViewerContainer-CvsUmbJ3.mjs} +1157 -1613
  29. package/.output/server/_ssr/{ReplayDialog-De9wKKzS.mjs → ReplayDialog-DS48dpFG.mjs} +18 -18
  30. package/.output/server/_ssr/{RequestAnatomy-Db4MxUYo.mjs → RequestAnatomy-DxYVQU1C.mjs} +58 -58
  31. package/.output/server/_ssr/{ResponseView-8rDXOiNB.mjs → ResponseView-BLXW73eq.mjs} +60 -60
  32. package/.output/server/_ssr/{StreamingChunkSequence-N9teZHkA.mjs → StreamingChunkSequence-DD0iFuy0.mjs} +16 -16
  33. package/.output/server/_ssr/{index-D4DvByuW.mjs → index-B10LIaK0.mjs} +15 -15
  34. package/.output/server/_ssr/index-RHVJnU2p.mjs +81 -0
  35. package/.output/server/_ssr/index.mjs +2 -2
  36. package/.output/server/_ssr/{json-viewer-BjoHRbjS.mjs → json-viewer-LDu2XBaK.mjs} +58 -58
  37. package/.output/server/_ssr/{router-BYlePDj8.mjs → router-GKvrwifJ.mjs} +9005 -6488
  38. package/.output/server/_tanstack-start-manifest_v-Df2n4mbT.mjs +4 -0
  39. package/.output/server/index.mjs +103 -75
  40. package/.output/workers/logFinalizer.worker.js +16908 -0
  41. package/.output/workers/sessionWorkerEntry.js +16904 -0
  42. package/README.md +21 -5
  43. package/package.json +6 -2
  44. package/src/assets/logos/qwen.webp +0 -0
  45. package/src/cli.ts +85 -10
  46. package/src/components/OnboardingBanner.tsx +41 -21
  47. package/src/components/ProxyViewer.tsx +198 -269
  48. package/src/components/ProxyViewerContainer.tsx +128 -17
  49. package/src/components/ecosystem/AgentLabDialog.tsx +166 -19
  50. package/src/components/errors/SafeErrorBoundary.tsx +201 -0
  51. package/src/components/pi-agent/PiAgentPanel.tsx +4 -1
  52. package/src/components/providers/ImportWizardDialog.tsx +18 -42
  53. package/src/components/providers/ProviderLogo.tsx +1 -1
  54. package/src/components/providers/ProvidersPanel.tsx +8 -5
  55. package/src/components/providers/SettingsDialog.tsx +34 -11
  56. package/src/components/proxy-viewer/ApplicationBar.tsx +63 -0
  57. package/src/components/proxy-viewer/CompareDrawer.tsx +92 -49
  58. package/src/components/proxy-viewer/LiveConnectionStatus.tsx +186 -0
  59. package/src/components/proxy-viewer/LogEntry.tsx +283 -275
  60. package/src/components/proxy-viewer/RawExportWarning.tsx +77 -0
  61. package/src/components/proxy-viewer/SessionLoadProgressBar.tsx +61 -0
  62. package/src/components/proxy-viewer/ThreadConnector.tsx +16 -1
  63. package/src/components/proxy-viewer/TurnGroup.tsx +13 -2
  64. package/src/components/proxy-viewer/accessibility.ts +8 -0
  65. package/src/components/proxy-viewer/lazy.ts +4 -0
  66. package/src/components/proxy-viewer/liveConnectionState.ts +158 -0
  67. package/src/components/proxy-viewer/proxyViewerLogic.ts +10 -0
  68. package/src/components/proxy-viewer/viewerState.ts +2 -6
  69. package/src/components/ui/select.tsx +1 -1
  70. package/src/components/ui/tabs.tsx +3 -3
  71. package/src/components/ui/transient-toast.tsx +1 -1
  72. package/src/lib/apiClient.ts +17 -2
  73. package/src/lib/ecosystemContract.ts +34 -0
  74. package/src/lib/export-logs.ts +1 -1
  75. package/src/lib/providerContract.ts +70 -4
  76. package/src/lib/providerImportContract.ts +27 -0
  77. package/src/lib/providerModelMetadata.ts +16 -7
  78. package/src/lib/resourceLimits.ts +152 -0
  79. package/src/lib/safeDiagnostic.ts +38 -0
  80. package/src/lib/useProviders.ts +4 -4
  81. package/src/mcp/server.ts +39 -6
  82. package/src/mcp/toolHandlers.ts +131 -4
  83. package/src/proxy/chunkStorage.ts +20 -6
  84. package/src/proxy/config.ts +20 -6
  85. package/src/proxy/dataDir.ts +3 -0
  86. package/src/proxy/ecosystemExecutionConfirmation.ts +114 -0
  87. package/src/proxy/ecosystemExecutionRoute.ts +116 -0
  88. package/src/proxy/ecosystemTasks.ts +48 -0
  89. package/src/proxy/evidenceExporter.ts +23 -9
  90. package/src/proxy/groupEvidenceExporter.ts +26 -5
  91. package/src/proxy/groupStore.ts +13 -3
  92. package/src/proxy/handler.ts +103 -20
  93. package/src/proxy/identityProxy.ts +333 -14
  94. package/src/proxy/logFinalizer.ts +73 -6
  95. package/src/proxy/logImportUpload.ts +128 -0
  96. package/src/proxy/logImporter.ts +321 -70
  97. package/src/proxy/logIndex.ts +16 -6
  98. package/src/proxy/logger.ts +294 -41
  99. package/src/proxy/privateDataPath.ts +183 -0
  100. package/src/proxy/providerScanStore.ts +87 -0
  101. package/src/proxy/providerSecretStore.ts +58 -19
  102. package/src/proxy/providers.ts +175 -58
  103. package/src/proxy/rawStreamCapture.ts +66 -5
  104. package/src/proxy/runStore.ts +13 -3
  105. package/src/proxy/runtimeAdmission.ts +52 -0
  106. package/src/proxy/runtimeHealth.ts +206 -0
  107. package/src/proxy/runtimeShutdown.ts +75 -0
  108. package/src/proxy/sessionArchive.ts +15 -2
  109. package/src/proxy/sessionProcess.ts +19 -0
  110. package/src/proxy/sessionRuntime.ts +7 -0
  111. package/src/proxy/shutdownCoordinator.ts +90 -0
  112. package/src/proxy/sqliteLogIndex.ts +18 -2
  113. package/src/proxy/store.ts +18 -2
  114. package/src/routes/__root.tsx +18 -1
  115. package/src/routes/api/alerts.summary.ts +12 -8
  116. package/src/routes/api/alerts.ts +27 -9
  117. package/src/routes/api/ecosystem.packages.$packageId.help.ts +3 -8
  118. package/src/routes/api/ecosystem.packages.$packageId.install.ts +3 -8
  119. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +3 -11
  120. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +3 -8
  121. package/src/routes/api/ecosystem.recipes.$recipeId.run.ts +3 -8
  122. package/src/routes/api/health.ts +9 -2
  123. package/src/routes/api/knowledge.project-context.ts +11 -0
  124. package/src/routes/api/knowledge.search.ts +33 -2
  125. package/src/routes/api/logs.$id.body.ts +16 -2
  126. package/src/routes/api/logs.import.ts +7 -18
  127. package/src/routes/api/logs.stream.ts +147 -69
  128. package/src/routes/api/logs.ts +55 -14
  129. package/src/routes/api/providers.$providerId.model-metadata.ts +22 -4
  130. package/src/routes/api/providers.$providerId.ts +37 -6
  131. package/src/routes/api/providers.export.ts +34 -16
  132. package/src/routes/api/providers.import.ts +42 -8
  133. package/src/routes/api/providers.scan.ts +13 -8
  134. package/src/routes/api/providers.ts +36 -5
  135. package/src/routes/api/runs.ts +12 -12
  136. package/src/routes/api/sessions.ts +15 -8
  137. package/src/routes/index.tsx +6 -0
  138. package/src/routes/livez.ts +13 -0
  139. package/src/routes/readyz.ts +18 -0
  140. package/src/routes/session/$sessionId.tsx +6 -0
  141. package/styles/globals.css +19 -3
  142. package/.output/public/assets/CompareDrawer-Cb5ccPeX.js +0 -1
  143. package/.output/public/assets/ProxyViewerContainer-COBe5GTT.js +0 -4234
  144. package/.output/public/assets/ResponseView-Cqq87lX_.js +0 -3
  145. package/.output/public/assets/StreamingChunkSequence-CnTa9mCr.js +0 -1
  146. package/.output/public/assets/index-DTLNVcgc.js +0 -76
  147. package/.output/public/assets/index-DliqmwUw.css +0 -1
  148. package/.output/public/assets/qwen-CONDcHqt.png +0 -0
  149. package/.output/server/_tanstack-start-manifest_v-DMCA8KK3.mjs +0 -4
  150. package/src/assets/logos/mcp.png +0 -0
  151. package/src/assets/logos/qwen.png +0 -0
  152. package/src/components/ui/mcp-logo.tsx +0 -20
@@ -1,5 +1,5 @@
1
1
  import { Suspense, useEffect, useMemo, useRef, useState } from "react";
2
- import type { JSX } from "react";
2
+ import type { JSX, KeyboardEvent as ReactKeyboardEvent } from "react";
3
3
  import {
4
4
  Check,
5
5
  ChevronRight,
@@ -46,6 +46,11 @@ type ChangedOp = Extract<DiffOp, { kind: "changed" }>;
46
46
 
47
47
  type DiffMode = "unified" | "split";
48
48
 
49
+ export const COMPARE_RESPONSIVE_HEADER_CLASS_NAME =
50
+ "flex flex-col gap-3 border-b border-border px-3 py-3 sm:flex-row sm:items-start sm:gap-4 sm:px-4";
51
+ export const COMPARE_RESPONSIVE_SPLIT_CLASS_NAME =
52
+ "grid min-w-0 grid-cols-1 gap-x-2 gap-y-0.5 px-3 py-2 text-xs md:grid-cols-[200px_minmax(0,1fr)_minmax(0,1fr)]";
53
+
49
54
  /** Walk the JsonNode tree and pretty-print it back to a JSON string for the
50
55
  * expanded-equal-subtree view. The node is a plain object structure so
51
56
  * `JSON.stringify` produces correct output. */
@@ -162,23 +167,23 @@ const KIND_VISUAL: Record<
162
167
  > = {
163
168
  added: {
164
169
  icon: Plus,
165
- accent: "text-emerald-600 dark:text-emerald-400",
166
- bg: "bg-emerald-500/5 hover:bg-emerald-500/10",
167
- border: "border-l-emerald-500",
170
+ accent: "text-status-success",
171
+ bg: "bg-status-success/5 hover:bg-status-success/10",
172
+ border: "border-l-status-success",
168
173
  label: "ADDED",
169
174
  },
170
175
  removed: {
171
176
  icon: Minus,
172
- accent: "text-rose-600 dark:text-rose-400",
173
- bg: "bg-rose-500/5 hover:bg-rose-500/10",
174
- border: "border-l-rose-500",
177
+ accent: "text-status-danger",
178
+ bg: "bg-status-danger/5 hover:bg-status-danger/10",
179
+ border: "border-l-status-danger",
175
180
  label: "REMOVED",
176
181
  },
177
182
  changed: {
178
183
  icon: Pencil,
179
- accent: "text-amber-600 dark:text-amber-400",
180
- bg: "bg-amber-500/5 hover:bg-amber-500/10",
181
- border: "border-l-amber-500",
184
+ accent: "text-status-watch",
185
+ bg: "bg-status-watch/5 hover:bg-status-watch/10",
186
+ border: "border-l-status-watch",
182
187
  label: "CHANGED",
183
188
  },
184
189
  equal: {
@@ -294,19 +299,19 @@ function UnifiedOpRow({
294
299
  ? [
295
300
  {
296
301
  text: previewNode(op.left, 400),
297
- tone: "text-rose-700 dark:text-rose-300 line-through",
302
+ tone: "text-status-danger line-through",
298
303
  },
299
- { text: previewNode(op.right, 400), tone: "text-emerald-700 dark:text-emerald-300" },
304
+ { text: previewNode(op.right, 400), tone: "text-status-success" },
300
305
  ]
301
306
  : op.kind === "removed"
302
307
  ? [
303
308
  {
304
309
  text: previewNode(op.value, 400),
305
- tone: "text-rose-700 dark:text-rose-300 line-through",
310
+ tone: "text-status-danger line-through",
306
311
  },
307
312
  ]
308
313
  : op.kind === "added"
309
- ? [{ text: previewNode(op.value, 400), tone: "text-emerald-700 dark:text-emerald-300" }]
314
+ ? [{ text: previewNode(op.value, 400), tone: "text-status-success" }]
310
315
  : [{ text: previewNode(op.value, 400), tone: "text-muted-foreground" }];
311
316
 
312
317
  const justCopied = copiedPath === op.path && op.path !== "";
@@ -376,7 +381,7 @@ function UnifiedOpRow({
376
381
  className={cn(
377
382
  "shrink-0 p-1 rounded transition-colors cursor-pointer inline-flex items-center justify-center",
378
383
  justCopied
379
- ? "text-emerald-500"
384
+ ? "text-status-success"
380
385
  : "text-muted-foreground/50 hover:text-foreground hover:bg-muted",
381
386
  )}
382
387
  aria-label={justCopied ? "Copied" : "Copy"}
@@ -427,12 +432,12 @@ function ExpandedSubtree({ op }: { op: AddedOp | RemovedOp | ChangedOp }): JSX.E
427
432
  }
428
433
  return (
429
434
  <div className="pl-5 mt-2 grid grid-cols-1 md:grid-cols-2 gap-2">
430
- <div className="border border-rose-500/30 rounded p-2 bg-rose-500/5">
431
- <div className="text-[10px] uppercase tracking-wider text-rose-500 mb-1">Old</div>
435
+ <div className="rounded border border-status-danger/30 bg-status-danger/5 p-2">
436
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-status-danger">Old</div>
432
437
  <LazyJsonStringView text={nodeToJsonString(op.left)} defaultExpandDepth={0} />
433
438
  </div>
434
- <div className="border border-emerald-500/30 rounded p-2 bg-emerald-500/5">
435
- <div className="text-[10px] uppercase tracking-wider text-emerald-500 mb-1">New</div>
439
+ <div className="rounded border border-status-success/30 bg-status-success/5 p-2">
440
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-status-success">New</div>
436
441
  <LazyJsonStringView text={nodeToJsonString(op.right)} defaultExpandDepth={0} />
437
442
  </div>
438
443
  </div>
@@ -461,7 +466,7 @@ function SummaryChips({
461
466
  className={cn(
462
467
  "inline-flex items-center gap-1 px-2 py-0.5 rounded-full border cursor-pointer transition-colors",
463
468
  counts.removed > 0
464
- ? "border-rose-500/40 text-rose-600 dark:text-rose-400 bg-rose-500/10 hover:bg-rose-500/20"
469
+ ? "border-status-danger/40 bg-status-danger/10 text-status-danger hover:bg-status-danger/20"
465
470
  : "border-border text-muted-foreground/40 cursor-not-allowed",
466
471
  )}
467
472
  title={counts.removed > 0 ? "Jump to first removed" : "No removals"}
@@ -476,7 +481,7 @@ function SummaryChips({
476
481
  className={cn(
477
482
  "inline-flex items-center gap-1 px-2 py-0.5 rounded-full border cursor-pointer transition-colors",
478
483
  counts.added > 0
479
- ? "border-emerald-500/40 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20"
484
+ ? "border-status-success/40 bg-status-success/10 text-status-success hover:bg-status-success/20"
480
485
  : "border-border text-muted-foreground/40 cursor-not-allowed",
481
486
  )}
482
487
  title={counts.added > 0 ? "Jump to first added" : "No additions"}
@@ -491,7 +496,7 @@ function SummaryChips({
491
496
  className={cn(
492
497
  "inline-flex items-center gap-1 px-2 py-0.5 rounded-full border cursor-pointer transition-colors",
493
498
  counts.changed > 0
494
- ? "border-amber-500/40 text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/20"
499
+ ? "border-status-watch/40 bg-status-watch/10 text-status-watch hover:bg-status-watch/20"
495
500
  : "border-border text-muted-foreground/40 cursor-not-allowed",
496
501
  )}
497
502
  title={counts.changed > 0 ? "Jump to first changed" : "No changes"}
@@ -563,27 +568,29 @@ function SideSummary({
563
568
  className={cn(
564
569
  "text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
565
570
  side === "left"
566
- ? "border-rose-500/40 text-rose-400"
567
- : "border-emerald-500/40 text-emerald-400",
571
+ ? "border-status-danger/40 text-status-danger"
572
+ : "border-status-success/40 text-status-success",
568
573
  )}
569
574
  >
570
575
  {side === "left" ? "← Left" : "Right →"}
571
576
  </Badge>
572
- <span className="font-mono text-blue-400/80" title={`Log ID ${String(log.id)}`}>
577
+ <span className="font-mono text-status-info" title={`Log ID ${String(log.id)}`}>
573
578
  #{displayNumber}
574
579
  </span>
575
580
  {log.model !== null && (
576
- <span className="font-mono leading-snug text-muted-foreground">{log.model}</span>
581
+ <span className="min-w-0 truncate font-mono leading-snug text-muted-foreground">
582
+ {log.model}
583
+ </span>
577
584
  )}
578
585
  </div>
579
- <div className="flex items-center gap-3 text-muted-foreground font-mono">
586
+ <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 font-mono text-muted-foreground">
580
587
  {log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
581
- <span className="text-emerald-400">
588
+ <span className="text-status-success">
582
589
  KV Cache +{formatTokens(log.cacheCreationInputTokens)}
583
590
  </span>
584
591
  )}
585
592
  {log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
586
- <span className="text-purple-400">
593
+ <span className="text-status-accent">
587
594
  KV Cache ~{formatTokens(log.cacheReadInputTokens)}
588
595
  </span>
589
596
  )}
@@ -605,6 +612,9 @@ export function CompareDrawer({
605
612
  rightDisplayNumber,
606
613
  onClose,
607
614
  }: CompareDrawerProps): JSX.Element {
615
+ const panelRef = useRef<HTMLDivElement>(null);
616
+ const closeButtonRef = useRef<HTMLButtonElement>(null);
617
+ const previousActiveElementRef = useRef<HTMLElement | null>(null);
608
618
  // Memoize the diff so re-renders (e.g. parent re-renders) don't recompute.
609
619
  const ops = useMemo<DiffOp[]>(() => {
610
620
  const leftRequest = getLogFormatAdapter(resolveLogFormat(left)).analyzeRequest(
@@ -716,8 +726,12 @@ export function CompareDrawer({
716
726
  }
717
727
  };
718
728
 
719
- // Esc keybinding + body scroll lock while the drawer is open.
729
+ // Esc keybinding, initial focus, focus restoration, and body scroll lock while open.
720
730
  useEffect(() => {
731
+ previousActiveElementRef.current =
732
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
733
+ closeButtonRef.current?.focus();
734
+
721
735
  const onKey = (e: KeyboardEvent) => {
722
736
  if (e.key === "Escape") onClose();
723
737
  };
@@ -727,9 +741,36 @@ export function CompareDrawer({
727
741
  return () => {
728
742
  document.removeEventListener("keydown", onKey);
729
743
  document.body.style.overflow = prevOverflow;
744
+ previousActiveElementRef.current?.focus();
730
745
  };
731
746
  }, [onClose]);
732
747
 
748
+ const handlePanelKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
749
+ event.stopPropagation();
750
+ if (event.key !== "Tab") return;
751
+
752
+ const panel = panelRef.current;
753
+ if (panel === null) return;
754
+ const focusable = Array.from(
755
+ panel.querySelectorAll<HTMLElement>(
756
+ 'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])',
757
+ ),
758
+ );
759
+ const first = focusable[0];
760
+ const last = focusable[focusable.length - 1];
761
+ if (first === undefined || last === undefined) return;
762
+
763
+ if (event.shiftKey && document.activeElement === first) {
764
+ event.preventDefault();
765
+ last.focus();
766
+ return;
767
+ }
768
+ if (!event.shiftKey && document.activeElement === last) {
769
+ event.preventDefault();
770
+ first.focus();
771
+ }
772
+ };
773
+
733
774
  const sameSession = getConversationId(left) === getConversationId(right);
734
775
  const allEqual = ops.length === 1 && ops[0]?.kind === "equal";
735
776
 
@@ -751,22 +792,24 @@ export function CompareDrawer({
751
792
 
752
793
  {/* Drawer panel */}
753
794
  <div
795
+ ref={panelRef}
754
796
  className={cn(
755
- "relative bg-background border-l border-border shadow-xl",
797
+ "relative overflow-x-hidden bg-background border-l border-border shadow-xl",
756
798
  "w-full md:w-[70vw] max-w-[1100px] flex flex-col h-full",
757
799
  )}
758
800
  onClick={(e) => e.stopPropagation()}
759
- onKeyDown={(e) => e.stopPropagation()}
801
+ onKeyDown={handlePanelKeyDown}
760
802
  >
761
803
  {/* Header */}
762
- <div className="flex items-start gap-4 px-4 py-3 border-b border-border">
763
- <div className="flex-1 flex gap-4 min-w-0">
804
+ <div className={COMPARE_RESPONSIVE_HEADER_CLASS_NAME}>
805
+ <div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
764
806
  <SideSummary log={left} side="left" displayNumber={leftDisplayNumber} />
765
807
  <SideSummary log={right} side="right" displayNumber={rightDisplayNumber} />
766
808
  </div>
767
- <div className="flex items-center gap-2 shrink-0">
809
+ <div className="flex w-full shrink-0 items-center justify-between gap-2 sm:w-auto sm:justify-start">
768
810
  <ModeToggle mode={mode} onChange={setMode} />
769
811
  <button
812
+ ref={closeButtonRef}
770
813
  type="button"
771
814
  onClick={onClose}
772
815
  aria-label="Close"
@@ -778,7 +821,7 @@ export function CompareDrawer({
778
821
  </div>
779
822
 
780
823
  {!sameSession && (
781
- <div className="px-4 py-1.5 text-xs text-amber-400 bg-amber-500/10 border-b border-border">
824
+ <div className="border-b border-border bg-warning/10 px-4 py-1.5 text-xs text-foreground">
782
825
  Heads up: the two selected logs are from different sessions.
783
826
  </div>
784
827
  )}
@@ -835,7 +878,7 @@ export function CompareDrawer({
835
878
  );
836
879
  }
837
880
 
838
- /** Legacy 3-column (path | left | right) view, retained as a toggle option. */
881
+ /** Three-column on wide screens and a bounded stacked comparison on narrow screens. */
839
882
  function SplitBody({
840
883
  grouped,
841
884
  left,
@@ -850,9 +893,9 @@ function SplitBody({
850
893
  rightDisplayNumber: number;
851
894
  }): JSX.Element {
852
895
  return (
853
- <div className="grid grid-cols-[200px_1fr_1fr] gap-x-2 gap-y-0.5 px-3 py-2 text-xs">
896
+ <div data-compare-layout="responsive-split" className={COMPARE_RESPONSIVE_SPLIT_CLASS_NAME}>
854
897
  {/* Column headers */}
855
- <div className="grid grid-cols-[200px_1fr_1fr] gap-x-2 col-span-3 pb-2 mb-2 border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
898
+ <div className="col-span-1 mb-2 grid grid-cols-1 gap-x-2 gap-y-1 border-b border-border pb-2 text-[10px] uppercase tracking-wider text-muted-foreground md:col-span-3 md:grid-cols-[200px_minmax(0,1fr)_minmax(0,1fr)]">
856
899
  <span>Path</span>
857
900
  <span title={`Log ID ${String(left.id)}`}>Left (Log #{leftDisplayNumber})</span>
858
901
  <span title={`Log ID ${String(right.id)}`}>Right (Log #{rightDisplayNumber})</span>
@@ -864,7 +907,7 @@ function SplitBody({
864
907
  <div
865
908
  // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
866
909
  key={i}
867
- className="col-span-3 px-2 py-1 text-xs text-muted-foreground/60"
910
+ className="col-span-1 px-2 py-1 text-xs text-muted-foreground/60 md:col-span-3"
868
911
  >
869
912
  {g.ops.length} equal siblings collapsed — switch to Unified to expand
870
913
  </div>
@@ -876,7 +919,7 @@ function SplitBody({
876
919
  <div
877
920
  // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
878
921
  key={i}
879
- className="col-span-3 grid grid-cols-[200px_1fr_1fr] gap-x-2 px-2 py-0.5 text-muted-foreground"
922
+ className="col-span-1 grid min-w-0 grid-cols-1 gap-x-2 gap-y-1 px-2 py-0.5 text-muted-foreground md:col-span-3 md:grid-cols-[200px_minmax(0,1fr)_minmax(0,1fr)]"
880
923
  >
881
924
  <span className="font-mono text-xs truncate" title={op.path}>
882
925
  {op.path}
@@ -895,10 +938,10 @@ function SplitBody({
895
938
  <div
896
939
  // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
897
940
  key={i}
898
- className="col-span-3 px-2 py-1 rounded text-xs border-l-2 border-l-emerald-400/70 bg-emerald-500/5"
941
+ className="col-span-1 rounded border-l-2 border-l-status-success bg-status-success/5 px-2 py-1 text-xs md:col-span-3"
899
942
  >
900
943
  <div className="font-mono text-xs text-muted-foreground mb-0.5">{op.path}</div>
901
- <div className="font-mono break-all text-emerald-300/90">
944
+ <div className="font-mono break-all text-status-success">
902
945
  + {previewNode(op.value, 400)}
903
946
  </div>
904
947
  </div>
@@ -909,10 +952,10 @@ function SplitBody({
909
952
  <div
910
953
  // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
911
954
  key={i}
912
- className="col-span-3 px-2 py-1 rounded text-xs border-l-2 border-l-rose-400/70 bg-rose-500/5"
955
+ className="col-span-1 rounded border-l-2 border-l-status-danger bg-status-danger/5 px-2 py-1 text-xs md:col-span-3"
913
956
  >
914
957
  <div className="font-mono text-xs text-muted-foreground mb-0.5">{op.path}</div>
915
- <div className="font-mono break-all text-rose-300/90 line-through">
958
+ <div className="font-mono break-all text-status-danger line-through">
916
959
  − {previewNode(op.value, 400)}
917
960
  </div>
918
961
  </div>
@@ -922,14 +965,14 @@ function SplitBody({
922
965
  <div
923
966
  // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
924
967
  key={i}
925
- className="col-span-3 px-2 py-1 rounded text-xs border-l-2 border-l-amber-400/70 bg-amber-500/5"
968
+ className="col-span-1 rounded border-l-2 border-l-status-watch bg-status-watch/5 px-2 py-1 text-xs md:col-span-3"
926
969
  >
927
970
  <div className="font-mono text-xs text-muted-foreground mb-1">{op.path}</div>
928
- <div className="grid grid-cols-2 gap-2">
929
- <div className="font-mono text-rose-300/90 break-all line-through">
971
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
972
+ <div className="font-mono break-all text-status-danger line-through">
930
973
  {previewNode(op.left, 400)}
931
974
  </div>
932
- <div className="font-mono text-emerald-300/90 break-all">
975
+ <div className="font-mono break-all text-status-success">
933
976
  {previewNode(op.right, 400)}
934
977
  </div>
935
978
  </div>
@@ -0,0 +1,186 @@
1
+ import { type JSX } from "react";
2
+ import { CircleStop, RefreshCw } from "lucide-react";
3
+ import { cn } from "../../lib/utils";
4
+ import type { LiveConnectionPhase, LiveConnectionState } from "./liveConnectionState";
5
+
6
+ type ConnectionPresentation = {
7
+ label: string;
8
+ description: string;
9
+ toneClass: string;
10
+ dotClass: string;
11
+ canRetry: boolean;
12
+ canStop: boolean;
13
+ };
14
+
15
+ function formatLastSuccess(lastSuccessAt: number | null): string | null {
16
+ if (lastSuccessAt === null) return null;
17
+ return new Date(lastSuccessAt).toLocaleTimeString([], {
18
+ hour: "2-digit",
19
+ minute: "2-digit",
20
+ second: "2-digit",
21
+ });
22
+ }
23
+
24
+ function buildDescription(state: LiveConnectionState, message: string, hasLogs: boolean): string {
25
+ const lastSuccess = formatLastSuccess(state.lastSuccessAt);
26
+ const retry = state.retryCount > 0 ? ` Retry ${state.retryCount.toString()}.` : "";
27
+ const retained = hasLogs ? " Captured logs remain visible." : "";
28
+ const lastUpdate = lastSuccess === null ? "" : ` Last update ${lastSuccess}.`;
29
+ return `${message}${lastUpdate}${retry}${retained}`;
30
+ }
31
+
32
+ function presentationForStatus(
33
+ status: LiveConnectionPhase,
34
+ state: LiveConnectionState,
35
+ hasLogs: boolean,
36
+ ): ConnectionPresentation {
37
+ switch (status) {
38
+ case "connecting":
39
+ return {
40
+ label: "Connecting",
41
+ description: buildDescription(state, "Waiting for the first live update.", hasLogs),
42
+ toneClass: "border-cyan-400/20 bg-cyan-500/8 text-cyan-100",
43
+ dotClass: "bg-cyan-300 animate-pulse motion-reduce:animate-none",
44
+ canRetry: false,
45
+ canStop: false,
46
+ };
47
+ case "live":
48
+ return {
49
+ label: "Live",
50
+ description: buildDescription(state, "Live updates are current.", hasLogs),
51
+ toneClass: "border-emerald-400/20 bg-emerald-500/8 text-emerald-100",
52
+ dotClass: "bg-emerald-300",
53
+ canRetry: false,
54
+ canStop: false,
55
+ };
56
+ case "reconnecting":
57
+ return {
58
+ label: "Reconnecting",
59
+ description: buildDescription(state, "Live updates were interrupted.", hasLogs),
60
+ toneClass: "border-amber-400/20 bg-amber-500/8 text-amber-100",
61
+ dotClass: "bg-amber-300 animate-pulse motion-reduce:animate-none",
62
+ canRetry: true,
63
+ canStop: true,
64
+ };
65
+ case "stale":
66
+ return {
67
+ label: "Stale data",
68
+ description: buildDescription(state, "Live updates have not resumed.", hasLogs),
69
+ toneClass: "border-orange-400/25 bg-orange-500/10 text-orange-100",
70
+ dotClass: "bg-orange-300",
71
+ canRetry: true,
72
+ canStop: true,
73
+ };
74
+ case "offline":
75
+ return {
76
+ label: "Offline",
77
+ description: buildDescription(
78
+ state,
79
+ state.errorMessage ?? "Live updates are paused.",
80
+ hasLogs,
81
+ ),
82
+ toneClass: "border-slate-400/20 bg-slate-500/10 text-slate-200",
83
+ dotClass: "bg-slate-400",
84
+ canRetry: true,
85
+ canStop: false,
86
+ };
87
+ case "error":
88
+ return {
89
+ label: "Connection error",
90
+ description: buildDescription(
91
+ state,
92
+ state.errorMessage ?? "The live stream reported a recoverable error.",
93
+ hasLogs,
94
+ ),
95
+ toneClass: "border-red-400/25 bg-red-500/10 text-red-100",
96
+ dotClass: "bg-red-300",
97
+ canRetry: true,
98
+ canStop: true,
99
+ };
100
+ }
101
+ }
102
+
103
+ export function LiveConnectionBadge({ state }: { state: LiveConnectionState }): JSX.Element {
104
+ const presentation = presentationForStatus(state.status, state, false);
105
+ const lastSuccess = formatLastSuccess(state.lastSuccessAt);
106
+ const retryLabel = state.retryCount > 0 ? `Retry ${state.retryCount.toString()}` : null;
107
+
108
+ return (
109
+ <span
110
+ role="status"
111
+ aria-label={presentation.description}
112
+ data-connection-state={state.status}
113
+ className={cn(
114
+ "inline-flex h-7 max-w-full items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium",
115
+ presentation.toneClass,
116
+ )}
117
+ >
118
+ <span className={cn("size-1.5 shrink-0 rounded-full", presentation.dotClass)} />
119
+ <span className="shrink-0">{presentation.label}</span>
120
+ {retryLabel !== null && (
121
+ <span className="hidden truncate text-current/70 sm:inline">{retryLabel}</span>
122
+ )}
123
+ {lastSuccess !== null && (
124
+ <span className="hidden truncate font-mono text-[10px] text-current/65 md:inline">
125
+ Last {lastSuccess}
126
+ </span>
127
+ )}
128
+ </span>
129
+ );
130
+ }
131
+
132
+ export function LiveConnectionStatus({
133
+ state,
134
+ hasLogs,
135
+ onRetry,
136
+ onStop,
137
+ }: {
138
+ state: LiveConnectionState;
139
+ hasLogs: boolean;
140
+ onRetry: () => void;
141
+ onStop: () => void;
142
+ }): JSX.Element {
143
+ const presentation = presentationForStatus(state.status, state, hasLogs);
144
+
145
+ return (
146
+ <div
147
+ role="status"
148
+ aria-live="polite"
149
+ data-connection-state={state.status}
150
+ className={cn(
151
+ "mt-2 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md border px-3 py-2 text-xs",
152
+ presentation.toneClass,
153
+ )}
154
+ >
155
+ <div className="flex min-w-0 flex-1 items-center gap-2">
156
+ <span className={cn("size-2 shrink-0 rounded-full", presentation.dotClass)} />
157
+ <span className="shrink-0 font-semibold">{presentation.label}</span>
158
+ <span className="min-w-0 text-current/75">{presentation.description}</span>
159
+ </div>
160
+ {(presentation.canRetry || presentation.canStop) && (
161
+ <div className="flex shrink-0 items-center gap-1.5">
162
+ {presentation.canRetry && (
163
+ <button
164
+ type="button"
165
+ onClick={onRetry}
166
+ className="inline-flex h-7 items-center gap-1 rounded-md bg-black/15 px-2 font-medium transition-colors hover:bg-black/25 focus-visible:ring-1 focus-visible:ring-current focus-visible:outline-none"
167
+ >
168
+ <RefreshCw className="size-3" />
169
+ Retry now
170
+ </button>
171
+ )}
172
+ {presentation.canStop && (
173
+ <button
174
+ type="button"
175
+ onClick={onStop}
176
+ className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-current/70 transition-colors hover:bg-black/15 hover:text-current focus-visible:ring-1 focus-visible:ring-current focus-visible:outline-none"
177
+ >
178
+ <CircleStop className="size-3" />
179
+ Stop retrying
180
+ </button>
181
+ )}
182
+ </div>
183
+ )}
184
+ </div>
185
+ );
186
+ }