@tonyclaw/llm-inspector 1.14.7 → 1.14.8

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 (28) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-CdnotuLh.js +105 -0
  3. package/.output/public/assets/index-vP91146S.css +1 -0
  4. package/.output/public/assets/{main-BV7uNIIz.js → main-CJ4MreBr.js} +1 -1
  5. package/.output/server/_libs/lucide-react.mjs +87 -79
  6. package/.output/server/_libs/radix-ui__react-id.mjs +1 -1
  7. package/.output/server/_ssr/{index-BvHLASu8.mjs → index-9uTJ4xYR.mjs} +744 -581
  8. package/.output/server/_ssr/index.mjs +2 -2
  9. package/.output/server/_ssr/{router-lUOA8pi6.mjs → router-BKnjB_zi.mjs} +2 -2
  10. package/.output/server/{_tanstack-start-manifest_v-XNH7fVPN.mjs → _tanstack-start-manifest_v-IsglLVKy.mjs} +1 -1
  11. package/.output/server/index.mjs +28 -28
  12. package/package.json +1 -1
  13. package/src/components/ProxyViewer.tsx +114 -146
  14. package/src/components/providers/ProviderCard.tsx +79 -26
  15. package/src/components/providers/ProviderForm.tsx +37 -22
  16. package/src/components/providers/ProvidersPanel.tsx +79 -47
  17. package/src/components/providers/SettingsDialog.tsx +25 -15
  18. package/src/components/proxy-viewer/ConversationGroup.tsx +50 -10
  19. package/src/components/proxy-viewer/ConversationHeader.tsx +48 -2
  20. package/src/components/proxy-viewer/LogEntry.tsx +116 -45
  21. package/src/components/proxy-viewer/LogEntryHeader.tsx +89 -71
  22. package/src/components/proxy-viewer/ReplayDialog.tsx +16 -6
  23. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +24 -16
  24. package/src/components/proxy-viewer/ThreadConnector.tsx +104 -0
  25. package/src/components/proxy-viewer/index.ts +2 -1
  26. package/src/lib/stopReason.ts +57 -0
  27. package/.output/public/assets/index-Cmi8TfeU.js +0 -105
  28. package/.output/public/assets/index-DXUNTCVh.css +0 -1
@@ -198,7 +198,7 @@ function getResponse() {
198
198
  return event.res;
199
199
  }
200
200
  async function getStartManifest(matchedRoutes) {
201
- const { tsrStartManifest } = await import("../_tanstack-start-manifest_v-XNH7fVPN.mjs");
201
+ const { tsrStartManifest } = await import("../_tanstack-start-manifest_v-IsglLVKy.mjs");
202
202
  const startManifest = tsrStartManifest();
203
203
  const rootRoute = startManifest.routes[rootRouteId] = startManifest.routes[rootRouteId] || {};
204
204
  rootRoute.assets = rootRoute.assets || [];
@@ -767,7 +767,7 @@ let entriesPromise;
767
767
  let baseManifestPromise;
768
768
  let cachedFinalManifestPromise;
769
769
  async function loadEntries() {
770
- const routerEntry = await import("./router-lUOA8pi6.mjs").then((n) => n.r);
770
+ const routerEntry = await import("./router-BKnjB_zi.mjs").then((n) => n.r);
771
771
  const startEntry = await import("./start-HYkvq4Ni.mjs");
772
772
  return { startEntry, routerEntry };
773
773
  }
@@ -45,7 +45,7 @@ import "../_libs/debounce-fn.mjs";
45
45
  import "../_libs/mimic-function.mjs";
46
46
  import "../_libs/semver.mjs";
47
47
  import "../_libs/uint8array-extras.mjs";
48
- const appCss = "/assets/index-DXUNTCVh.css";
48
+ const appCss = "/assets/index-vP91146S.css";
49
49
  const Route$k = createRootRoute({
50
50
  head: () => ({
51
51
  meta: [
@@ -69,7 +69,7 @@ function RootDocument({ children }) {
69
69
  ] })
70
70
  ] });
71
71
  }
72
- const $$splitComponentImporter = () => import("./index-BvHLASu8.mjs");
72
+ const $$splitComponentImporter = () => import("./index-9uTJ4xYR.mjs");
73
73
  const Route$j = createFileRoute("/")({
74
74
  component: lazyRouteComponent($$splitComponentImporter, "component")
75
75
  });
@@ -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-BV7uNIIz.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-Cmi8TfeU.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-BV7uNIIz.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-CJ4MreBr.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-CdnotuLh.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-CJ4MreBr.js" });
2
2
  export {
3
3
  tsrStartManifest
4
4
  };
@@ -38,51 +38,51 @@ const assets = {
38
38
  "/assets/alibaba-TTwafVwX.svg": {
39
39
  "type": "image/svg+xml",
40
40
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
41
- "mtime": "2026-06-11T07:46:13.437Z",
41
+ "mtime": "2026-06-11T12:14:10.996Z",
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-11T07:46:13.439Z",
49
- "size": 6918,
50
- "path": "../public/assets/minimax-BPMzvuL-.jpeg"
45
+ "/assets/index-vP91146S.css": {
46
+ "type": "text/css; charset=utf-8",
47
+ "etag": '"14a58-Ts4ulNygZtoNVfw4qwf2tNfAXoU"',
48
+ "mtime": "2026-06-11T12:14:10.997Z",
49
+ "size": 84568,
50
+ "path": "../public/assets/index-vP91146S.css"
51
51
  },
52
52
  "/assets/zhipuai-BPNAnxo-.svg": {
53
53
  "type": "image/svg+xml",
54
54
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
55
- "mtime": "2026-06-11T07:46:13.439Z",
55
+ "mtime": "2026-06-11T12:14:10.996Z",
56
56
  "size": 11256,
57
57
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
58
58
  },
59
+ "/assets/minimax-BPMzvuL-.jpeg": {
60
+ "type": "image/jpeg",
61
+ "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
62
+ "mtime": "2026-06-11T12:14:10.996Z",
63
+ "size": 6918,
64
+ "path": "../public/assets/minimax-BPMzvuL-.jpeg"
65
+ },
66
+ "/assets/main-CJ4MreBr.js": {
67
+ "type": "text/javascript; charset=utf-8",
68
+ "etag": '"50599-xS7/i8WCDvqJMx9qZW3sZ2pNsvE"',
69
+ "mtime": "2026-06-11T12:14:10.997Z",
70
+ "size": 329113,
71
+ "path": "../public/assets/main-CJ4MreBr.js"
72
+ },
59
73
  "/assets/qwen-CONDcHqt.png": {
60
74
  "type": "image/png",
61
75
  "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
62
- "mtime": "2026-06-11T07:46:13.439Z",
76
+ "mtime": "2026-06-11T12:14:10.996Z",
63
77
  "size": 357059,
64
78
  "path": "../public/assets/qwen-CONDcHqt.png"
65
79
  },
66
- "/assets/main-BV7uNIIz.js": {
67
- "type": "text/javascript; charset=utf-8",
68
- "etag": '"50599-Hw6XWHNqYOzGgMQUjQUlo6V5ZGQ"',
69
- "mtime": "2026-06-11T07:46:13.439Z",
70
- "size": 329113,
71
- "path": "../public/assets/main-BV7uNIIz.js"
72
- },
73
- "/assets/index-DXUNTCVh.css": {
74
- "type": "text/css; charset=utf-8",
75
- "etag": '"145d7-BpJnON+Y0T31X0nkA2icHh97eLY"',
76
- "mtime": "2026-06-11T07:46:13.439Z",
77
- "size": 83415,
78
- "path": "../public/assets/index-DXUNTCVh.css"
79
- },
80
- "/assets/index-Cmi8TfeU.js": {
80
+ "/assets/index-CdnotuLh.js": {
81
81
  "type": "text/javascript; charset=utf-8",
82
- "etag": '"94069-2UhstwPg+t7SdSjBWTNqX+kv980"',
83
- "mtime": "2026-06-11T07:46:13.440Z",
84
- "size": 606313,
85
- "path": "../public/assets/index-Cmi8TfeU.js"
82
+ "etag": '"95a74-8hHRRtv/cPIlS0z++Wpqpv18rpY"',
83
+ "mtime": "2026-06-11T12:14:10.999Z",
84
+ "size": 612980,
85
+ "path": "../public/assets/index-CdnotuLh.js"
86
86
  }
87
87
  };
88
88
  function readAsset(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/llm-inspector",
3
- "version": "1.14.7",
3
+ "version": "1.14.8",
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",
@@ -1,6 +1,7 @@
1
1
  import { type JSX, useCallback, useEffect, useMemo, useState, useRef } from "react";
2
2
  import { useVirtualizer } from "@tanstack/react-virtual";
3
- import { Download, GitCompareArrows, LayoutGrid, List, X } from "lucide-react";
3
+ import { Download, GitBranch, LayoutGrid, List } from "lucide-react";
4
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./ui/tooltip";
4
5
  import type { CapturedLog } from "../proxy/schemas";
5
6
  import { exportLogsAsZip } from "../lib/export-logs";
6
7
  import packageJson from "../../package.json";
@@ -8,14 +9,16 @@ import {
8
9
  ConversationGroup,
9
10
  groupLogsByConversation,
10
11
  LogEntry,
12
+ ThreadConnector,
11
13
  type ConversationGroupData,
14
+ type ViewMode,
12
15
  } from "./proxy-viewer";
16
+ import { extractStopReason } from "../lib/stopReason";
13
17
  import { CrabLogo } from "./ui/crab-logo";
14
18
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
15
19
  import { SettingsDialog } from "./providers/SettingsDialog";
16
20
  import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
17
21
  import { CompareDrawer } from "./proxy-viewer/CompareDrawer";
18
- import { getConversationId } from "./proxy-viewer/ConversationHeader";
19
22
 
20
23
  function truncateSessionId(id: string): string {
21
24
  if (id.length <= 30) return id;
@@ -114,9 +117,8 @@ export function ProxyViewer({
114
117
  }: ProxyViewerProps): JSX.Element {
115
118
  const { totalIn, totalOut } = computeTokenSummary(logs);
116
119
  const [groupedView, setGroupedView] = useState(true);
120
+ const [groupViewMode, setGroupViewMode] = useState<ViewMode>("thread");
117
121
  const [exporting, setExporting] = useState(false);
118
- const [selectedLogIds, setSelectedLogIds] = useState<number[]>([]);
119
- const [compareOpen, setCompareOpen] = useState(false);
120
122
  const [comparePair, setComparePair] = useState<[CapturedLog, CapturedLog] | null>(null);
121
123
 
122
124
  const handleExport = useCallback(async () => {
@@ -129,96 +131,33 @@ export function ProxyViewer({
129
131
  }, [logs]);
130
132
  const parentRef = useRef<HTMLDivElement>(null);
131
133
 
132
- const handleToggleSelect = useCallback((logId: number) => {
133
- setSelectedLogIds((prev) => {
134
- if (prev.includes(logId)) {
135
- return prev.filter((id) => id !== logId);
136
- }
137
- if (prev.length < 2) {
138
- return [...prev, logId];
139
- }
140
- // FIFO eviction: drop the oldest, append the new id.
141
- const newer = prev[1];
142
- if (newer === undefined) return prev;
143
- return [newer, logId];
144
- });
145
- }, []);
146
-
147
- // Reset the selection (and close the compare drawer) whenever the user
148
- // changes the session or model filter, since the selected logs may no
149
- // longer be in the visible list.
134
+ // Close the compare drawer when the user changes the session or model
135
+ // filter, since the predecessor relationship may no longer be meaningful.
150
136
  useEffect(() => {
151
- setSelectedLogIds([]);
152
- setCompareOpen(false);
137
+ setComparePair(null);
153
138
  }, [selectedSession, selectedModel]);
154
139
 
155
- const selectedSet = useMemo(() => new Set(selectedLogIds), [selectedLogIds]);
156
-
157
- const openCompare = useCallback(() => {
158
- if (selectedLogIds.length !== 2) return;
159
- const [idA, idB] = selectedLogIds;
160
- if (idA === undefined || idB === undefined) return;
161
- const logA = logs.find((l) => l.id === idA);
162
- const logB = logs.find((l) => l.id === idB);
163
- if (logA === undefined || logB === undefined) return;
164
- setComparePair([logA, logB]);
165
- setCompareOpen(true);
166
- }, [selectedLogIds, logs]);
167
-
168
140
  const closeCompare = useCallback(() => {
169
- setCompareOpen(false);
170
- // Keep `comparePair` so the selection survives across the drawer being
171
- // closed and re-opened; it is replaced the next time the user opens
172
- // the drawer with a different pair.
173
- }, []);
174
-
175
- const clearSelection = useCallback(() => {
176
- setSelectedLogIds([]);
141
+ setComparePair(null);
177
142
  }, []);
178
143
 
179
- const selectedSummary = useMemo(() => {
180
- if (selectedLogIds.length !== 2) return null;
181
- const [idA, idB] = selectedLogIds;
182
- if (idA === undefined || idB === undefined) return null;
183
- const logA = logs.find((l) => l.id === idA);
184
- const logB = logs.find((l) => l.id === idB);
185
- if (logA === undefined || logB === undefined) return null;
186
- const sameSession = getConversationId(logA) === getConversationId(logB);
187
- let elapsed = "";
188
- if (logA.timestamp !== null && logB.timestamp !== null) {
189
- const a = Date.parse(logA.timestamp);
190
- const b = Date.parse(logB.timestamp);
191
- if (!Number.isNaN(a) && !Number.isNaN(b)) {
192
- const ms = Math.abs(b - a);
193
- elapsed = formatElapsed(ms);
194
- }
195
- }
196
- return {
197
- logA,
198
- logB,
199
- sameSession,
200
- elapsed,
201
- };
202
- }, [selectedLogIds, logs]);
203
-
204
- function formatElapsed(ms: number): string {
205
- if (ms < 1000) return `${ms}ms`;
206
- const sec = Math.floor(ms / 1000);
207
- if (sec < 60) return `${sec}s`;
208
- const min = Math.floor(sec / 60);
209
- if (min < 60) return `${min}m`;
210
- const hr = Math.floor(min / 60);
211
- return `${hr}h${min % 60}m`;
212
- }
144
+ const handleCompareWithPrevious = useCallback(
145
+ (log: CapturedLog) => {
146
+ const idx = logs.indexOf(log);
147
+ if (idx <= 0) return;
148
+ const predecessor = logs[idx - 1];
149
+ if (predecessor === undefined) return;
150
+ setComparePair([predecessor, log]);
151
+ },
152
+ [logs],
153
+ );
213
154
 
214
155
  const groups = useMemo(() => groupLogsByConversation(logs), [logs]);
215
156
  const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
157
+ const stopReasons = useMemo(() => logs.map((log) => extractStopReason(log)), [logs]);
216
158
 
217
159
  // Determine what items to render (groups or individual logs)
218
- const renderGroups =
219
- logs.length > 0 &&
220
- groupedView &&
221
- !(groups.length === 1 && groups[0]?.logs.length === logs.length);
160
+ const renderGroups = logs.length > 0 && groupedView && groups.length > 1;
222
161
 
223
162
  const rowVirtualizer = useVirtualizer({
224
163
  count: renderGroups ? groups.length : logs.length,
@@ -242,30 +181,39 @@ export function ProxyViewer({
242
181
  <span className="text-xs text-muted-foreground font-mono">v{packageJson.version}</span>
243
182
  </span>
244
183
  </h1>
245
- <div className="flex items-center border border-border rounded-md overflow-hidden">
246
- <button
247
- type="button"
248
- onClick={() => onViewModeChange("simple")}
249
- className={`px-2 py-1 cursor-pointer transition-colors text-xs ${
250
- viewMode === "simple"
251
- ? "bg-muted text-foreground"
252
- : "text-muted-foreground hover:bg-muted/50"
253
- }`}
254
- >
255
- Simple
256
- </button>
257
- <button
258
- type="button"
259
- onClick={() => onViewModeChange("full")}
260
- className={`px-2 py-1 cursor-pointer transition-colors text-xs ${
261
- viewMode === "full"
262
- ? "bg-muted text-foreground"
263
- : "text-muted-foreground hover:bg-muted/50"
264
- }`}
265
- >
266
- Full
267
- </button>
268
- </div>
184
+ <TooltipProvider>
185
+ <Tooltip>
186
+ <TooltipTrigger asChild>
187
+ <div className="flex items-center border border-border rounded-md overflow-hidden">
188
+ <button
189
+ type="button"
190
+ onClick={() => onViewModeChange("simple")}
191
+ className={`px-2 py-1 cursor-pointer transition-colors text-xs ${
192
+ viewMode === "simple"
193
+ ? "bg-muted text-foreground"
194
+ : "text-muted-foreground hover:bg-muted/50"
195
+ }`}
196
+ >
197
+ Simple
198
+ </button>
199
+ <button
200
+ type="button"
201
+ onClick={() => onViewModeChange("full")}
202
+ className={`px-2 py-1 cursor-pointer transition-colors text-xs ${
203
+ viewMode === "full"
204
+ ? "bg-muted text-foreground"
205
+ : "text-muted-foreground hover:bg-muted/50"
206
+ }`}
207
+ >
208
+ Full
209
+ </button>
210
+ </div>
211
+ </TooltipTrigger>
212
+ <TooltipContent>
213
+ Simple shows parsed output; Full adds raw headers and tokens
214
+ </TooltipContent>
215
+ </Tooltip>
216
+ </TooltipProvider>
269
217
  <SettingsDialog />
270
218
  <span className="text-muted-foreground text-xs font-mono">
271
219
  {logs.length} request{logs.length !== 1 ? "s" : ""}
@@ -356,6 +304,34 @@ export function ProxyViewer({
356
304
  <List className="size-4" />
357
305
  </button>
358
306
  </div>
307
+
308
+ {/* Thread/flat mode toggle */}
309
+ <div className="flex items-center border border-border rounded-md overflow-hidden">
310
+ <button
311
+ type="button"
312
+ onClick={() => setGroupViewMode("thread")}
313
+ className={`px-2 py-1.5 cursor-pointer transition-colors ${
314
+ groupViewMode === "thread"
315
+ ? "bg-amber-500/15 text-amber-400 border-r border-amber-500/30"
316
+ : "text-muted-foreground hover:bg-muted/50"
317
+ }`}
318
+ title="Thread view (connected timeline)"
319
+ >
320
+ <GitBranch className="size-4" />
321
+ </button>
322
+ <button
323
+ type="button"
324
+ onClick={() => setGroupViewMode("flat")}
325
+ className={`px-2 py-1.5 cursor-pointer transition-colors ${
326
+ groupViewMode === "flat"
327
+ ? "bg-muted text-foreground"
328
+ : "text-muted-foreground hover:bg-muted/50"
329
+ }`}
330
+ title="Flat view (card list)"
331
+ >
332
+ <List className="size-4" />
333
+ </button>
334
+ </div>
359
335
  </div>
360
336
 
361
337
  {/* Log list */}
@@ -397,14 +373,15 @@ export function ProxyViewer({
397
373
  viewMode={viewMode}
398
374
  strip={strip}
399
375
  cacheTrends={cacheTrends}
400
- selectedSet={selectedSet}
401
- onToggleSelect={handleToggleSelect}
376
+ onCompareWithPrevious={handleCompareWithPrevious}
377
+ defaultGroupViewMode={groupViewMode}
402
378
  />
403
379
  </div>
404
380
  );
405
381
  } else {
406
382
  const log = logs[virtualRow.index];
407
383
  if (log === undefined) return null;
384
+ const idx = virtualRow.index;
408
385
  return (
409
386
  <div
410
387
  key={log.id}
@@ -418,14 +395,33 @@ export function ProxyViewer({
418
395
  transform: `translateY(${virtualRow.start}px)`,
419
396
  }}
420
397
  >
421
- <LogEntry
422
- log={log}
423
- viewMode={viewMode}
424
- strip={strip}
425
- cacheTrend={cacheTrends.get(log.id) ?? null}
426
- isSelected={selectedSet.has(log.id)}
427
- onToggleSelect={handleToggleSelect}
428
- />
398
+ {groupViewMode === "thread" ? (
399
+ <div className="flex items-stretch ml-3">
400
+ <ThreadConnector
401
+ stopReason={stopReasons[idx] ?? null}
402
+ isPending={log.responseStatus === null}
403
+ isFirst={idx === 0}
404
+ isLast={idx === logs.length - 1}
405
+ />
406
+ <div className="flex-1 min-w-0 mb-2">
407
+ <LogEntry
408
+ log={log}
409
+ viewMode={viewMode}
410
+ strip={strip}
411
+ cacheTrend={cacheTrends.get(log.id) ?? null}
412
+ onCompareWithPrevious={() => handleCompareWithPrevious(log)}
413
+ />
414
+ </div>
415
+ </div>
416
+ ) : (
417
+ <LogEntry
418
+ log={log}
419
+ viewMode={viewMode}
420
+ strip={strip}
421
+ cacheTrend={cacheTrends.get(log.id) ?? null}
422
+ onCompareWithPrevious={() => handleCompareWithPrevious(log)}
423
+ />
424
+ )}
429
425
  </div>
430
426
  );
431
427
  }
@@ -435,36 +431,8 @@ export function ProxyViewer({
435
431
  )}
436
432
  </div>
437
433
 
438
- {/* Floating action bar — shown only when 2 logs are selected. */}
439
- {selectedSummary !== null && (
440
- <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-background border border-border rounded-lg shadow-lg px-4 py-2 text-xs">
441
- <GitCompareArrows className="size-4 text-amber-400 shrink-0" />
442
- <span className="text-muted-foreground font-mono">
443
- #{selectedSummary.logA.id} ↔ #{selectedSummary.logB.id}
444
- {" · "}
445
- {selectedSummary.sameSession ? "same session" : "different sessions"}
446
- {selectedSummary.elapsed !== "" && ` · ${selectedSummary.elapsed} apart`}
447
- </span>
448
- <button
449
- type="button"
450
- onClick={clearSelection}
451
- className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer inline-flex items-center gap-1"
452
- >
453
- <X className="size-3" />
454
- Clear
455
- </button>
456
- <button
457
- type="button"
458
- onClick={openCompare}
459
- className="bg-amber-400 text-amber-950 hover:bg-amber-300 transition-colors px-3 py-1 rounded font-medium cursor-pointer"
460
- >
461
- Compare 2 logs
462
- </button>
463
- </div>
464
- )}
465
-
466
434
  {/* Compare drawer — sibling of the log list, not a route change. */}
467
- {compareOpen && comparePair !== null && (
435
+ {comparePair !== null && (
468
436
  <CompareDrawer left={comparePair[0]} right={comparePair[1]} onClose={closeCompare} />
469
437
  )}
470
438
  </div>
@@ -1,5 +1,6 @@
1
1
  import { type JSX, type ReactNode, useCallback, useRef, useState } from "react";
2
2
  import { Button } from "../ui/button";
3
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
3
4
  import {
4
5
  Eye,
5
6
  EyeOff,
@@ -109,16 +110,30 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
109
110
  }
110
111
  if (result.cacheCreationInputTokens !== undefined && result.cacheCreationInputTokens > 0) {
111
112
  tokenParts.push(
112
- <span key="cache-create" className="font-mono tabular-nums text-emerald-400">
113
- +{result.cacheCreationInputTokens} cache
114
- </span>,
113
+ <TooltipProvider key="cache-create">
114
+ <Tooltip>
115
+ <TooltipTrigger asChild>
116
+ <span className="font-mono tabular-nums text-emerald-400">
117
+ +{result.cacheCreationInputTokens} cache
118
+ </span>
119
+ </TooltipTrigger>
120
+ <TooltipContent>Tokens cached for reuse, reducing future API cost</TooltipContent>
121
+ </Tooltip>
122
+ </TooltipProvider>,
115
123
  );
116
124
  }
117
125
  if (result.cacheReadInputTokens !== undefined && result.cacheReadInputTokens > 0) {
118
126
  tokenParts.push(
119
- <span key="cache-read" className="font-mono tabular-nums text-purple-400">
120
- ~{result.cacheReadInputTokens} cached
121
- </span>,
127
+ <TooltipProvider key="cache-read">
128
+ <Tooltip>
129
+ <TooltipTrigger asChild>
130
+ <span className="font-mono tabular-nums text-purple-400">
131
+ ~{result.cacheReadInputTokens} cached
132
+ </span>
133
+ </TooltipTrigger>
134
+ <TooltipContent>Tokens served from cache, reducing API cost</TooltipContent>
135
+ </Tooltip>
136
+ </TooltipProvider>,
122
137
  );
123
138
  }
124
139
  const displayTokens: ReactNode[] = [];
@@ -127,11 +142,20 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
127
142
  displayTokens.push(tokenParts[i]);
128
143
  }
129
144
  return (
130
- <div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
131
- <CheckCircle className="size-3" />
132
- <span>Connected</span>
133
- {tokenParts.length > 0 && <span className="text-muted-foreground">({displayTokens})</span>}
134
- </div>
145
+ <TooltipProvider>
146
+ <Tooltip>
147
+ <TooltipTrigger asChild>
148
+ <div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
149
+ <CheckCircle className="size-3" />
150
+ <span>Connected</span>
151
+ {tokenParts.length > 0 && (
152
+ <span className="text-muted-foreground">({displayTokens})</span>
153
+ )}
154
+ </div>
155
+ </TooltipTrigger>
156
+ <TooltipContent>Connection test passed</TooltipContent>
157
+ </Tooltip>
158
+ </TooltipProvider>
135
159
  );
136
160
  }
137
161
 
@@ -145,16 +169,31 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
145
169
  <div className="flex flex-col gap-1 shrink-0">
146
170
  <div className="flex items-center gap-1 text-xs text-red-600 max-w-[200px]">
147
171
  {getErrorIcon(errorType)}
148
- <span className="truncate">{errorMessage}</span>
172
+ <TooltipProvider>
173
+ <Tooltip>
174
+ <TooltipTrigger asChild>
175
+ <span className="truncate">{errorMessage}</span>
176
+ </TooltipTrigger>
177
+ <TooltipContent>Connection test failed</TooltipContent>
178
+ </Tooltip>
179
+ </TooltipProvider>
149
180
  {errorDetails !== undefined && (
150
- <button
151
- type="button"
152
- onClick={() => setShowDetails(!showDetails)}
153
- className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
154
- title={showDetails ? "Hide details" : "Show details"}
155
- >
156
- {showDetails ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
157
- </button>
181
+ <TooltipProvider>
182
+ <Tooltip>
183
+ <TooltipTrigger asChild>
184
+ <button
185
+ type="button"
186
+ onClick={() => setShowDetails(!showDetails)}
187
+ className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
188
+ >
189
+ {showDetails ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
190
+ </button>
191
+ </TooltipTrigger>
192
+ <TooltipContent>
193
+ {showDetails ? "Hide error details" : "Show detailed error information"}
194
+ </TooltipContent>
195
+ </Tooltip>
196
+ </TooltipProvider>
158
197
  )}
159
198
  </div>
160
199
  {showDetails && errorDetails !== undefined && (
@@ -243,14 +282,28 @@ export function ProviderCard({
243
282
  <div className="flex items-center gap-2 min-w-0">
244
283
  <span className="font-medium truncate">{provider.name}</span>
245
284
  {provider.source === "company" && (
246
- <span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 shrink-0">
247
- 公司
248
- </span>
285
+ <TooltipProvider>
286
+ <Tooltip>
287
+ <TooltipTrigger asChild>
288
+ <span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 shrink-0">
289
+ 公司
290
+ </span>
291
+ </TooltipTrigger>
292
+ <TooltipContent>Company-provided API key</TooltipContent>
293
+ </Tooltip>
294
+ </TooltipProvider>
249
295
  )}
250
296
  {provider.source === "personal" && (
251
- <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 shrink-0">
252
- 个人
253
- </span>
297
+ <TooltipProvider>
298
+ <Tooltip>
299
+ <TooltipTrigger asChild>
300
+ <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 shrink-0">
301
+ 个人
302
+ </span>
303
+ </TooltipTrigger>
304
+ <TooltipContent>Your personal API key</TooltipContent>
305
+ </Tooltip>
306
+ </TooltipProvider>
254
307
  )}
255
308
  </div>
256
309
  {docsUrl !== undefined && (