@tonyclaw/llm-inspector 1.18.0 → 1.18.2

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 (44) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/CompareDrawer-C-4ypEWs.js +1 -0
  3. package/.output/public/assets/ProxyViewerContainer-WRenRpeh.js +101 -0
  4. package/.output/public/assets/ReplayDialog-CyBKOgba.js +1 -0
  5. package/.output/public/assets/RequestAnatomy-C0IrVQ3q.js +1 -0
  6. package/.output/public/assets/ResponseView-MogToC4i.js +1 -0
  7. package/.output/public/assets/StreamingChunkSequence-ClhUhT-s.js +1 -0
  8. package/.output/public/assets/_sessionId-BO47oA3Z.js +1 -0
  9. package/.output/public/assets/index-BRvz6-L6.css +1 -0
  10. package/.output/public/assets/index-Btw8ec7-.js +1 -0
  11. package/.output/public/assets/{json-viewer-D-z1r1Pp.js → json-viewer-BicGakI5.js} +1 -1
  12. package/.output/public/assets/{main-CZJ63sQh.js → main-Be2qqUUW.js} +8 -7
  13. package/.output/server/_libs/lucide-react.mjs +23 -17
  14. package/.output/server/_sessionId-DhKJIdQC.mjs +122 -0
  15. package/.output/server/_ssr/{CompareDrawer-BJr-913n.mjs → CompareDrawer-BGUgukJ8.mjs} +57 -57
  16. package/.output/server/_ssr/{index-C7I_Qgt0.mjs → ProxyViewerContainer--3K3o3Sm.mjs} +277 -191
  17. package/.output/server/_ssr/{ReplayDialog-BwmToGuR.mjs → ReplayDialog-Bo86xZI4.mjs} +57 -57
  18. package/.output/server/_ssr/{RequestAnatomy-BmMiPRPB.mjs → RequestAnatomy-jRU5qgwB.mjs} +4 -4
  19. package/.output/server/_ssr/{ResponseView-ZB9-8Raw.mjs → ResponseView-DdO_-79a.mjs} +5 -5
  20. package/.output/server/_ssr/{StreamingChunkSequence-DWm4CQWC.mjs → StreamingChunkSequence-BigLwhh4.mjs} +57 -57
  21. package/.output/server/_ssr/index-BHG6vOnr.mjs +117 -0
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{json-viewer-D9XETzwp.mjs → json-viewer-B4c_WjXD.mjs} +4 -4
  24. package/.output/server/_ssr/{router-711KpGkz.mjs → router-DVixpJO-.mjs} +79 -22
  25. package/.output/server/_tanstack-start-manifest_v-BbvWUF4v.mjs +4 -0
  26. package/.output/server/index.mjs +70 -56
  27. package/README.md +109 -59
  28. package/package.json +1 -1
  29. package/src/assets/logos/mcp.png +0 -0
  30. package/src/components/ProxyViewer.tsx +240 -64
  31. package/src/components/ProxyViewerContainer.tsx +42 -11
  32. package/src/components/proxy-viewer/ConversationGroup.tsx +1 -8
  33. package/src/components/proxy-viewer/ConversationHeader.tsx +35 -7
  34. package/src/components/ui/mcp-logo.tsx +20 -0
  35. package/src/lib/sessionUrl.ts +44 -0
  36. package/src/routes/session/$sessionId.tsx +23 -0
  37. package/.output/public/assets/CompareDrawer-BpwZCB6M.js +0 -1
  38. package/.output/public/assets/ReplayDialog-Clratkzl.js +0 -1
  39. package/.output/public/assets/RequestAnatomy-EtiX0r_G.js +0 -1
  40. package/.output/public/assets/ResponseView-CJqxo-EN.js +0 -1
  41. package/.output/public/assets/StreamingChunkSequence-BIbRqQiV.js +0 -1
  42. package/.output/public/assets/index-B-0F9n1w.js +0 -101
  43. package/.output/public/assets/index-DoGvsnbA.css +0 -1
  44. package/.output/server/_tanstack-start-manifest_v-noQw0Vmw.mjs +0 -4
Binary file
@@ -1,5 +1,5 @@
1
1
  import { type JSX, useCallback, useEffect, useMemo, useRef, useState, Suspense } from "react";
2
- import { Download } from "lucide-react";
2
+ import { ArrowLeft, Check, Copy, Download, Plus } from "lucide-react";
3
3
 
4
4
  import type { CapturedLog } from "../proxy/schemas";
5
5
  import { exportLogsAsZip } from "../lib/export-logs";
@@ -9,7 +9,9 @@ import { ConversationGroup, groupLogsByConversation } from "./proxy-viewer";
9
9
 
10
10
  import { CrabLogo } from "./ui/crab-logo";
11
11
  import { crabVariants } from "./ui/crab-variants";
12
+ import { McpLogo } from "./ui/mcp-logo";
12
13
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
14
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
13
15
  import { SettingsDialog } from "./providers/SettingsDialog";
14
16
  import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
15
17
  import { LazyCompareDrawer } from "./proxy-viewer/lazy";
@@ -31,6 +33,28 @@ function computeTokenSummary(logs: CapturedLog[]): { totalIn: number; totalOut:
31
33
  return { totalIn, totalOut };
32
34
  }
33
35
 
36
+ function formatTimeRange(logs: CapturedLog[]): string | null {
37
+ const first = logs[0];
38
+ const last = logs[logs.length - 1];
39
+ if (first === undefined || last === undefined) return null;
40
+ const format = (iso: string): string =>
41
+ new Date(iso).toLocaleTimeString([], {
42
+ hour: "2-digit",
43
+ minute: "2-digit",
44
+ second: "2-digit",
45
+ });
46
+ return `${format(first.timestamp)} - ${format(last.timestamp)}`;
47
+ }
48
+
49
+ function getFirstUserAgent(logs: CapturedLog[]): string | null {
50
+ for (const log of logs) {
51
+ if (log.userAgent !== null && log.userAgent !== undefined && log.userAgent !== "") {
52
+ return log.userAgent;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
34
58
  function CopyableCommand({ command }: { command: string }): JSX.Element {
35
59
  const [copied, setCopied] = useState(false);
36
60
 
@@ -83,6 +107,97 @@ function CopyableCommand({ command }: { command: string }): JSX.Element {
83
107
  );
84
108
  }
85
109
 
110
+ function McpReadyBadge(): JSX.Element {
111
+ return (
112
+ <TooltipProvider>
113
+ <Tooltip>
114
+ <TooltipTrigger asChild>
115
+ <span className="inline-flex h-7 items-center gap-2 rounded-md border border-cyan-400/30 bg-cyan-500/10 px-2.5 font-mono text-[11px] font-medium text-cyan-300 shadow-[0_0_16px_rgba(34,211,238,0.08)]">
116
+ <span className="size-1.5 rounded-full bg-emerald-300 shadow-[0_0_8px_rgba(110,231,183,0.8)]" />
117
+ MCP Ready
118
+ <span className="hidden text-cyan-200/70 sm:inline">/api/mcp</span>
119
+ </span>
120
+ </TooltipTrigger>
121
+ <TooltipContent sideOffset={8} className="max-w-[320px] text-left leading-relaxed">
122
+ Coding agents can inspect logs, replay requests, test providers, and debug sessions
123
+ through MCP at /api/mcp.
124
+ </TooltipContent>
125
+ </Tooltip>
126
+ </TooltipProvider>
127
+ );
128
+ }
129
+
130
+ function SessionContextBar({
131
+ sessionId,
132
+ logs,
133
+ totalIn,
134
+ totalOut,
135
+ }: {
136
+ sessionId: string;
137
+ logs: CapturedLog[];
138
+ totalIn: number;
139
+ totalOut: number;
140
+ }): JSX.Element {
141
+ const [copied, setCopied] = useState(false);
142
+ const timeRange = useMemo(() => formatTimeRange(logs), [logs]);
143
+ const userAgent = useMemo(() => getFirstUserAgent(logs), [logs]);
144
+
145
+ const handleCopyLink = useCallback(() => {
146
+ void window.navigator.clipboard.writeText(window.location.href).then(() => {
147
+ setCopied(true);
148
+ setTimeout(() => setCopied(false), 2000);
149
+ });
150
+ }, []);
151
+
152
+ return (
153
+ <div className="mb-4 flex items-center gap-3 border border-border rounded-md bg-muted/20 px-3 py-2 text-xs">
154
+ <a
155
+ href="/"
156
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
157
+ aria-label="Back to all sessions"
158
+ title="Back to all sessions"
159
+ >
160
+ <ArrowLeft className="size-3.5" />
161
+ </a>
162
+ <div className="min-w-0 flex-1">
163
+ <div className="flex min-w-0 items-center gap-2">
164
+ <span className="font-mono font-semibold text-purple-400/90 truncate" title={sessionId}>
165
+ {truncateSessionId(sessionId)}
166
+ </span>
167
+ {userAgent !== null && (
168
+ <span
169
+ className="font-mono text-muted-foreground truncate max-w-[220px]"
170
+ title={userAgent}
171
+ >
172
+ {userAgent}
173
+ </span>
174
+ )}
175
+ </div>
176
+ <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
177
+ <span>
178
+ {logs.length} request{logs.length !== 1 ? "s" : ""}
179
+ </span>
180
+ {timeRange !== null && <span>{timeRange}</span>}
181
+ {(totalIn > 0 || totalOut > 0) && (
182
+ <span className="font-mono">
183
+ {formatTokens(totalIn)} in / {formatTokens(totalOut)} out
184
+ </span>
185
+ )}
186
+ </div>
187
+ </div>
188
+ <button
189
+ type="button"
190
+ onClick={handleCopyLink}
191
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
192
+ aria-label={copied ? "Copied session link" : "Copy session link"}
193
+ title={copied ? "Copied session link" : "Copy session link"}
194
+ >
195
+ {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
196
+ </button>
197
+ </div>
198
+ );
199
+ }
200
+
86
201
  export type ProxyViewerProps = {
87
202
  logs: CapturedLog[];
88
203
  sessions: string[];
@@ -101,6 +216,12 @@ export type ProxyViewerProps = {
101
216
  strip: boolean;
102
217
  /** Slow-response threshold in seconds. `0` disables the warning indicator. */
103
218
  slowResponseThresholdSeconds: number;
219
+ /** Hide the session filter dropdown. Used on `/session/$id` routes where
220
+ * the session is already pinned by the URL and the dropdown would just
221
+ * fight the URL state. */
222
+ hideSessionFilter?: boolean;
223
+ /** Session id pinned by a `/session/$id` route. Enables session-page chrome. */
224
+ pinnedSessionId?: string;
104
225
  };
105
226
 
106
227
  export function ProxyViewer({
@@ -117,6 +238,8 @@ export function ProxyViewer({
117
238
  onViewModeChange,
118
239
  strip,
119
240
  slowResponseThresholdSeconds,
241
+ hideSessionFilter = false,
242
+ pinnedSessionId,
120
243
  }: ProxyViewerProps): JSX.Element {
121
244
  const { totalIn, totalOut } = useMemo(() => computeTokenSummary(logs), [logs]);
122
245
  const [exporting, setExporting] = useState(false);
@@ -140,6 +263,15 @@ export function ProxyViewer({
140
263
  };
141
264
  }, []);
142
265
 
266
+ useEffect(() => {
267
+ if (pinnedSessionId === undefined) {
268
+ document.title = "LLM Inspector";
269
+ return;
270
+ }
271
+ const requestLabel = logs.length === 1 ? "1 req" : `${logs.length} req`;
272
+ document.title = `${truncateSessionId(pinnedSessionId)} - ${requestLabel} - LLM Inspector`;
273
+ }, [logs.length, pinnedSessionId]);
274
+
143
275
  const handleExport = useCallback(async () => {
144
276
  setExporting(true);
145
277
  try {
@@ -172,73 +304,92 @@ export function ProxyViewer({
172
304
  return (
173
305
  <div className="max-w-[1400px] xl:max-w-[1600px] 2xl:max-w-[1800px] mx-auto px-6 pb-6">
174
306
  {/* Brand row */}
175
- <div className="flex items-end pt-6 pb-8 relative">
176
- <h1 className="text-lg font-bold flex items-end gap-2 absolute left-1/2 -translate-x-1/2 whitespace-nowrap">
177
- {/* Crab family hover to animate together */}
178
- <span className="flex items-end gap-1 group cursor-default" aria-hidden="true">
179
- <CrabLogo className="size-10 text-amber-500 transition-all duration-300 group-hover:scale-125 group-hover:-translate-y-1.5" />
180
- <span className="flex items-end gap-0.5">
181
- {crabVariants.map((Crab, i) => {
182
- const color = [
183
- "text-amber-500",
184
- "text-rose-500",
185
- "text-sky-500",
186
- "text-emerald-500",
187
- "text-violet-500",
188
- "text-orange-500",
189
- "text-cyan-500",
190
- "text-pink-500",
191
- "text-lime-500",
192
- "text-blue-500",
193
- "text-yellow-500",
194
- "text-fuchsia-500",
195
- ][i];
196
- const entranceClass =
197
- crabEntrancePhase === "hidden"
198
- ? "opacity-0 scale-0"
199
- : crabEntrancePhase === "playing"
200
- ? "animate-crab-piano-pop"
201
- : "";
202
- return (
203
- <Crab
204
- key={i}
205
- className={`size-5 ${color} transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1 ${entranceClass}`}
206
- style={{
207
- transitionDelay: `${i * 50}ms`,
208
- ...(crabEntrancePhase === "playing"
209
- ? { animationDelay: `${i * 400}ms` }
210
- : {}),
211
- }}
212
- />
213
- );
214
- })}
307
+ <div className="grid grid-cols-[1fr_auto_1fr] items-start gap-3 pt-6 pb-8">
308
+ <div />
309
+ <h1 className="flex min-w-0 flex-col items-center gap-2 text-center">
310
+ <span className="flex max-w-[calc(100vw-7rem)] items-end gap-2 whitespace-nowrap">
311
+ {/* Crab family hover to animate together */}
312
+ <span className="flex shrink-0 items-end gap-1 group cursor-default" aria-hidden="true">
313
+ <CrabLogo className="size-10 text-amber-500 transition-all duration-300 group-hover:scale-125 group-hover:-translate-y-1.5" />
314
+ <span className="hidden items-end gap-0.5 sm:flex">
315
+ {crabVariants.map((Crab, i) => {
316
+ const color = [
317
+ "text-amber-500",
318
+ "text-rose-500",
319
+ "text-sky-500",
320
+ "text-emerald-500",
321
+ "text-violet-500",
322
+ "text-orange-500",
323
+ "text-cyan-500",
324
+ "text-pink-500",
325
+ "text-lime-500",
326
+ "text-blue-500",
327
+ "text-yellow-500",
328
+ "text-fuchsia-500",
329
+ ][i];
330
+ const entranceClass =
331
+ crabEntrancePhase === "hidden"
332
+ ? "opacity-0 scale-0"
333
+ : crabEntrancePhase === "playing"
334
+ ? "animate-crab-piano-pop"
335
+ : "";
336
+ return (
337
+ <Crab
338
+ key={i}
339
+ className={`size-5 ${color} transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1 ${entranceClass}`}
340
+ style={{
341
+ transitionDelay: `${i * 50}ms`,
342
+ ...(crabEntrancePhase === "playing"
343
+ ? { animationDelay: `${i * 400}ms` }
344
+ : {}),
345
+ }}
346
+ />
347
+ );
348
+ })}
349
+ </span>
215
350
  </span>
351
+ <span className="flex min-w-0 items-baseline gap-2 pl-1">
352
+ <span className="truncate text-lg font-bold">LLM Inspector</span>
353
+ <span className="shrink-0 font-mono text-xs font-semibold text-muted-foreground">
354
+ v{packageJson.version}
355
+ </span>
356
+ </span>
357
+ <Plus className="size-4 shrink-0 text-muted-foreground/70" aria-hidden="true" />
358
+ <McpLogo className="size-10 shrink-0" />
216
359
  </span>
217
- <span className="flex items-baseline gap-2">
218
- LLM Inspector
219
- <span className="text-xs text-muted-foreground font-mono">v{packageJson.version}</span>
220
- </span>
360
+ <McpReadyBadge />
221
361
  </h1>
222
- <div className="ml-auto">
362
+ <div className="justify-self-end">
223
363
  <SettingsDialog />
224
364
  </div>
225
365
  </div>
226
366
 
367
+ {pinnedSessionId !== undefined && (
368
+ <SessionContextBar
369
+ sessionId={pinnedSessionId}
370
+ logs={logs}
371
+ totalIn={totalIn}
372
+ totalOut={totalOut}
373
+ />
374
+ )}
375
+
227
376
  {/* Controls + Filters */}
228
377
  <div className="flex items-center gap-3 mb-4">
229
- <Select value={selectedSession} onValueChange={onSessionChange}>
230
- <SelectTrigger className="flex-1 max-w-[350px] text-xs">
231
- <SelectValue placeholder="All sessions" />
232
- </SelectTrigger>
233
- <SelectContent>
234
- <SelectItem value="__all__">All sessions</SelectItem>
235
- {sessions.map((s) => (
236
- <SelectItem key={s} value={s}>
237
- {truncateSessionId(s)}
238
- </SelectItem>
239
- ))}
240
- </SelectContent>
241
- </Select>
378
+ {!hideSessionFilter && (
379
+ <Select value={selectedSession} onValueChange={onSessionChange}>
380
+ <SelectTrigger className="flex-1 max-w-[350px] text-xs">
381
+ <SelectValue placeholder="All sessions" />
382
+ </SelectTrigger>
383
+ <SelectContent>
384
+ <SelectItem value="__all__">All sessions</SelectItem>
385
+ {sessions.map((s) => (
386
+ <SelectItem key={s} value={s}>
387
+ {truncateSessionId(s)}
388
+ </SelectItem>
389
+ ))}
390
+ </SelectContent>
391
+ </Select>
392
+ )}
242
393
  <Select value={selectedModel} onValueChange={onModelChange}>
243
394
  <SelectTrigger className="flex-1 max-w-[250px] text-xs">
244
395
  <SelectValue placeholder="All models" />
@@ -316,11 +467,36 @@ export function ProxyViewer({
316
467
  {/* Log list */}
317
468
  <div>
318
469
  {logs.length === 0 ? (
319
- <div className="text-center text-muted-foreground py-16 space-y-4">
320
- <p className="text-sm">No requests captured yet.</p>
321
- <p className="text-xs">Route AI coding tools through the proxy:</p>
322
- <CopyableCommand command="LLM_BASE_URL=http://localhost:25947/proxy <your-tool>" />
323
- </div>
470
+ selectedSession !== "__all__" ? (
471
+ <div className="text-center text-muted-foreground py-16 space-y-4">
472
+ <p className="text-sm font-medium">Session not found</p>
473
+ <p className="text-xs font-mono bg-muted px-3 py-1 rounded inline-block max-w-[500px] break-all">
474
+ {truncateSessionId(selectedSession)}
475
+ </p>
476
+ <p className="text-xs">
477
+ This session may have been cleared or never existed.{" "}
478
+ {hideSessionFilter ? (
479
+ <a href="/" className="underline hover:text-foreground transition-colors">
480
+ Back to all sessions
481
+ </a>
482
+ ) : (
483
+ <button
484
+ type="button"
485
+ onClick={() => onSessionChange("__all__")}
486
+ className="underline hover:text-foreground transition-colors"
487
+ >
488
+ Show all sessions
489
+ </button>
490
+ )}
491
+ </p>
492
+ </div>
493
+ ) : (
494
+ <div className="text-center text-muted-foreground py-16 space-y-4">
495
+ <p className="text-sm">No requests captured yet.</p>
496
+ <p className="text-xs">Route AI coding tools through the proxy:</p>
497
+ <CopyableCommand command="LLM_BASE_URL=http://localhost:25947/proxy <your-tool>" />
498
+ </div>
499
+ )
324
500
  ) : (
325
501
  <div
326
502
  ref={logListWrapperRef}
@@ -62,12 +62,32 @@ function filterLogs(
62
62
 
63
63
  const DEBOUNCE_MS = 50;
64
64
 
65
- export function ProxyViewerContainer(): JSX.Element {
65
+ function buildLogsStreamUrl(sessionId: string | undefined): string {
66
+ if (sessionId === undefined) return "/api/logs/stream";
67
+ const params = new URLSearchParams({ sessionId });
68
+ return `/api/logs/stream?${params.toString()}`;
69
+ }
70
+
71
+ export function ProxyViewerContainer({
72
+ initialSessionId,
73
+ }: {
74
+ /**
75
+ * Initial session filter. When the route is `/session/$id`, pass the URL
76
+ * param here so the page opens already scoped to that session. Default
77
+ * `__all__` keeps the existing "all sessions" behavior on `/`.
78
+ *
79
+ * NOTE: the value is only used as the initial state on mount. To pick up
80
+ * a new value (e.g. user navigates to a different `/session/$id`), the
81
+ * caller should also pass a `key` prop matching the session id, which
82
+ * forces a remount.
83
+ */
84
+ initialSessionId?: string;
85
+ } = {}): JSX.Element {
66
86
  // `allLogs` is the unfiltered set populated by the SSE. The single SSE
67
87
  // connection never re-opens on filter change — we always carry the full
68
88
  // set and derive the displayed view with `useMemo` below.
69
89
  const [allLogs, setAllLogs] = useState<CapturedLog[]>([]);
70
- const [selectedSession, setSelectedSession] = useState("__all__");
90
+ const [selectedSession, setSelectedSession] = useState(initialSessionId ?? "__all__");
71
91
  const [selectedModel, setSelectedModel] = useState("__all__");
72
92
  const [viewMode, setViewMode] = useState<"simple" | "full">("simple");
73
93
  const [error, setError] = useState<string | null>(null);
@@ -128,12 +148,7 @@ export function ProxyViewerContainer(): JSX.Element {
128
148
  eventSourceRef.current.close();
129
149
  }
130
150
 
131
- // Stable, unfiltered connection. The frontend derives filtered views
132
- // from the complete set, so the SSE never needs to reopen on filter
133
- // change. If the in-memory set ever grows past what the client can
134
- // handle, swap this back to a parameterized URL and reconnect on
135
- // filter change.
136
- const es = new EventSource("/api/logs/stream");
151
+ const es = new EventSource(buildLogsStreamUrl(initialSessionId));
137
152
  eventSourceRef.current = es;
138
153
 
139
154
  es.onmessage = (event: MessageEvent) => {
@@ -179,7 +194,7 @@ export function ProxyViewerContainer(): JSX.Element {
179
194
  }
180
195
  reconnectTimeoutRef.current = setTimeout(connectSSE, 3000);
181
196
  };
182
- }, [scheduleUpdate]);
197
+ }, [initialSessionId, scheduleUpdate]);
183
198
 
184
199
  useEffect(() => {
185
200
  connectSSE();
@@ -200,9 +215,22 @@ export function ProxyViewerContainer(): JSX.Element {
200
215
  }, [connectSSE]);
201
216
 
202
217
  const handleClearAll = useCallback(() => {
218
+ if (initialSessionId !== undefined && allLogs.length === 0) return;
203
219
  void (async () => {
204
220
  try {
205
- const res = await fetch("/api/logs", { method: "DELETE" });
221
+ const body =
222
+ initialSessionId === undefined
223
+ ? undefined
224
+ : JSON.stringify({ ids: allLogs.map((log) => log.id) });
225
+ const res = await fetch("/api/logs", {
226
+ method: "DELETE",
227
+ ...(body === undefined
228
+ ? {}
229
+ : {
230
+ headers: { "Content-Type": "application/json" },
231
+ body,
232
+ }),
233
+ });
206
234
  if (!res.ok) {
207
235
  setError("Failed to clear logs");
208
236
  return;
@@ -214,7 +242,7 @@ export function ProxyViewerContainer(): JSX.Element {
214
242
  setError(err instanceof Error ? err.message : "Unknown error clearing logs");
215
243
  }
216
244
  })();
217
- }, []);
245
+ }, [allLogs, initialSessionId]);
218
246
 
219
247
  const handleClearGroup = useCallback((ids: number[]) => {
220
248
  if (ids.length === 0) return;
@@ -274,6 +302,9 @@ export function ProxyViewerContainer(): JSX.Element {
274
302
  onViewModeChange={setViewMode}
275
303
  strip={strip}
276
304
  slowResponseThresholdSeconds={slowResponseThresholdSeconds}
305
+ // Session filter is the URL's job when `initialSessionId` was given.
306
+ hideSessionFilter={initialSessionId !== undefined}
307
+ pinnedSessionId={initialSessionId}
277
308
  />
278
309
  </>
279
310
  );
@@ -65,18 +65,11 @@ export const ConversationGroup = memo(function ({
65
65
 
66
66
  // Pre-compute stop reasons for each log — used by turnIndices
67
67
  const turnGroups = useMemo(() => buildTurnGroups(group.logs), [group.logs]);
68
- const displayId =
69
- group.conversationId.startsWith("PID:") || group.conversationId.includes("|")
70
- ? group.conversationId
71
- : group.conversationId.length > 24
72
- ? group.conversationId.slice(0, 12) + "…" + group.conversationId.slice(-12)
73
- : group.conversationId;
74
-
75
68
  return (
76
69
  <div className="mb-2">
77
70
  {!standalone && (
78
71
  <ConversationHeader
79
- conversationId={displayId}
72
+ conversationId={group.conversationId}
80
73
  startTime={startTime}
81
74
  endTime={endTime}
82
75
  totalCalls={group.logs.length}
@@ -1,8 +1,9 @@
1
- import { useState } from "react";
1
+ import { useCallback, useState } from "react";
2
2
  import {
3
3
  ChevronDown,
4
4
  ChevronRight,
5
5
  Clock,
6
+ ExternalLink,
6
7
  Loader2,
7
8
  MessageSquare,
8
9
  Trash2,
@@ -10,6 +11,7 @@ import {
10
11
  Zap,
11
12
  } from "lucide-react";
12
13
  import type { JSX } from "react";
14
+ import { getSessionPath } from "../../lib/sessionUrl";
13
15
  import { cn, formatTokens } from "../../lib/utils";
14
16
  import type { CapturedLog } from "../../proxy/schemas";
15
17
  import { Badge } from "../ui/badge";
@@ -34,7 +36,7 @@ export type ConversationHeaderProps = {
34
36
  expanded: boolean;
35
37
  onToggle: () => void;
36
38
  /** Hide the API format badge on the header (used when the group contains
37
- * mixed formats the per-log badges are shown instead). */
39
+ * mixed formats - the per-log badges are shown instead). */
38
40
  hideApiFormat?: boolean;
39
41
  /** When true and the group is collapsed, show a spinner instead of the
40
42
  * expand chevron to indicate an in-flight request inside the group. */
@@ -74,6 +76,14 @@ export function ConversationHeader({
74
76
  setConfirmOpen(true);
75
77
  };
76
78
 
79
+ const handleOpenInNewTab = useCallback(
80
+ (e: React.MouseEvent | React.KeyboardEvent): void => {
81
+ e.stopPropagation();
82
+ window.open(getSessionPath(conversationId), "_blank", "noopener,noreferrer");
83
+ },
84
+ [conversationId],
85
+ );
86
+
77
87
  return (
78
88
  <div
79
89
  role="button"
@@ -96,7 +106,7 @@ export function ConversationHeader({
96
106
  }
97
107
  }}
98
108
  >
99
- {/* Expand chevron shows spinner when collapsed and group has pending logs */}
109
+ {/* Expand chevron - shows spinner when collapsed and group has pending logs */}
100
110
  {expanded ? (
101
111
  <ChevronDown className="size-4 text-muted-foreground shrink-0" />
102
112
  ) : isLoading ? (
@@ -110,9 +120,11 @@ export function ConversationHeader({
110
120
  className="text-purple-400/90 font-mono text-xs font-semibold shrink-0"
111
121
  title={conversationId}
112
122
  >
113
- {conversationId.length > 24
114
- ? conversationId.slice(0, 12) + "…" + conversationId.slice(-12)
115
- : conversationId}
123
+ {conversationId.startsWith("PID:") || conversationId.includes("|")
124
+ ? conversationId
125
+ : conversationId.length > 24
126
+ ? conversationId.slice(0, 12) + "..." + conversationId.slice(-12)
127
+ : conversationId}
116
128
  </span>
117
129
 
118
130
  {/* User-Agent */}
@@ -170,7 +182,23 @@ export function ConversationHeader({
170
182
  {/* Spacer */}
171
183
  <span className="flex-1 min-w-0" />
172
184
 
173
- {/* Per-group Clear does not toggle the group's expand state */}
185
+ {/* Open this session in a new tab - deep link to /session/$id */}
186
+ <button
187
+ type="button"
188
+ onClick={handleOpenInNewTab}
189
+ onKeyDown={(e) => {
190
+ if (e.key === "Enter" || e.key === " ") {
191
+ handleOpenInNewTab(e);
192
+ }
193
+ }}
194
+ aria-label={`Open session ${conversationId} in a new tab`}
195
+ title="Open this session in a new tab"
196
+ className="text-muted-foreground hover:text-foreground transition-colors shrink-0 inline-flex items-center justify-center size-8 rounded hover:bg-muted cursor-pointer"
197
+ >
198
+ <ExternalLink className="size-3.5" />
199
+ </button>
200
+
201
+ {/* Per-group Clear - does not toggle the group's expand state */}
174
202
  {onClear !== undefined && (
175
203
  <button
176
204
  type="button"
@@ -0,0 +1,20 @@
1
+ import type { JSX } from "react";
2
+ import { cn } from "../../lib/utils";
3
+ import McpLogoPng from "../../assets/logos/mcp.png";
4
+
5
+ /**
6
+ * Official Model Context Protocol logo (from Wikimedia Commons). The PNG
7
+ * is a black-on-white raster of the official mark — flipped to a white
8
+ * silhouette on the dark header via `invert` so it reads against the
9
+ * app background.
10
+ */
11
+ export function McpLogo({ className }: { className?: string }): JSX.Element {
12
+ return (
13
+ <img
14
+ src={McpLogoPng}
15
+ alt="Model Context Protocol"
16
+ aria-hidden="true"
17
+ className={cn("inline-block size-8 object-contain invert", className)}
18
+ />
19
+ );
20
+ }
@@ -0,0 +1,44 @@
1
+ const B64URL_RE = /^[A-Za-z0-9_-]+$/;
2
+
3
+ function bytesToBinary(bytes: Uint8Array): string {
4
+ let binary = "";
5
+ for (const byte of bytes) {
6
+ binary += String.fromCharCode(byte);
7
+ }
8
+ return binary;
9
+ }
10
+
11
+ function binaryToBytes(binary: string): Uint8Array {
12
+ const bytes = new Uint8Array(binary.length);
13
+ for (let i = 0; i < binary.length; i++) {
14
+ bytes[i] = binary.charCodeAt(i);
15
+ }
16
+ return bytes;
17
+ }
18
+
19
+ export function encodeSessionIdForPath(sessionId: string): string {
20
+ const bytes = new TextEncoder().encode(sessionId);
21
+ const base64 = btoa(bytesToBinary(bytes));
22
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
23
+ }
24
+
25
+ export function decodeSessionIdFromPath(encoded: string): string {
26
+ if (encoded.startsWith("%")) {
27
+ return decodeURIComponent(encoded);
28
+ }
29
+ if (!B64URL_RE.test(encoded)) {
30
+ return encoded;
31
+ }
32
+ try {
33
+ const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, "=");
34
+ const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
35
+ const binary = atob(base64);
36
+ return new TextDecoder().decode(binaryToBytes(binary));
37
+ } catch {
38
+ return encoded;
39
+ }
40
+ }
41
+
42
+ export function getSessionPath(sessionId: string): string {
43
+ return `/session/${encodeSessionIdForPath(sessionId)}`;
44
+ }