@tonyclaw/llm-inspector 1.14.7 → 1.14.9

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 (34) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-Dv-dj1xH.js +105 -0
  3. package/.output/public/assets/index-bqeypwJB.css +1 -0
  4. package/.output/public/assets/{main-BV7uNIIz.js → main-C8OUJKbz.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-_9xcAkkw.mjs} +861 -608
  8. package/.output/server/_ssr/index.mjs +2 -2
  9. package/.output/server/_ssr/{router-lUOA8pi6.mjs → router-CmanwZJc.mjs} +45 -14
  10. package/.output/server/{_tanstack-start-manifest_v-XNH7fVPN.mjs → _tanstack-start-manifest_v-BVIiyDeJ.mjs} +1 -1
  11. package/.output/server/index.mjs +23 -23
  12. package/package.json +1 -1
  13. package/src/components/ProxyViewer.tsx +137 -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 +74 -11
  19. package/src/components/proxy-viewer/ConversationHeader.tsx +63 -2
  20. package/src/components/proxy-viewer/LogEntry.tsx +184 -54
  21. package/src/components/proxy-viewer/LogEntryHeader.tsx +148 -143
  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 +93 -0
  25. package/src/components/proxy-viewer/index.ts +2 -1
  26. package/src/lib/stopReason.ts +57 -0
  27. package/src/proxy/formats/anthropic/handler.ts +2 -5
  28. package/src/proxy/formats/openai/handler.ts +33 -7
  29. package/src/proxy/formats/openai/schemas.ts +1 -0
  30. package/src/proxy/formats/openai/stream.ts +24 -0
  31. package/src/proxy/handler.ts +8 -2
  32. package/src/proxy/schemas.ts +6 -3
  33. package/.output/public/assets/index-Cmi8TfeU.js +0 -105
  34. 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-BVIiyDeJ.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-CmanwZJc.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-bqeypwJB.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-_9xcAkkw.mjs");
73
73
  const Route$j = createFileRoute("/")({
74
74
  component: lazyRouteComponent($$splitComponentImporter, "component")
75
75
  });
@@ -577,6 +577,7 @@ const OpenAIRequestSchema = object({
577
577
  stream: boolean().optional(),
578
578
  tools: array(OpenAIToolDefinition).optional(),
579
579
  tool_choice: union([
580
+ _enum(["auto", "none", "required"]),
580
581
  object({ type: literal("auto") }),
581
582
  object({ type: literal("none") }),
582
583
  object({ type: literal("function"), function: object({ name: string() }) })
@@ -1276,11 +1277,7 @@ const AnthropicFormatHandler = {
1276
1277
  const json = JSON.parse(rawBody);
1277
1278
  if (typeof json === "object" && json !== null && !Array.isArray(json)) {
1278
1279
  const keys = Object.keys(json);
1279
- if (keys.includes("model") && keys.includes("messages")) {
1280
- if (keys.includes("system") || keys.includes("tools")) {
1281
- return true;
1282
- }
1283
- }
1280
+ return keys.includes("model") && keys.includes("messages") && keys.includes("system");
1284
1281
  }
1285
1282
  return false;
1286
1283
  } catch {
@@ -1410,6 +1407,21 @@ function extractOpenAIStream(raw, log, fallbackModel, collectChunks = true) {
1410
1407
  promptTokens = chunk.usage.prompt_tokens ?? 0;
1411
1408
  completionTokens = chunk.usage.completion_tokens ?? 0;
1412
1409
  log.inputTokens = promptTokens;
1410
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
1411
+ const usageDesc = Object.getOwnPropertyDescriptor(parsed, "usage");
1412
+ if (usageDesc !== void 0 && typeof usageDesc.value === "object" && usageDesc.value !== null) {
1413
+ const detailsDesc = Object.getOwnPropertyDescriptor(
1414
+ usageDesc.value,
1415
+ "prompt_tokens_details"
1416
+ );
1417
+ if (detailsDesc !== void 0 && typeof detailsDesc.value === "object" && detailsDesc.value !== null) {
1418
+ const cacheDesc = Object.getOwnPropertyDescriptor(detailsDesc.value, "cached_tokens");
1419
+ if (cacheDesc !== void 0 && typeof cacheDesc.value === "number") {
1420
+ log.cacheReadInputTokens = cacheDesc.value;
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1413
1425
  usageCaptured = true;
1414
1426
  }
1415
1427
  for (const choice of chunk.choices) {
@@ -1506,11 +1518,31 @@ const OpenAIFormatHandler = {
1506
1518
  extractTokens(responseBody) {
1507
1519
  const parsed = parseOpenAIResponse(responseBody);
1508
1520
  if (parsed) {
1521
+ let cacheReadInputTokens = null;
1522
+ try {
1523
+ const raw = JSON.parse(responseBody);
1524
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
1525
+ const usageDesc = Object.getOwnPropertyDescriptor(raw, "usage");
1526
+ if (usageDesc !== void 0 && typeof usageDesc.value === "object" && usageDesc.value !== null) {
1527
+ const detailsDesc = Object.getOwnPropertyDescriptor(
1528
+ usageDesc.value,
1529
+ "prompt_tokens_details"
1530
+ );
1531
+ if (detailsDesc !== void 0 && typeof detailsDesc.value === "object" && detailsDesc.value !== null) {
1532
+ const cacheDesc = Object.getOwnPropertyDescriptor(detailsDesc.value, "cached_tokens");
1533
+ if (cacheDesc !== void 0 && typeof cacheDesc.value === "number") {
1534
+ cacheReadInputTokens = cacheDesc.value;
1535
+ }
1536
+ }
1537
+ }
1538
+ }
1539
+ } catch {
1540
+ }
1509
1541
  return {
1510
1542
  inputTokens: parsed.usage.prompt_tokens ?? null,
1511
1543
  outputTokens: parsed.usage.completion_tokens ?? null,
1512
1544
  cacheCreationInputTokens: null,
1513
- cacheReadInputTokens: null
1545
+ cacheReadInputTokens
1514
1546
  };
1515
1547
  }
1516
1548
  return {
@@ -1529,11 +1561,7 @@ const OpenAIFormatHandler = {
1529
1561
  const json = JSON.parse(rawBody);
1530
1562
  if (typeof json === "object" && json !== null && !Array.isArray(json)) {
1531
1563
  const keys = Object.keys(json);
1532
- if (keys.includes("model") && keys.includes("messages")) {
1533
- if (!keys.includes("system") && !keys.includes("tools")) {
1534
- return true;
1535
- }
1536
- }
1564
+ return keys.includes("model") && keys.includes("messages") && !keys.includes("system");
1537
1565
  }
1538
1566
  return false;
1539
1567
  } catch {
@@ -2504,6 +2532,8 @@ async function handleProxy(req) {
2504
2532
  upstreamHeaders.forEach((value, key) => {
2505
2533
  upstreamHeadersObj[key.toLowerCase()] = value;
2506
2534
  });
2535
+ const bodyFormat = formatRegistry.detectFormat(requestBody);
2536
+ const displayApiFormat = bodyFormat !== "unknown" ? bodyFormat : formatHandler.format;
2507
2537
  const log = await createLog(
2508
2538
  req.method,
2509
2539
  parsed.apiPath,
@@ -2512,7 +2542,7 @@ async function handleProxy(req) {
2512
2542
  clientInfo,
2513
2543
  rawHeaders,
2514
2544
  upstreamHeadersObj,
2515
- formatHandler.format,
2545
+ displayApiFormat,
2516
2546
  model,
2517
2547
  sessionId,
2518
2548
  preAcquiredId
@@ -4713,6 +4743,7 @@ const router = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProper
4713
4743
  export {
4714
4744
  CapturedLogSchema as C,
4715
4745
  InspectorResponseSchema as I,
4746
+ OpenAIRequestSchema as O,
4716
4747
  ProviderTestResultsSchema as P,
4717
4748
  RuntimeConfigSchema as R,
4718
4749
  parseRequest as a,
@@ -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-C8OUJKbz.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-Dv-dj1xH.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-C8OUJKbz.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-11T13:44:56.125Z",
42
42
  "size": 5915,
43
43
  "path": "../public/assets/alibaba-TTwafVwX.svg"
44
44
  },
45
+ "/assets/index-bqeypwJB.css": {
46
+ "type": "text/css; charset=utf-8",
47
+ "etag": '"14d2d-fciDL/LvvG4KiFnlq0WNx0FqBmw"',
48
+ "mtime": "2026-06-11T13:44:56.125Z",
49
+ "size": 85293,
50
+ "path": "../public/assets/index-bqeypwJB.css"
51
+ },
45
52
  "/assets/minimax-BPMzvuL-.jpeg": {
46
53
  "type": "image/jpeg",
47
54
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
48
- "mtime": "2026-06-11T07:46:13.439Z",
55
+ "mtime": "2026-06-11T13:44:56.125Z",
49
56
  "size": 6918,
50
57
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
51
58
  },
52
59
  "/assets/zhipuai-BPNAnxo-.svg": {
53
60
  "type": "image/svg+xml",
54
61
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
55
- "mtime": "2026-06-11T07:46:13.439Z",
62
+ "mtime": "2026-06-11T13:44:56.122Z",
56
63
  "size": 11256,
57
64
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
58
65
  },
66
+ "/assets/main-C8OUJKbz.js": {
67
+ "type": "text/javascript; charset=utf-8",
68
+ "etag": '"50599-x4LjDXM7jigK2tdjHCfXl0STwVQ"',
69
+ "mtime": "2026-06-11T13:44:56.125Z",
70
+ "size": 329113,
71
+ "path": "../public/assets/main-C8OUJKbz.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-11T13:44:56.125Z",
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-Dv-dj1xH.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": '"9600f-3dnGI92f5ekK1BS9qp5iLg0Map4"',
83
+ "mtime": "2026-06-11T13:44:56.127Z",
84
+ "size": 614415,
85
+ "path": "../public/assets/index-Dv-dj1xH.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.9",
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,8 @@
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 { cn } from "../lib/utils";
5
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./ui/tooltip";
4
6
  import type { CapturedLog } from "../proxy/schemas";
5
7
  import { exportLogsAsZip } from "../lib/export-logs";
6
8
  import packageJson from "../../package.json";
@@ -8,14 +10,16 @@ import {
8
10
  ConversationGroup,
9
11
  groupLogsByConversation,
10
12
  LogEntry,
13
+ ThreadConnector,
11
14
  type ConversationGroupData,
15
+ type ViewMode,
12
16
  } from "./proxy-viewer";
17
+ import { extractStopReason } from "../lib/stopReason";
13
18
  import { CrabLogo } from "./ui/crab-logo";
14
19
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
15
20
  import { SettingsDialog } from "./providers/SettingsDialog";
16
21
  import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
17
22
  import { CompareDrawer } from "./proxy-viewer/CompareDrawer";
18
- import { getConversationId } from "./proxy-viewer/ConversationHeader";
19
23
 
20
24
  function truncateSessionId(id: string): string {
21
25
  if (id.length <= 30) return id;
@@ -114,9 +118,8 @@ export function ProxyViewer({
114
118
  }: ProxyViewerProps): JSX.Element {
115
119
  const { totalIn, totalOut } = computeTokenSummary(logs);
116
120
  const [groupedView, setGroupedView] = useState(true);
121
+ const [groupViewMode, setGroupViewMode] = useState<ViewMode>("thread");
117
122
  const [exporting, setExporting] = useState(false);
118
- const [selectedLogIds, setSelectedLogIds] = useState<number[]>([]);
119
- const [compareOpen, setCompareOpen] = useState(false);
120
123
  const [comparePair, setComparePair] = useState<[CapturedLog, CapturedLog] | null>(null);
121
124
 
122
125
  const handleExport = useCallback(async () => {
@@ -129,96 +132,45 @@ export function ProxyViewer({
129
132
  }, [logs]);
130
133
  const parentRef = useRef<HTMLDivElement>(null);
131
134
 
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.
135
+ // Close the compare drawer when the user changes the session or model
136
+ // filter, since the predecessor relationship may no longer be meaningful.
150
137
  useEffect(() => {
151
- setSelectedLogIds([]);
152
- setCompareOpen(false);
138
+ setComparePair(null);
153
139
  }, [selectedSession, selectedModel]);
154
140
 
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
141
  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([]);
142
+ setComparePair(null);
177
143
  }, []);
178
144
 
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
- }
145
+ const handleCompareWithPrevious = useCallback(
146
+ (log: CapturedLog) => {
147
+ const idx = logs.indexOf(log);
148
+ if (idx <= 0) return;
149
+ const predecessor = logs[idx - 1];
150
+ if (predecessor === undefined) return;
151
+ setComparePair([predecessor, log]);
152
+ },
153
+ [logs],
154
+ );
213
155
 
214
156
  const groups = useMemo(() => groupLogsByConversation(logs), [logs]);
215
157
  const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
158
+ const stopReasons = useMemo(() => logs.map((log) => extractStopReason(log)), [logs]);
159
+
160
+ const turnIndices = useMemo(() => {
161
+ const indices: number[] = [];
162
+ let turn = 0;
163
+ for (let i = 0; i < stopReasons.length; i++) {
164
+ if (i > 0 && (stopReasons[i - 1] === "end_turn" || stopReasons[i - 1] === "stop")) {
165
+ turn++;
166
+ }
167
+ indices.push(turn);
168
+ }
169
+ return indices;
170
+ }, [stopReasons]);
216
171
 
217
172
  // 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);
173
+ const renderGroups = logs.length > 0 && groupedView && groups.length > 1;
222
174
 
223
175
  const rowVirtualizer = useVirtualizer({
224
176
  count: renderGroups ? groups.length : logs.length,
@@ -242,30 +194,39 @@ export function ProxyViewer({
242
194
  <span className="text-xs text-muted-foreground font-mono">v{packageJson.version}</span>
243
195
  </span>
244
196
  </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>
197
+ <TooltipProvider>
198
+ <Tooltip>
199
+ <TooltipTrigger asChild>
200
+ <div className="flex items-center border border-border rounded-md overflow-hidden">
201
+ <button
202
+ type="button"
203
+ onClick={() => onViewModeChange("simple")}
204
+ className={`px-2 py-1 cursor-pointer transition-colors text-xs ${
205
+ viewMode === "simple"
206
+ ? "bg-muted text-foreground"
207
+ : "text-muted-foreground hover:bg-muted/50"
208
+ }`}
209
+ >
210
+ Simple
211
+ </button>
212
+ <button
213
+ type="button"
214
+ onClick={() => onViewModeChange("full")}
215
+ className={`px-2 py-1 cursor-pointer transition-colors text-xs ${
216
+ viewMode === "full"
217
+ ? "bg-muted text-foreground"
218
+ : "text-muted-foreground hover:bg-muted/50"
219
+ }`}
220
+ >
221
+ Full
222
+ </button>
223
+ </div>
224
+ </TooltipTrigger>
225
+ <TooltipContent>
226
+ Simple shows parsed output; Full adds raw headers and tokens
227
+ </TooltipContent>
228
+ </Tooltip>
229
+ </TooltipProvider>
269
230
  <SettingsDialog />
270
231
  <span className="text-muted-foreground text-xs font-mono">
271
232
  {logs.length} request{logs.length !== 1 ? "s" : ""}
@@ -356,6 +317,34 @@ export function ProxyViewer({
356
317
  <List className="size-4" />
357
318
  </button>
358
319
  </div>
320
+
321
+ {/* Thread/flat mode toggle */}
322
+ <div className="flex items-center border border-border rounded-md overflow-hidden">
323
+ <button
324
+ type="button"
325
+ onClick={() => setGroupViewMode("thread")}
326
+ className={`px-2 py-1.5 cursor-pointer transition-colors ${
327
+ groupViewMode === "thread"
328
+ ? "bg-amber-500/15 text-amber-400 border-r border-amber-500/30"
329
+ : "text-muted-foreground hover:bg-muted/50"
330
+ }`}
331
+ title="Thread view (connected timeline)"
332
+ >
333
+ <GitBranch className="size-4" />
334
+ </button>
335
+ <button
336
+ type="button"
337
+ onClick={() => setGroupViewMode("flat")}
338
+ className={`px-2 py-1.5 cursor-pointer transition-colors ${
339
+ groupViewMode === "flat"
340
+ ? "bg-muted text-foreground"
341
+ : "text-muted-foreground hover:bg-muted/50"
342
+ }`}
343
+ title="Flat view (card list)"
344
+ >
345
+ <List className="size-4" />
346
+ </button>
347
+ </div>
359
348
  </div>
360
349
 
361
350
  {/* Log list */}
@@ -397,14 +386,15 @@ export function ProxyViewer({
397
386
  viewMode={viewMode}
398
387
  strip={strip}
399
388
  cacheTrends={cacheTrends}
400
- selectedSet={selectedSet}
401
- onToggleSelect={handleToggleSelect}
389
+ onCompareWithPrevious={handleCompareWithPrevious}
390
+ defaultGroupViewMode={groupViewMode}
402
391
  />
403
392
  </div>
404
393
  );
405
394
  } else {
406
395
  const log = logs[virtualRow.index];
407
396
  if (log === undefined) return null;
397
+ const idx = virtualRow.index;
408
398
  return (
409
399
  <div
410
400
  key={log.id}
@@ -418,14 +408,43 @@ export function ProxyViewer({
418
408
  transform: `translateY(${virtualRow.start}px)`,
419
409
  }}
420
410
  >
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
- />
411
+ {groupViewMode === "thread" ? (
412
+ <div className="flex items-stretch ml-3">
413
+ <ThreadConnector
414
+ stopReason={stopReasons[idx] ?? null}
415
+ isPending={log.responseStatus === null}
416
+ isFirst={idx === 0}
417
+ isLast={idx === logs.length - 1}
418
+ isTurnStart={
419
+ idx === 0 ||
420
+ stopReasons[idx - 1] === "end_turn" ||
421
+ stopReasons[idx - 1] === "stop"
422
+ }
423
+ />
424
+ <div
425
+ className={cn(
426
+ "flex-1 min-w-0 mb-2 rounded-lg",
427
+ (turnIndices[idx] ?? 0) % 2 === 0 ? "bg-muted/10" : "bg-muted/25",
428
+ )}
429
+ >
430
+ <LogEntry
431
+ log={log}
432
+ viewMode={viewMode}
433
+ strip={strip}
434
+ cacheTrend={cacheTrends.get(log.id) ?? null}
435
+ onCompareWithPrevious={() => handleCompareWithPrevious(log)}
436
+ />
437
+ </div>
438
+ </div>
439
+ ) : (
440
+ <LogEntry
441
+ log={log}
442
+ viewMode={viewMode}
443
+ strip={strip}
444
+ cacheTrend={cacheTrends.get(log.id) ?? null}
445
+ onCompareWithPrevious={() => handleCompareWithPrevious(log)}
446
+ />
447
+ )}
429
448
  </div>
430
449
  );
431
450
  }
@@ -435,36 +454,8 @@ export function ProxyViewer({
435
454
  )}
436
455
  </div>
437
456
 
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
457
  {/* Compare drawer — sibling of the log list, not a route change. */}
467
- {compareOpen && comparePair !== null && (
458
+ {comparePair !== null && (
468
459
  <CompareDrawer left={comparePair[0]} right={comparePair[1]} onClose={closeCompare} />
469
460
  )}
470
461
  </div>