@tonyclaw/llm-inspector 1.16.5 → 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 (37) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-C1w4KUGZ.js → CompareDrawer-C4fie5g5.js} +1 -1
  3. package/.output/public/assets/{ReplayDialog-DR2Sgq_g.js → ReplayDialog-Dme5uOR9.js} +1 -1
  4. package/.output/public/assets/{RequestAnatomy-DAre35kj.js → RequestAnatomy-ChBLDNFH.js} +1 -1
  5. package/.output/public/assets/{ResponseView-ackes7_g.js → ResponseView-wGeqBzVU.js} +1 -1
  6. package/.output/public/assets/{StreamingChunkSequence-GrXwIGKA.js → StreamingChunkSequence-zeJZQLqT.js} +1 -1
  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-C_QUhGeu.js → json-viewer-BV-WUszW.js} +1 -1
  10. package/.output/public/assets/{main-CDMdNDY_.js → main-DRu10KNQ.js} +1 -1
  11. package/.output/server/_libs/lucide-react.mjs +6 -6
  12. package/.output/server/_ssr/{CompareDrawer-ftkJxyk6.mjs → CompareDrawer-C4-CQL5w.mjs} +4 -4
  13. package/.output/server/_ssr/{ReplayDialog-DcmE3lj5.mjs → ReplayDialog-BTb1Bam8.mjs} +4 -4
  14. package/.output/server/_ssr/{RequestAnatomy-rK_LNMdG.mjs → RequestAnatomy-CZFV1IvL.mjs} +2 -2
  15. package/.output/server/_ssr/{ResponseView-CbQ4n-aJ.mjs → ResponseView-CTZekh65.mjs} +4 -4
  16. package/.output/server/_ssr/{StreamingChunkSequence-84FZkIzv.mjs → StreamingChunkSequence-C38Ynabd.mjs} +3 -3
  17. package/.output/server/_ssr/{index-CDjLoMsk.mjs → index-Cnu-QzAy.mjs} +159 -32
  18. package/.output/server/_ssr/index.mjs +2 -2
  19. package/.output/server/_ssr/{json-viewer-B-qpM5xC.mjs → json-viewer-DROqpjS9.mjs} +2 -2
  20. package/.output/server/_ssr/{router-BrdjOUEW.mjs → router-pP4GCTQx.mjs} +20 -6
  21. package/.output/server/{_tanstack-start-manifest_v-DmOZEcJ3.mjs → _tanstack-start-manifest_v-CphS4rZd.mjs} +1 -1
  22. package/.output/server/index.mjs +58 -58
  23. package/package.json +1 -1
  24. package/src/components/ProxyViewer.tsx +6 -1
  25. package/src/components/ProxyViewerContainer.tsx +2 -1
  26. package/src/components/providers/SettingsDialog.tsx +52 -1
  27. package/src/components/proxy-viewer/ConversationGroup.tsx +4 -0
  28. package/src/components/proxy-viewer/LogEntry.tsx +4 -0
  29. package/src/components/proxy-viewer/LogEntryHeader.tsx +47 -4
  30. package/src/components/proxy-viewer/TurnGroup.tsx +36 -7
  31. package/src/lib/runtimeConfig.ts +9 -0
  32. package/src/lib/useOnboarding.ts +7 -1
  33. package/src/lib/useStripConfig.ts +33 -2
  34. package/src/proxy/config.ts +17 -7
  35. package/src/routes/api/config.ts +7 -0
  36. package/.output/public/assets/index-BGzHFOEX.css +0 -1
  37. package/.output/public/assets/index-DX88k9br.js +0 -101
@@ -38,91 +38,91 @@ const assets = {
38
38
  "/assets/alibaba-TTwafVwX.svg": {
39
39
  "type": "image/svg+xml",
40
40
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
41
- "mtime": "2026-06-15T12:23:59.073Z",
41
+ "mtime": "2026-06-16T13:43:26.730Z",
42
42
  "size": 5915,
43
43
  "path": "../public/assets/alibaba-TTwafVwX.svg"
44
44
  },
45
- "/assets/minimax-BPMzvuL-.jpeg": {
46
- "type": "image/jpeg",
47
- "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
48
- "mtime": "2026-06-15T12:23:59.073Z",
49
- "size": 6918,
50
- "path": "../public/assets/minimax-BPMzvuL-.jpeg"
51
- },
52
- "/assets/CompareDrawer-C1w4KUGZ.js": {
45
+ "/assets/CompareDrawer-C4fie5g5.js": {
53
46
  "type": "text/javascript; charset=utf-8",
54
- "etag": '"4a10-U7vRVdbG90QCagHyB/PsN2Edv9Y"',
55
- "mtime": "2026-06-15T12:23:59.073Z",
47
+ "etag": '"4a10-3djWuOwKSKXlQPoy4NHzqd2tggE"',
48
+ "mtime": "2026-06-16T13:43:26.730Z",
56
49
  "size": 18960,
57
- "path": "../public/assets/CompareDrawer-C1w4KUGZ.js"
58
- },
59
- "/assets/json-viewer-C_QUhGeu.js": {
60
- "type": "text/javascript; charset=utf-8",
61
- "etag": '"1e60c-NxhepLRPlTKqcPUc59jhJtOyGpg"',
62
- "mtime": "2026-06-15T12:23:59.074Z",
63
- "size": 124428,
64
- "path": "../public/assets/json-viewer-C_QUhGeu.js"
50
+ "path": "../public/assets/CompareDrawer-C4fie5g5.js"
65
51
  },
66
- "/assets/index-BGzHFOEX.css": {
52
+ "/assets/index-DoGvsnbA.css": {
67
53
  "type": "text/css; charset=utf-8",
68
- "etag": '"16d02-Bx7KW/5wmN1RfMrbWqhaqF7lGHk"',
69
- "mtime": "2026-06-15T12:23:59.073Z",
70
- "size": 93442,
71
- "path": "../public/assets/index-BGzHFOEX.css"
54
+ "etag": '"16d26-qw65JIM4oxztXa/jhWYD9PPuvfA"',
55
+ "mtime": "2026-06-16T13:43:26.730Z",
56
+ "size": 93478,
57
+ "path": "../public/assets/index-DoGvsnbA.css"
72
58
  },
73
- "/assets/ReplayDialog-DR2Sgq_g.js": {
59
+ "/assets/index-DpbutOvo.js": {
74
60
  "type": "text/javascript; charset=utf-8",
75
- "etag": '"11b1-xYgR1cdDnesImArvyIDX3s2xRMU"',
76
- "mtime": "2026-06-15T12:23:59.073Z",
77
- "size": 4529,
78
- "path": "../public/assets/ReplayDialog-DR2Sgq_g.js"
61
+ "etag": '"744e2-P9RhN2wY9pxyu6b/wpeHlefe59k"',
62
+ "mtime": "2026-06-16T13:43:26.731Z",
63
+ "size": 476386,
64
+ "path": "../public/assets/index-DpbutOvo.js"
79
65
  },
80
- "/assets/ResponseView-ackes7_g.js": {
66
+ "/assets/ReplayDialog-Dme5uOR9.js": {
81
67
  "type": "text/javascript; charset=utf-8",
82
- "etag": '"6e82-UP1WD/TqChwiuI7DV8LDxr8mcoI"',
83
- "mtime": "2026-06-15T12:23:59.074Z",
84
- "size": 28290,
85
- "path": "../public/assets/ResponseView-ackes7_g.js"
68
+ "etag": '"11b1-SKRfqUa9SY8yNi8IxWzCC6VvjQI"',
69
+ "mtime": "2026-06-16T13:43:26.730Z",
70
+ "size": 4529,
71
+ "path": "../public/assets/ReplayDialog-Dme5uOR9.js"
86
72
  },
87
- "/assets/zhipuai-BPNAnxo-.svg": {
88
- "type": "image/svg+xml",
89
- "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
90
- "mtime": "2026-06-15T12:23:59.073Z",
91
- "size": 11256,
92
- "path": "../public/assets/zhipuai-BPNAnxo-.svg"
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"
93
79
  },
94
- "/assets/RequestAnatomy-DAre35kj.js": {
80
+ "/assets/RequestAnatomy-ChBLDNFH.js": {
95
81
  "type": "text/javascript; charset=utf-8",
96
- "etag": '"141b-+jsq3YAE5FysxZARhUPCzQUpXJE"',
97
- "mtime": "2026-06-15T12:23:59.074Z",
82
+ "etag": '"141b-nxiiCAE0vs7SLaqcUISSgXq1IGo"',
83
+ "mtime": "2026-06-16T13:43:26.730Z",
98
84
  "size": 5147,
99
- "path": "../public/assets/RequestAnatomy-DAre35kj.js"
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"
100
93
  },
101
- "/assets/main-CDMdNDY_.js": {
94
+ "/assets/main-DRu10KNQ.js": {
102
95
  "type": "text/javascript; charset=utf-8",
103
- "etag": '"505ae-1nSD340FKDRNgunwXT0ErZchYkU"',
104
- "mtime": "2026-06-15T12:23:59.074Z",
96
+ "etag": '"505ae-BP/aj8/2FZWooG/jXqGZEmVfl3E"',
97
+ "mtime": "2026-06-16T13:43:26.730Z",
105
98
  "size": 329134,
106
- "path": "../public/assets/main-CDMdNDY_.js"
99
+ "path": "../public/assets/main-DRu10KNQ.js"
107
100
  },
108
- "/assets/StreamingChunkSequence-GrXwIGKA.js": {
101
+ "/assets/StreamingChunkSequence-zeJZQLqT.js": {
109
102
  "type": "text/javascript; charset=utf-8",
110
- "etag": '"d72-eczKwdvLd8GmG1QNwNn/d+VEOq0"',
111
- "mtime": "2026-06-15T12:23:59.074Z",
103
+ "etag": '"d72-nuJyJopptXOHt0oPHw//D9VSW+k"',
104
+ "mtime": "2026-06-16T13:43:26.731Z",
112
105
  "size": 3442,
113
- "path": "../public/assets/StreamingChunkSequence-GrXwIGKA.js"
106
+ "path": "../public/assets/StreamingChunkSequence-zeJZQLqT.js"
114
107
  },
115
- "/assets/index-DX88k9br.js": {
108
+ "/assets/ResponseView-wGeqBzVU.js": {
116
109
  "type": "text/javascript; charset=utf-8",
117
- "etag": '"738e0-qSt/VXgdC2y0xa/XDlbeCzFrP6k"',
118
- "mtime": "2026-06-15T12:23:59.073Z",
119
- "size": 473312,
120
- "path": "../public/assets/index-DX88k9br.js"
110
+ "etag": '"6e82-ZzZo1WT+DlE16b9M9AMC/410RcI"',
111
+ "mtime": "2026-06-16T13:43:26.731Z",
112
+ "size": 28290,
113
+ "path": "../public/assets/ResponseView-wGeqBzVU.js"
114
+ },
115
+ "/assets/zhipuai-BPNAnxo-.svg": {
116
+ "type": "image/svg+xml",
117
+ "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
118
+ "mtime": "2026-06-16T13:43:26.730Z",
119
+ "size": 11256,
120
+ "path": "../public/assets/zhipuai-BPNAnxo-.svg"
121
121
  },
122
122
  "/assets/qwen-CONDcHqt.png": {
123
123
  "type": "image/png",
124
124
  "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
125
- "mtime": "2026-06-15T12:23:59.073Z",
125
+ "mtime": "2026-06-16T13:43:26.730Z",
126
126
  "size": 357059,
127
127
  "path": "../public/assets/qwen-CONDcHqt.png"
128
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/llm-inspector",
3
- "version": "1.16.5",
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",
@@ -3,6 +3,7 @@ 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
 
@@ -98,6 +99,8 @@ export type ProxyViewerProps = {
98
99
  onViewModeChange: (mode: "simple" | "full") => void;
99
100
  /** Live strip-Claude-Code-billing-header flag, sourced once at the container. */
100
101
  strip: boolean;
102
+ /** Slow-response threshold in seconds. `0` disables the warning indicator. */
103
+ slowResponseThresholdSeconds: number;
101
104
  };
102
105
 
103
106
  export function ProxyViewer({
@@ -113,6 +116,7 @@ export function ProxyViewer({
113
116
  viewMode,
114
117
  onViewModeChange,
115
118
  strip,
119
+ slowResponseThresholdSeconds,
116
120
  }: ProxyViewerProps): JSX.Element {
117
121
  const { totalIn, totalOut } = useMemo(() => computeTokenSummary(logs), [logs]);
118
122
  const [exporting, setExporting] = useState(false);
@@ -276,7 +280,7 @@ export function ProxyViewer({
276
280
  <span className="text-muted-foreground text-xs font-mono">
277
281
  {logs.length} request{logs.length !== 1 ? "s" : ""}
278
282
  {totalIn > 0 || totalOut > 0
279
- ? ` · ${totalIn.toLocaleString()} in / ${totalOut.toLocaleString()} out`
283
+ ? ` · ${formatTokens(totalIn)} in / ${formatTokens(totalOut)} out`
280
284
  : ""}
281
285
  </span>
282
286
  {logs.length > 0 && (
@@ -330,6 +334,7 @@ export function ProxyViewer({
330
334
  group={group}
331
335
  viewMode={viewMode}
332
336
  strip={strip}
337
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
333
338
  cacheTrends={cacheTrends}
334
339
  onCompareWithPrevious={handleCompareWithPrevious}
335
340
  comparisonPredecessors={comparisonPredecessors}
@@ -250,7 +250,7 @@ export function ProxyViewerContainer(): JSX.Element {
250
250
 
251
251
  // Read the strip config once at the container so the virtualized list does
252
252
  // not need N independent SWR subscriptions per row.
253
- const { strip } = useStripConfig();
253
+ const { strip, slowResponseThresholdSeconds } = useStripConfig();
254
254
 
255
255
  return (
256
256
  <>
@@ -273,6 +273,7 @@ export function ProxyViewerContainer(): JSX.Element {
273
273
  viewMode={viewMode}
274
274
  onViewModeChange={setViewMode}
275
275
  strip={strip}
276
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
276
277
  />
277
278
  </>
278
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,
@@ -97,6 +100,7 @@ export const ConversationGroup = memo(function ({
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}
@@ -45,6 +45,8 @@ export type LogEntryProps = {
45
45
  * cost).
46
46
  */
47
47
  strip: boolean;
48
+ /** Slow-response threshold in seconds. `0` disables the warning indicator. */
49
+ slowResponseThresholdSeconds: number;
48
50
  /**
49
51
  * Per-log cache token trend, looked up in the viewer-level trend map.
50
52
  * `null` (or absent) means the header should render with no arrows.
@@ -180,6 +182,7 @@ export const LogEntry = memo(function ({
180
182
  log,
181
183
  viewMode = "simple",
182
184
  strip,
185
+ slowResponseThresholdSeconds,
183
186
  cacheTrend = null,
184
187
  onCompareWithPrevious,
185
188
  }: LogEntryProps): JSX.Element {
@@ -242,6 +245,7 @@ export const LogEntry = memo(function ({
242
245
  onToggle={() => setExpanded(!expanded)}
243
246
  responseToolNames={responseAnalysis.toolNames}
244
247
  cacheTrend={cacheTrend}
248
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
245
249
  onReplay={
246
250
  onCompareWithPrevious === undefined
247
251
  ? undefined
@@ -41,6 +41,14 @@ function formatElapsed(ms: number): string {
41
41
  return `${(ms / 1000).toFixed(1)}s`;
42
42
  }
43
43
 
44
+ function formatTimestamp(iso: string): string {
45
+ const d = new Date(iso);
46
+ const hh = String(d.getHours()).padStart(2, "0");
47
+ const mm = String(d.getMinutes()).padStart(2, "0");
48
+ const ss = String(d.getSeconds()).padStart(2, "0");
49
+ return `${hh}:${mm}:${ss}`;
50
+ }
51
+
44
52
  /**
45
53
  * Inline trend indicator: small arrow (green up / red down) plus the absolute
46
54
  * delta in compact form. Returns `null` when there is no trend to display.
@@ -91,6 +99,8 @@ export type LogEntryHeaderProps = {
91
99
  isExpanded: boolean;
92
100
  isPending: boolean;
93
101
  } | null;
102
+ /** Slow-response threshold in seconds. `0` disables the warning indicator. */
103
+ slowResponseThresholdSeconds?: number;
94
104
  };
95
105
 
96
106
  export const LogEntryHeader = memo(function ({
@@ -106,8 +116,13 @@ export const LogEntryHeader = memo(function ({
106
116
  requestCopied = false,
107
117
  onToggleRequestExpansion,
108
118
  requestExpansionState = null,
119
+ slowResponseThresholdSeconds = 0,
109
120
  }: LogEntryHeaderProps): JSX.Element {
110
121
  const statusCategory = getStatusCategory(log.responseStatus);
122
+ const isSlowResponse =
123
+ log.elapsedMs !== null &&
124
+ slowResponseThresholdSeconds > 0 &&
125
+ log.elapsedMs > slowResponseThresholdSeconds * 1000;
111
126
 
112
127
  const hasTokens = log.inputTokens !== null || log.outputTokens !== null;
113
128
  const toolNamesJoined = useMemo(() => responseToolNames?.join(", ") ?? null, [responseToolNames]);
@@ -138,6 +153,17 @@ export const LogEntryHeader = memo(function ({
138
153
  #{log.id}
139
154
  </span>
140
155
 
156
+ {/* Timestamp */}
157
+ <Tooltip>
158
+ <TooltipTrigger asChild>
159
+ <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
160
+ <Clock className="size-3" />
161
+ <span className="font-mono tabular-nums">{formatTimestamp(log.timestamp)}</span>
162
+ </span>
163
+ </TooltipTrigger>
164
+ <TooltipContent>{log.timestamp}</TooltipContent>
165
+ </Tooltip>
166
+
141
167
  {/* Model — logo icon only, model name in tooltip */}
142
168
  {log.model !== null && (
143
169
  <Tooltip>
@@ -174,10 +200,27 @@ export const LogEntryHeader = memo(function ({
174
200
 
175
201
  {/* Elapsed time */}
176
202
  {log.elapsedMs !== null && (
177
- <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
178
- <Clock className="size-3" />
179
- <span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
180
- </span>
203
+ <Tooltip>
204
+ <TooltipTrigger asChild>
205
+ <span
206
+ className={cn(
207
+ "flex items-center gap-1 text-xs shrink-0",
208
+ isSlowResponse ? "text-amber-400" : "text-muted-foreground",
209
+ )}
210
+ >
211
+ <Clock className="size-3" />
212
+ <span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
213
+ {isSlowResponse && <AlertTriangle className="size-3" aria-label="Slow response" />}
214
+ </span>
215
+ </TooltipTrigger>
216
+ <TooltipContent>
217
+ {isSlowResponse
218
+ ? `Slow response: ${formatElapsed(log.elapsedMs)} exceeds ${formatElapsed(
219
+ slowResponseThresholdSeconds * 1000,
220
+ )}`
221
+ : "Elapsed response time"}
222
+ </TooltipContent>
223
+ </Tooltip>
181
224
  )}
182
225
 
183
226
  {/* Token counts */}
@@ -1,10 +1,11 @@
1
1
  import { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { ChevronRight, Clock, Zap } from "lucide-react";
2
+ import { AlertTriangle, ChevronRight, Clock, Zap } from "lucide-react";
3
3
  import { isTurnBoundary } from "../../lib/stopReason";
4
4
  import { cn, formatTokens } from "../../lib/utils";
5
5
  import type { CapturedLog } from "../../proxy/schemas";
6
6
  import { getCrabVariant } from "../ui/crab-variants";
7
7
  import { ProviderLogo, detectProvider, type Provider } from "../providers/ProviderLogo";
8
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
8
9
  import type { CacheTrendEntry } from "./cacheTrend";
9
10
  import { LogEntry } from "./LogEntry";
10
11
  import { ThreadConnector } from "./ThreadConnector";
@@ -19,6 +20,7 @@ type TurnGroupProps = {
19
20
  entries: TurnEntry[];
20
21
  viewMode: "simple" | "full";
21
22
  strip: boolean;
23
+ slowResponseThresholdSeconds: number;
22
24
  cacheTrends?: Map<number, CacheTrendEntry>;
23
25
  onCompareWithPrevious: (log: CapturedLog) => void;
24
26
  comparisonPredecessors: Map<number, CapturedLog>;
@@ -29,6 +31,7 @@ export const TurnGroup = memo(function TurnGroup({
29
31
  entries,
30
32
  viewMode,
31
33
  strip,
34
+ slowResponseThresholdSeconds,
32
35
  cacheTrends,
33
36
  onCompareWithPrevious,
34
37
  comparisonPredecessors,
@@ -102,6 +105,10 @@ export const TurnGroup = memo(function TurnGroup({
102
105
  const EndCrab = useMemo(() => getCrabVariant(entries[lastIdx]?.log.id ?? 0), [entries, lastIdx]);
103
106
 
104
107
  const bgClass = turnIndex % 2 === 0 ? "bg-muted/10" : "bg-muted/25";
108
+ const aggregateIsSlow =
109
+ aggregate.hasElapsed &&
110
+ slowResponseThresholdSeconds > 0 &&
111
+ aggregate.totalElapsed > slowResponseThresholdSeconds * 1000;
105
112
 
106
113
  // ResizeObserver → re-render connectors when any LogEntry height changes
107
114
  const [layoutVersion, setLayoutVersion] = useState(0);
@@ -234,12 +241,33 @@ export const TurnGroup = memo(function TurnGroup({
234
241
 
235
242
  {/* Elapsed */}
236
243
  {aggregate.hasElapsed && (
237
- <span className="flex items-center gap-1 text-muted-foreground shrink-0">
238
- <Clock className="size-3" />
239
- <span className="font-mono tabular-nums">
240
- {formatElapsed(aggregate.totalElapsed)}
241
- </span>
242
- </span>
244
+ <TooltipProvider>
245
+ <Tooltip>
246
+ <TooltipTrigger asChild>
247
+ <span
248
+ className={cn(
249
+ "flex items-center gap-1 shrink-0",
250
+ aggregateIsSlow ? "text-amber-400" : "text-muted-foreground",
251
+ )}
252
+ >
253
+ <Clock className="size-3" />
254
+ <span className="font-mono tabular-nums">
255
+ {formatElapsed(aggregate.totalElapsed)}
256
+ </span>
257
+ {aggregateIsSlow && (
258
+ <AlertTriangle className="size-3" aria-label="Slow response" />
259
+ )}
260
+ </span>
261
+ </TooltipTrigger>
262
+ <TooltipContent>
263
+ {aggregateIsSlow
264
+ ? `Slow response: ${formatElapsed(
265
+ aggregate.totalElapsed,
266
+ )} exceeds ${formatElapsed(slowResponseThresholdSeconds * 1000)}`
267
+ : "Total elapsed response time"}
268
+ </TooltipContent>
269
+ </Tooltip>
270
+ </TooltipProvider>
243
271
  )}
244
272
 
245
273
  {/* Tokens */}
@@ -296,6 +324,7 @@ export const TurnGroup = memo(function TurnGroup({
296
324
  log={log}
297
325
  viewMode={viewMode}
298
326
  strip={strip}
327
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
299
328
  cacheTrend={cacheTrends?.get(log.id) ?? null}
300
329
  onCompareWithPrevious={
301
330
  comparisonPredecessors.has(log.id) ? onCompareWithPrevious : undefined
@@ -1,5 +1,8 @@
1
1
  import { z } from "zod";
2
2
 
3
+ export const DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS = 10;
4
+ export const MAX_SLOW_RESPONSE_THRESHOLD_SECONDS = 600;
5
+
3
6
  /**
4
7
  * Schema for the runtime proxy config. Shared between server
5
8
  * (src/proxy/config.ts) and client (src/lib/useStripConfig.ts) so that
@@ -11,6 +14,12 @@ import { z } from "zod";
11
14
  export const RuntimeConfigSchema = z.object({
12
15
  stripClaudeCodeBillingHeader: z.boolean(),
13
16
  hasSeenOnboarding: z.boolean().default(false),
17
+ slowResponseThresholdSeconds: z
18
+ .number()
19
+ .int()
20
+ .min(0)
21
+ .max(MAX_SLOW_RESPONSE_THRESHOLD_SECONDS)
22
+ .default(DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS),
14
23
  });
15
24
 
16
25
  export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
@@ -1,6 +1,10 @@
1
1
  import useSWR, { type SWRResponse, useSWRConfig } from "swr";
2
2
  import { fetchJson } from "./apiClient";
3
- import { RuntimeConfigSchema, type RuntimeConfig } from "./runtimeConfig";
3
+ import {
4
+ DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
5
+ RuntimeConfigSchema,
6
+ type RuntimeConfig,
7
+ } from "./runtimeConfig";
4
8
 
5
9
  export const ONBOARDING_SWR_KEY = "/api/config";
6
10
 
@@ -60,6 +64,8 @@ export function useOnboarding(): UseOnboarding {
60
64
  optimisticData: {
61
65
  stripClaudeCodeBillingHeader: response.data?.stripClaudeCodeBillingHeader ?? false,
62
66
  hasSeenOnboarding: true,
67
+ slowResponseThresholdSeconds:
68
+ response.data?.slowResponseThresholdSeconds ?? DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
63
69
  },
64
70
  rollbackOnError: true,
65
71
  revalidate: false,
@@ -1,6 +1,10 @@
1
1
  import useSWR, { type SWRResponse, useSWRConfig } from "swr";
2
2
  import { fetchJson } from "./apiClient";
3
- import { RuntimeConfigSchema, type RuntimeConfig } from "./runtimeConfig";
3
+ import {
4
+ DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
5
+ RuntimeConfigSchema,
6
+ type RuntimeConfig,
7
+ } from "./runtimeConfig";
4
8
 
5
9
  export const STRIP_CONFIG_SWR_KEY = "/api/config";
6
10
 
@@ -31,15 +35,18 @@ export async function setRuntimeConfig(patch: Partial<RuntimeConfig>): Promise<R
31
35
 
32
36
  export type UseStripConfig = {
33
37
  strip: boolean;
38
+ slowResponseThresholdSeconds: number;
34
39
  isLoading: boolean;
35
40
  isError: boolean;
36
41
  setStrip: (next: boolean) => Promise<void>;
42
+ setSlowResponseThresholdSeconds: (next: number) => Promise<void>;
37
43
  };
38
44
 
39
45
  /**
40
46
  * Hook reading the runtime config from /api/config.
41
47
  *
42
48
  * - `strip` is the live value from the server (or the optimistic value mid-PATCH).
49
+ * - `slowResponseThresholdSeconds` controls the UI's slow-response indicator.
43
50
  * - `setStrip(next)` PATCHes the server with optimistic update; on failure
44
51
  * it throws and SWR rolls back the optimistic value.
45
52
  */
@@ -55,13 +62,35 @@ export function useStripConfig(): UseStripConfig {
55
62
  const { mutate: globalMutate } = useSWRConfig();
56
63
 
57
64
  const strip = response.data?.stripClaudeCodeBillingHeader ?? false;
65
+ const slowResponseThresholdSeconds =
66
+ response.data?.slowResponseThresholdSeconds ?? DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS;
67
+
68
+ const optimisticConfig = (patch: Partial<RuntimeConfig>): RuntimeConfig => ({
69
+ stripClaudeCodeBillingHeader: response.data?.stripClaudeCodeBillingHeader ?? false,
70
+ hasSeenOnboarding: response.data?.hasSeenOnboarding ?? false,
71
+ slowResponseThresholdSeconds:
72
+ response.data?.slowResponseThresholdSeconds ?? DEFAULT_SLOW_RESPONSE_THRESHOLD_SECONDS,
73
+ ...patch,
74
+ });
58
75
 
59
76
  const setStrip = async (next: boolean): Promise<void> => {
60
77
  await globalMutate(
61
78
  STRIP_CONFIG_SWR_KEY,
62
79
  setRuntimeConfig({ stripClaudeCodeBillingHeader: next }),
63
80
  {
64
- optimisticData: { stripClaudeCodeBillingHeader: next },
81
+ optimisticData: optimisticConfig({ stripClaudeCodeBillingHeader: next }),
82
+ rollbackOnError: true,
83
+ revalidate: false,
84
+ },
85
+ );
86
+ };
87
+
88
+ const setSlowResponseThresholdSeconds = async (next: number): Promise<void> => {
89
+ await globalMutate(
90
+ STRIP_CONFIG_SWR_KEY,
91
+ setRuntimeConfig({ slowResponseThresholdSeconds: next }),
92
+ {
93
+ optimisticData: optimisticConfig({ slowResponseThresholdSeconds: next }),
65
94
  rollbackOnError: true,
66
95
  revalidate: false,
67
96
  },
@@ -70,8 +99,10 @@ export function useStripConfig(): UseStripConfig {
70
99
 
71
100
  return {
72
101
  strip,
102
+ slowResponseThresholdSeconds,
73
103
  isLoading: response.isLoading,
74
104
  isError: response.error !== undefined,
75
105
  setStrip,
106
+ setSlowResponseThresholdSeconds,
76
107
  };
77
108
  }