@tonyclaw/agent-inspector 2.1.14 → 2.1.16

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 (70) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/CompareDrawer-Ccrrcx1j.js +1 -0
  3. package/.output/public/assets/ProxyViewerContainer-DLqClc9A.js +106 -0
  4. package/.output/public/assets/ReplayDialog-C7axhr-l.js +1 -0
  5. package/.output/public/assets/{RequestAnatomy-BNahe83D.js → RequestAnatomy-WOptg8j_.js} +1 -1
  6. package/.output/public/assets/{ResponseView-DSOnGqi6.js → ResponseView-CSAcxh_M.js} +2 -2
  7. package/.output/public/assets/{StreamingChunkSequence-BEKTDklB.js → StreamingChunkSequence-DJS5KhPx.js} +1 -1
  8. package/.output/public/assets/_sessionId-DUSalzKH.js +1 -0
  9. package/.output/public/assets/index-BPpA21dY.css +1 -0
  10. package/.output/public/assets/index-Bt0Az2I2.js +1 -0
  11. package/.output/public/assets/{index-DWOkqdCa.js → index-DMPNh46t.js} +1 -1
  12. package/.output/public/assets/{json-viewer-C2JpgcW0.js → json-viewer-inX5QSa3.js} +1 -1
  13. package/.output/public/assets/{main-CSONBwwn.js → main-aCAKYGSD.js} +2 -2
  14. package/.output/server/_libs/lucide-react.mjs +224 -194
  15. package/.output/server/{_sessionId-CPAa37n5.mjs → _sessionId-C0fhvQnn.mjs} +2 -2
  16. package/.output/server/_ssr/{CompareDrawer-ceW5VxMo.mjs → CompareDrawer-CO-Ti5sg.mjs} +36 -15
  17. package/.output/server/_ssr/{ProxyViewerContainer-CDfEE_w-.mjs → ProxyViewerContainer-N4J7uBCX.mjs} +1153 -494
  18. package/.output/server/_ssr/{ReplayDialog-V0s_eEbR.mjs → ReplayDialog-J1VxC0In.mjs} +14 -7
  19. package/.output/server/_ssr/{RequestAnatomy-f1ccwR9d.mjs → RequestAnatomy-DBH-F3rq.mjs} +3 -3
  20. package/.output/server/_ssr/{ResponseView-BIRrqG4H.mjs → ResponseView-DLFwV7Hd.mjs} +3 -3
  21. package/.output/server/_ssr/{StreamingChunkSequence-V3JFjCgX.mjs → StreamingChunkSequence-BywmuND0.mjs} +3 -3
  22. package/.output/server/_ssr/{index-DsykulzS.mjs → index-CStymIc0.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-Dcnm0Ivf.mjs → json-viewer-DlQu0N1x.mjs} +3 -3
  25. package/.output/server/_ssr/{router-4bdm6Mt2.mjs → router-CQ4Zxhmr.mjs} +118 -47
  26. package/.output/server/{_tanstack-start-manifest_v-Xp4CO64V.mjs → _tanstack-start-manifest_v-BHeJBGfo.mjs} +1 -1
  27. package/.output/server/index.mjs +75 -75
  28. package/package.json +1 -1
  29. package/src/components/ProxyViewer.tsx +174 -107
  30. package/src/components/ProxyViewerContainer.tsx +77 -67
  31. package/src/components/alerts/AlertsDialog.tsx +2 -1
  32. package/src/components/clients/ClientLogo.tsx +132 -0
  33. package/src/components/groups/GroupsDialog.tsx +37 -14
  34. package/src/components/providers/ProviderCard.tsx +1 -1
  35. package/src/components/providers/ProviderForm.tsx +18 -18
  36. package/src/components/providers/ProviderLogo.tsx +6 -1
  37. package/src/components/providers/ProvidersPanel.tsx +6 -6
  38. package/src/components/providers/SettingsDialog.tsx +13 -6
  39. package/src/components/proxy-viewer/AgentTraceSummary.tsx +62 -29
  40. package/src/components/proxy-viewer/CompareDrawer.tsx +38 -8
  41. package/src/components/proxy-viewer/ConversationGroup.tsx +43 -32
  42. package/src/components/proxy-viewer/ConversationGroupList.tsx +452 -89
  43. package/src/components/proxy-viewer/ConversationHeader.tsx +65 -61
  44. package/src/components/proxy-viewer/LogEntry.tsx +31 -18
  45. package/src/components/proxy-viewer/LogEntryHeader.tsx +42 -49
  46. package/src/components/proxy-viewer/ProviderLogoStack.tsx +60 -0
  47. package/src/components/proxy-viewer/ReplayDialog.tsx +8 -2
  48. package/src/components/proxy-viewer/RequestToolsPanel.tsx +9 -9
  49. package/src/components/proxy-viewer/ThreadConnector.tsx +22 -11
  50. package/src/components/proxy-viewer/TurnGroup.tsx +30 -36
  51. package/src/components/proxy-viewer/TurnGroupList.tsx +3 -0
  52. package/src/components/proxy-viewer/viewerState.ts +5 -6
  53. package/src/components/ui/icon-trigger.ts +2 -0
  54. package/src/components/ui/transient-toast.tsx +104 -0
  55. package/src/lib/sessionInfoContract.ts +5 -0
  56. package/src/lib/stopReason.ts +48 -16
  57. package/src/mcp/server.ts +71 -27
  58. package/src/mcp/toolHandlers.ts +23 -4
  59. package/src/proxy/formats/openai/alibabaProvider.ts +0 -4
  60. package/src/proxy/sessionInfo.ts +36 -3
  61. package/src/proxy/sessionSupervisor.ts +20 -1
  62. package/src/proxy/store.ts +1 -1
  63. package/src/routes/api/providers.$providerId.test.log.ts +8 -4
  64. package/styles/globals.css +180 -180
  65. package/.output/public/assets/CompareDrawer-DjgjIFx7.js +0 -1
  66. package/.output/public/assets/ProxyViewerContainer-CWUQZLYy.js +0 -106
  67. package/.output/public/assets/ReplayDialog-CU0Tbb2c.js +0 -1
  68. package/.output/public/assets/_sessionId-Cif8JZdn.js +0 -1
  69. package/.output/public/assets/index-D_WfwzUi.js +0 -1
  70. package/.output/public/assets/index-DtLuQrs0.css +0 -1
@@ -12,6 +12,7 @@ import {
12
12
  type SessionMembershipEvidence,
13
13
  } from "./ProxyViewer";
14
14
  import { dispatchLogFocusRequest } from "./proxy-viewer/logFocus";
15
+ import { TransientToast, useTransientToast } from "./ui/transient-toast";
15
16
 
16
17
  type SSEUpdate =
17
18
  | {
@@ -185,7 +186,7 @@ export function ProxyViewerContainer({
185
186
  const [allLogs, setAllLogs] = useState<CapturedLog[]>([]);
186
187
  const [selectedSession, setSelectedSession] = useState(initialSessionId ?? "__all__");
187
188
  const [selectedModel, setSelectedModel] = useState("__all__");
188
- const [error, setError] = useState<string | null>(null);
189
+ const { notice, showToast, dismissToast } = useTransientToast();
189
190
  const [streamInitialized, setStreamInitialized] = useState(initialSessionId === undefined);
190
191
  const [logPage, setLogPage] = useState<LogCursorPage | null>(null);
191
192
  const [sessionPageLoading, setSessionPageLoading] = useState(initialSessionId !== undefined);
@@ -202,6 +203,12 @@ export function ProxyViewerContainer({
202
203
  // Debounce buffer for SSE updates
203
204
  const pendingUpdatesRef = useRef<CapturedLog[]>([]);
204
205
  const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
206
+ const showErrorToast = useCallback(
207
+ (message: string) => {
208
+ showToast({ message, tone: "error" });
209
+ },
210
+ [showToast],
211
+ );
205
212
 
206
213
  // Derived view: the logs the user actually sees. Recomputed only when the
207
214
  // underlying set or filter changes — never on every SSE message.
@@ -269,7 +276,7 @@ export function ProxyViewerContainer({
269
276
  const parsed: unknown = JSON.parse(rawData);
270
277
  const updateResult = SSEUpdateSchema.safeParse(parsed);
271
278
  if (!updateResult.success) {
272
- setError("Failed to parse SSE data");
279
+ showErrorToast("Failed to parse SSE data");
273
280
  return;
274
281
  }
275
282
  const update = updateResult.data;
@@ -285,17 +292,17 @@ export function ProxyViewerContainer({
285
292
  logIndexRef.current = buildLogIndex(nextLogs);
286
293
  setAllLogs(nextLogs);
287
294
  setStreamInitialized(true);
288
- setError(null);
295
+ dismissToast();
289
296
  } else if (update.type === "update") {
290
297
  scheduleUpdate(update.log);
291
298
  }
292
299
  } catch {
293
- setError("Failed to parse SSE data");
300
+ showErrorToast("Failed to parse SSE data");
294
301
  }
295
302
  };
296
303
 
297
304
  es.onerror = () => {
298
- setError("SSE connection lost, reconnecting...");
305
+ showErrorToast("SSE connection lost, reconnecting...");
299
306
  setStreamInitialized(true);
300
307
  es.close();
301
308
  if (reconnectTimeoutRef.current !== null) {
@@ -303,7 +310,7 @@ export function ProxyViewerContainer({
303
310
  }
304
311
  reconnectTimeoutRef.current = setTimeout(connectSSE, 3000);
305
312
  };
306
- }, [initialSessionId, scheduleUpdate]);
313
+ }, [dismissToast, initialSessionId, scheduleUpdate, showErrorToast]);
307
314
 
308
315
  const loadSessionPage = useCallback(
309
316
  (request: SessionPageRequest) => {
@@ -312,7 +319,7 @@ export function ProxyViewerContainer({
312
319
  const requestId = sessionPageRequestIdRef.current + 1;
313
320
  sessionPageRequestIdRef.current = requestId;
314
321
  setSessionPageLoading(true);
315
- setError(null);
322
+ dismissToast();
316
323
 
317
324
  void fetchJson(buildSessionLogsPageUrl(initialSessionId, request), LogCursorPageSchema)
318
325
  .then((page) => {
@@ -323,17 +330,17 @@ export function ProxyViewerContainer({
323
330
  setLogPage(page);
324
331
  setSelectedSession(initialSessionId);
325
332
  setSessionPageLoading(false);
326
- setError(null);
333
+ dismissToast();
327
334
  })
328
335
  .catch((err: unknown) => {
329
336
  if (sessionPageRequestIdRef.current !== requestId) return;
330
337
  setAllLogs([]);
331
338
  setLogPage(null);
332
339
  setSessionPageLoading(false);
333
- setError(err instanceof Error ? err.message : "Failed to load session logs");
340
+ showErrorToast(err instanceof Error ? err.message : "Failed to load session logs");
334
341
  });
335
342
  },
336
- [initialSessionId],
343
+ [dismissToast, initialSessionId, showErrorToast],
337
344
  );
338
345
 
339
346
  useEffect(() => {
@@ -458,67 +465,73 @@ export function ProxyViewerContainer({
458
465
  }),
459
466
  });
460
467
  if (!res.ok) {
461
- setError("Failed to clear logs");
468
+ showErrorToast("Failed to clear logs");
462
469
  return;
463
470
  }
464
471
  logIndexRef.current.clear();
465
472
  setAllLogs([]);
466
- setError(null);
473
+ dismissToast();
467
474
  } catch (err) {
468
- setError(err instanceof Error ? err.message : "Unknown error clearing logs");
475
+ showErrorToast(err instanceof Error ? err.message : "Unknown error clearing logs");
469
476
  }
470
477
  })();
471
- }, [allLogs, initialSessionId]);
472
-
473
- const handleClearGroup = useCallback((ids: number[]) => {
474
- if (ids.length === 0) return;
475
- void (async () => {
476
- try {
477
- const res = await fetch("/api/logs", {
478
- method: "DELETE",
479
- headers: { "Content-Type": "application/json" },
480
- body: JSON.stringify({ ids }),
481
- });
482
- if (!res.ok) {
483
- setError("Failed to clear group");
484
- return;
478
+ }, [allLogs, dismissToast, initialSessionId, showErrorToast]);
479
+
480
+ const handleClearGroup = useCallback(
481
+ (ids: number[]) => {
482
+ if (ids.length === 0) return;
483
+ void (async () => {
484
+ try {
485
+ const res = await fetch("/api/logs", {
486
+ method: "DELETE",
487
+ headers: { "Content-Type": "application/json" },
488
+ body: JSON.stringify({ ids }),
489
+ });
490
+ if (!res.ok) {
491
+ showErrorToast("Failed to clear group");
492
+ return;
493
+ }
494
+ const idSet = new Set(ids);
495
+ setAllLogs((prev) => {
496
+ const remaining = prev.filter((l) => !idSet.has(l.id));
497
+ logIndexRef.current = buildLogIndex(remaining);
498
+ return remaining;
499
+ });
500
+ dismissToast();
501
+ } catch (err) {
502
+ showErrorToast(err instanceof Error ? err.message : "Unknown error clearing group");
485
503
  }
486
- const idSet = new Set(ids);
487
- setAllLogs((prev) => {
488
- const remaining = prev.filter((l) => !idSet.has(l.id));
489
- logIndexRef.current = buildLogIndex(remaining);
490
- return remaining;
491
- });
492
- setError(null);
493
- } catch (err) {
494
- setError(err instanceof Error ? err.message : "Unknown error clearing group");
495
- }
496
- })();
497
- }, []);
498
-
499
- const handleImportLogs = useCallback(async (file: File): Promise<ImportLogsResponse> => {
500
- const formData = new FormData();
501
- formData.set("file", file);
502
- const result = await fetchJson("/api/logs/import", ImportLogsResponseSchema, {
503
- method: "POST",
504
- body: formData,
505
- });
504
+ })();
505
+ },
506
+ [dismissToast, showErrorToast],
507
+ );
506
508
 
507
- setAllLogs((prev) => {
508
- const importedIds = new Set(result.logs.map((log) => log.id));
509
- const next = [...prev.filter((log) => !importedIds.has(log.id)), ...result.logs].sort(
510
- (left, right) => left.id - right.id,
511
- );
512
- const trimmed = trimClientLogs(next);
513
- logIndexRef.current = buildLogIndex(trimmed);
514
- return trimmed;
515
- });
516
- setSelectedSession(result.sessionId);
517
- setSelectedModel("__all__");
518
- setLogPage(null);
519
- setError(null);
520
- return result;
521
- }, []);
509
+ const handleImportLogs = useCallback(
510
+ async (file: File): Promise<ImportLogsResponse> => {
511
+ const formData = new FormData();
512
+ formData.set("file", file);
513
+ const result = await fetchJson("/api/logs/import", ImportLogsResponseSchema, {
514
+ method: "POST",
515
+ body: formData,
516
+ });
517
+
518
+ setAllLogs((prev) => {
519
+ const importedIds = new Set(result.logs.map((log) => log.id));
520
+ const next = [...prev.filter((log) => !importedIds.has(log.id)), ...result.logs].sort(
521
+ (left, right) => left.id - right.id,
522
+ );
523
+ const trimmed = trimClientLogs(next);
524
+ logIndexRef.current = buildLogIndex(trimmed);
525
+ return trimmed;
526
+ });
527
+ setSelectedSession(result.sessionId);
528
+ setSelectedModel("__all__");
529
+ setLogPage(null);
530
+ dismissToast();
531
+ return result;
532
+ },
533
+ [dismissToast],
534
+ );
522
535
 
523
536
  // Read the strip config once at the container so the virtualized list does
524
537
  // not need N independent SWR subscriptions per row.
@@ -569,11 +582,7 @@ export function ProxyViewerContainer({
569
582
 
570
583
  return (
571
584
  <>
572
- {error !== null && (
573
- <div className="fixed top-4 right-4 bg-destructive text-destructive-foreground px-4 py-2 rounded-md text-sm z-50">
574
- {error}
575
- </div>
576
- )}
585
+ <TransientToast notice={notice} onDismiss={dismissToast} />
577
586
  <OnboardingBanner />
578
587
  <ProxyViewer
579
588
  logs={logs}
@@ -585,6 +594,7 @@ export function ProxyViewerContainer({
585
594
  onModelChange={setSelectedModel}
586
595
  onClearAll={handleClearAll}
587
596
  onClearGroup={handleClearGroup}
597
+ onNotify={showToast}
588
598
  onImportLogs={initialSessionId === undefined ? handleImportLogs : undefined}
589
599
  isLoading={initialSessionId === undefined ? !streamInitialized : sessionPageLoading}
590
600
  pagination={pagination}
@@ -5,6 +5,7 @@ import { type AlertListFilters, useAlerts, useAlertSummary } from "../../lib/use
5
5
  import { cn } from "../../lib/utils";
6
6
  import { Badge } from "../ui/badge";
7
7
  import { Button } from "../ui/button";
8
+ import { INSPECTOR_ICON_TRIGGER_CLASS } from "../ui/icon-trigger";
8
9
  import {
9
10
  Dialog,
10
11
  DialogContent,
@@ -477,7 +478,7 @@ export function AlertsDialog(): JSX.Element {
477
478
  type="button"
478
479
  variant="ghost"
479
480
  size="icon"
480
- className="relative size-8"
481
+ className={INSPECTOR_ICON_TRIGGER_CLASS}
481
482
  aria-label="Alerts"
482
483
  title="Alerts"
483
484
  >
@@ -0,0 +1,132 @@
1
+ import type { JSX } from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ export type ClientApp = "codex" | "opencode" | "mimo-code" | "claude-code" | "unknown";
5
+
6
+ export type ClientAppDetectionInput = {
7
+ userAgent?: string | null;
8
+ conversationId?: string | null;
9
+ clientProjectFolder?: string | null;
10
+ };
11
+
12
+ const CLIENT_LABELS: Record<Exclude<ClientApp, "unknown">, string> = {
13
+ codex: "Codex",
14
+ opencode: "OpenCode",
15
+ "mimo-code": "MiMo Code",
16
+ "claude-code": "Claude Code",
17
+ };
18
+
19
+ function normalize(value: string | null | undefined): string {
20
+ return value?.toLowerCase() ?? "";
21
+ }
22
+
23
+ function matchesAny(value: string, candidates: readonly string[]): boolean {
24
+ for (const candidate of candidates) {
25
+ if (value.includes(candidate)) return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ export function detectClientApp(input: ClientAppDetectionInput): ClientApp {
31
+ const haystack = [
32
+ normalize(input.userAgent),
33
+ normalize(input.conversationId),
34
+ normalize(input.clientProjectFolder),
35
+ ].join(" ");
36
+
37
+ if (matchesAny(haystack, ["mimocode", "mimo-code", "mimo code"])) return "mimo-code";
38
+ if (matchesAny(haystack, ["opencode", "open-code", "open code"])) return "opencode";
39
+ if (matchesAny(haystack, ["claude-code", "claude code", "claudecode"])) return "claude-code";
40
+ if (matchesAny(haystack, ["codex desktop", "codex-cli", "openai-codex", "codex"])) {
41
+ return "codex";
42
+ }
43
+
44
+ return "unknown";
45
+ }
46
+
47
+ export function clientAppLabel(client: ClientApp): string | null {
48
+ switch (client) {
49
+ case "codex":
50
+ case "opencode":
51
+ case "mimo-code":
52
+ case "claude-code":
53
+ return CLIENT_LABELS[client];
54
+ case "unknown":
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function CodexMark(): JSX.Element {
60
+ return (
61
+ <svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
62
+ <circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeWidth="2.2" />
63
+ <path
64
+ d="M14.8 8.6a4.8 4.8 0 1 0 0 6.8"
65
+ fill="none"
66
+ stroke="currentColor"
67
+ strokeLinecap="round"
68
+ strokeWidth="2.2"
69
+ />
70
+ </svg>
71
+ );
72
+ }
73
+
74
+ function OpenCodeMark(): JSX.Element {
75
+ return (
76
+ <svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
77
+ <path d="M7 4h10v16H7z" fill="none" stroke="currentColor" strokeWidth="2.2" />
78
+ <path d="M10 8h4M10 12h4M10 16h2" stroke="currentColor" strokeLinecap="round" />
79
+ </svg>
80
+ );
81
+ }
82
+
83
+ function MiMoCodeMark(): JSX.Element {
84
+ return (
85
+ <svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
86
+ <path
87
+ d="M5 18V6h3.2l3.8 6.1L15.8 6H19v12h-3V11.3l-3.1 5h-1.8L8 11.3V18z"
88
+ fill="currentColor"
89
+ />
90
+ </svg>
91
+ );
92
+ }
93
+
94
+ function ClaudeCodeMark(): JSX.Element {
95
+ return (
96
+ <svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
97
+ <path
98
+ fill="currentColor"
99
+ fillRule="evenodd"
100
+ clipRule="evenodd"
101
+ d="M21 10.95h3v3.1h-3v3.03h-1.49V20H18v-2.92h-1.49V20H15v-2.92H9V20H7.49v-2.92H6V20H4.49v-2.92H3v-3.03H0v-3.1h3V5h18v5.95zM6 10.95h1.49V8.1H6v2.85zm10.51 0H18V8.1h-1.49v2.85z"
102
+ />
103
+ </svg>
104
+ );
105
+ }
106
+
107
+ export function ClientLogo({
108
+ client,
109
+ className,
110
+ }: {
111
+ client: ClientApp;
112
+ className?: string;
113
+ }): JSX.Element | null {
114
+ const label = clientAppLabel(client);
115
+ if (label === null) return null;
116
+
117
+ return (
118
+ <span
119
+ className={cn(
120
+ "border border-border bg-muted/40 inline-flex size-6 shrink-0 items-center justify-center rounded-md p-1 text-cyan-100",
121
+ className,
122
+ )}
123
+ aria-label={label}
124
+ title={label}
125
+ >
126
+ {client === "codex" && <CodexMark />}
127
+ {client === "opencode" && <OpenCodeMark />}
128
+ {client === "mimo-code" && <MiMoCodeMark />}
129
+ {client === "claude-code" && <ClaudeCodeMark />}
130
+ </span>
131
+ );
132
+ }
@@ -14,6 +14,7 @@ import {
14
14
  import { Button } from "../ui/button";
15
15
  import { Badge } from "../ui/badge";
16
16
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
17
+ import { INSPECTOR_ICON_TRIGGER_CLASS } from "../ui/icon-trigger";
17
18
  import { cn } from "../../lib/utils";
18
19
  import { parseJsonResponse, readApiError } from "../../lib/apiClient";
19
20
  import {
@@ -29,6 +30,7 @@ import {
29
30
  import type { EvidenceReport } from "../../lib/runContract";
30
31
  import type { SessionLogSummary } from "../../lib/sessionInfoContract";
31
32
  import { getSessionPath } from "../../lib/sessionUrl";
33
+ import { ClientLogo, clientAppLabel, detectClientApp } from "../clients/ClientLogo";
32
34
  import { useGroupEvidence } from "../../lib/useGroupEvidence";
33
35
  import { useGroups } from "../../lib/useGroups";
34
36
  import { useCopyFeedback } from "../proxy-viewer/useCopyFeedback";
@@ -195,6 +197,13 @@ function firstNonEmpty(...values: Array<string | null>): string {
195
197
  return "-";
196
198
  }
197
199
 
200
+ function firstOptionalText(...values: Array<string | null | undefined>): string | null {
201
+ for (const value of values) {
202
+ if (value !== null && value !== undefined && value.trim().length > 0) return value;
203
+ }
204
+ return null;
205
+ }
206
+
198
207
  function providerModel(member: InspectorGroupMember): string {
199
208
  const provider = member.provider;
200
209
  const model = member.model;
@@ -400,7 +409,7 @@ export function GroupsDialog(): JSX.Element {
400
409
  type="button"
401
410
  variant="ghost"
402
411
  size="icon"
403
- className="relative size-8"
412
+ className={INSPECTOR_ICON_TRIGGER_CLASS}
404
413
  aria-label="Evaluation groups"
405
414
  title="Evaluation groups"
406
415
  >
@@ -981,6 +990,14 @@ function MemberRow({
981
990
  const tokens = session?.tokenUsage.total ?? null;
982
991
  const firstChunkMs = log?.firstChunkMs ?? null;
983
992
  const sessionLabel = firstNonEmpty(member.label, member.sessionId);
993
+ const clientUserAgent = firstOptionalText(session?.clientUserAgent, log?.userAgent, member.agent);
994
+ const clientProjectFolder = session?.clientProjectFolder ?? null;
995
+ const clientApp = detectClientApp({
996
+ userAgent: clientUserAgent,
997
+ conversationId: member.sessionId,
998
+ clientProjectFolder,
999
+ });
1000
+ const clientLabel = clientAppLabel(clientApp);
984
1001
 
985
1002
  return (
986
1003
  <tr className="border-b border-border last:border-0 hover:bg-muted/20">
@@ -1003,6 +1020,7 @@ function MemberRow({
1003
1020
  <ChevronRight className="size-3.5" />
1004
1021
  )}
1005
1022
  </Button>
1023
+ {clientLabel !== null && <ClientLogo client={clientApp} className="size-5" />}
1006
1024
  {canOpenLiveSession ? (
1007
1025
  <a
1008
1026
  href={sessionPath}
@@ -1173,19 +1191,24 @@ function LatestLogsPreview({ logs }: { logs: SessionLogSummary[] }): JSX.Element
1173
1191
  Latest logs
1174
1192
  </div>
1175
1193
  <div className="divide-y divide-border">
1176
- {visibleLogs.map((log) => (
1177
- <div
1178
- key={log.id}
1179
- className="grid gap-2 px-3 py-2 text-xs sm:grid-cols-[72px_1fr_88px_88px]"
1180
- >
1181
- <span className="font-mono text-muted-foreground">#{log.id}</span>
1182
- <span className="min-w-0 truncate">
1183
- {displayText(log.model)} · {log.apiFormat} · {log.path}
1184
- </span>
1185
- <span className="font-mono text-muted-foreground">{formatMs(log.firstChunkMs)}</span>
1186
- <span className="font-mono text-muted-foreground">{formatMs(log.latencyMs)}</span>
1187
- </div>
1188
- ))}
1194
+ {visibleLogs.map((log) => {
1195
+ const displayNumber = log.sessionLogNumber ?? log.id;
1196
+ return (
1197
+ <div
1198
+ key={log.id}
1199
+ className="grid gap-2 px-3 py-2 text-xs sm:grid-cols-[72px_1fr_88px_88px]"
1200
+ >
1201
+ <span className="font-mono text-muted-foreground" title={`Log ID ${String(log.id)}`}>
1202
+ #{displayNumber}
1203
+ </span>
1204
+ <span className="min-w-0 truncate">
1205
+ {displayText(log.model)} · {log.apiFormat} · {log.path}
1206
+ </span>
1207
+ <span className="font-mono text-muted-foreground">{formatMs(log.firstChunkMs)}</span>
1208
+ <span className="font-mono text-muted-foreground">{formatMs(log.latencyMs)}</span>
1209
+ </div>
1210
+ );
1211
+ })}
1189
1212
  </div>
1190
1213
  </div>
1191
1214
  );
@@ -443,7 +443,7 @@ export function ProviderCard({
443
443
 
444
444
  return (
445
445
  <div
446
- className={`border rounded-lg p-4 flex flex-col gap-3 bg-card transition-all duration-500 ${isHighlighted === true ? "ring-2 ring-primary shadow-md" : ""}`}
446
+ className={`border border-border bg-card shadow-sm flex flex-col gap-3 rounded-[8px] p-4 transition-all duration-500 ${isHighlighted === true ? "ring-2 ring-primary shadow-md" : ""}`}
447
447
  >
448
448
  <div className="flex items-start justify-between gap-2">
449
449
  <div className="flex items-center gap-2 min-w-0">
@@ -107,7 +107,7 @@ const MINIMAX_MODELS = [
107
107
  ];
108
108
 
109
109
  // Alibaba model options
110
- const ALIBABA_MODELS = ["glm-5", "glm-5.1", "glm-5.2", "qwen3.6-plus", "qwen3.7-max"];
110
+ const ALIBABA_MODELS = ["qwen3.6-plus", "qwen3.7-max"];
111
111
 
112
112
  // ZhipuAI / Z.AI model options
113
113
  const ZHIPU_MODELS = [
@@ -291,7 +291,7 @@ function EndpointUrlPreview({
291
291
  if (finalUrl === null) return null;
292
292
 
293
293
  return (
294
- <div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
294
+ <div className="border border-border bg-muted/40 rounded-md px-3 py-2 text-xs">
295
295
  <div className="text-muted-foreground">Final URL</div>
296
296
  <div className="break-all font-mono text-foreground">{finalUrl}</div>
297
297
  </div>
@@ -656,7 +656,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
656
656
  }
657
657
  className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
658
658
  >
659
- <option value="">—</option>
659
+ <option value="">Unspecified</option>
660
660
  <option value="personal">个人 (Personal)</option>
661
661
  <option value="company">公司 (Company)</option>
662
662
  </select>
@@ -681,7 +681,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
681
681
  <p className="text-xs text-destructive">{errors.modelMetadataUrl}</p>
682
682
  )}
683
683
  {modelMetadataUrl.trim() === "" && (
684
- <div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
684
+ <div className="border border-border bg-muted/40 rounded-md px-3 py-2 text-xs text-muted-foreground">
685
685
  Built-in limits cover DeepSeek v4 pro/flash, MiniMax M3/M2.7/M2.5/M2.1, and GLM coding
686
686
  models. Use a registry URL for private or custom model limits.
687
687
  </div>
@@ -751,7 +751,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
751
751
  ref={(el) => {
752
752
  modelRowRefs.current[i] = el;
753
753
  }}
754
- className="space-y-2 rounded-md border border-border bg-muted/10 p-2"
754
+ className="border border-border bg-card shadow-sm space-y-2 rounded-[8px] p-2"
755
755
  >
756
756
  <div className="flex items-center gap-2">
757
757
  <div className="relative flex-1">
@@ -775,7 +775,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
775
775
  <ChevronDown className="size-4" />
776
776
  </button>
777
777
  {openModelDropdown === i && (
778
- <div className="absolute left-0 right-0 top-full mt-1 z-50 bg-popover border border-border rounded-md shadow-md max-h-48 overflow-y-auto">
778
+ <div className="bg-popover text-popover-foreground absolute left-0 right-0 top-full z-50 mt-1 max-h-48 overflow-y-auto rounded-md border shadow-md">
779
779
  {suggestedModels.map((opt) => (
780
780
  <button
781
781
  key={opt}
@@ -784,7 +784,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
784
784
  updateModel(i, opt);
785
785
  setOpenModelDropdown(null);
786
786
  }}
787
- className="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
787
+ className="w-full px-3 py-2 text-left text-sm transition-colors hover:bg-white/[0.06]"
788
788
  >
789
789
  {opt}
790
790
  </button>
@@ -899,7 +899,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
899
899
  </div>
900
900
 
901
901
  <div className="space-y-2">
902
- <div className="flex gap-1 border-b border-border">
902
+ <div className="border border-border bg-muted/40 flex gap-1 rounded-[8px] p-1">
903
903
  <TooltipProvider>
904
904
  <Tooltip>
905
905
  <TooltipTrigger asChild>
@@ -945,7 +945,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
945
945
  <label htmlFor="provider-anthropic-base-url" className="text-sm font-medium">
946
946
  Anthropic Base URL
947
947
  </label>
948
- <div className="flex min-w-0 overflow-hidden rounded-md border border-input bg-background ring-offset-background focus-within:border-ring focus-within:outline-ring focus-within:ring-[3px]">
948
+ <div className="border border-input bg-background hover:bg-accent hover:text-accent-foreground flex min-w-0 overflow-hidden rounded-md focus-within:border-ring focus-within:outline-ring focus-within:ring-[3px]">
949
949
  <input
950
950
  id="provider-anthropic-base-url"
951
951
  type="text"
@@ -955,9 +955,9 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
955
955
  setAnthropicBaseUrl(e.target.value);
956
956
  }}
957
957
  placeholder="https://api.anthropic.com"
958
- className="min-w-0 flex-1 bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
958
+ className="min-w-0 flex-1 bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
959
959
  />
960
- <span className="shrink-0 select-none border-l bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
960
+ <span className="shrink-0 select-none border-l border-white/10 bg-white/[0.04] px-3 py-2 font-mono text-xs text-muted-foreground">
961
961
  {ANTHROPIC_MESSAGES_ENDPOINT}
962
962
  </span>
963
963
  </div>
@@ -978,7 +978,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
978
978
  <label htmlFor="provider-openai-base-url" className="text-sm font-medium">
979
979
  OpenAI Chat Base URL
980
980
  </label>
981
- <div className="flex min-w-0 overflow-hidden rounded-md border border-input bg-background ring-offset-background focus-within:border-ring focus-within:outline-ring focus-within:ring-[3px]">
981
+ <div className="border border-input bg-background hover:bg-accent hover:text-accent-foreground flex min-w-0 overflow-hidden rounded-md focus-within:border-ring focus-within:outline-ring focus-within:ring-[3px]">
982
982
  <input
983
983
  id="provider-openai-base-url"
984
984
  type="text"
@@ -988,9 +988,9 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
988
988
  setOpenaiBaseUrl(e.target.value);
989
989
  }}
990
990
  placeholder="https://api.openai.com"
991
- className="min-w-0 flex-1 bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
991
+ className="min-w-0 flex-1 bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
992
992
  />
993
- <span className="shrink-0 select-none border-l bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
993
+ <span className="shrink-0 select-none border-l border-white/10 bg-white/[0.04] px-3 py-2 font-mono text-xs text-muted-foreground">
994
994
  {OPENAI_CHAT_COMPLETIONS_ENDPOINT}
995
995
  </span>
996
996
  </div>
@@ -1010,7 +1010,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
1010
1010
  <label htmlFor="provider-openai-responses-base-url" className="text-sm font-medium">
1011
1011
  OpenAI Responses Base URL
1012
1012
  </label>
1013
- <div className="flex min-w-0 overflow-hidden rounded-md border border-input bg-background ring-offset-background focus-within:border-ring focus-within:outline-ring focus-within:ring-[3px]">
1013
+ <div className="border border-input bg-background hover:bg-accent hover:text-accent-foreground flex min-w-0 overflow-hidden rounded-md focus-within:border-ring focus-within:outline-ring focus-within:ring-[3px]">
1014
1014
  <input
1015
1015
  id="provider-openai-responses-base-url"
1016
1016
  type="text"
@@ -1020,9 +1020,9 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
1020
1020
  setOpenaiResponsesBaseUrl(e.target.value);
1021
1021
  }}
1022
1022
  placeholder="https://api.openai.com"
1023
- className="min-w-0 flex-1 bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
1023
+ className="min-w-0 flex-1 bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
1024
1024
  />
1025
- <span className="shrink-0 select-none border-l bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
1025
+ <span className="shrink-0 select-none border-l border-white/10 bg-white/[0.04] px-3 py-2 font-mono text-xs text-muted-foreground">
1026
1026
  {OPENAI_RESPONSES_ENDPOINT}
1027
1027
  </span>
1028
1028
  </div>
@@ -1058,7 +1058,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
1058
1058
  </p>
1059
1059
  </div>
1060
1060
 
1061
- <div className="sticky bottom-0 bg-card border-t flex gap-2 justify-end pt-2 pb-2">
1061
+ <div className="border border-border bg-background/95 shadow-sm sticky bottom-0 flex justify-end gap-2 rounded-[8px] px-3 py-2">
1062
1062
  <Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
1063
1063
  Cancel
1064
1064
  </Button>
@@ -26,12 +26,17 @@ const PROVIDER_MAP: Record<string, Provider> = {
26
26
  "deepseek-": "deepseek",
27
27
  MiniMax: "minimax",
28
28
  qwen: "qwen",
29
- "glm-": "zhipuai",
30
29
  };
31
30
 
31
+ function isGlmModel(modelLower: string): boolean {
32
+ const normalized = modelLower.replace(/[_\s]+/g, "-");
33
+ return normalized.split(/[/:@]/).some((segment) => segment.startsWith("glm-"));
34
+ }
35
+
32
36
  export function detectProvider(model: string | null): Provider {
33
37
  if (model === null) return "unknown";
34
38
  const modelLower = model.toLowerCase();
39
+ if (isGlmModel(modelLower)) return "zhipuai";
35
40
  for (const [prefix, provider] of Object.entries(PROVIDER_MAP)) {
36
41
  if (modelLower.startsWith(prefix.toLowerCase())) {
37
42
  return provider;