@tonyclaw/llm-inspector 1.16.4 → 1.17.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 (62) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/CompareDrawer-C4fie5g5.js +1 -0
  3. package/.output/public/assets/ReplayDialog-Dme5uOR9.js +1 -0
  4. package/.output/public/assets/RequestAnatomy-ChBLDNFH.js +1 -0
  5. package/.output/public/assets/ResponseView-wGeqBzVU.js +1 -0
  6. package/.output/public/assets/StreamingChunkSequence-zeJZQLqT.js +1 -0
  7. package/.output/public/assets/index-DoGvsnbA.css +1 -0
  8. package/.output/public/assets/index-DpbutOvo.js +101 -0
  9. package/.output/public/assets/json-viewer-BV-WUszW.js +14 -0
  10. package/.output/public/assets/{main-DbWwVQFh.js → main-DRu10KNQ.js} +1 -1
  11. package/.output/server/_libs/lucide-react.mjs +105 -85
  12. package/.output/server/_ssr/CompareDrawer-C4-CQL5w.mjs +1040 -0
  13. package/.output/server/_ssr/ReplayDialog-BTb1Bam8.mjs +321 -0
  14. package/.output/server/_ssr/RequestAnatomy-CZFV1IvL.mjs +351 -0
  15. package/.output/server/_ssr/ResponseView-CTZekh65.mjs +601 -0
  16. package/.output/server/_ssr/StreamingChunkSequence-C38Ynabd.mjs +301 -0
  17. package/.output/server/_ssr/{index-C-z-fZtq.mjs → index-Cnu-QzAy.mjs} +1141 -2443
  18. package/.output/server/_ssr/index.mjs +2 -2
  19. package/.output/server/_ssr/json-viewer-DROqpjS9.mjs +510 -0
  20. package/.output/server/_ssr/{router-CNM9Kbi0.mjs → router-pP4GCTQx.mjs} +42 -18
  21. package/.output/server/{_tanstack-start-manifest_v-BWfLeIsC.mjs → _tanstack-start-manifest_v-CphS4rZd.mjs} +1 -1
  22. package/.output/server/index.mjs +69 -27
  23. package/package.json +1 -1
  24. package/src/components/OnboardingBanner.tsx +2 -2
  25. package/src/components/ProxyViewer.tsx +44 -27
  26. package/src/components/ProxyViewerContainer.tsx +5 -25
  27. package/src/components/providers/SettingsDialog.tsx +52 -1
  28. package/src/components/proxy-viewer/ConversationGroup.tsx +5 -1
  29. package/src/components/proxy-viewer/ConversationHeader.tsx +4 -1
  30. package/src/components/proxy-viewer/LogEntry.tsx +217 -181
  31. package/src/components/proxy-viewer/LogEntryHeader.tsx +181 -40
  32. package/src/components/proxy-viewer/ThreadConnector.tsx +17 -2
  33. package/src/components/proxy-viewer/TurnGroup.tsx +124 -72
  34. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +98 -0
  35. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +196 -0
  36. package/src/components/proxy-viewer/anatomy/tokenEstimate.ts +53 -0
  37. package/src/components/proxy-viewer/anatomy/types.ts +39 -0
  38. package/src/components/proxy-viewer/anatomy/useAnatomyJump.ts +114 -0
  39. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +3 -23
  40. package/src/components/proxy-viewer/formats/anthropic/thinkingExtract.ts +21 -0
  41. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +5 -3
  42. package/src/components/proxy-viewer/lazy.ts +37 -0
  43. package/src/components/proxy-viewer/log-formats/anthropic.ts +146 -0
  44. package/src/components/proxy-viewer/log-formats/openai.ts +127 -0
  45. package/src/components/proxy-viewer/log-formats/types.ts +7 -0
  46. package/src/components/proxy-viewer/log-formats/unknown.ts +4 -0
  47. package/src/components/proxy-viewer/logEntryVisibility.ts +39 -0
  48. package/src/components/proxy-viewer/useKeyboardNavigation.ts +190 -0
  49. package/src/components/proxy-viewer/viewerState.ts +8 -0
  50. package/src/components/ui/crab-variants.tsx +11 -0
  51. package/src/components/ui/json-expansion-button.tsx +56 -0
  52. package/src/components/ui/json-viewer-bulk.ts +97 -0
  53. package/src/components/ui/json-viewer.tsx +58 -183
  54. package/src/lib/runtimeConfig.ts +9 -0
  55. package/src/lib/useOnboarding.ts +7 -1
  56. package/src/lib/useStripConfig.ts +33 -2
  57. package/src/lib/utils.ts +2 -3
  58. package/src/proxy/config.ts +17 -7
  59. package/src/routes/api/config.ts +7 -0
  60. package/src/routes/api/logs.stream.ts +26 -16
  61. package/.output/public/assets/index-DRRCmu5p.css +0 -1
  62. package/.output/public/assets/index-X7CHS7fS.js +0 -107
@@ -1,4 +1,4 @@
1
- const tsrStartManifest = () => ({ "routes": { "__root__": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/__root.tsx", "children": ["/", "/api/config", "/api/health", "/api/logs", "/api/mcp", "/api/models", "/api/providers", "/api/sessions", "/proxy/$"], "preloads": ["/assets/main-DbWwVQFh.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-X7CHS7fS.js"] }, "/api/config": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.ts", "children": ["/api/config/paths"] }, "/api/health": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/health.ts" }, "/api/logs": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.ts", "children": ["/api/logs/$id", "/api/logs/stream"] }, "/api/mcp": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/mcp.ts" }, "/api/models": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/models.ts" }, "/api/providers": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.ts", "children": ["/api/providers/$providerId", "/api/providers/export", "/api/providers/import", "/api/providers/scan"] }, "/api/sessions": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/sessions.ts" }, "/proxy/$": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/proxy/$.ts" }, "/api/config/paths": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.paths.ts" }, "/api/logs/$id": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.ts", "children": ["/api/logs/$id/chunks", "/api/logs/$id/replay"] }, "/api/logs/stream": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.stream.ts" }, "/api/providers/$providerId": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.ts", "children": ["/api/providers/$providerId/test"] }, "/api/providers/export": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.export.ts" }, "/api/providers/import": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.import.ts" }, "/api/providers/scan": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.scan.ts" }, "/api/logs/$id/chunks": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.chunks.ts" }, "/api/logs/$id/replay": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.replay.ts" }, "/api/providers/$providerId/test": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.test.ts", "children": ["/api/providers/$providerId/test/log"] }, "/api/providers/$providerId/test/log": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.test.log.ts" } }, "clientEntry": "/assets/main-DbWwVQFh.js" });
1
+ const tsrStartManifest = () => ({ "routes": { "__root__": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/__root.tsx", "children": ["/", "/api/config", "/api/health", "/api/logs", "/api/mcp", "/api/models", "/api/providers", "/api/sessions", "/proxy/$"], "preloads": ["/assets/main-DRu10KNQ.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-DpbutOvo.js"] }, "/api/config": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.ts", "children": ["/api/config/paths"] }, "/api/health": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/health.ts" }, "/api/logs": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.ts", "children": ["/api/logs/$id", "/api/logs/stream"] }, "/api/mcp": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/mcp.ts" }, "/api/models": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/models.ts" }, "/api/providers": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.ts", "children": ["/api/providers/$providerId", "/api/providers/export", "/api/providers/import", "/api/providers/scan"] }, "/api/sessions": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/sessions.ts" }, "/proxy/$": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/proxy/$.ts" }, "/api/config/paths": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.paths.ts" }, "/api/logs/$id": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.ts", "children": ["/api/logs/$id/chunks", "/api/logs/$id/replay"] }, "/api/logs/stream": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.stream.ts" }, "/api/providers/$providerId": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.ts", "children": ["/api/providers/$providerId/test"] }, "/api/providers/export": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.export.ts" }, "/api/providers/import": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.import.ts" }, "/api/providers/scan": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.scan.ts" }, "/api/logs/$id/chunks": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.chunks.ts" }, "/api/logs/$id/replay": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.replay.ts" }, "/api/providers/$providerId/test": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.test.ts", "children": ["/api/providers/$providerId/test/log"] }, "/api/providers/$providerId/test/log": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.test.log.ts" } }, "clientEntry": "/assets/main-DRu10KNQ.js" });
2
2
  export {
3
3
  tsrStartManifest
4
4
  };
@@ -35,54 +35,96 @@ const headers = ((m) => function headersRouteRule(event) {
35
35
  }
36
36
  });
37
37
  const assets = {
38
- "/assets/minimax-BPMzvuL-.jpeg": {
39
- "type": "image/jpeg",
40
- "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
41
- "mtime": "2026-06-15T02:31:02.872Z",
42
- "size": 6918,
43
- "path": "../public/assets/minimax-BPMzvuL-.jpeg"
44
- },
45
38
  "/assets/alibaba-TTwafVwX.svg": {
46
39
  "type": "image/svg+xml",
47
40
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
48
- "mtime": "2026-06-15T02:31:02.873Z",
41
+ "mtime": "2026-06-16T13:43:26.730Z",
49
42
  "size": 5915,
50
43
  "path": "../public/assets/alibaba-TTwafVwX.svg"
51
44
  },
52
- "/assets/index-DRRCmu5p.css": {
45
+ "/assets/CompareDrawer-C4fie5g5.js": {
46
+ "type": "text/javascript; charset=utf-8",
47
+ "etag": '"4a10-3djWuOwKSKXlQPoy4NHzqd2tggE"',
48
+ "mtime": "2026-06-16T13:43:26.730Z",
49
+ "size": 18960,
50
+ "path": "../public/assets/CompareDrawer-C4fie5g5.js"
51
+ },
52
+ "/assets/index-DoGvsnbA.css": {
53
53
  "type": "text/css; charset=utf-8",
54
- "etag": '"15b6a-YPwntwMQpIU2AWIj8lgGWZpCwQw"',
55
- "mtime": "2026-06-15T02:31:02.875Z",
56
- "size": 88938,
57
- "path": "../public/assets/index-DRRCmu5p.css"
54
+ "etag": '"16d26-qw65JIM4oxztXa/jhWYD9PPuvfA"',
55
+ "mtime": "2026-06-16T13:43:26.730Z",
56
+ "size": 93478,
57
+ "path": "../public/assets/index-DoGvsnbA.css"
58
+ },
59
+ "/assets/index-DpbutOvo.js": {
60
+ "type": "text/javascript; charset=utf-8",
61
+ "etag": '"744e2-P9RhN2wY9pxyu6b/wpeHlefe59k"',
62
+ "mtime": "2026-06-16T13:43:26.731Z",
63
+ "size": 476386,
64
+ "path": "../public/assets/index-DpbutOvo.js"
65
+ },
66
+ "/assets/ReplayDialog-Dme5uOR9.js": {
67
+ "type": "text/javascript; charset=utf-8",
68
+ "etag": '"11b1-SKRfqUa9SY8yNi8IxWzCC6VvjQI"',
69
+ "mtime": "2026-06-16T13:43:26.730Z",
70
+ "size": 4529,
71
+ "path": "../public/assets/ReplayDialog-Dme5uOR9.js"
72
+ },
73
+ "/assets/json-viewer-BV-WUszW.js": {
74
+ "type": "text/javascript; charset=utf-8",
75
+ "etag": '"1e60c-26TCWxphlokObc5HBPeY7NRlcXU"',
76
+ "mtime": "2026-06-16T13:43:26.731Z",
77
+ "size": 124428,
78
+ "path": "../public/assets/json-viewer-BV-WUszW.js"
58
79
  },
59
- "/assets/main-DbWwVQFh.js": {
80
+ "/assets/RequestAnatomy-ChBLDNFH.js": {
60
81
  "type": "text/javascript; charset=utf-8",
61
- "etag": '"50599-mPNNNxfSpwFWn5i6kPquWEqJjGk"',
62
- "mtime": "2026-06-15T02:31:02.875Z",
63
- "size": 329113,
64
- "path": "../public/assets/main-DbWwVQFh.js"
82
+ "etag": '"141b-nxiiCAE0vs7SLaqcUISSgXq1IGo"',
83
+ "mtime": "2026-06-16T13:43:26.730Z",
84
+ "size": 5147,
85
+ "path": "../public/assets/RequestAnatomy-ChBLDNFH.js"
86
+ },
87
+ "/assets/minimax-BPMzvuL-.jpeg": {
88
+ "type": "image/jpeg",
89
+ "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
90
+ "mtime": "2026-06-16T13:43:26.730Z",
91
+ "size": 6918,
92
+ "path": "../public/assets/minimax-BPMzvuL-.jpeg"
93
+ },
94
+ "/assets/main-DRu10KNQ.js": {
95
+ "type": "text/javascript; charset=utf-8",
96
+ "etag": '"505ae-BP/aj8/2FZWooG/jXqGZEmVfl3E"',
97
+ "mtime": "2026-06-16T13:43:26.730Z",
98
+ "size": 329134,
99
+ "path": "../public/assets/main-DRu10KNQ.js"
100
+ },
101
+ "/assets/StreamingChunkSequence-zeJZQLqT.js": {
102
+ "type": "text/javascript; charset=utf-8",
103
+ "etag": '"d72-nuJyJopptXOHt0oPHw//D9VSW+k"',
104
+ "mtime": "2026-06-16T13:43:26.731Z",
105
+ "size": 3442,
106
+ "path": "../public/assets/StreamingChunkSequence-zeJZQLqT.js"
107
+ },
108
+ "/assets/ResponseView-wGeqBzVU.js": {
109
+ "type": "text/javascript; charset=utf-8",
110
+ "etag": '"6e82-ZzZo1WT+DlE16b9M9AMC/410RcI"',
111
+ "mtime": "2026-06-16T13:43:26.731Z",
112
+ "size": 28290,
113
+ "path": "../public/assets/ResponseView-wGeqBzVU.js"
65
114
  },
66
115
  "/assets/zhipuai-BPNAnxo-.svg": {
67
116
  "type": "image/svg+xml",
68
117
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
69
- "mtime": "2026-06-15T02:31:02.873Z",
118
+ "mtime": "2026-06-16T13:43:26.730Z",
70
119
  "size": 11256,
71
120
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
72
121
  },
73
122
  "/assets/qwen-CONDcHqt.png": {
74
123
  "type": "image/png",
75
124
  "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
76
- "mtime": "2026-06-15T02:31:02.875Z",
125
+ "mtime": "2026-06-16T13:43:26.730Z",
77
126
  "size": 357059,
78
127
  "path": "../public/assets/qwen-CONDcHqt.png"
79
- },
80
- "/assets/index-X7CHS7fS.js": {
81
- "type": "text/javascript; charset=utf-8",
82
- "etag": '"9c554-RW/7IDotWnzOK06cvwJGAOWPrdk"',
83
- "mtime": "2026-06-15T02:31:02.875Z",
84
- "size": 640340,
85
- "path": "../public/assets/index-X7CHS7fS.js"
86
128
  }
87
129
  };
88
130
  function readAsset(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/llm-inspector",
3
- "version": "1.16.4",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "description": "LLM API proxy inspector — captures and displays requests/responses from AI coding tools in a web UI",
6
6
  "license": "MIT",
@@ -46,10 +46,10 @@ export function OnboardingBanner(): JSX.Element | null {
46
46
  onClick={() => {
47
47
  void markSeen();
48
48
  }}
49
- className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-amber-500/40 text-amber-700 dark:text-amber-300 hover:bg-amber-500/10 transition-colors shrink-0"
49
+ className="inline-flex items-center gap-1.5 text-xs h-8 px-3 rounded-md border border-amber-500/40 text-amber-700 dark:text-amber-300 hover:bg-amber-500/10 transition-colors shrink-0"
50
50
  aria-label="Dismiss onboarding tip"
51
51
  >
52
- <Check className="size-3" />
52
+ <Check className="size-3.5" />
53
53
  Got it
54
54
  </button>
55
55
  <button
@@ -1,8 +1,9 @@
1
- import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
1
+ import { type JSX, useCallback, useEffect, useMemo, useRef, useState, Suspense } from "react";
2
2
  import { Download } from "lucide-react";
3
3
 
4
4
  import type { CapturedLog } from "../proxy/schemas";
5
5
  import { exportLogsAsZip } from "../lib/export-logs";
6
+ import { formatTokens } from "../lib/utils";
6
7
  import packageJson from "../../package.json";
7
8
  import { ConversationGroup, groupLogsByConversation } from "./proxy-viewer";
8
9
 
@@ -11,8 +12,9 @@ import { crabVariants } from "./ui/crab-variants";
11
12
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
12
13
  import { SettingsDialog } from "./providers/SettingsDialog";
13
14
  import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
14
- import { CompareDrawer } from "./proxy-viewer/CompareDrawer";
15
+ import { LazyCompareDrawer } from "./proxy-viewer/lazy";
15
16
  import { buildValidPredecessors } from "./proxy-viewer/viewerState";
17
+ import { useKeyboardNavigation } from "./proxy-viewer/useKeyboardNavigation";
16
18
 
17
19
  function truncateSessionId(id: string): string {
18
20
  if (id.length <= 30) return id;
@@ -97,6 +99,8 @@ export type ProxyViewerProps = {
97
99
  onViewModeChange: (mode: "simple" | "full") => void;
98
100
  /** Live strip-Claude-Code-billing-header flag, sourced once at the container. */
99
101
  strip: boolean;
102
+ /** Slow-response threshold in seconds. `0` disables the warning indicator. */
103
+ slowResponseThresholdSeconds: number;
100
104
  };
101
105
 
102
106
  export function ProxyViewer({
@@ -112,6 +116,7 @@ export function ProxyViewer({
112
116
  viewMode,
113
117
  onViewModeChange,
114
118
  strip,
119
+ slowResponseThresholdSeconds,
115
120
  }: ProxyViewerProps): JSX.Element {
116
121
  const { totalIn, totalOut } = useMemo(() => computeTokenSummary(logs), [logs]);
117
122
  const [exporting, setExporting] = useState(false);
@@ -119,6 +124,9 @@ export function ProxyViewer({
119
124
  const [crabEntrancePhase, setCrabEntrancePhase] = useState<"hidden" | "playing" | "done">(
120
125
  "hidden",
121
126
  );
127
+ const logListRef = useRef<HTMLDivElement>(null);
128
+ const logListWrapperRef = useRef<HTMLDivElement>(null);
129
+ useKeyboardNavigation(logListRef, logListWrapperRef);
122
130
 
123
131
  useEffect(() => {
124
132
  const perCrabDuration = 400;
@@ -162,9 +170,9 @@ export function ProxyViewer({
162
170
  );
163
171
 
164
172
  return (
165
- <div className="max-w-[1200px] mx-auto flex flex-col h-screen" style={{ maxHeight: "100vh" }}>
173
+ <div className="max-w-[1400px] xl:max-w-[1600px] 2xl:max-w-[1800px] mx-auto px-6 pb-6">
166
174
  {/* Brand row */}
167
- <div className="flex items-end px-6 pt-6 pb-8 relative">
175
+ <div className="flex items-end pt-6 pb-8 relative">
168
176
  <h1 className="text-lg font-bold flex items-end gap-2 absolute left-1/2 -translate-x-1/2 whitespace-nowrap">
169
177
  {/* Crab family — hover to animate together */}
170
178
  <span className="flex items-end gap-1 group cursor-default" aria-hidden="true">
@@ -217,7 +225,7 @@ export function ProxyViewer({
217
225
  </div>
218
226
 
219
227
  {/* Controls + Filters */}
220
- <div className="flex items-center gap-3 px-6 mb-4">
228
+ <div className="flex items-center gap-3 mb-4">
221
229
  <Select value={selectedSession} onValueChange={onSessionChange}>
222
230
  <SelectTrigger className="flex-1 max-w-[350px] text-xs">
223
231
  <SelectValue placeholder="All sessions" />
@@ -248,7 +256,7 @@ export function ProxyViewer({
248
256
  <button
249
257
  type="button"
250
258
  onClick={() => onViewModeChange("simple")}
251
- className={`px-2 py-1 cursor-pointer transition-colors text-xs ${
259
+ className={`h-8 px-3 cursor-pointer transition-colors text-xs ${
252
260
  viewMode === "simple"
253
261
  ? "bg-muted text-foreground"
254
262
  : "text-muted-foreground hover:bg-muted/50"
@@ -259,7 +267,7 @@ export function ProxyViewer({
259
267
  <button
260
268
  type="button"
261
269
  onClick={() => onViewModeChange("full")}
262
- className={`px-2 py-1 cursor-pointer transition-colors text-xs ${
270
+ className={`h-8 px-3 cursor-pointer transition-colors text-xs ${
263
271
  viewMode === "full"
264
272
  ? "bg-muted text-foreground"
265
273
  : "text-muted-foreground hover:bg-muted/50"
@@ -272,7 +280,7 @@ export function ProxyViewer({
272
280
  <span className="text-muted-foreground text-xs font-mono">
273
281
  {logs.length} request{logs.length !== 1 ? "s" : ""}
274
282
  {totalIn > 0 || totalOut > 0
275
- ? ` · ${totalIn.toLocaleString()} in / ${totalOut.toLocaleString()} out`
283
+ ? ` · ${formatTokens(totalIn)} in / ${formatTokens(totalOut)} out`
276
284
  : ""}
277
285
  </span>
278
286
  {logs.length > 0 && (
@@ -282,14 +290,14 @@ export function ProxyViewer({
282
290
  void handleExport();
283
291
  }}
284
292
  disabled={exporting}
285
- className="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"
293
+ 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"
286
294
  title="Export all logs as JSON ZIP"
287
295
  >
288
296
  {exporting ? (
289
297
  <span>Exporting...</span>
290
298
  ) : (
291
299
  <>
292
- <Download className="size-3" />
300
+ <Download className="size-3.5" />
293
301
  <span>Export</span>
294
302
  </>
295
303
  )}
@@ -298,7 +306,7 @@ export function ProxyViewer({
298
306
  <button
299
307
  type="button"
300
308
  onClick={onClearAll}
301
- className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
309
+ className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-md hover:bg-muted"
302
310
  title="Clear all logs"
303
311
  >
304
312
  Clear
@@ -306,7 +314,7 @@ export function ProxyViewer({
306
314
  </div>
307
315
 
308
316
  {/* Log list */}
309
- <div className="flex-1 min-h-0 px-6 pb-6">
317
+ <div>
310
318
  {logs.length === 0 ? (
311
319
  <div className="text-center text-muted-foreground py-16 space-y-4">
312
320
  <p className="text-sm">No requests captured yet.</p>
@@ -314,27 +322,36 @@ export function ProxyViewer({
314
322
  <CopyableCommand command="LLM_BASE_URL=http://localhost:25947/proxy <your-tool>" />
315
323
  </div>
316
324
  ) : (
317
- <div className="overflow-y-auto h-full flex flex-col gap-2">
318
- {groups.map((group) => (
319
- <ConversationGroup
320
- key={group.id}
321
- group={group}
322
- viewMode={viewMode}
323
- strip={strip}
324
- cacheTrends={cacheTrends}
325
- onCompareWithPrevious={handleCompareWithPrevious}
326
- comparisonPredecessors={comparisonPredecessors}
327
- onClearGroup={onClearGroup}
328
- standalone={groups.length === 1}
329
- />
330
- ))}
325
+ <div
326
+ ref={logListWrapperRef}
327
+ tabIndex={0}
328
+ className="flex flex-col gap-2 focus:outline-none"
329
+ >
330
+ <div ref={logListRef}>
331
+ {groups.map((group) => (
332
+ <ConversationGroup
333
+ key={group.id}
334
+ group={group}
335
+ viewMode={viewMode}
336
+ strip={strip}
337
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
338
+ cacheTrends={cacheTrends}
339
+ onCompareWithPrevious={handleCompareWithPrevious}
340
+ comparisonPredecessors={comparisonPredecessors}
341
+ onClearGroup={onClearGroup}
342
+ standalone={groups.length === 1}
343
+ />
344
+ ))}
345
+ </div>
331
346
  </div>
332
347
  )}
333
348
  </div>
334
349
 
335
350
  {/* Compare drawer — sibling of the log list, not a route change. */}
336
351
  {comparePair !== null && (
337
- <CompareDrawer left={comparePair[0]} right={comparePair[1]} onClose={closeCompare} />
352
+ <Suspense fallback={null}>
353
+ <LazyCompareDrawer left={comparePair[0]} right={comparePair[1]} onClose={closeCompare} />
354
+ </Suspense>
338
355
  )}
339
356
  </div>
340
357
  );
@@ -67,8 +67,6 @@ export function ProxyViewerContainer(): JSX.Element {
67
67
  // connection never re-opens on filter change — we always carry the full
68
68
  // set and derive the displayed view with `useMemo` below.
69
69
  const [allLogs, setAllLogs] = useState<CapturedLog[]>([]);
70
- const [sessions, setSessions] = useState<string[]>([]);
71
- const [models, setModels] = useState<string[]>([]);
72
70
  const [selectedSession, setSelectedSession] = useState("__all__");
73
71
  const [selectedModel, setSelectedModel] = useState("__all__");
74
72
  const [viewMode, setViewMode] = useState<"simple" | "full">("simple");
@@ -78,7 +76,6 @@ export function ProxyViewerContainer(): JSX.Element {
78
76
 
79
77
  // O(1) log lookup by id
80
78
  const logIndexRef = useRef<Map<number, number>>(new Map());
81
- const logsRef = useRef<CapturedLog[]>([]);
82
79
 
83
80
  // Debounce buffer for SSE updates
84
81
  const pendingUpdatesRef = useRef<CapturedLog[]>([]);
@@ -90,6 +87,8 @@ export function ProxyViewerContainer(): JSX.Element {
90
87
  () => filterLogs(allLogs, selectedSession, selectedModel),
91
88
  [allLogs, selectedSession, selectedModel],
92
89
  );
90
+ const sessions = useMemo(() => extractSessions(allLogs), [allLogs]);
91
+ const models = useMemo(() => extractModels(allLogs), [allLogs]);
93
92
 
94
93
  const flushUpdates = useCallback(() => {
95
94
  flushTimerRef.current = null;
@@ -99,8 +98,6 @@ export function ProxyViewerContainer(): JSX.Element {
99
98
 
100
99
  setAllLogs((prev) => {
101
100
  let next = prev;
102
- let sessionsChanged = false;
103
- let modelsChanged = false;
104
101
  for (const log of updates) {
105
102
  const idx = logIndexRef.current.get(log.id);
106
103
  if (idx !== undefined) {
@@ -110,19 +107,10 @@ export function ProxyViewerContainer(): JSX.Element {
110
107
  logIndexRef.current.set(log.id, next.length);
111
108
  next = [...next, log];
112
109
  }
113
- if (log.sessionId !== null && log.sessionId !== "" && !sessions.includes(log.sessionId)) {
114
- sessionsChanged = true;
115
- }
116
- if (log.model !== null && log.model !== "" && !models.includes(log.model)) {
117
- modelsChanged = true;
118
- }
119
110
  }
120
- logsRef.current = next;
121
- if (sessionsChanged) setSessions((s) => extractSessions(next));
122
- if (modelsChanged) setModels((m) => extractModels(next));
123
111
  return next;
124
112
  });
125
- }, [sessions, models]);
113
+ }, []);
126
114
 
127
115
  const scheduleUpdate = useCallback(
128
116
  (log: CapturedLog) => {
@@ -173,10 +161,7 @@ export function ProxyViewerContainer(): JSX.Element {
173
161
  if (log !== undefined) idx.set(log.id, i);
174
162
  }
175
163
  logIndexRef.current = idx;
176
- logsRef.current = update.logs;
177
164
  setAllLogs(update.logs);
178
- setSessions(extractSessions(update.logs));
179
- setModels(extractModels(update.logs));
180
165
  setError(null);
181
166
  } else if (update.type === "update") {
182
167
  scheduleUpdate(update.log);
@@ -223,10 +208,7 @@ export function ProxyViewerContainer(): JSX.Element {
223
208
  return;
224
209
  }
225
210
  logIndexRef.current.clear();
226
- logsRef.current = [];
227
211
  setAllLogs([]);
228
- setSessions([]);
229
- setModels([]);
230
212
  setError(null);
231
213
  } catch (err) {
232
214
  setError(err instanceof Error ? err.message : "Unknown error clearing logs");
@@ -257,9 +239,6 @@ export function ProxyViewerContainer(): JSX.Element {
257
239
  if (log !== undefined) idx.set(log.id, i);
258
240
  }
259
241
  logIndexRef.current = idx;
260
- logsRef.current = remaining;
261
- setSessions(extractSessions(remaining));
262
- setModels(extractModels(remaining));
263
242
  return remaining;
264
243
  });
265
244
  setError(null);
@@ -271,7 +250,7 @@ export function ProxyViewerContainer(): JSX.Element {
271
250
 
272
251
  // Read the strip config once at the container so the virtualized list does
273
252
  // not need N independent SWR subscriptions per row.
274
- const { strip } = useStripConfig();
253
+ const { strip, slowResponseThresholdSeconds } = useStripConfig();
275
254
 
276
255
  return (
277
256
  <>
@@ -294,6 +273,7 @@ export function ProxyViewerContainer(): JSX.Element {
294
273
  viewMode={viewMode}
295
274
  onViewModeChange={setViewMode}
296
275
  strip={strip}
276
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
297
277
  />
298
278
  </>
299
279
  );
@@ -6,6 +6,7 @@ import { Button } from "../ui/button";
6
6
  import { ProvidersPanel } from "./ProvidersPanel";
7
7
  import { useProviders } from "../../lib/useProviders";
8
8
  import { useStripConfig } from "../../lib/useStripConfig";
9
+ import { MAX_SLOW_RESPONSE_THRESHOLD_SECONDS } from "../../lib/runtimeConfig";
9
10
 
10
11
  export function SettingsDialog(): JSX.Element {
11
12
  const [open, setOpen] = useState(false);
@@ -96,7 +97,13 @@ export function SettingsDialog(): JSX.Element {
96
97
  }
97
98
 
98
99
  function ProxySettingsTab(): JSX.Element {
99
- const { strip, isLoading, setStrip } = useStripConfig();
100
+ const {
101
+ strip,
102
+ slowResponseThresholdSeconds,
103
+ isLoading,
104
+ setStrip,
105
+ setSlowResponseThresholdSeconds,
106
+ } = useStripConfig();
100
107
  const [error, setError] = useState<string | null>(null);
101
108
  const [pending, setPending] = useState(false);
102
109
 
@@ -115,6 +122,21 @@ function ProxySettingsTab(): JSX.Element {
115
122
  [setStrip],
116
123
  );
117
124
 
125
+ const handleThresholdChange = useCallback(
126
+ async (next: number) => {
127
+ setError(null);
128
+ setPending(true);
129
+ try {
130
+ await setSlowResponseThresholdSeconds(next);
131
+ } catch (err) {
132
+ setError(err instanceof Error ? err.message : String(err));
133
+ } finally {
134
+ setPending(false);
135
+ }
136
+ },
137
+ [setSlowResponseThresholdSeconds],
138
+ );
139
+
118
140
  return (
119
141
  <div className="space-y-4">
120
142
  <div className="space-y-1">
@@ -145,6 +167,35 @@ function ProxySettingsTab(): JSX.Element {
145
167
  </span>
146
168
  </label>
147
169
 
170
+ <div className="space-y-1">
171
+ <label htmlFor="slow-response-threshold" className="text-sm font-semibold">
172
+ Slow response threshold
173
+ </label>
174
+ <p className="text-xs text-muted-foreground">
175
+ Logs whose elapsed time exceeds this many seconds show a warning icon next to the
176
+ duration. Set to <code>0</code> to disable the indicator.
177
+ </p>
178
+ <div className="flex items-center gap-2">
179
+ <input
180
+ id="slow-response-threshold"
181
+ type="number"
182
+ min={0}
183
+ max={MAX_SLOW_RESPONSE_THRESHOLD_SECONDS}
184
+ step={1}
185
+ value={slowResponseThresholdSeconds}
186
+ disabled={isLoading || pending}
187
+ onChange={(e) => {
188
+ const next = Number(e.currentTarget.value);
189
+ if (!Number.isInteger(next)) return;
190
+ if (next < 0 || next > MAX_SLOW_RESPONSE_THRESHOLD_SECONDS) return;
191
+ void handleThresholdChange(next);
192
+ }}
193
+ className="h-8 w-24 rounded-md border border-input bg-transparent px-2 text-sm font-mono disabled:cursor-not-allowed disabled:opacity-50"
194
+ />
195
+ <span className="text-xs text-muted-foreground">seconds</span>
196
+ </div>
197
+ </div>
198
+
148
199
  {error !== null && <p className="text-xs text-destructive">Failed to save: {error}</p>}
149
200
  </div>
150
201
  );
@@ -16,6 +16,8 @@ export type ConversationGroupProps = {
16
16
  viewMode?: "simple" | "full";
17
17
  /** Live strip-Claude-Code-billing-header flag from the viewer container. */
18
18
  strip: boolean;
19
+ /** Slow-response threshold in seconds. `0` disables the warning indicator. */
20
+ slowResponseThresholdSeconds: number;
19
21
  /**
20
22
  * Pre-computed per-log cache token trend map (keyed by `log.id`) shared
21
23
  * across the whole viewer. Each `LogEntry` looks up its own entry.
@@ -47,6 +49,7 @@ export const ConversationGroup = memo(function ({
47
49
  group,
48
50
  viewMode = "simple",
49
51
  strip,
52
+ slowResponseThresholdSeconds,
50
53
  cacheTrends,
51
54
  onCompareWithPrevious,
52
55
  comparisonPredecessors,
@@ -90,13 +93,14 @@ export const ConversationGroup = memo(function ({
90
93
  )}
91
94
 
92
95
  {shouldRenderConversationContent(standalone, expanded) && (
93
- <div className="max-h-[70vh] overflow-y-auto">
96
+ <div>
94
97
  {turnGroups.map((tg) => (
95
98
  <TurnGroup
96
99
  key={tg.turnIndex}
97
100
  entries={tg.entries}
98
101
  viewMode={viewMode}
99
102
  strip={strip}
103
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
100
104
  cacheTrends={cacheTrends}
101
105
  onCompareWithPrevious={onCompareWithPrevious}
102
106
  comparisonPredecessors={comparisonPredecessors}
@@ -78,11 +78,14 @@ export function ConversationHeader({
78
78
  <div
79
79
  role="button"
80
80
  tabIndex={0}
81
+ data-nav-id={`conv-${conversationId}`}
82
+ data-nav-action={expanded ? "collapse" : "expand"}
81
83
  className={cn(
82
84
  "flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors",
83
85
  "hover:bg-muted/50",
84
86
  "select-none",
85
87
  "border border-border rounded-lg mb-2 bg-background sticky top-0 z-10",
88
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none",
86
89
  )}
87
90
  onClick={onToggle}
88
91
  onKeyDown={(e) => {
@@ -174,7 +177,7 @@ export function ConversationHeader({
174
177
  onClick={handleClearClick}
175
178
  aria-label={`Clear group (${totalCalls} request${totalCalls !== 1 ? "s" : ""})`}
176
179
  title="Clear this group"
177
- className="text-muted-foreground hover:text-foreground transition-colors shrink-0 inline-flex items-center justify-center size-6 rounded hover:bg-muted cursor-pointer"
180
+ className="text-muted-foreground hover:text-foreground transition-colors shrink-0 inline-flex items-center justify-center size-8 rounded hover:bg-muted cursor-pointer"
178
181
  >
179
182
  <Trash2 className="size-3.5" />
180
183
  </button>