@tonyclaw/agent-inspector 2.0.11 → 2.0.12

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 (47) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-C9OWdxHM.js → CompareDrawer-ClM_uVRy.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-CTNNzXSa.js +114 -0
  4. package/.output/public/assets/{ReplayDialog-CwE6I1Pe.js → ReplayDialog-7rp7N_KJ.js} +1 -1
  5. package/.output/public/assets/RequestAnatomy-sL2FFo_N.js +1 -0
  6. package/.output/public/assets/ResponseView-Cjlf3b6-.js +1 -0
  7. package/.output/public/assets/{StreamingChunkSequence-CLcLZ7-W.js → StreamingChunkSequence-Ctx3sT3r.js} +1 -1
  8. package/.output/public/assets/_sessionId-DEWzDQ8-.js +1 -0
  9. package/.output/public/assets/index-DGIIhZTn.js +1 -0
  10. package/.output/public/assets/index-D_nZj9Vt.css +1 -0
  11. package/.output/public/assets/{main-D1Xanzu7.js → main-FvIQopAy.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +51 -39
  13. package/.output/server/{_sessionId-D3IyPpws.mjs → _sessionId-BDSgtrsI.mjs} +2 -2
  14. package/.output/server/_ssr/{CompareDrawer-BIMThqxy.mjs → CompareDrawer-Cqvv0I3x.mjs} +3 -3
  15. package/.output/server/_ssr/{ProxyViewerContainer-BiBdKWtj.mjs → ProxyViewerContainer-C0YEoG8N.mjs} +1611 -183
  16. package/.output/server/_ssr/{ReplayDialog-D_wiDyM2.mjs → ReplayDialog-CrsynUKI.mjs} +4 -4
  17. package/.output/server/_ssr/RequestAnatomy-CR_JUkcL.mjs +688 -0
  18. package/.output/server/_ssr/{ResponseView-Drk5ghmL.mjs → ResponseView-Dba2Et69.mjs} +3 -3
  19. package/.output/server/_ssr/{StreamingChunkSequence-F889rmG0.mjs → StreamingChunkSequence-BIMHLstI.mjs} +2 -2
  20. package/.output/server/_ssr/{index-CaPniq9z.mjs → index-DveIUTm5.mjs} +2 -2
  21. package/.output/server/_ssr/index.mjs +2 -2
  22. package/.output/server/_ssr/{router-C7S8FRth.mjs → router-D0yXVG0Q.mjs} +85 -22
  23. package/.output/server/{_tanstack-start-manifest_v-CVTkFOdT.mjs → _tanstack-start-manifest_v-pe0xJFuQ.mjs} +1 -1
  24. package/.output/server/index.mjs +62 -62
  25. package/package.json +1 -1
  26. package/src/components/ProxyViewer.tsx +453 -52
  27. package/src/components/providers/ProviderCard.tsx +45 -2
  28. package/src/components/providers/ProviderForm.tsx +245 -80
  29. package/src/components/providers/ProvidersPanel.tsx +7 -1
  30. package/src/components/proxy-viewer/ConversationGroup.tsx +18 -1
  31. package/src/components/proxy-viewer/LogEntry.tsx +25 -0
  32. package/src/components/proxy-viewer/TurnGroup.tsx +15 -0
  33. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +99 -79
  34. package/src/components/proxy-viewer/anatomy/contextIntelligence.ts +22 -4
  35. package/src/components/proxy-viewer/anatomy/sessionContextSummary.ts +196 -0
  36. package/src/components/proxy-viewer/logFocus.ts +33 -0
  37. package/src/lib/providerModelMetadata.ts +64 -12
  38. package/src/lib/utils.ts +7 -0
  39. package/src/proxy/providers.ts +25 -0
  40. package/src/routes/api/providers.ts +3 -0
  41. package/.output/public/assets/ProxyViewerContainer-CNzay4u8.js +0 -114
  42. package/.output/public/assets/RequestAnatomy-DEehC3yz.js +0 -1
  43. package/.output/public/assets/ResponseView-CE5UsuUq.js +0 -1
  44. package/.output/public/assets/_sessionId-DE__tPTo.js +0 -1
  45. package/.output/public/assets/index-BFO4Jmw5.js +0 -1
  46. package/.output/public/assets/index-CDzZ7l_S.css +0 -1
  47. package/.output/server/_ssr/RequestAnatomy-5UiMTESr.mjs +0 -1383
@@ -2,7 +2,7 @@ import { type JSX, useState, useEffect, useRef } from "react";
2
2
  import { Button } from "../ui/button";
3
3
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
4
4
  import { Eye, EyeOff, Copy, Check, ChevronDown } from "lucide-react";
5
- import type { ProviderConfig } from "../../proxy/providers";
5
+ import type { ProviderConfig, ProviderModelMetadata } from "../../lib/providerContract";
6
6
  import { maskApiKey } from "../../lib/mask";
7
7
 
8
8
  // Known provider presets - maps provider name keywords to their API URLs
@@ -38,7 +38,7 @@ const MINIMAX_MODELS = [
38
38
  ];
39
39
 
40
40
  // Alibaba model options
41
- const ALIBABA_MODELS = ["glm-5", "glm-5.1", "qwen3.6-plus", "qwen3.7-max"];
41
+ const ALIBABA_MODELS = ["glm-5", "glm-5.1", "glm-5.2", "qwen3.6-plus", "qwen3.7-max"];
42
42
 
43
43
  type ProviderFormProps = {
44
44
  provider?: ProviderConfig;
@@ -50,11 +50,58 @@ type ProviderFormProps = {
50
50
  openaiBaseUrl?: string;
51
51
  apiDocsUrl?: string;
52
52
  modelMetadataUrl?: string;
53
+ modelMetadata?: ProviderModelMetadata[];
53
54
  source?: "company" | "personal";
54
55
  }) => void;
55
56
  onCancel: () => void;
56
57
  };
57
58
 
59
+ type ModelMetadataDraft = {
60
+ contextWindow: string;
61
+ outputLimit: string;
62
+ };
63
+
64
+ function emptyMetadataDraft(): ModelMetadataDraft {
65
+ return { contextWindow: "", outputLimit: "" };
66
+ }
67
+
68
+ function normalizeModelName(value: string): string {
69
+ return value.trim().toLowerCase().replace(/\s+/g, "-");
70
+ }
71
+
72
+ function findModelMetadata(
73
+ provider: ProviderConfig | undefined,
74
+ model: string,
75
+ ): ProviderModelMetadata | null {
76
+ const normalized = normalizeModelName(model);
77
+ return (
78
+ provider?.modelMetadata?.find(
79
+ (metadata) => normalizeModelName(metadata.model) === normalized,
80
+ ) ?? null
81
+ );
82
+ }
83
+
84
+ function createMetadataDrafts(
85
+ provider: ProviderConfig | undefined,
86
+ models: readonly string[],
87
+ ): ModelMetadataDraft[] {
88
+ return models.map((model) => {
89
+ const metadata = findModelMetadata(provider, model);
90
+ return {
91
+ contextWindow: metadata?.contextWindow?.toString() ?? "",
92
+ outputLimit: metadata?.outputLimit?.toString() ?? "",
93
+ };
94
+ });
95
+ }
96
+
97
+ function readPositiveIntegerInput(value: string): number | null {
98
+ const trimmed = value.trim();
99
+ if (trimmed === "") return null;
100
+ const parsed = Number(trimmed);
101
+ if (!Number.isInteger(parsed) || parsed <= 0) return null;
102
+ return parsed;
103
+ }
104
+
58
105
  export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps): JSX.Element {
59
106
  const [name, setName] = useState(provider?.name ?? "");
60
107
  const [apiKey, setApiKey] = useState(provider?.apiKey ?? "");
@@ -64,6 +111,12 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
64
111
  const [models, setModels] = useState<string[]>(
65
112
  initialModels !== undefined && initialModels.length > 0 ? initialModels : [""],
66
113
  );
114
+ const [modelMetadataDrafts, setModelMetadataDrafts] = useState<ModelMetadataDraft[]>(() =>
115
+ createMetadataDrafts(
116
+ provider,
117
+ initialModels !== undefined && initialModels.length > 0 ? initialModels : [""],
118
+ ),
119
+ );
67
120
  const [activeTab, setActiveTab] = useState<"anthropic" | "openai">("anthropic");
68
121
  const [anthropicBaseUrl, setAnthropicBaseUrl] = useState(provider?.anthropicBaseUrl ?? "");
69
122
  const [openaiBaseUrl, setOpenaiBaseUrl] = useState(provider?.openaiBaseUrl ?? "");
@@ -111,7 +164,9 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
111
164
  if (provider) {
112
165
  setName(provider.name);
113
166
  setApiKey(provider.apiKey);
114
- setModels((provider.models?.length ?? 0) > 0 ? provider.models : [""]);
167
+ const nextModels = (provider.models?.length ?? 0) > 0 ? provider.models : [""];
168
+ setModels(nextModels);
169
+ setModelMetadataDrafts(createMetadataDrafts(provider, nextModels));
115
170
  setAnthropicBaseUrl(provider.anthropicBaseUrl ?? "");
116
171
  setOpenaiBaseUrl(provider.openaiBaseUrl ?? "");
117
172
  setApiDocsUrl(provider.apiDocsUrl ?? "");
@@ -168,6 +223,21 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
168
223
  if (modelMetadataUrl.trim() && !isValidUrl(modelMetadataUrl.trim())) {
169
224
  newErrors.modelMetadataUrl = "Invalid URL format";
170
225
  }
226
+ modelMetadataDrafts.forEach((draft, index) => {
227
+ const contextWindow = readPositiveIntegerInput(draft.contextWindow);
228
+ const outputLimit = readPositiveIntegerInput(draft.outputLimit);
229
+ if (draft.contextWindow.trim() !== "" && contextWindow === null) {
230
+ newErrors[`modelMetadata.${index}.contextWindow`] =
231
+ "Context window must be a positive integer";
232
+ }
233
+ if (draft.outputLimit.trim() !== "" && outputLimit === null) {
234
+ newErrors[`modelMetadata.${index}.outputLimit`] = "Output limit must be a positive integer";
235
+ }
236
+ if (contextWindow !== null && outputLimit !== null && outputLimit > contextWindow) {
237
+ newErrors[`modelMetadata.${index}.outputLimit`] =
238
+ "Output limit should not exceed context window";
239
+ }
240
+ });
171
241
  if (!anthropicBaseUrl.trim() && !openaiBaseUrl.trim()) {
172
242
  newErrors.format = "At least one format URL (Anthropic or OpenAI) is required";
173
243
  }
@@ -184,6 +254,68 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
184
254
  }
185
255
  }
186
256
 
257
+ function updateModel(index: number, value: string): void {
258
+ setModels((prev) => {
259
+ const next = [...prev];
260
+ next[index] = value;
261
+ return next;
262
+ });
263
+ }
264
+
265
+ function updateMetadataDraft(index: number, patch: Partial<ModelMetadataDraft>): void {
266
+ setModelMetadataDrafts((prev) => {
267
+ const next = [...prev];
268
+ next[index] = { ...(next[index] ?? emptyMetadataDraft()), ...patch };
269
+ return next;
270
+ });
271
+ }
272
+
273
+ function removeModel(index: number): void {
274
+ setModels((prev) => prev.filter((_, idx) => idx !== index));
275
+ setModelMetadataDrafts((prev) => prev.filter((_, idx) => idx !== index));
276
+ }
277
+
278
+ function addModel(): void {
279
+ setModels((prev) => [...prev, ""]);
280
+ setModelMetadataDrafts((prev) => [...prev, emptyMetadataDraft()]);
281
+ }
282
+
283
+ function buildModelMetadata(): ProviderModelMetadata[] | undefined {
284
+ const now = new Date().toISOString();
285
+ const metadata: ProviderModelMetadata[] = [];
286
+
287
+ models.forEach((model, index) => {
288
+ const trimmedModel = model.trim();
289
+ if (trimmedModel === "") return;
290
+ const draft = modelMetadataDrafts[index] ?? emptyMetadataDraft();
291
+ const contextWindow = readPositiveIntegerInput(draft.contextWindow);
292
+ const outputLimit = readPositiveIntegerInput(draft.outputLimit);
293
+ if (contextWindow === null && outputLimit === null) return;
294
+
295
+ const existing = findModelMetadata(provider, trimmedModel);
296
+ if (
297
+ existing !== null &&
298
+ existing.contextWindow === (contextWindow ?? undefined) &&
299
+ existing.outputLimit === (outputLimit ?? undefined)
300
+ ) {
301
+ metadata.push({ ...existing, model: trimmedModel });
302
+ return;
303
+ }
304
+
305
+ const entry: ProviderModelMetadata = {
306
+ model: trimmedModel,
307
+ source: "manual",
308
+ updatedAt: now,
309
+ };
310
+ if (contextWindow !== null) entry.contextWindow = contextWindow;
311
+ if (outputLimit !== null) entry.outputLimit = outputLimit;
312
+ metadata.push(entry);
313
+ });
314
+
315
+ if (metadata.length > 0) return metadata;
316
+ return provider?.modelMetadata !== undefined ? [] : undefined;
317
+ }
318
+
187
319
  function handleSubmit(e: React.FormEvent): void {
188
320
  e.preventDefault();
189
321
  if (!validate()) return;
@@ -197,6 +329,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
197
329
  openaiBaseUrl: openaiBaseUrl.trim() || undefined,
198
330
  apiDocsUrl: apiDocsUrl.trim() || undefined,
199
331
  modelMetadataUrl: modelMetadataUrl.trim() || undefined,
332
+ modelMetadata: buildModelMetadata(),
200
333
  source,
201
334
  });
202
335
  } finally {
@@ -320,91 +453,123 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
320
453
  <label className="text-sm font-medium">
321
454
  Models <span className="text-destructive">*</span>
322
455
  </label>
323
- {models.map((m, i) => (
324
- <div
325
- key={i}
326
- ref={(el) => {
327
- modelRowRefs.current[i] = el;
328
- }}
329
- className="flex items-center gap-2"
330
- >
331
- <div className="relative flex-1">
332
- <input
333
- type="text"
334
- value={m}
335
- onChange={(e) => {
336
- setModels((prev) => {
337
- const next = [...prev];
338
- next[i] = e.target.value;
339
- return next;
340
- });
341
- }}
342
- placeholder={isMiniMax || isAlibaba ? "Type or select a model..." : "Model name"}
343
- className="w-full rounded-md border border-input bg-background px-4 py-3 pr-8 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
344
- />
345
- {(isMiniMax || isAlibaba) && (
346
- <>
456
+ {models.map((m, i) => {
457
+ const draft = modelMetadataDrafts[i] ?? emptyMetadataDraft();
458
+ const contextWindowError = errors[`modelMetadata.${i}.contextWindow`];
459
+ const outputLimitError = errors[`modelMetadata.${i}.outputLimit`];
460
+ return (
461
+ <div
462
+ key={i}
463
+ ref={(el) => {
464
+ modelRowRefs.current[i] = el;
465
+ }}
466
+ className="space-y-2 rounded-md border border-border bg-muted/10 p-2"
467
+ >
468
+ <div className="flex items-center gap-2">
469
+ <div className="relative flex-1">
470
+ <input
471
+ type="text"
472
+ value={m}
473
+ onChange={(e) => updateModel(i, e.target.value)}
474
+ placeholder={
475
+ isMiniMax || isAlibaba ? "Type or select a model..." : "Model name"
476
+ }
477
+ className="w-full rounded-md border border-input bg-background px-4 py-3 pr-8 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
478
+ />
479
+ {(isMiniMax || isAlibaba) && (
480
+ <>
481
+ <button
482
+ type="button"
483
+ onClick={() => setOpenModelDropdown(openModelDropdown === i ? null : i)}
484
+ className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-1 z-10"
485
+ aria-label="Show model suggestions"
486
+ >
487
+ <ChevronDown className="size-4" />
488
+ </button>
489
+ {openModelDropdown === i && (
490
+ <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">
491
+ {(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((opt) => (
492
+ <button
493
+ key={opt}
494
+ type="button"
495
+ onClick={() => {
496
+ updateModel(i, opt);
497
+ setOpenModelDropdown(null);
498
+ }}
499
+ className="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
500
+ >
501
+ {opt}
502
+ </button>
503
+ ))}
504
+ </div>
505
+ )}
506
+ </>
507
+ )}
508
+ </div>
509
+ {models.length > 1 && (
347
510
  <button
348
511
  type="button"
349
- onClick={() => setOpenModelDropdown(openModelDropdown === i ? null : i)}
350
- className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-1 z-10"
351
- aria-label="Show model suggestions"
512
+ onClick={() => removeModel(i)}
513
+ className="text-muted-foreground hover:text-destructive transition-colors p-1 shrink-0"
514
+ aria-label="Remove model"
352
515
  >
353
- <ChevronDown className="size-4" />
516
+ <svg
517
+ xmlns="http://www.w3.org/2000/svg"
518
+ width="16"
519
+ height="16"
520
+ viewBox="0 0 24 24"
521
+ fill="none"
522
+ stroke="currentColor"
523
+ strokeWidth="2"
524
+ strokeLinecap="round"
525
+ strokeLinejoin="round"
526
+ >
527
+ <path d="M3 6h18" />
528
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
529
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
530
+ </svg>
354
531
  </button>
355
- {openModelDropdown === i && (
356
- <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">
357
- {(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((opt) => (
358
- <button
359
- key={opt}
360
- type="button"
361
- onClick={() => {
362
- setModels((prev) => {
363
- const next = [...prev];
364
- next[i] = opt;
365
- return next;
366
- });
367
- setOpenModelDropdown(null);
368
- }}
369
- className="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
370
- >
371
- {opt}
372
- </button>
373
- ))}
374
- </div>
532
+ )}
533
+ </div>
534
+
535
+ <div className="grid gap-2 sm:grid-cols-2">
536
+ <label className="space-y-1 text-xs text-muted-foreground">
537
+ <span>Context Window</span>
538
+ <input
539
+ type="number"
540
+ min={1}
541
+ step={1000}
542
+ value={draft.contextWindow}
543
+ onChange={(e) => updateMetadataDraft(i, { contextWindow: e.target.value })}
544
+ placeholder="e.g. 1048576"
545
+ className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px]"
546
+ />
547
+ {contextWindowError !== undefined && (
548
+ <span className="block text-destructive">{contextWindowError}</span>
549
+ )}
550
+ </label>
551
+ <label className="space-y-1 text-xs text-muted-foreground">
552
+ <span>Output Limit</span>
553
+ <input
554
+ type="number"
555
+ min={1}
556
+ step={1000}
557
+ value={draft.outputLimit}
558
+ onChange={(e) => updateMetadataDraft(i, { outputLimit: e.target.value })}
559
+ placeholder="e.g. 8192"
560
+ className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px]"
561
+ />
562
+ {outputLimitError !== undefined && (
563
+ <span className="block text-destructive">{outputLimitError}</span>
375
564
  )}
376
- </>
377
- )}
565
+ </label>
566
+ </div>
378
567
  </div>
379
- {models.length > 1 && (
380
- <button
381
- type="button"
382
- onClick={() => setModels((prev) => prev.filter((_, idx) => idx !== i))}
383
- className="text-muted-foreground hover:text-destructive transition-colors p-1 shrink-0"
384
- aria-label="Remove model"
385
- >
386
- <svg
387
- xmlns="http://www.w3.org/2000/svg"
388
- width="16"
389
- height="16"
390
- viewBox="0 0 24 24"
391
- fill="none"
392
- stroke="currentColor"
393
- strokeWidth="2"
394
- strokeLinecap="round"
395
- strokeLinejoin="round"
396
- >
397
- <path d="M3 6h18" />
398
- <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
399
- <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
400
- </svg>
401
- </button>
402
- )}
403
- </div>
404
- ))}
568
+ );
569
+ })}
405
570
  <button
406
571
  type="button"
407
- onClick={() => setModels((prev) => [...prev, ""])}
572
+ onClick={addModel}
408
573
  className="text-xs text-primary hover:underline flex items-center gap-1"
409
574
  >
410
575
  + Add Model
@@ -8,7 +8,11 @@ import { ConfirmDialog } from "../ui/confirm-dialog";
8
8
  import { ProviderCard } from "./ProviderCard";
9
9
  import { ProviderForm } from "./ProviderForm";
10
10
  import { parseJsonResponse, readApiError } from "../../lib/apiClient";
11
- import { ProviderConfigSchema, type ProviderConfig } from "../../lib/providerContract";
11
+ import {
12
+ ProviderConfigSchema,
13
+ type ProviderConfig,
14
+ type ProviderModelMetadata,
15
+ } from "../../lib/providerContract";
12
16
  import {
13
17
  createFailedProviderTestResults,
14
18
  createPendingProviderTestResults,
@@ -48,6 +52,7 @@ type ProviderFormData = {
48
52
  openaiBaseUrl?: string;
49
53
  apiDocsUrl?: string;
50
54
  modelMetadataUrl?: string;
55
+ modelMetadata?: ProviderModelMetadata[];
51
56
  source?: "company" | "personal";
52
57
  };
53
58
 
@@ -60,6 +65,7 @@ function createProviderPayload(data: ProviderFormData) {
60
65
  openaiBaseUrl: (data.openaiBaseUrl?.length ?? 0) > 0 ? data.openaiBaseUrl : undefined,
61
66
  apiDocsUrl: (data.apiDocsUrl?.length ?? 0) > 0 ? data.apiDocsUrl : undefined,
62
67
  modelMetadataUrl: (data.modelMetadataUrl?.length ?? 0) > 0 ? data.modelMetadataUrl : undefined,
68
+ modelMetadata: data.modelMetadata,
63
69
  source: data.source,
64
70
  };
65
71
  }
@@ -1,4 +1,4 @@
1
- import { useState, memo, useMemo } from "react";
1
+ import { useState, memo, useEffect, useMemo } from "react";
2
2
  import type { JSX } from "react";
3
3
  import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
4
4
  import type { CapturedLog } from "../../proxy/schemas";
@@ -11,6 +11,7 @@ import {
11
11
  import { TurnGroup } from "./TurnGroup";
12
12
  import { AgentTraceSummary } from "./AgentTraceSummary";
13
13
  import type { CacheTrendEntry } from "./cacheTrend";
14
+ import { LOG_FOCUS_REQUEST_EVENT, readLogFocusRequest } from "./logFocus";
14
15
  import { buildTurnGroups, shouldRenderConversationContent } from "./viewerState";
15
16
 
16
17
  export type ConversationGroupProps = {
@@ -74,6 +75,22 @@ export const ConversationGroup = memo(function ({
74
75
 
75
76
  // Pre-compute stop reasons for each log — used by turnIndices
76
77
  const turnGroups = useMemo(() => buildTurnGroups(group.logs), [group.logs]);
78
+
79
+ useEffect(() => {
80
+ const handleLogFocusRequest = (event: Event): void => {
81
+ if (standalone) return;
82
+ const request = readLogFocusRequest(event);
83
+ if (request === null) return;
84
+ if (!group.logs.some((log) => log.id === request.logId)) return;
85
+ setExpanded(true);
86
+ };
87
+
88
+ window.addEventListener(LOG_FOCUS_REQUEST_EVENT, handleLogFocusRequest);
89
+ return () => {
90
+ window.removeEventListener(LOG_FOCUS_REQUEST_EVENT, handleLogFocusRequest);
91
+ };
92
+ }, [group.logs, standalone]);
93
+
77
94
  return (
78
95
  <div className="mb-2">
79
96
  {!standalone && (
@@ -25,6 +25,7 @@ import { LogEntryHeader } from "./LogEntryHeader";
25
25
  import { RequestTools } from "./RequestToolsPanel";
26
26
  import type { CacheTrendEntry } from "./cacheTrend";
27
27
  import { getLogFormatAdapter, resolveLogFormat } from "./log-formats";
28
+ import { LOG_FOCUS_REQUEST_EVENT, readLogFocusRequest, type LogFocusTab } from "./logFocus";
28
29
  import {
29
30
  shouldShowHeadersDiffButton,
30
31
  shouldShowRawRequestTab,
@@ -40,6 +41,15 @@ function TabFallback(): JSX.Element {
40
41
  return <div className="h-1" aria-hidden="true" />;
41
42
  }
42
43
 
44
+ function resolveFocusedTab(tab: LogFocusTab, anatomySegments: AnatomySegment[] | null): string {
45
+ switch (tab) {
46
+ case "anatomy":
47
+ return anatomySegments === null ? "request" : "anatomy";
48
+ case "request":
49
+ return "request";
50
+ }
51
+ }
52
+
43
53
  export type LogEntryProps = {
44
54
  log: CapturedLog;
45
55
  viewMode?: "simple" | "full";
@@ -302,6 +312,21 @@ export const LogEntry = memo(function ({
302
312
  setActiveTab(anatomySegments !== null ? "anatomy" : "request");
303
313
  }, [anatomySegments, expanded]);
304
314
 
315
+ useEffect(() => {
316
+ const handleLogFocusRequest = (event: Event): void => {
317
+ const request = readLogFocusRequest(event);
318
+ if (request === null) return;
319
+ if (request.logId !== log.id) return;
320
+ setExpanded(true);
321
+ setActiveTab(resolveFocusedTab(request.tab, anatomySegments));
322
+ };
323
+
324
+ window.addEventListener(LOG_FOCUS_REQUEST_EVENT, handleLogFocusRequest);
325
+ return () => {
326
+ window.removeEventListener(LOG_FOCUS_REQUEST_EVENT, handleLogFocusRequest);
327
+ };
328
+ }, [anatomySegments, log.id]);
329
+
305
330
  return (
306
331
  <TooltipProvider>
307
332
  <div className="border border-border rounded-lg mb-1 overflow-hidden">
@@ -9,6 +9,7 @@ import { ProviderLogo, detectProvider, type Provider } from "../providers/Provid
9
9
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
10
10
  import type { CacheTrendEntry } from "./cacheTrend";
11
11
  import { LogEntry } from "./LogEntry";
12
+ import { LOG_FOCUS_REQUEST_EVENT, readLogFocusRequest } from "./logFocus";
12
13
  import { ThreadConnector } from "./ThreadConnector";
13
14
  import { ToolTraceEvents } from "./ToolTraceEvents";
14
15
  import { extractToolTraceEvents, isTurnCollapsible, type TurnEntry } from "./viewerState";
@@ -60,6 +61,20 @@ export const TurnGroup = memo(function TurnGroup({
60
61
  prevCompleteRef.current = isComplete;
61
62
  }, [collapsible, isComplete]);
62
63
 
64
+ useEffect(() => {
65
+ const handleLogFocusRequest = (event: Event): void => {
66
+ const request = readLogFocusRequest(event);
67
+ if (request === null) return;
68
+ if (!entries.some((entry) => entry.log.id === request.logId)) return;
69
+ if (collapsible) setCollapsed(false);
70
+ };
71
+
72
+ window.addEventListener(LOG_FOCUS_REQUEST_EVENT, handleLogFocusRequest);
73
+ return () => {
74
+ window.removeEventListener(LOG_FOCUS_REQUEST_EVENT, handleLogFocusRequest);
75
+ };
76
+ }, [collapsible, entries]);
77
+
63
78
  const toggleCollapse = useCallback(() => {
64
79
  if (collapsible) setCollapsed((prev) => !prev);
65
80
  }, [collapsible]);