@tonyclaw/llm-inspector 1.14.8 → 1.15.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 (30) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-CMuJQyt1.js +105 -0
  3. package/.output/public/assets/index-DciyfYBk.css +1 -0
  4. package/.output/public/assets/{main-CJ4MreBr.js → main-BLYgekFx.js} +1 -1
  5. package/.output/server/_libs/lucide-react.mjs +85 -111
  6. package/.output/server/_libs/radix-ui__react-id.mjs +1 -1
  7. package/.output/server/_ssr/{index-9uTJ4xYR.mjs → index-P66uoVEU.mjs} +677 -304
  8. package/.output/server/_ssr/index.mjs +2 -2
  9. package/.output/server/_ssr/{router-BKnjB_zi.mjs → router-DpLCKk51.mjs} +45 -14
  10. package/.output/server/{_tanstack-start-manifest_v-IsglLVKy.mjs → _tanstack-start-manifest_v-C9Wq6YdJ.mjs} +1 -1
  11. package/.output/server/index.mjs +22 -22
  12. package/package.json +1 -1
  13. package/src/components/ProxyViewer.tsx +99 -180
  14. package/src/components/proxy-viewer/ConversationGroup.tsx +70 -66
  15. package/src/components/proxy-viewer/ConversationHeader.tsx +15 -39
  16. package/src/components/proxy-viewer/LogEntry.tsx +68 -9
  17. package/src/components/proxy-viewer/LogEntryHeader.tsx +62 -75
  18. package/src/components/proxy-viewer/ThreadConnector.tsx +78 -65
  19. package/src/components/proxy-viewer/TurnGroup.tsx +83 -0
  20. package/src/components/ui/crab-variants.tsx +456 -0
  21. package/src/lib/stopReason.ts +7 -6
  22. package/src/proxy/formats/anthropic/handler.ts +2 -5
  23. package/src/proxy/formats/openai/handler.ts +33 -7
  24. package/src/proxy/formats/openai/schemas.ts +1 -0
  25. package/src/proxy/formats/openai/stream.ts +24 -0
  26. package/src/proxy/handler.ts +8 -2
  27. package/src/proxy/schemas.ts +6 -3
  28. package/styles/globals.css +38 -0
  29. package/.output/public/assets/index-CdnotuLh.js +0 -105
  30. package/.output/public/assets/index-vP91146S.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-IsglLVKy.mjs");
201
+ const { tsrStartManifest } = await import("../_tanstack-start-manifest_v-C9Wq6YdJ.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-BKnjB_zi.mjs").then((n) => n.r);
770
+ const routerEntry = await import("./router-DpLCKk51.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-vP91146S.css";
48
+ const appCss = "/assets/index-DciyfYBk.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-9uTJ4xYR.mjs");
72
+ const $$splitComponentImporter = () => import("./index-P66uoVEU.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-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" });
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-BLYgekFx.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-CMuJQyt1.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-BLYgekFx.js" });
2
2
  export {
3
3
  tsrStartManifest
4
4
  };
@@ -35,54 +35,54 @@ const headers = ((m) => function headersRouteRule(event) {
35
35
  }
36
36
  });
37
37
  const assets = {
38
+ "/assets/index-DciyfYBk.css": {
39
+ "type": "text/css; charset=utf-8",
40
+ "etag": '"15867-TOX1Gddns0p3jKt+7co1NuZS+d8"',
41
+ "mtime": "2026-06-12T07:17:56.431Z",
42
+ "size": 88167,
43
+ "path": "../public/assets/index-DciyfYBk.css"
44
+ },
38
45
  "/assets/alibaba-TTwafVwX.svg": {
39
46
  "type": "image/svg+xml",
40
47
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
41
- "mtime": "2026-06-11T12:14:10.996Z",
48
+ "mtime": "2026-06-12T07:17:56.428Z",
42
49
  "size": 5915,
43
50
  "path": "../public/assets/alibaba-TTwafVwX.svg"
44
51
  },
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"
52
+ "/assets/main-BLYgekFx.js": {
53
+ "type": "text/javascript; charset=utf-8",
54
+ "etag": '"50599-tks6aTeb/Edw4laR3cdpiWA48mc"',
55
+ "mtime": "2026-06-12T07:17:56.431Z",
56
+ "size": 329113,
57
+ "path": "../public/assets/main-BLYgekFx.js"
51
58
  },
52
59
  "/assets/zhipuai-BPNAnxo-.svg": {
53
60
  "type": "image/svg+xml",
54
61
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
55
- "mtime": "2026-06-11T12:14:10.996Z",
62
+ "mtime": "2026-06-12T07:17:56.431Z",
56
63
  "size": 11256,
57
64
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
58
65
  },
59
66
  "/assets/minimax-BPMzvuL-.jpeg": {
60
67
  "type": "image/jpeg",
61
68
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
62
- "mtime": "2026-06-11T12:14:10.996Z",
69
+ "mtime": "2026-06-12T07:17:56.431Z",
63
70
  "size": 6918,
64
71
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
65
72
  },
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
- },
73
73
  "/assets/qwen-CONDcHqt.png": {
74
74
  "type": "image/png",
75
75
  "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
76
- "mtime": "2026-06-11T12:14:10.996Z",
76
+ "mtime": "2026-06-12T07:17:56.431Z",
77
77
  "size": 357059,
78
78
  "path": "../public/assets/qwen-CONDcHqt.png"
79
79
  },
80
- "/assets/index-CdnotuLh.js": {
80
+ "/assets/index-CMuJQyt1.js": {
81
81
  "type": "text/javascript; charset=utf-8",
82
- "etag": '"95a74-8hHRRtv/cPIlS0z++Wpqpv18rpY"',
83
- "mtime": "2026-06-11T12:14:10.999Z",
84
- "size": 612980,
85
- "path": "../public/assets/index-CdnotuLh.js"
82
+ "etag": '"97f56-eCDTvraNzTc2Un6pdqimYNiZVhw"',
83
+ "mtime": "2026-06-12T07:17:56.431Z",
84
+ "size": 622422,
85
+ "path": "../public/assets/index-CMuJQyt1.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.8",
3
+ "version": "1.15.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",
@@ -1,20 +1,15 @@
1
1
  import { type JSX, useCallback, useEffect, useMemo, useState, useRef } from "react";
2
2
  import { useVirtualizer } from "@tanstack/react-virtual";
3
- import { Download, GitBranch, LayoutGrid, List } from "lucide-react";
3
+ import { Download } from "lucide-react";
4
+
4
5
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./ui/tooltip";
5
6
  import type { CapturedLog } from "../proxy/schemas";
6
7
  import { exportLogsAsZip } from "../lib/export-logs";
7
8
  import packageJson from "../../package.json";
8
- import {
9
- ConversationGroup,
10
- groupLogsByConversation,
11
- LogEntry,
12
- ThreadConnector,
13
- type ConversationGroupData,
14
- type ViewMode,
15
- } from "./proxy-viewer";
16
- import { extractStopReason } from "../lib/stopReason";
9
+ import { ConversationGroup, groupLogsByConversation } from "./proxy-viewer";
10
+
17
11
  import { CrabLogo } from "./ui/crab-logo";
12
+ import { crabVariants } from "./ui/crab-variants";
18
13
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
19
14
  import { SettingsDialog } from "./providers/SettingsDialog";
20
15
  import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
@@ -116,8 +111,6 @@ export function ProxyViewer({
116
111
  strip,
117
112
  }: ProxyViewerProps): JSX.Element {
118
113
  const { totalIn, totalOut } = computeTokenSummary(logs);
119
- const [groupedView, setGroupedView] = useState(true);
120
- const [groupViewMode, setGroupViewMode] = useState<ViewMode>("thread");
121
114
  const [exporting, setExporting] = useState(false);
122
115
  const [comparePair, setComparePair] = useState<[CapturedLog, CapturedLog] | null>(null);
123
116
 
@@ -154,13 +147,9 @@ export function ProxyViewer({
154
147
 
155
148
  const groups = useMemo(() => groupLogsByConversation(logs), [logs]);
156
149
  const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
157
- const stopReasons = useMemo(() => logs.map((log) => extractStopReason(log)), [logs]);
158
-
159
- // Determine what items to render (groups or individual logs)
160
- const renderGroups = logs.length > 0 && groupedView && groups.length > 1;
161
150
 
162
151
  const rowVirtualizer = useVirtualizer({
163
- count: renderGroups ? groups.length : logs.length,
152
+ count: groups.length,
164
153
  getScrollElement: () => parentRef.current,
165
154
  estimateSize: () => 150,
166
155
  measureElement:
@@ -172,15 +161,75 @@ export function ProxyViewer({
172
161
 
173
162
  return (
174
163
  <div className="max-w-[1200px] mx-auto flex flex-col h-screen" style={{ maxHeight: "100vh" }}>
175
- {/* Header */}
176
- <div className="flex items-center gap-4 mb-4 px-6 pt-6">
177
- <h1 className="text-lg font-bold flex-1 flex items-center gap-2">
178
- <CrabLogo className="size-10 text-amber-500 self-center" />
164
+ {/* Brand row */}
165
+ <div className="flex items-end px-6 pt-6 pb-3 relative">
166
+ <h1 className="text-lg font-bold flex items-end gap-2 absolute left-1/2 -translate-x-1/2 whitespace-nowrap">
167
+ {/* Crab family hover to animate together */}
168
+ <span className="flex items-end gap-1 group cursor-default" aria-hidden="true">
169
+ <CrabLogo className="size-10 text-amber-500 transition-all duration-300 group-hover:scale-125 group-hover:-translate-y-1.5" />
170
+ <span className="flex items-end gap-0.5">
171
+ {crabVariants.map((Crab, i) => (
172
+ <Crab
173
+ key={i}
174
+ className={`size-5 ${
175
+ [
176
+ "text-amber-500",
177
+ "text-rose-500",
178
+ "text-sky-500",
179
+ "text-emerald-500",
180
+ "text-violet-500",
181
+ "text-orange-500",
182
+ "text-cyan-500",
183
+ "text-pink-500",
184
+ "text-lime-500",
185
+ "text-blue-500",
186
+ "text-yellow-500",
187
+ "text-fuchsia-500",
188
+ ][i]
189
+ } transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1`}
190
+ style={{ transitionDelay: `${i * 50}ms` }}
191
+ />
192
+ ))}
193
+ </span>
194
+ </span>
179
195
  <span className="flex items-baseline gap-2">
180
196
  LLM Inspector
181
197
  <span className="text-xs text-muted-foreground font-mono">v{packageJson.version}</span>
182
198
  </span>
183
199
  </h1>
200
+ <div className="ml-auto">
201
+ <SettingsDialog />
202
+ </div>
203
+ </div>
204
+
205
+ {/* Controls + Filters */}
206
+ <div className="flex items-center gap-3 px-6 mb-4">
207
+ <Select value={selectedSession} onValueChange={onSessionChange}>
208
+ <SelectTrigger className="flex-1 max-w-[350px] text-xs">
209
+ <SelectValue placeholder="All sessions" />
210
+ </SelectTrigger>
211
+ <SelectContent>
212
+ <SelectItem value="__all__">All sessions</SelectItem>
213
+ {sessions.map((s) => (
214
+ <SelectItem key={s} value={s}>
215
+ {truncateSessionId(s)}
216
+ </SelectItem>
217
+ ))}
218
+ </SelectContent>
219
+ </Select>
220
+ <Select value={selectedModel} onValueChange={onModelChange}>
221
+ <SelectTrigger className="flex-1 max-w-[250px] text-xs">
222
+ <SelectValue placeholder="All models" />
223
+ </SelectTrigger>
224
+ <SelectContent>
225
+ <SelectItem value="__all__">All models</SelectItem>
226
+ {models.map((m) => (
227
+ <SelectItem key={m} value={m}>
228
+ {m}
229
+ </SelectItem>
230
+ ))}
231
+ </SelectContent>
232
+ </Select>
184
233
  <TooltipProvider>
185
234
  <Tooltip>
186
235
  <TooltipTrigger asChild>
@@ -214,7 +263,7 @@ export function ProxyViewer({
214
263
  </TooltipContent>
215
264
  </Tooltip>
216
265
  </TooltipProvider>
217
- <SettingsDialog />
266
+ <div className="flex-1" />
218
267
  <span className="text-muted-foreground text-xs font-mono">
219
268
  {logs.length} request{logs.length !== 1 ? "s" : ""}
220
269
  {totalIn > 0 || totalOut > 0
@@ -236,7 +285,7 @@ export function ProxyViewer({
236
285
  ) : (
237
286
  <>
238
287
  <Download className="size-3" />
239
- <span>Export All</span>
288
+ <span>Export</span>
240
289
  </>
241
290
  )}
242
291
  </button>
@@ -247,97 +296,15 @@ export function ProxyViewer({
247
296
  className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
248
297
  title="Clear all logs"
249
298
  >
250
- Clear All
299
+ Clear
251
300
  </button>
252
301
  </div>
253
302
 
254
- {/* Filters */}
255
- <div className="flex gap-3 px-6 mb-4">
256
- <Select value={selectedSession} onValueChange={onSessionChange}>
257
- <SelectTrigger className="flex-1 max-w-[400px] text-xs">
258
- <SelectValue placeholder="All sessions" />
259
- </SelectTrigger>
260
- <SelectContent>
261
- <SelectItem value="__all__">All sessions</SelectItem>
262
- {sessions.map((s) => (
263
- <SelectItem key={s} value={s}>
264
- {truncateSessionId(s)}
265
- </SelectItem>
266
- ))}
267
- </SelectContent>
268
- </Select>
269
-
270
- <Select value={selectedModel} onValueChange={onModelChange}>
271
- <SelectTrigger className="text-xs">
272
- <SelectValue placeholder="All models" />
273
- </SelectTrigger>
274
- <SelectContent>
275
- <SelectItem value="__all__">All models</SelectItem>
276
- {models.map((m) => (
277
- <SelectItem key={m} value={m}>
278
- {m}
279
- </SelectItem>
280
- ))}
281
- </SelectContent>
282
- </Select>
283
-
284
- {/* View toggle */}
285
- <div className="flex items-center border border-border rounded-md overflow-hidden">
286
- <button
287
- type="button"
288
- onClick={() => setGroupedView(true)}
289
- className={`px-2 py-1.5 cursor-pointer transition-colors ${
290
- groupedView ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50"
291
- }`}
292
- title="Grouped view"
293
- >
294
- <LayoutGrid className="size-4" />
295
- </button>
296
- <button
297
- type="button"
298
- onClick={() => setGroupedView(false)}
299
- className={`px-2 py-1.5 cursor-pointer transition-colors ${
300
- !groupedView ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50"
301
- }`}
302
- title="Flat view"
303
- >
304
- <List className="size-4" />
305
- </button>
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>
335
- </div>
336
-
337
303
  {/* Log list */}
338
304
  <div className="flex-1 min-h-0 px-6 pb-6">
339
305
  {logs.length === 0 ? (
340
306
  <div className="text-center text-muted-foreground py-16 space-y-4">
307
+ <CrabLogo className="size-12 text-muted-foreground/20 mx-auto" />
341
308
  <p className="text-sm">No requests captured yet.</p>
342
309
  <p className="text-xs">Route AI coding tools through the proxy:</p>
343
310
  <CopyableCommand command="LLM_BASE_URL=http://localhost:25947/proxy <your-tool>" />
@@ -352,79 +319,31 @@ export function ProxyViewer({
352
319
  }}
353
320
  >
354
321
  {rowVirtualizer.getVirtualItems().map((virtualRow) => {
355
- if (renderGroups) {
356
- const group = groups[virtualRow.index];
357
- if (group === undefined) return null;
358
- return (
359
- <div
360
- key={group.id}
361
- data-index={virtualRow.index}
362
- ref={rowVirtualizer.measureElement}
363
- style={{
364
- position: "absolute",
365
- top: 0,
366
- left: 0,
367
- width: "100%",
368
- transform: `translateY(${virtualRow.start}px)`,
369
- }}
370
- >
371
- <ConversationGroup
372
- group={group}
373
- viewMode={viewMode}
374
- strip={strip}
375
- cacheTrends={cacheTrends}
376
- onCompareWithPrevious={handleCompareWithPrevious}
377
- defaultGroupViewMode={groupViewMode}
378
- />
379
- </div>
380
- );
381
- } else {
382
- const log = logs[virtualRow.index];
383
- if (log === undefined) return null;
384
- const idx = virtualRow.index;
385
- return (
386
- <div
387
- key={log.id}
388
- data-index={virtualRow.index}
389
- ref={rowVirtualizer.measureElement}
390
- style={{
391
- position: "absolute",
392
- top: 0,
393
- left: 0,
394
- width: "100%",
395
- transform: `translateY(${virtualRow.start}px)`,
396
- }}
397
- >
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
- )}
425
- </div>
426
- );
427
- }
322
+ const group = groups[virtualRow.index];
323
+ if (group === undefined) return null;
324
+ return (
325
+ <div
326
+ key={group.id}
327
+ data-index={virtualRow.index}
328
+ ref={rowVirtualizer.measureElement}
329
+ style={{
330
+ position: "absolute",
331
+ top: 0,
332
+ left: 0,
333
+ width: "100%",
334
+ transform: `translateY(${virtualRow.start}px)`,
335
+ }}
336
+ >
337
+ <ConversationGroup
338
+ group={group}
339
+ viewMode={viewMode}
340
+ strip={strip}
341
+ cacheTrends={cacheTrends}
342
+ onCompareWithPrevious={handleCompareWithPrevious}
343
+ standalone={groups.length === 1}
344
+ />
345
+ </div>
346
+ );
428
347
  })}
429
348
  </div>
430
349
  </div>