@tonyclaw/llm-inspector 1.12.0 → 1.14.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 (35) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-B5q3Llgm.css +1 -0
  3. package/.output/public/assets/index-C6tbslcs.js +105 -0
  4. package/.output/public/assets/{main-BYCM7aJx.js → main-C1k6vRnH.js} +3 -3
  5. package/.output/server/_libs/cfworker__json-schema.mjs +1 -0
  6. package/.output/server/_libs/lucide-react.mjs +64 -58
  7. package/.output/server/_libs/modelcontextprotocol__server.mjs +9738 -0
  8. package/.output/server/_libs/zod.mjs +79 -16
  9. package/.output/server/_ssr/{index-DhChP_jV.mjs → index-AxruZp16.mjs} +1201 -165
  10. package/.output/server/_ssr/index.mjs +2 -2
  11. package/.output/server/_ssr/{router-PZjNwOcw.mjs → router-DtleGqN8.mjs} +650 -29
  12. package/.output/server/_tanstack-start-manifest_v-B1WAHWIa.mjs +4 -0
  13. package/.output/server/index.mjs +24 -24
  14. package/README.md +98 -0
  15. package/package.json +3 -1
  16. package/src/components/ProxyViewer.tsx +126 -2
  17. package/src/components/proxy-viewer/CompareDrawer.tsx +880 -0
  18. package/src/components/proxy-viewer/ConversationGroup.tsx +8 -0
  19. package/src/components/proxy-viewer/LogEntry.tsx +14 -1
  20. package/src/components/proxy-viewer/LogEntryHeader.tsx +28 -0
  21. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +74 -4
  22. package/src/components/proxy-viewer/requestDiff.ts +277 -0
  23. package/src/lib/serverPort.ts +41 -0
  24. package/src/mcp/loopback.ts +76 -0
  25. package/src/mcp/previewExtractor.ts +166 -0
  26. package/src/mcp/server.ts +320 -0
  27. package/src/mcp/toolHandlers.ts +259 -0
  28. package/src/proxy/formats/openai/schemas.ts +19 -0
  29. package/src/proxy/handler.ts +23 -2
  30. package/src/proxy/openaiOrphanToolStrip.ts +148 -0
  31. package/src/proxy/schemas.ts +1 -0
  32. package/src/routes/api/mcp.ts +25 -0
  33. package/.output/public/assets/index-DVgdkDgq.js +0 -105
  34. package/.output/public/assets/index-DZx2yk8v.css +0 -1
  35. package/.output/server/_tanstack-start-manifest_v-l1kWkG0h.mjs +0 -4
@@ -0,0 +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-C1k6vRnH.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-C6tbslcs.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/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/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" } }, "clientEntry": "/assets/main-C1k6vRnH.js" });
2
+ export {
3
+ tsrStartManifest
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-09T08:02:30.828Z",
41
+ "mtime": "2026-06-10T06:24:38.532Z",
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-09T08:02:30.828Z",
49
- "size": 6918,
50
- "path": "../public/assets/minimax-BPMzvuL-.jpeg"
51
- },
52
- "/assets/index-DZx2yk8v.css": {
45
+ "/assets/index-B5q3Llgm.css": {
53
46
  "type": "text/css; charset=utf-8",
54
- "etag": '"1177b-93l31JbQrAbdmLoUOxZGbqTQwcc"',
55
- "mtime": "2026-06-09T08:02:30.830Z",
56
- "size": 71547,
57
- "path": "../public/assets/index-DZx2yk8v.css"
47
+ "etag": '"13e7d-80ipn+yqCPCa5t/vCr1MDg4cP4g"',
48
+ "mtime": "2026-06-10T06:24:38.532Z",
49
+ "size": 81533,
50
+ "path": "../public/assets/index-B5q3Llgm.css"
58
51
  },
59
52
  "/assets/zhipuai-BPNAnxo-.svg": {
60
53
  "type": "image/svg+xml",
61
54
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
62
- "mtime": "2026-06-09T08:02:30.828Z",
55
+ "mtime": "2026-06-10T06:24:38.529Z",
63
56
  "size": 11256,
64
57
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
65
58
  },
66
- "/assets/main-BYCM7aJx.js": {
59
+ "/assets/main-C1k6vRnH.js": {
67
60
  "type": "text/javascript; charset=utf-8",
68
- "etag": '"50599-LT3fOJsfcl49n+Hp+rSe62uyiuk"',
69
- "mtime": "2026-06-09T08:02:30.830Z",
61
+ "etag": '"50599-AYrUeIFAuGF/4DPZ+5lAMslWd7Q"',
62
+ "mtime": "2026-06-10T06:24:38.532Z",
70
63
  "size": 329113,
71
- "path": "../public/assets/main-BYCM7aJx.js"
64
+ "path": "../public/assets/main-C1k6vRnH.js"
65
+ },
66
+ "/assets/minimax-BPMzvuL-.jpeg": {
67
+ "type": "image/jpeg",
68
+ "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
69
+ "mtime": "2026-06-10T06:24:38.532Z",
70
+ "size": 6918,
71
+ "path": "../public/assets/minimax-BPMzvuL-.jpeg"
72
72
  },
73
73
  "/assets/qwen-CONDcHqt.png": {
74
74
  "type": "image/png",
75
75
  "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
76
- "mtime": "2026-06-09T08:02:30.830Z",
76
+ "mtime": "2026-06-10T06:24:38.532Z",
77
77
  "size": 357059,
78
78
  "path": "../public/assets/qwen-CONDcHqt.png"
79
79
  },
80
- "/assets/index-DVgdkDgq.js": {
80
+ "/assets/index-C6tbslcs.js": {
81
81
  "type": "text/javascript; charset=utf-8",
82
- "etag": '"8a87f-jt4qwgWrMm8cZtArzPx7qX8p12k"',
83
- "mtime": "2026-06-09T08:02:30.830Z",
84
- "size": 567423,
85
- "path": "../public/assets/index-DVgdkDgq.js"
82
+ "etag": '"90252-JCPOfSZg2vTS69tqlOJ4qp75AeM"',
83
+ "mtime": "2026-06-10T06:24:38.532Z",
84
+ "size": 590418,
85
+ "path": "../public/assets/index-C6tbslcs.js"
86
86
  }
87
87
  };
88
88
  function readAsset(id) {
package/README.md CHANGED
@@ -77,6 +77,103 @@ ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>
77
77
 
78
78
  ---
79
79
 
80
+ ## MCP Server (Coding Agent Integration)
81
+
82
+ llm-inspector ships with an embedded [Model Context Protocol](https://modelcontextprotocol.io)
83
+ (MCP) server, mounted at `POST http://localhost:25947/api/mcp` on the same port as
84
+ the Web UI. Any MCP-aware Coding Agent running on the same machine can
85
+ discover and invoke 11 `inspector_*` tools to read captured traffic and
86
+ manage provider configuration without a human round-trip.
87
+
88
+ ### Tool catalog (11 tools, all prefixed `inspector_`)
89
+
90
+ **Read (6) — cheap discovery of what was just sent**
91
+
92
+ | Tool | Purpose |
93
+ |------|---------|
94
+ | `inspector_list_logs` | Recent captured logs in reverse-chronological order, with parsed previews of the last user message and the assistant response. Default limit 3, hard-capped at 5. |
95
+ | `inspector_get_log` | Full `CapturedLog` for a single id, including `rawRequestBody` and `responseText` with no truncation. Streaming chunks are NOT included. |
96
+ | `inspector_get_log_chunks` | SSE streaming chunks for a single log, in order received. |
97
+ | `inspector_list_sessions` | All session ids that have been observed in captured logs. |
98
+ | `inspector_list_models` | Distinct model names seen across all logs. |
99
+ | `inspector_list_providers` | All configured providers, including `apiKey` in plaintext. |
100
+
101
+ **Action (2)**
102
+
103
+ | Tool | Purpose |
104
+ |------|---------|
105
+ | `inspector_get_provider` | Full `ProviderConfig` for a single id, `apiKey` in plaintext. |
106
+ | `inspector_replay_log` | Re-send a captured request body to its upstream and return the response summary. |
107
+
108
+ **Provider write (3) — mutate provider config**
109
+
110
+ | Tool | Purpose |
111
+ |------|---------|
112
+ | `inspector_add_provider` | Persist a new provider to `<dataDir>/providers.json`. |
113
+ | `inspector_update_provider` | Patch-style update of an existing provider. |
114
+ | `inspector_test_provider` | Run the UI's connectivity test against a provider. |
115
+
116
+ **Explicitly NOT exposed** (kept UI-only by design):
117
+
118
+ - `delete_provider` — irreversible.
119
+ - `clear_log_cache` — only clears the in-memory LRU; misleads agents that expect a full wipe.
120
+ - `get_runtime_config` / `set_runtime_config` — runtime policy surface; UI is the source of truth.
121
+
122
+ ### Connecting an MCP client
123
+
124
+ The server speaks **HTTP Streamable transport** (JSON-RPC over HTTP). The
125
+ simplest way to point a client at it is to register the endpoint URL. For
126
+ example, in Claude Code's `.mcp.json`:
127
+
128
+ ```json
129
+ {
130
+ "mcpServers": {
131
+ "llm-inspector": {
132
+ "type": "http",
133
+ "url": "http://localhost:25947/api/mcp"
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ For raw clients that send JSON-RPC directly:
140
+
141
+ ```bash
142
+ curl -X POST http://localhost:25947/api/mcp \
143
+ -H 'content-type: application/json' \
144
+ -H 'accept: application/json, text/event-stream' \
145
+ -d '{
146
+ "jsonrpc": "2.0",
147
+ "id": 1,
148
+ "method": "initialize",
149
+ "params": {
150
+ "protocolVersion": "2025-06-18",
151
+ "capabilities": {},
152
+ "clientInfo": { "name": "my-client", "version": "0.0.0" }
153
+ }
154
+ }'
155
+ ```
156
+
157
+ ### Posture and limitations
158
+
159
+ - **Localhost only, no auth.** The MCP endpoint inherits inspector's
160
+ existing localhost binding. Any process that can call `/api/mcp` can also
161
+ read `<dataDir>/providers.json` directly. Do not run inspector on a
162
+ host where untrusted local users can reach it.
163
+ - **Provider API keys are returned in plaintext** by `inspector_list_providers`
164
+ and `inspector_get_provider`. This is intentional — redacting in MCP but
165
+ not in the underlying JSON file would be performative security.
166
+ - **Reflexive loop:** `inspector_list_logs` may include the agent's own
167
+ recent `/proxy` calls. Filter by `clientPid` (your own PID) or by
168
+ timestamp on the client side. The tool description surfaces this warning.
169
+ - **List limits are hardcoded** at default 3, max 5. Not user-configurable.
170
+ - **Deferred to v1.1** (not in this change): MCP Resources for logs
171
+ (`mcp://llm-inspector/log/{id}`), notifications/subscriptions for live
172
+ log push, filtering out reflexive-loop entries server-side, full-text
173
+ or time-range search, MCP-level auth.
174
+
175
+ ---
176
+
80
177
  ## Documentation / 文档目录
81
178
 
82
179
  - [特性 (Features)](docs/Features.md) - 核心功能介绍
@@ -84,3 +181,4 @@ ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>
84
181
  - [使用 (Usage)](docs/Usage.md) - 详细使用说明
85
182
  - [架构 (Architecture)](docs/Architecture.md) - 系统架构
86
183
  - [开发 (Development)](docs/Development.md) - 开发指南
184
+ - [MCP Server](docs/MCP-Server.md) - 把 llm-inspector 当作 MCP 工具接入 Coding Agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/llm-inspector",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "type": "module",
5
5
  "description": "LLM API proxy inspector — captures and displays requests/responses from AI coding tools in a web UI",
6
6
  "license": "MIT",
@@ -46,6 +46,7 @@
46
46
  "ladle:build": "ladle build"
47
47
  },
48
48
  "dependencies": {
49
+ "@modelcontextprotocol/server": "^2.0.0-alpha.2",
49
50
  "@radix-ui/react-dialog": "^1.1.14",
50
51
  "@radix-ui/react-select": "^2.2.6",
51
52
  "@tailwindcss/typography": "^0.5.19",
@@ -69,6 +70,7 @@
69
70
  },
70
71
  "devDependencies": {
71
72
  "@biomejs/biome": "^2.3.14",
73
+ "@cfworker/json-schema": "^4.1.1",
72
74
  "@eslint/js": "^10.0.1",
73
75
  "@ladle/react": "^5.1.1",
74
76
  "@tailwindcss/vite": "^4.1.18",
@@ -1,6 +1,6 @@
1
- import { type JSX, useCallback, useMemo, useState, useRef } from "react";
1
+ import { type JSX, useCallback, useEffect, useMemo, useState, useRef } from "react";
2
2
  import { useVirtualizer } from "@tanstack/react-virtual";
3
- import { Download, LayoutGrid, List } from "lucide-react";
3
+ import { Download, GitCompareArrows, LayoutGrid, List, X } from "lucide-react";
4
4
  import type { CapturedLog } from "../proxy/schemas";
5
5
  import { exportLogsAsZip } from "../lib/export-logs";
6
6
  import packageJson from "../../package.json";
@@ -14,6 +14,8 @@ import { CrabLogo } from "./ui/crab-logo";
14
14
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
15
15
  import { SettingsDialog } from "./providers/SettingsDialog";
16
16
  import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
17
+ import { CompareDrawer } from "./proxy-viewer/CompareDrawer";
18
+ import { getConversationId } from "./proxy-viewer/ConversationHeader";
17
19
 
18
20
  function truncateSessionId(id: string): string {
19
21
  if (id.length <= 30) return id;
@@ -113,6 +115,9 @@ export function ProxyViewer({
113
115
  const { totalIn, totalOut } = computeTokenSummary(logs);
114
116
  const [groupedView, setGroupedView] = useState(true);
115
117
  const [exporting, setExporting] = useState(false);
118
+ const [selectedLogIds, setSelectedLogIds] = useState<number[]>([]);
119
+ const [compareOpen, setCompareOpen] = useState(false);
120
+ const [comparePair, setComparePair] = useState<[CapturedLog, CapturedLog] | null>(null);
116
121
 
117
122
  const handleExport = useCallback(async () => {
118
123
  setExporting(true);
@@ -124,6 +129,88 @@ export function ProxyViewer({
124
129
  }, [logs]);
125
130
  const parentRef = useRef<HTMLDivElement>(null);
126
131
 
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.
150
+ useEffect(() => {
151
+ setSelectedLogIds([]);
152
+ setCompareOpen(false);
153
+ }, [selectedSession, selectedModel]);
154
+
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
+ 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([]);
177
+ }, []);
178
+
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
+ }
213
+
127
214
  const groups = useMemo(() => groupLogsByConversation(logs), [logs]);
128
215
  const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
129
216
 
@@ -310,6 +397,8 @@ export function ProxyViewer({
310
397
  viewMode={viewMode}
311
398
  strip={strip}
312
399
  cacheTrends={cacheTrends}
400
+ selectedSet={selectedSet}
401
+ onToggleSelect={handleToggleSelect}
313
402
  />
314
403
  </div>
315
404
  );
@@ -334,6 +423,8 @@ export function ProxyViewer({
334
423
  viewMode={viewMode}
335
424
  strip={strip}
336
425
  cacheTrend={cacheTrends.get(log.id) ?? null}
426
+ isSelected={selectedSet.has(log.id)}
427
+ onToggleSelect={handleToggleSelect}
337
428
  />
338
429
  </div>
339
430
  );
@@ -343,6 +434,39 @@ export function ProxyViewer({
343
434
  </div>
344
435
  )}
345
436
  </div>
437
+
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
+ {/* Compare drawer — sibling of the log list, not a route change. */}
467
+ {compareOpen && comparePair !== null && (
468
+ <CompareDrawer left={comparePair[0]} right={comparePair[1]} onClose={closeCompare} />
469
+ )}
346
470
  </div>
347
471
  );
348
472
  }