@tonyclaw/llm-inspector 1.15.1 → 1.16.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 (39) hide show
  1. package/.output/cli.js +1 -0
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/index-BmkN9DxE.js +107 -0
  4. package/.output/public/assets/index-DPe3eOih.css +1 -0
  5. package/.output/public/assets/{main-C2-qvdhC.js → main-BjnjXVBU.js} +1 -1
  6. package/.output/server/_ssr/{index-C001qcnM.mjs → index-BIOEVAzU.mjs} +216 -180
  7. package/.output/server/_ssr/index.mjs +2 -2
  8. package/.output/server/_ssr/{router-D7aEu4dR.mjs → router-THS9ptvu.mjs} +392 -170
  9. package/.output/server/{_tanstack-start-manifest_v-BXMwlSXD.mjs → _tanstack-start-manifest_v-BYhN7q_z.mjs} +1 -1
  10. package/.output/server/index.mjs +27 -27
  11. package/package.json +1 -1
  12. package/src/cli.ts +1 -0
  13. package/src/components/ProxyViewer.tsx +16 -54
  14. package/src/components/ProxyViewerContainer.tsx +121 -77
  15. package/src/components/providers/ImportWizardDialog.tsx +27 -3
  16. package/src/components/proxy-viewer/ConversationGroup.tsx +3 -3
  17. package/src/components/proxy-viewer/ConversationHeader.tsx +1 -1
  18. package/src/components/proxy-viewer/LogEntry.tsx +184 -171
  19. package/src/components/proxy-viewer/LogEntryHeader.tsx +126 -137
  20. package/src/components/proxy-viewer/ResponseView.tsx +3 -2
  21. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +3 -3
  22. package/src/components/proxy-viewer/TurnGroup.tsx +4 -4
  23. package/src/components/proxy-viewer/diff/DiffView.tsx +5 -3
  24. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +13 -9
  25. package/src/components/proxy-viewer/formats/anthropic/ResponseView.tsx +3 -3
  26. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +7 -3
  27. package/src/components/ui/json-viewer.tsx +3 -3
  28. package/src/lib/objectUtils.ts +22 -0
  29. package/src/proxy/claudeCodeStrip.ts +5 -8
  30. package/src/proxy/logIndex.ts +58 -43
  31. package/src/proxy/logger.ts +51 -27
  32. package/src/proxy/openaiOrphanToolStrip.ts +11 -17
  33. package/src/proxy/providerImporters.ts +245 -19
  34. package/src/proxy/providers.ts +20 -7
  35. package/src/proxy/schemas.ts +5 -9
  36. package/src/proxy/socketTracker.ts +109 -78
  37. package/src/proxy/store.ts +52 -83
  38. package/.output/public/assets/index-BIUbzgJN.css +0 -1
  39. package/.output/public/assets/index-TwYgzIL4.js +0 -107
@@ -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-C2-qvdhC.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-TwYgzIL4.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-C2-qvdhC.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-BjnjXVBU.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-BmkN9DxE.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-BjnjXVBU.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-13T01:52:42.193Z",
41
+ "mtime": "2026-06-13T05:45:10.081Z",
42
42
  "size": 5915,
43
43
  "path": "../public/assets/alibaba-TTwafVwX.svg"
44
44
  },
45
- "/assets/index-BIUbzgJN.css": {
45
+ "/assets/index-DPe3eOih.css": {
46
46
  "type": "text/css; charset=utf-8",
47
- "etag": '"15a69-hayhIEDa3A+N2SY+Poyq3TNPtcE"',
48
- "mtime": "2026-06-13T01:52:42.193Z",
49
- "size": 88681,
50
- "path": "../public/assets/index-BIUbzgJN.css"
47
+ "etag": '"15a32-YUlUFJPXe5WrOzMkLVz9gOyERE4"',
48
+ "mtime": "2026-06-13T05:45:10.081Z",
49
+ "size": 88626,
50
+ "path": "../public/assets/index-DPe3eOih.css"
51
+ },
52
+ "/assets/qwen-CONDcHqt.png": {
53
+ "type": "image/png",
54
+ "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
55
+ "mtime": "2026-06-13T05:45:10.081Z",
56
+ "size": 357059,
57
+ "path": "../public/assets/qwen-CONDcHqt.png"
58
+ },
59
+ "/assets/main-BjnjXVBU.js": {
60
+ "type": "text/javascript; charset=utf-8",
61
+ "etag": '"50599-TBFFt8m3KX+84o0h5FGqLcw9zvM"',
62
+ "mtime": "2026-06-13T05:45:10.081Z",
63
+ "size": 329113,
64
+ "path": "../public/assets/main-BjnjXVBU.js"
51
65
  },
52
66
  "/assets/minimax-BPMzvuL-.jpeg": {
53
67
  "type": "image/jpeg",
54
68
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
55
- "mtime": "2026-06-13T01:52:42.191Z",
69
+ "mtime": "2026-06-13T05:45:10.078Z",
56
70
  "size": 6918,
57
71
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
58
72
  },
59
73
  "/assets/zhipuai-BPNAnxo-.svg": {
60
74
  "type": "image/svg+xml",
61
75
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
62
- "mtime": "2026-06-13T01:52:42.193Z",
76
+ "mtime": "2026-06-13T05:45:10.081Z",
63
77
  "size": 11256,
64
78
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
65
79
  },
66
- "/assets/qwen-CONDcHqt.png": {
67
- "type": "image/png",
68
- "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
69
- "mtime": "2026-06-13T01:52:42.193Z",
70
- "size": 357059,
71
- "path": "../public/assets/qwen-CONDcHqt.png"
72
- },
73
- "/assets/main-C2-qvdhC.js": {
74
- "type": "text/javascript; charset=utf-8",
75
- "etag": '"50599-m0++N6iqOd8eV+QO9BaomZTQ8VY"',
76
- "mtime": "2026-06-13T01:52:42.193Z",
77
- "size": 329113,
78
- "path": "../public/assets/main-C2-qvdhC.js"
79
- },
80
- "/assets/index-TwYgzIL4.js": {
80
+ "/assets/index-BmkN9DxE.js": {
81
81
  "type": "text/javascript; charset=utf-8",
82
- "etag": '"9abab-Xq73HVsqEPkBjYtK8k/1UBY8SRY"',
83
- "mtime": "2026-06-13T01:52:42.193Z",
84
- "size": 633771,
85
- "path": "../public/assets/index-TwYgzIL4.js"
82
+ "etag": '"9ae0c-h7IO5tRUMmZre12bMt7hEiFWLhQ"',
83
+ "mtime": "2026-06-13T05:45:10.081Z",
84
+ "size": 634380,
85
+ "path": "../public/assets/index-BmkN9DxE.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.15.1",
3
+ "version": "1.16.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",
package/src/cli.ts CHANGED
@@ -126,6 +126,7 @@ console.log(``);
126
126
  console.log(`Route AI coding tools through the proxy:`);
127
127
  console.log(` Claude Code: ANTHROPIC_BASE_URL=${url}/proxy claude`);
128
128
  console.log(` OpenCode: LLM_BASE_URL=${url}/proxy opencode`);
129
+ console.log(` MiMo Code: OPENAI_BASE_URL=${url}/proxy mimo`);
129
130
  console.log(` Direct HTTP: curl ${url}/proxy/v1/messages -d '{"model":"...","messages":[...]}'`);
130
131
  console.log(``);
131
132
  console.log(`Routing environment variables:`);
@@ -1,5 +1,4 @@
1
- import { type JSX, useCallback, useEffect, useMemo, useState, useRef } from "react";
2
- import { useVirtualizer } from "@tanstack/react-virtual";
1
+ import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
3
2
  import { Download } from "lucide-react";
4
3
 
5
4
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./ui/tooltip";
@@ -115,7 +114,7 @@ export function ProxyViewer({
115
114
  onViewModeChange,
116
115
  strip,
117
116
  }: ProxyViewerProps): JSX.Element {
118
- const { totalIn, totalOut } = computeTokenSummary(logs);
117
+ const { totalIn, totalOut } = useMemo(() => computeTokenSummary(logs), [logs]);
119
118
  const [exporting, setExporting] = useState(false);
120
119
  const [comparePair, setComparePair] = useState<[CapturedLog, CapturedLog] | null>(null);
121
120
  const [crabEntrancePhase, setCrabEntrancePhase] = useState<"hidden" | "playing" | "done">(
@@ -142,8 +141,6 @@ export function ProxyViewer({
142
141
  setExporting(false);
143
142
  }
144
143
  }, [logs]);
145
- const parentRef = useRef<HTMLDivElement>(null);
146
-
147
144
  // Close the compare drawer when the user changes the session or model
148
145
  // filter, since the predecessor relationship may no longer be meaningful.
149
146
  useEffect(() => {
@@ -165,17 +162,6 @@ export function ProxyViewer({
165
162
  [comparisonPredecessors],
166
163
  );
167
164
 
168
- const rowVirtualizer = useVirtualizer({
169
- count: groups.length,
170
- getScrollElement: () => parentRef.current,
171
- estimateSize: () => 150,
172
- measureElement:
173
- typeof window !== "undefined"
174
- ? (element) => element.getBoundingClientRect().height
175
- : undefined,
176
- overscan: 5,
177
- });
178
-
179
165
  return (
180
166
  <div className="max-w-[1200px] mx-auto flex flex-col h-screen" style={{ maxHeight: "100vh" }}>
181
167
  {/* Brand row */}
@@ -338,44 +324,20 @@ export function ProxyViewer({
338
324
  <CopyableCommand command="LLM_BASE_URL=http://localhost:25947/proxy <your-tool>" />
339
325
  </div>
340
326
  ) : (
341
- <div ref={parentRef} className="overflow-y-auto h-full">
342
- <div
343
- style={{
344
- height: `${rowVirtualizer.getTotalSize()}px`,
345
- width: "100%",
346
- position: "relative",
347
- }}
348
- >
349
- {rowVirtualizer.getVirtualItems().map((virtualRow) => {
350
- const group = groups[virtualRow.index];
351
- if (group === undefined) return null;
352
- return (
353
- <div
354
- key={group.id}
355
- data-index={virtualRow.index}
356
- ref={rowVirtualizer.measureElement}
357
- style={{
358
- position: "absolute",
359
- top: 0,
360
- left: 0,
361
- width: "100%",
362
- transform: `translateY(${virtualRow.start}px)`,
363
- }}
364
- >
365
- <ConversationGroup
366
- group={group}
367
- viewMode={viewMode}
368
- strip={strip}
369
- cacheTrends={cacheTrends}
370
- onCompareWithPrevious={handleCompareWithPrevious}
371
- comparisonPredecessors={comparisonPredecessors}
372
- onClearGroup={onClearGroup}
373
- standalone={groups.length === 1}
374
- />
375
- </div>
376
- );
377
- })}
378
- </div>
327
+ <div className="overflow-y-auto h-full flex flex-col gap-2">
328
+ {groups.map((group) => (
329
+ <ConversationGroup
330
+ key={group.id}
331
+ group={group}
332
+ viewMode={viewMode}
333
+ strip={strip}
334
+ cacheTrends={cacheTrends}
335
+ onCompareWithPrevious={handleCompareWithPrevious}
336
+ comparisonPredecessors={comparisonPredecessors}
337
+ onClearGroup={onClearGroup}
338
+ standalone={groups.length === 1}
339
+ />
340
+ ))}
379
341
  </div>
380
342
  )}
381
343
  </div>
@@ -1,23 +1,9 @@
1
- import { useState, useEffect, useCallback, useRef, type JSX } from "react";
1
+ import { useState, useEffect, useCallback, useRef, useMemo, type JSX } from "react";
2
2
  import { z } from "zod";
3
3
  import { CapturedLogSchema, type CapturedLog } from "../proxy/schemas";
4
4
  import { useStripConfig } from "../lib/useStripConfig";
5
5
  import { ProxyViewer } from "./ProxyViewer";
6
6
 
7
- type logsResponse = {
8
- logs: CapturedLog[];
9
- total: number;
10
- offset: number;
11
- limit: number;
12
- };
13
-
14
- const LogsResponseSchema = z.object({
15
- logs: z.array(CapturedLogSchema),
16
- total: z.number(),
17
- offset: z.number(),
18
- limit: z.number(),
19
- });
20
-
21
7
  type SSEUpdate =
22
8
  | {
23
9
  type: "init";
@@ -55,8 +41,31 @@ function extractModels(logs: CapturedLog[]): string[] {
55
41
  return [...set];
56
42
  }
57
43
 
44
+ /**
45
+ * Filter logs by selected session/model. Both `__all__` and missing values
46
+ * pass through. Returns the input array reference when no log matches the
47
+ * filter, so referential equality is preserved for empty results.
48
+ */
49
+ function filterLogs(
50
+ logs: CapturedLog[],
51
+ selectedSession: string,
52
+ selectedModel: string,
53
+ ): CapturedLog[] {
54
+ if (selectedSession === "__all__" && selectedModel === "__all__") return logs;
55
+ return logs.filter((l) => {
56
+ if (selectedSession !== "__all__" && l.sessionId !== selectedSession) return false;
57
+ if (selectedModel !== "__all__" && l.model !== selectedModel) return false;
58
+ return true;
59
+ });
60
+ }
61
+
62
+ const DEBOUNCE_MS = 50;
63
+
58
64
  export function ProxyViewerContainer(): JSX.Element {
59
- const [logs, setLogs] = useState<CapturedLog[]>([]);
65
+ // `allLogs` is the unfiltered set populated by the SSE. The single SSE
66
+ // connection never re-opens on filter change — we always carry the full
67
+ // set and derive the displayed view with `useMemo` below.
68
+ const [allLogs, setAllLogs] = useState<CapturedLog[]>([]);
60
69
  const [sessions, setSessions] = useState<string[]>([]);
61
70
  const [models, setModels] = useState<string[]>([]);
62
71
  const [selectedSession, setSelectedSession] = useState("__all__");
@@ -66,24 +75,63 @@ export function ProxyViewerContainer(): JSX.Element {
66
75
  const eventSourceRef = useRef<EventSource | null>(null);
67
76
  const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
68
77
 
69
- const fetchSessionsAndModels = useCallback(async () => {
70
- try {
71
- const [sessionsRes, modelsRes] = await Promise.all([
72
- fetch("/api/sessions"),
73
- fetch("/api/models"),
74
- ]);
75
- if (sessionsRes.ok && modelsRes.ok) {
76
- const sessionsJson: unknown = await sessionsRes.json();
77
- const modelsJson: unknown = await modelsRes.json();
78
- const sessionsResult = z.array(z.string()).safeParse(sessionsJson);
79
- const modelsResult = z.array(z.string()).safeParse(modelsJson);
80
- if (sessionsResult.success) setSessions(sessionsResult.data);
81
- if (modelsResult.success) setModels(modelsResult.data);
78
+ // O(1) log lookup by id
79
+ const logIndexRef = useRef<Map<number, number>>(new Map());
80
+ const logsRef = useRef<CapturedLog[]>([]);
81
+
82
+ // Debounce buffer for SSE updates
83
+ const pendingUpdatesRef = useRef<CapturedLog[]>([]);
84
+ const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
85
+
86
+ // Derived view: the logs the user actually sees. Recomputed only when the
87
+ // underlying set or filter changes — never on every SSE message.
88
+ const logs = useMemo(
89
+ () => filterLogs(allLogs, selectedSession, selectedModel),
90
+ [allLogs, selectedSession, selectedModel],
91
+ );
92
+
93
+ const flushUpdates = useCallback(() => {
94
+ flushTimerRef.current = null;
95
+ const updates = pendingUpdatesRef.current;
96
+ pendingUpdatesRef.current = [];
97
+ if (updates.length === 0) return;
98
+
99
+ setAllLogs((prev) => {
100
+ let next = prev;
101
+ let sessionsChanged = false;
102
+ let modelsChanged = false;
103
+ for (const log of updates) {
104
+ const idx = logIndexRef.current.get(log.id);
105
+ if (idx !== undefined) {
106
+ // Replace existing entry
107
+ next = [...next.slice(0, idx), log, ...next.slice(idx + 1)];
108
+ } else {
109
+ logIndexRef.current.set(log.id, next.length);
110
+ next = [...next, log];
111
+ }
112
+ if (log.sessionId !== null && log.sessionId !== "" && !sessions.includes(log.sessionId)) {
113
+ sessionsChanged = true;
114
+ }
115
+ if (log.model !== null && log.model !== "" && !models.includes(log.model)) {
116
+ modelsChanged = true;
117
+ }
118
+ }
119
+ logsRef.current = next;
120
+ if (sessionsChanged) setSessions((s) => extractSessions(next));
121
+ if (modelsChanged) setModels((m) => extractModels(next));
122
+ return next;
123
+ });
124
+ }, [sessions, models]);
125
+
126
+ const scheduleUpdate = useCallback(
127
+ (log: CapturedLog) => {
128
+ pendingUpdatesRef.current.push(log);
129
+ if (flushTimerRef.current === null) {
130
+ flushTimerRef.current = setTimeout(flushUpdates, DEBOUNCE_MS);
82
131
  }
83
- } catch {
84
- // SSE already has sessions/models from init
85
- }
86
- }, []);
132
+ },
133
+ [flushUpdates],
134
+ );
87
135
 
88
136
  const connectSSE = useCallback(() => {
89
137
  // Clean up existing connection
@@ -91,11 +139,12 @@ export function ProxyViewerContainer(): JSX.Element {
91
139
  eventSourceRef.current.close();
92
140
  }
93
141
 
94
- const params = new URLSearchParams();
95
- if (selectedSession !== "__all__") params.set("sessionId", selectedSession);
96
- if (selectedModel !== "__all__") params.set("model", selectedModel);
97
-
98
- const es = new EventSource(`/api/logs/stream?${params}`);
142
+ // Stable, unfiltered connection. The frontend derives filtered views
143
+ // from the complete set, so the SSE never needs to reopen on filter
144
+ // change. If the in-memory set ever grows past what the client can
145
+ // handle, swap this back to a parameterized URL and reconnect on
146
+ // filter change.
147
+ const es = new EventSource("/api/logs/stream");
99
148
  eventSourceRef.current = es;
100
149
 
101
150
  es.onmessage = (event: MessageEvent) => {
@@ -109,42 +158,27 @@ export function ProxyViewerContainer(): JSX.Element {
109
158
  }
110
159
  const update = updateResult.data;
111
160
  if (update.type === "init") {
112
- setLogs(update.logs);
161
+ // Flush any pending debounce
162
+ if (flushTimerRef.current !== null) {
163
+ clearTimeout(flushTimerRef.current);
164
+ flushTimerRef.current = null;
165
+ }
166
+ pendingUpdatesRef.current = [];
167
+
168
+ // Build fresh index
169
+ const idx = new Map<number, number>();
170
+ for (let i = 0; i < update.logs.length; i++) {
171
+ const log = update.logs[i];
172
+ if (log !== undefined) idx.set(log.id, i);
173
+ }
174
+ logIndexRef.current = idx;
175
+ logsRef.current = update.logs;
176
+ setAllLogs(update.logs);
113
177
  setSessions(extractSessions(update.logs));
114
178
  setModels(extractModels(update.logs));
115
179
  setError(null);
116
180
  } else if (update.type === "update") {
117
- setLogs((prev) => {
118
- // Filter by current selection before adding
119
- const sessionMatch =
120
- selectedSession === "__all__" || update.log.sessionId === selectedSession;
121
- const modelMatch = selectedModel === "__all__" || update.log.model === selectedModel;
122
- if (!sessionMatch || !modelMatch) return prev;
123
-
124
- // Update existing log or add as new
125
- const existsIndex = prev.findIndex((l) => l.id === update.log.id);
126
- if (existsIndex >= 0) {
127
- // Replace the existing log with updated data
128
- return prev.map((l) => (l.id === update.log.id ? update.log : l));
129
- }
130
- // Add to end (newest)
131
- return [...prev, update.log];
132
- });
133
- // Update sessions and models
134
- setSessions((prev) => {
135
- const sessionId = update.log.sessionId;
136
- if (sessionId !== null && sessionId !== undefined && sessionId !== "") {
137
- return prev.includes(sessionId) ? prev : [...prev, sessionId];
138
- }
139
- return prev;
140
- });
141
- setModels((prev) => {
142
- const model = update.log.model;
143
- if (model !== null && model !== undefined && model !== "") {
144
- return prev.includes(model) ? prev : [...prev, model];
145
- }
146
- return prev;
147
- });
181
+ scheduleUpdate(update.log);
148
182
  }
149
183
  } catch {
150
184
  setError("Failed to parse SSE data");
@@ -154,16 +188,12 @@ export function ProxyViewerContainer(): JSX.Element {
154
188
  es.onerror = () => {
155
189
  setError("SSE connection lost, reconnecting...");
156
190
  es.close();
157
- // Clear any existing reconnect timeout
158
191
  if (reconnectTimeoutRef.current !== null) {
159
192
  clearTimeout(reconnectTimeoutRef.current);
160
193
  }
161
- // Reconnect after 3 seconds
162
194
  reconnectTimeoutRef.current = setTimeout(connectSSE, 3000);
163
195
  };
164
-
165
- void fetchSessionsAndModels();
166
- }, [selectedSession, selectedModel, fetchSessionsAndModels]);
196
+ }, [scheduleUpdate]);
167
197
 
168
198
  useEffect(() => {
169
199
  connectSSE();
@@ -176,6 +206,10 @@ export function ProxyViewerContainer(): JSX.Element {
176
206
  clearTimeout(reconnectTimeoutRef.current);
177
207
  reconnectTimeoutRef.current = null;
178
208
  }
209
+ if (flushTimerRef.current !== null) {
210
+ clearTimeout(flushTimerRef.current);
211
+ flushTimerRef.current = null;
212
+ }
179
213
  };
180
214
  }, [connectSSE]);
181
215
 
@@ -187,7 +221,9 @@ export function ProxyViewerContainer(): JSX.Element {
187
221
  setError("Failed to clear logs");
188
222
  return;
189
223
  }
190
- setLogs([]);
224
+ logIndexRef.current.clear();
225
+ logsRef.current = [];
226
+ setAllLogs([]);
191
227
  setSessions([]);
192
228
  setModels([]);
193
229
  setError(null);
@@ -211,8 +247,16 @@ export function ProxyViewerContainer(): JSX.Element {
211
247
  return;
212
248
  }
213
249
  const idSet = new Set(ids);
214
- setLogs((prev) => {
250
+ setAllLogs((prev) => {
215
251
  const remaining = prev.filter((l) => !idSet.has(l.id));
252
+ // Rebuild index
253
+ const idx = new Map<number, number>();
254
+ for (let i = 0; i < remaining.length; i++) {
255
+ const log = remaining[i];
256
+ if (log !== undefined) idx.set(log.id, i);
257
+ }
258
+ logIndexRef.current = idx;
259
+ logsRef.current = remaining;
216
260
  setSessions(extractSessions(remaining));
217
261
  setModels(extractModels(remaining));
218
262
  return remaining;
@@ -38,6 +38,29 @@ function OpenCodeIcon(): JSX.Element {
38
38
  );
39
39
  }
40
40
 
41
+ function MiMoCodeIcon(): JSX.Element {
42
+ return (
43
+ <svg
44
+ fill="currentColor"
45
+ viewBox="0 0 24 24"
46
+ xmlns="http://www.w3.org/2000/svg"
47
+ className="size-6 shrink-0"
48
+ >
49
+ <title>MiMo Code</title>
50
+ <text
51
+ x="12"
52
+ y="18"
53
+ textAnchor="middle"
54
+ fontSize="16"
55
+ fontWeight="800"
56
+ fontFamily="system-ui, sans-serif"
57
+ >
58
+ M
59
+ </text>
60
+ </svg>
61
+ );
62
+ }
63
+
41
64
  const ExternalProviderSchema = z.object({
42
65
  name: z.string(),
43
66
  apiKey: z.string(),
@@ -45,7 +68,7 @@ const ExternalProviderSchema = z.object({
45
68
  anthropicBaseUrl: z.string(),
46
69
  openaiBaseUrl: z.string(),
47
70
  models: z.array(z.string()),
48
- sourceTool: z.enum(["claude-code", "opencode"]),
71
+ sourceTool: z.enum(["claude-code", "opencode", "mimo-code"]),
49
72
  alreadyExists: z.boolean(),
50
73
  });
51
74
 
@@ -64,6 +87,7 @@ const ImportResponseSchema = z.object({
64
87
  const sourceLogoMap: Record<string, () => JSX.Element> = {
65
88
  "claude-code": ClaudeCodeIcon,
66
89
  opencode: OpenCodeIcon,
90
+ "mimo-code": MiMoCodeIcon,
67
91
  };
68
92
 
69
93
  type ImportWizardDialogProps = {
@@ -213,7 +237,7 @@ export function ImportWizardDialog({
213
237
  <DialogHeader>
214
238
  <DialogTitle>Import from External Tools</DialogTitle>
215
239
  <p className="text-xs text-muted-foreground">
216
- Detect provider configurations from Claude Code and OpenCode.
240
+ Detect provider configurations from Claude Code, OpenCode, and MiMo Code.
217
241
  </p>
218
242
  </DialogHeader>
219
243
 
@@ -238,7 +262,7 @@ export function ImportWizardDialog({
238
262
  <br />
239
263
  <span className="text-xs">
240
264
  Supported tools: Claude Code (~/.claude/settings.json), OpenCode
241
- (~/.config/opencode/opencode.json)
265
+ (~/.config/opencode/opencode.json), MiMo Code (~/.config/mimocode/mimocode.jsonc)
242
266
  </span>
243
267
  </p>
244
268
  )}
@@ -54,7 +54,7 @@ export const ConversationGroup = memo(function ({
54
54
  standalone = false,
55
55
  }: ConversationGroupProps): JSX.Element {
56
56
  const [expanded, setExpanded] = useState(false);
57
- const stats = computeStats(group.logs);
57
+ const stats = useMemo(() => computeStats(group.logs), [group.logs]);
58
58
  const startTime = group.logs[0]?.timestamp ?? new Date().toISOString();
59
59
  const endTime = group.logs[group.logs.length - 1]?.timestamp ?? new Date().toISOString();
60
60
  const mixed = hasMixedApiFormat(group.logs);
@@ -70,7 +70,7 @@ export const ConversationGroup = memo(function ({
70
70
  : group.conversationId;
71
71
 
72
72
  return (
73
- <div className="mb-4">
73
+ <div className="mb-2">
74
74
  {!standalone && (
75
75
  <ConversationHeader
76
76
  conversationId={displayId}
@@ -90,7 +90,7 @@ export const ConversationGroup = memo(function ({
90
90
  )}
91
91
 
92
92
  {shouldRenderConversationContent(standalone, expanded) && (
93
- <div>
93
+ <div className="max-h-[70vh] overflow-y-auto">
94
94
  {turnGroups.map((tg) => (
95
95
  <TurnGroup
96
96
  key={tg.turnIndex}
@@ -82,7 +82,7 @@ export function ConversationHeader({
82
82
  "flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors",
83
83
  "hover:bg-muted/50",
84
84
  "select-none",
85
- "border border-border rounded-lg mb-2 bg-muted/30",
85
+ "border border-border rounded-lg mb-2 bg-background sticky top-0 z-10",
86
86
  )}
87
87
  onClick={onToggle}
88
88
  onKeyDown={(e) => {