@tonyclaw/agent-inspector 2.0.3 → 2.0.5

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 (68) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-D5A4bTfV.js → CompareDrawer-3nRwtk8J.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-CbW5VRER.js +101 -0
  4. package/.output/public/assets/ReplayDialog-Cl62N9PI.js +1 -0
  5. package/.output/public/assets/RequestAnatomy-DgQWGvjs.js +1 -0
  6. package/.output/public/assets/ResponseView-Cvc-ct4E.js +1 -0
  7. package/.output/public/assets/StreamingChunkSequence-BCQaCAIe.js +1 -0
  8. package/.output/public/assets/_sessionId-CcD_aLGq.js +1 -0
  9. package/.output/public/assets/index-B_dffD3u.js +1 -0
  10. package/.output/public/assets/index-CX796gvi.css +1 -0
  11. package/.output/public/assets/{json-viewer-BbU0n8eM.js → json-viewer-IXejqXB0.js} +1 -1
  12. package/.output/public/assets/{main-CZT_F-gu.js → main-2NlGzgOe.js} +2 -2
  13. package/.output/server/_libs/lucide-react.mjs +181 -114
  14. package/.output/server/{_sessionId-B-s9P7fJ.mjs → _sessionId-DWCTasJU.mjs} +3 -3
  15. package/.output/server/_ssr/{CompareDrawer-C08L3UOO.mjs → CompareDrawer-DhrN1uC2.mjs} +6 -6
  16. package/.output/server/_ssr/{ProxyViewerContainer-CMWl3Ijy.mjs → ProxyViewerContainer-DRl51s_n.mjs} +910 -186
  17. package/.output/server/_ssr/{ReplayDialog-CPDo9_G5.mjs → ReplayDialog-BQT_ygxC.mjs} +240 -14
  18. package/.output/server/_ssr/{RequestAnatomy-D9wt_K1E.mjs → RequestAnatomy-DS2tZOgq.mjs} +5 -5
  19. package/.output/server/_ssr/{ResponseView-DXaL7nY3.mjs → ResponseView-e0kL2C3x.mjs} +25 -21
  20. package/.output/server/_ssr/{StreamingChunkSequence-B_hudZyb.mjs → StreamingChunkSequence-BJG-m7xs.mjs} +3 -3
  21. package/.output/server/_ssr/{index-CuE_BN86.mjs → index-Dea3OeRw.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{json-viewer-Ci6kkjde.mjs → json-viewer-DDU55MLK.mjs} +3 -3
  24. package/.output/server/_ssr/{router-BemxgIg7.mjs → router-Dl7oh0zx.mjs} +164 -82
  25. package/.output/server/_tanstack-start-manifest_v-m-FJNBVf.mjs +4 -0
  26. package/.output/server/index.mjs +70 -70
  27. package/package.json +1 -1
  28. package/src/components/OnboardingBanner.tsx +11 -19
  29. package/src/components/ProxyViewer.tsx +26 -16
  30. package/src/components/ProxyViewerContainer.tsx +2 -1
  31. package/src/components/providers/ProviderCard.tsx +6 -20
  32. package/src/components/providers/SettingsDialog.tsx +140 -3
  33. package/src/components/proxy-viewer/AgentTraceSummary.tsx +731 -72
  34. package/src/components/proxy-viewer/AnswerMarkdown.tsx +16 -0
  35. package/src/components/proxy-viewer/CompareDrawer.tsx +4 -2
  36. package/src/components/proxy-viewer/ConversationGroup.tsx +12 -0
  37. package/src/components/proxy-viewer/ConversationHeader.tsx +6 -6
  38. package/src/components/proxy-viewer/LogEntry.tsx +5 -5
  39. package/src/components/proxy-viewer/LogEntryHeader.tsx +21 -36
  40. package/src/components/proxy-viewer/ReplayDialog.tsx +190 -8
  41. package/src/components/proxy-viewer/ResponseView.tsx +4 -8
  42. package/src/components/proxy-viewer/ToolTraceEvents.tsx +37 -17
  43. package/src/components/proxy-viewer/TurnGroup.tsx +18 -2
  44. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +2 -2
  45. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +6 -12
  46. package/src/components/proxy-viewer/formats/anthropic/ResponseView.tsx +2 -2
  47. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +10 -14
  48. package/src/components/proxy-viewer/replayComparison.ts +131 -0
  49. package/src/components/proxy-viewer/useKeyboardNavigation.ts +64 -22
  50. package/src/components/proxy-viewer/viewerState.ts +14 -2
  51. package/src/knowledge/candidateStore.ts +32 -1
  52. package/src/lib/runtimeConfig.ts +6 -0
  53. package/src/lib/timeDisplay.ts +22 -0
  54. package/src/lib/useOnboarding.ts +2 -0
  55. package/src/lib/useStripConfig.ts +16 -0
  56. package/src/proxy/config.ts +3 -0
  57. package/src/routes/api/config.ts +5 -1
  58. package/src/routes/api/knowledge.candidates.$candidateId.ts +50 -0
  59. package/src/routes/api/knowledge.sessions.$sessionId.candidates.ts +12 -2
  60. package/.output/public/assets/ProxyViewerContainer-Da0jpBkp.js +0 -101
  61. package/.output/public/assets/ReplayDialog-CxUk_TF0.js +0 -1
  62. package/.output/public/assets/RequestAnatomy-DIlzjgjJ.js +0 -1
  63. package/.output/public/assets/ResponseView-DQCuKJ1G.js +0 -1
  64. package/.output/public/assets/StreamingChunkSequence-DHk4SGGL.js +0 -1
  65. package/.output/public/assets/_sessionId-dY1TTl7N.js +0 -1
  66. package/.output/public/assets/index-D7wwbwly.css +0 -1
  67. package/.output/public/assets/index-FqQZbfl2.js +0 -1
  68. package/.output/server/_tanstack-start-manifest_v--L1_b4sd.mjs +0 -4
@@ -1,32 +1,52 @@
1
1
  import { type JSX } from "react";
2
- import { ChevronRight, Wrench } from "lucide-react";
2
+ import { Check, ChevronRight, Copy, Wrench } from "lucide-react";
3
3
  import type { ToolTraceEvent } from "./viewerState";
4
+ import { useCopyFeedback } from "./useCopyFeedback";
4
5
 
5
6
  type ToolTraceEventsProps = {
6
7
  events: ToolTraceEvent[];
7
8
  };
8
9
 
10
+ function ToolTraceEventRow({ event }: { event: ToolTraceEvent }): JSX.Element {
11
+ const argumentCopy = useCopyFeedback(event.argumentsText);
12
+ const canCopyArguments = event.argumentsText !== null;
13
+
14
+ return (
15
+ <div
16
+ key={event.id}
17
+ className="group/tool-trace flex min-w-0 items-center gap-2 rounded-md border border-border/70 bg-muted/20 px-2.5 py-1.5 text-xs"
18
+ >
19
+ <Wrench className="size-3.5 shrink-0 text-sky-400/70" />
20
+ <span className="font-mono font-semibold text-foreground/80">{event.name}</span>
21
+ {event.argumentsPreview !== null && (
22
+ <>
23
+ <ChevronRight className="size-3 shrink-0 text-muted-foreground/60" />
24
+ <span className="min-w-0 truncate font-mono text-muted-foreground">
25
+ {event.argumentsPreview}
26
+ </span>
27
+ </>
28
+ )}
29
+ {canCopyArguments && (
30
+ <button
31
+ type="button"
32
+ className="ml-auto inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-background/80 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/tool-trace:opacity-100 group-focus-within/tool-trace:opacity-100"
33
+ onClick={argumentCopy.copy}
34
+ aria-label={argumentCopy.copied ? "Copied tool arguments" : "Copy tool arguments"}
35
+ title={argumentCopy.copied ? "Copied tool arguments" : "Copy tool arguments"}
36
+ >
37
+ {argumentCopy.copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
38
+ </button>
39
+ )}
40
+ </div>
41
+ );
42
+ }
43
+
9
44
  export function ToolTraceEvents({ events }: ToolTraceEventsProps): JSX.Element | null {
10
45
  if (events.length === 0) return null;
11
46
  return (
12
47
  <div className="mx-3 mb-2 grid gap-1.5">
13
48
  {events.map((event) => (
14
- <div
15
- key={event.id}
16
- className="flex min-w-0 items-center gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-2.5 py-1.5 text-xs"
17
- >
18
- <Wrench className="size-3.5 shrink-0 text-amber-400" />
19
- <span className="font-mono font-semibold text-amber-300">{event.name}</span>
20
- <span className="font-mono text-muted-foreground">#{event.logId}</span>
21
- {event.argumentsPreview !== null && (
22
- <>
23
- <ChevronRight className="size-3 shrink-0 text-muted-foreground/60" />
24
- <span className="min-w-0 truncate font-mono text-muted-foreground">
25
- {event.argumentsPreview}
26
- </span>
27
- </>
28
- )}
29
- </div>
49
+ <ToolTraceEventRow key={event.id} event={event} />
30
50
  ))}
31
51
  </div>
32
52
  );
@@ -1,6 +1,7 @@
1
1
  import { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { AlertTriangle, ChevronRight, Clock, Zap } from "lucide-react";
3
3
  import { isTurnBoundary } from "../../lib/stopReason";
4
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
4
5
  import { cn, formatTokens } from "../../lib/utils";
5
6
  import type { CapturedLog } from "../../proxy/schemas";
6
7
  import { getCrabVariant } from "../ui/crab-variants";
@@ -26,6 +27,7 @@ type TurnGroupProps = {
26
27
  onCompareWithPrevious: (log: CapturedLog) => void;
27
28
  comparisonPredecessors: Map<number, CapturedLog>;
28
29
  turnIndex?: number;
30
+ timeDisplayFormat: TimeDisplayFormat;
29
31
  };
30
32
 
31
33
  export const TurnGroup = memo(function TurnGroup({
@@ -37,6 +39,7 @@ export const TurnGroup = memo(function TurnGroup({
37
39
  onCompareWithPrevious,
38
40
  comparisonPredecessors,
39
41
  turnIndex = 0,
42
+ timeDisplayFormat,
40
43
  }: TurnGroupProps): JSX.Element {
41
44
  const lastIdx = entries.length - 1;
42
45
  const lastStop = entries[lastIdx]?.stopReason ?? null;
@@ -134,15 +137,27 @@ export const TurnGroup = memo(function TurnGroup({
134
137
  };
135
138
  }, []);
136
139
 
140
+ const firstLogId = entries[0]?.log.id ?? turnIndex;
141
+ const turnLabel = `Turn ${String(turnIndex + 1)}`;
142
+
137
143
  return (
138
144
  <div
139
145
  ref={containerRef}
140
- className={cn("border rounded-lg", isPending ? "border-amber-500/10" : "border-transparent")}
146
+ tabIndex={collapsed ? undefined : 0}
147
+ role={collapsed ? undefined : "group"}
148
+ aria-label={collapsed ? undefined : turnLabel}
149
+ data-nav-id={collapsed ? undefined : `turn-${String(firstLogId)}`}
150
+ className={cn(
151
+ "border rounded-lg",
152
+ isPending ? "border-amber-500/10" : "border-transparent",
153
+ !collapsed &&
154
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none",
155
+ )}
141
156
  >
142
157
  {collapsed ? (
143
158
  /* ---- Collapsed: dual-crab (+ summary card for multi-log turns) ---- */
144
159
  <div
145
- data-nav-id={`turn-collapsed-${entries[0]?.log.id ?? turnIndex}`}
160
+ data-nav-id={`turn-collapsed-${String(firstLogId)}`}
146
161
  data-nav-action="expand"
147
162
  role="button"
148
163
  tabIndex={0}
@@ -331,6 +346,7 @@ export const TurnGroup = memo(function TurnGroup({
331
346
  viewMode={viewMode}
332
347
  strip={strip}
333
348
  slowResponseThresholdSeconds={slowResponseThresholdSeconds}
349
+ timeDisplayFormat={timeDisplayFormat}
334
350
  cacheTrend={cacheTrends?.get(log.id) ?? null}
335
351
  onCompareWithPrevious={
336
352
  comparisonPredecessors.has(log.id) ? onCompareWithPrevious : undefined
@@ -7,7 +7,7 @@ const ROLE_COLOR_CLASSES: Record<AnatomyRole, string> = {
7
7
  system: "bg-sky-500/70",
8
8
  user: "bg-emerald-500/70",
9
9
  assistant: "bg-violet-500/70",
10
- tool: "bg-amber-500/70",
10
+ tool: "bg-sky-400/55",
11
11
  tools: "bg-slate-500/70",
12
12
  };
13
13
 
@@ -15,7 +15,7 @@ const ROLE_FOCUS_RING: Record<AnatomyRole, string> = {
15
15
  system: "focus-visible:ring-sky-300",
16
16
  user: "focus-visible:ring-emerald-300",
17
17
  assistant: "focus-visible:ring-violet-300",
18
- tool: "focus-visible:ring-amber-300",
18
+ tool: "focus-visible:ring-sky-300",
19
19
  tools: "focus-visible:ring-slate-300",
20
20
  };
21
21
 
@@ -1,7 +1,7 @@
1
1
  import { Brain, ChevronDown, ChevronRight, Terminal } from "lucide-react";
2
2
  import { type JSX, memo, useState } from "react";
3
- import ReactMarkdown from "react-markdown";
4
3
  import type { ResponseContentBlockType } from "../../../../proxy/schemas";
4
+ import { AnswerMarkdown } from "../../AnswerMarkdown";
5
5
  import { Badge } from "../../../ui/badge";
6
6
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../ui/collapsible";
7
7
  import { JsonViewer } from "../../../ui/json-viewer";
@@ -30,9 +30,7 @@ function SystemReminderBlock({ text }: { text: string }): JSX.Element {
30
30
  </CollapsibleTrigger>
31
31
  <CollapsibleContent>
32
32
  <div className="pl-4 pt-1">
33
- <div className="prose prose-sm dark:prose-invert max-w-none [&_pre]:bg-muted [&_pre]:text-foreground [&_code]:text-[0.8em] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1">
34
- <ReactMarkdown>{text}</ReactMarkdown>
35
- </div>
33
+ <AnswerMarkdown text={text} />
36
34
  </div>
37
35
  </CollapsibleContent>
38
36
  </Collapsible>
@@ -50,11 +48,7 @@ export const TextBlock = memo(function TextBlock({ text }: { text: string }): JS
50
48
  return (
51
49
  <div className="space-y-2">
52
50
  {thinking !== null && <ThinkingBlock thinking={thinking} />}
53
- {remainingText.length > 0 && (
54
- <div className="prose prose-sm dark:prose-invert max-w-none [&_pre]:bg-muted [&_pre]:text-foreground [&_code]:text-[0.8em] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1">
55
- <ReactMarkdown>{remainingText}</ReactMarkdown>
56
- </div>
57
- )}
51
+ {remainingText.length > 0 && <AnswerMarkdown text={remainingText} />}
58
52
  {thinking === null && remainingText.length === 0 && (
59
53
  <p className="text-xs text-muted-foreground italic">Empty text block</p>
60
54
  )}
@@ -113,9 +107,9 @@ export const ToolUseBlock = memo(function ToolUseBlock({
113
107
 
114
108
  return (
115
109
  <Collapsible open={open} onOpenChange={setOpen}>
116
- <div className="border-l-2 border-blue-500/40 my-1">
117
- <CollapsibleTrigger className="flex items-center gap-1.5 px-3 py-1 w-full text-left cursor-pointer hover:bg-blue-500/5 transition-colors rounded-r-sm group">
118
- <Terminal className="size-3.5 text-blue-400 shrink-0" />
110
+ <div className="border-l-2 border-sky-400/25 my-1">
111
+ <CollapsibleTrigger className="flex items-center gap-1.5 px-3 py-1 w-full text-left cursor-pointer hover:bg-sky-400/[0.04] transition-colors rounded-r-sm group">
112
+ <Terminal className="size-3.5 text-sky-400/70 shrink-0" />
119
113
  <Badge variant="outline" className="text-[10px] font-mono px-1.5 py-0 h-4">
120
114
  {name}
121
115
  </Badge>
@@ -38,14 +38,14 @@ export const StructuredResponseViewAnthropic = memo(function StructuredResponseV
38
38
  response.usage.cache_creation_input_tokens !== null &&
39
39
  response.usage.cache_creation_input_tokens > 0 && (
40
40
  <span className="font-mono tabular-nums text-emerald-400">
41
- Cache +{formatTokens(response.usage.cache_creation_input_tokens)}
41
+ KV Cache +{formatTokens(response.usage.cache_creation_input_tokens)}
42
42
  </span>
43
43
  )}
44
44
  {response.usage.cache_read_input_tokens !== undefined &&
45
45
  response.usage.cache_read_input_tokens !== null &&
46
46
  response.usage.cache_read_input_tokens > 0 && (
47
47
  <span className="font-mono tabular-nums text-purple-400">
48
- Cache ~{formatTokens(response.usage.cache_read_input_tokens)}
48
+ KV Cache ~{formatTokens(response.usage.cache_read_input_tokens)}
49
49
  </span>
50
50
  )}
51
51
  </span>
@@ -1,8 +1,8 @@
1
1
  import { StopCircle, Terminal, Zap } from "lucide-react";
2
2
  import { memo, useState, type JSX } from "react";
3
- import ReactMarkdown from "react-markdown";
4
3
  import type { OpenAIResponse, OpenAIToolCall } from "../../../../proxy/schemas";
5
4
  import { formatTokens } from "../../../../lib/utils";
5
+ import { AnswerMarkdown } from "../../AnswerMarkdown";
6
6
  import { Badge } from "../../../ui/badge";
7
7
  import { JsonViewer } from "../../../ui/json-viewer";
8
8
  import { safeJsonValue } from "../../../ui/json-viewer-bulk";
@@ -29,8 +29,8 @@ function parseToolArguments(raw: string | undefined): unknown {
29
29
  }
30
30
 
31
31
  /** One collapsible tool_use row, mirroring the Anthropic ToolUseBlock visual
32
- * treatment (Terminal icon, blue accent, name as a Badge, JSON input in a
33
- * scrollable JsonViewer). */
32
+ * treatment (Terminal icon, soft tool accent, name as a Badge, JSON input in
33
+ * a scrollable JsonViewer). */
34
34
  function OpenAIToolCallBlock({ call }: { call: OpenAIToolCall }): JSX.Element {
35
35
  const [open, setOpen] = useState(false);
36
36
  const name = call.function.name ?? "(unnamed tool)";
@@ -38,9 +38,9 @@ function OpenAIToolCallBlock({ call }: { call: OpenAIToolCall }): JSX.Element {
38
38
 
39
39
  return (
40
40
  <Collapsible open={open} onOpenChange={setOpen}>
41
- <div className="border-l-2 border-blue-500/40 my-1">
42
- <CollapsibleTrigger className="flex items-center gap-1.5 px-3 py-1 w-full text-left cursor-pointer hover:bg-blue-500/5 transition-colors rounded-r-sm group">
43
- <Terminal className="size-3.5 text-blue-400 shrink-0" />
41
+ <div className="border-l-2 border-sky-400/25 my-1">
42
+ <CollapsibleTrigger className="flex items-center gap-1.5 px-3 py-1 w-full text-left cursor-pointer hover:bg-sky-400/[0.04] transition-colors rounded-r-sm group">
43
+ <Terminal className="size-3.5 text-sky-400/70 shrink-0" />
44
44
  <Badge variant="outline" className="text-[10px] font-mono px-1.5 py-0 h-4">
45
45
  {name}
46
46
  </Badge>
@@ -133,11 +133,7 @@ export const OpenAIResponseView = memo(function OpenAIResponseView({
133
133
  <div className="space-y-2">
134
134
  {/* Show thinking from tags only if no reasoning_content field */}
135
135
  {thinking !== null && !hasReasoningField && <ThinkingBlock thinking={thinking} />}
136
- {remainingText.length > 0 && (
137
- <div className="prose prose-sm dark:prose-invert max-w-none [&_pre]:bg-muted [&_pre]:text-foreground [&_code]:text-[0.8em] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1">
138
- <ReactMarkdown>{remainingText}</ReactMarkdown>
139
- </div>
140
- )}
136
+ {remainingText.length > 0 && <AnswerMarkdown text={remainingText} />}
141
137
  </div>
142
138
  );
143
139
  })()}
@@ -146,10 +142,10 @@ export const OpenAIResponseView = memo(function OpenAIResponseView({
146
142
  <OpenAIToolCallBlock key={call.id ?? `tc-${i}`} call={call} />
147
143
  ))}
148
144
  {message?.function_call !== null && message?.function_call !== undefined && (
149
- <div className="border border-blue-500/30 rounded-md p-3 bg-blue-500/5">
150
- <div className="text-xs text-blue-400 font-mono mb-1">function_call</div>
145
+ <div className="border border-sky-400/20 rounded-md p-3 bg-muted/20">
146
+ <div className="text-xs text-sky-400/80 font-mono mb-1">function_call</div>
151
147
  <div className="font-mono text-xs">
152
- <span className="text-blue-300">{message.function_call.name}</span>
148
+ <span className="text-foreground/80">{message.function_call.name}</span>
153
149
  <span className="text-muted-foreground">({message.function_call.arguments})</span>
154
150
  </div>
155
151
  </div>
@@ -0,0 +1,131 @@
1
+ export type ReplayMetricValue = number | boolean | null;
2
+
3
+ export type ReplayMetrics = {
4
+ status: number | null;
5
+ elapsedMs: number | null;
6
+ inputTokens: number | null;
7
+ outputTokens: number | null;
8
+ responseBytes: number | null;
9
+ streaming: boolean;
10
+ };
11
+
12
+ export type ReplayMetricComparison = {
13
+ id: "status" | "elapsed" | "input" | "output" | "bytes" | "streaming";
14
+ label: string;
15
+ original: ReplayMetricValue;
16
+ replay: ReplayMetricValue;
17
+ delta: number | null;
18
+ };
19
+
20
+ type JsonObject = Record<string, unknown>;
21
+
22
+ function isJsonObject(value: unknown): value is JsonObject {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+
26
+ function byteLength(value: string | null | undefined): number | null {
27
+ if (value === null || value === undefined) return null;
28
+ return new TextEncoder().encode(value).length;
29
+ }
30
+
31
+ export function readRequestModel(body: string): string | null {
32
+ try {
33
+ const parsed: unknown = JSON.parse(body);
34
+ if (!isJsonObject(parsed)) return null;
35
+ const model = parsed["model"];
36
+ return typeof model === "string" && model.length > 0 ? model : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function replaceRequestModel(
43
+ body: string,
44
+ model: string,
45
+ ): { body: string; error: string | null } {
46
+ try {
47
+ const parsed: unknown = JSON.parse(body);
48
+ if (!isJsonObject(parsed)) {
49
+ return { body, error: "Request body must be a JSON object." };
50
+ }
51
+ return {
52
+ body: JSON.stringify({ ...parsed, model }, null, 2),
53
+ error: null,
54
+ };
55
+ } catch {
56
+ return { body, error: "Request body must be valid JSON before changing the replay model." };
57
+ }
58
+ }
59
+
60
+ export function buildReplayMetrics(input: {
61
+ status: number | null | undefined;
62
+ elapsedMs: number | null | undefined;
63
+ inputTokens: number | null | undefined;
64
+ outputTokens: number | null | undefined;
65
+ responseText: string | null | undefined;
66
+ streaming: boolean | null | undefined;
67
+ }): ReplayMetrics {
68
+ return {
69
+ status: input.status ?? null,
70
+ elapsedMs: input.elapsedMs ?? null,
71
+ inputTokens: input.inputTokens ?? null,
72
+ outputTokens: input.outputTokens ?? null,
73
+ responseBytes: byteLength(input.responseText),
74
+ streaming: input.streaming === true,
75
+ };
76
+ }
77
+
78
+ function numericDelta(original: number | null, replay: number | null): number | null {
79
+ if (original === null || replay === null) return null;
80
+ return replay - original;
81
+ }
82
+
83
+ export function buildReplayComparisons(
84
+ original: ReplayMetrics,
85
+ replay: ReplayMetrics,
86
+ ): ReplayMetricComparison[] {
87
+ return [
88
+ {
89
+ id: "status",
90
+ label: "Status",
91
+ original: original.status,
92
+ replay: replay.status,
93
+ delta: numericDelta(original.status, replay.status),
94
+ },
95
+ {
96
+ id: "elapsed",
97
+ label: "Elapsed",
98
+ original: original.elapsedMs,
99
+ replay: replay.elapsedMs,
100
+ delta: numericDelta(original.elapsedMs, replay.elapsedMs),
101
+ },
102
+ {
103
+ id: "input",
104
+ label: "Input",
105
+ original: original.inputTokens,
106
+ replay: replay.inputTokens,
107
+ delta: numericDelta(original.inputTokens, replay.inputTokens),
108
+ },
109
+ {
110
+ id: "output",
111
+ label: "Output",
112
+ original: original.outputTokens,
113
+ replay: replay.outputTokens,
114
+ delta: numericDelta(original.outputTokens, replay.outputTokens),
115
+ },
116
+ {
117
+ id: "bytes",
118
+ label: "Bytes",
119
+ original: original.responseBytes,
120
+ replay: replay.responseBytes,
121
+ delta: numericDelta(original.responseBytes, replay.responseBytes),
122
+ },
123
+ {
124
+ id: "streaming",
125
+ label: "Stream",
126
+ original: original.streaming,
127
+ replay: replay.streaming,
128
+ delta: null,
129
+ },
130
+ ];
131
+ }
@@ -5,6 +5,11 @@ const NAV_ACTION_ATTR = "data-nav-action";
5
5
 
6
6
  export type NavAction = "toggle" | "expand" | "collapse";
7
7
 
8
+ type KeyboardNavigationOptions = {
9
+ /** When true, Arrow/WASD navigation works even when focus is on the page body. */
10
+ pageWide?: boolean;
11
+ };
12
+
8
13
  function findNavItems(container: HTMLElement): HTMLElement[] {
9
14
  return Array.from(container.querySelectorAll<HTMLElement>(`[${NAV_ATTR}]`));
10
15
  }
@@ -41,18 +46,40 @@ function safeItemAt(items: HTMLElement[], index: number): HTMLElement | null {
41
46
 
42
47
  function isEditableTarget(target: HTMLElement): boolean {
43
48
  const tag = target.tagName;
44
- if (tag === "INPUT" || tag === "TEXTAREA") return true;
49
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
45
50
  if (target.isContentEditable) return true;
46
51
  return false;
47
52
  }
48
53
 
54
+ function isInteractiveTarget(target: HTMLElement): boolean {
55
+ const tag = target.tagName;
56
+ if (tag === "BUTTON" || tag === "A") return true;
57
+ if (isEditableTarget(target)) return true;
58
+ return target.closest("[role='button'],[role='menuitem'],[role='option']") !== null;
59
+ }
60
+
61
+ function isInOpenOverlay(target: HTMLElement): boolean {
62
+ if (target.closest("[role='dialog'],[role='menu'],[role='listbox']") !== null) return true;
63
+ return document.querySelector("[role='dialog'],[role='menu'],[role='listbox']") !== null;
64
+ }
65
+
66
+ function hasActiveTextSelection(): boolean {
67
+ const selection = window.getSelection();
68
+ if (selection === null) return false;
69
+ return selection.type === "Range" && selection.toString().length > 0;
70
+ }
71
+
72
+ function isLetterNavigationKey(event: KeyboardEvent, key: "W" | "A" | "S" | "D"): boolean {
73
+ return event.key === key && event.shiftKey;
74
+ }
75
+
49
76
  /**
50
77
  * Enables keyboard navigation within a log-list container.
51
78
  *
52
79
  * Navigation keys:
53
- * - ArrowUp / Shift+W : previous item
54
- * - ArrowDown / Shift+S : next item
55
- * - ArrowLeft / Shift+A : collapse or move to prev item
80
+ * - ArrowUp / Shift+W : previous thread/turn/log item
81
+ * - ArrowDown / Shift+S : next thread/turn/log item
82
+ * - ArrowLeft / Shift+A : collapse or move to previous item
56
83
  * - ArrowRight / Shift+D : expand or move to next item
57
84
  * - Space : toggle expand/collapse
58
85
  *
@@ -66,17 +93,20 @@ export function useKeyboardNavigation(
66
93
  /** Ref to the outer focus-receiving wrapper (the one with tabIndex).
67
94
  * Defaults to `containerRef` when omitted. */
68
95
  wrapperRef?: React.RefObject<HTMLElement | null>,
96
+ options: KeyboardNavigationOptions = {},
69
97
  ): void {
70
98
  const rootRef = wrapperRef ?? containerRef;
99
+ const pageWide = options.pageWide === true;
71
100
 
72
101
  const handleFocusContainer = useCallback(
73
102
  (e: FocusEvent) => {
74
103
  const container = containerRef.current;
104
+ const root = rootRef.current;
75
105
  if (!container) return;
106
+ if (!root) return;
76
107
  const target = e.target;
77
108
  if (!isFocusTarget(target)) return;
78
- if (target !== rootRef.current) return;
79
- if (!container.contains(target)) return;
109
+ if (target !== root) return;
80
110
  const items = findNavItems(container);
81
111
  const first = safeItemAt(items, 0);
82
112
  if (first !== null) focusAndScroll(first);
@@ -85,17 +115,27 @@ export function useKeyboardNavigation(
85
115
  );
86
116
 
87
117
  useEffect(() => {
88
- const root = rootRef.current;
89
- const container = containerRef.current;
90
- if (!root || !container) return;
91
-
92
118
  const handleKeyDown = (e: KeyboardEvent) => {
119
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
120
+
121
+ const container = containerRef.current;
122
+ if (!container) return;
123
+
93
124
  const target = e.target;
94
125
  if (!isFocusTarget(target)) return;
95
126
 
96
- if (!container.contains(target)) return;
97
-
98
127
  if (isEditableTarget(target)) return;
128
+ if (hasActiveTextSelection()) return;
129
+
130
+ const isInsideContainer = container.contains(target);
131
+ if (isInsideContainer && isInteractiveTarget(target) && !target.hasAttribute(NAV_ATTR)) {
132
+ return;
133
+ }
134
+
135
+ if (!isInsideContainer) {
136
+ if (!pageWide) return;
137
+ if (isInteractiveTarget(target) || isInOpenOverlay(target)) return;
138
+ }
99
139
 
100
140
  const items = findNavItems(container);
101
141
  if (items.length === 0) return;
@@ -108,7 +148,8 @@ export function useKeyboardNavigation(
108
148
  switch (e.key) {
109
149
  case "ArrowUp":
110
150
  case "W": {
111
- if (e.key === "W" && !e.shiftKey) break;
151
+ if (e.shiftKey && e.key === "ArrowUp") break;
152
+ if (e.key === "W" && !isLetterNavigationKey(e, "W")) break;
112
153
  e.preventDefault();
113
154
  const prevIdx =
114
155
  currentIdx > 0 ? currentIdx - 1 : currentIdx === -1 ? items.length - 1 : -1;
@@ -121,7 +162,8 @@ export function useKeyboardNavigation(
121
162
  }
122
163
  case "ArrowDown":
123
164
  case "S": {
124
- if (e.key === "S" && !e.shiftKey) break;
165
+ if (e.shiftKey && e.key === "ArrowDown") break;
166
+ if (e.key === "S" && !isLetterNavigationKey(e, "S")) break;
125
167
  e.preventDefault();
126
168
  const nextIdx =
127
169
  currentIdx < items.length - 1 ? currentIdx + 1 : currentIdx === -1 ? 0 : -1;
@@ -134,7 +176,8 @@ export function useKeyboardNavigation(
134
176
  }
135
177
  case "ArrowLeft":
136
178
  case "A": {
137
- if (e.key === "A" && !e.shiftKey) break;
179
+ if (e.shiftKey && e.key === "ArrowLeft") break;
180
+ if (e.key === "A" && !isLetterNavigationKey(e, "A")) break;
138
181
  if (current === null) break;
139
182
  e.preventDefault();
140
183
  const action = getAction(current);
@@ -149,7 +192,8 @@ export function useKeyboardNavigation(
149
192
  }
150
193
  case "ArrowRight":
151
194
  case "D": {
152
- if (e.key === "D" && !e.shiftKey) break;
195
+ if (e.shiftKey && e.key === "ArrowRight") break;
196
+ if (e.key === "D" && !isLetterNavigationKey(e, "D")) break;
153
197
  if (current === null) break;
154
198
  e.preventDefault();
155
199
  const action = getAction(current);
@@ -179,12 +223,10 @@ export function useKeyboardNavigation(
179
223
 
180
224
  document.addEventListener("keydown", handleKeyDown, { capture: true });
181
225
  return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
182
- }, [containerRef, rootRef]);
226
+ }, [containerRef, pageWide, rootRef]);
183
227
 
184
228
  useEffect(() => {
185
- const root = rootRef.current;
186
- if (!root) return;
187
- root.addEventListener("focus", handleFocusContainer);
188
- return () => root.removeEventListener("focus", handleFocusContainer);
189
- }, [handleFocusContainer, rootRef]);
229
+ document.addEventListener("focusin", handleFocusContainer);
230
+ return () => document.removeEventListener("focusin", handleFocusContainer);
231
+ }, [handleFocusContainer]);
190
232
  }
@@ -40,6 +40,7 @@ export type ToolTraceEvent = {
40
40
  index: number;
41
41
  provider: "anthropic" | "openai";
42
42
  name: string;
43
+ argumentsText: string | null;
43
44
  argumentsPreview: string | null;
44
45
  };
45
46
 
@@ -118,6 +119,13 @@ function previewValue(value: unknown): string | null {
118
119
  : normalized;
119
120
  }
120
121
 
122
+ function copyValue(value: unknown): string | null {
123
+ if (value === undefined || value === null) return null;
124
+ if (typeof value === "string") return value.length > 0 ? value : null;
125
+ const raw = JSON.stringify(value, null, 2);
126
+ return raw === undefined || raw.length === 0 ? null : raw;
127
+ }
128
+
121
129
  function extractAnthropicToolTraceEvents(log: CapturedLog): ToolTraceEvent[] {
122
130
  const parsed = parseJsonResponse(log.responseText);
123
131
  const content = safeGetOwnProperty(parsed, "content");
@@ -129,13 +137,15 @@ function extractAnthropicToolTraceEvents(log: CapturedLog): ToolTraceEvent[] {
129
137
  if (type !== "tool_use") continue;
130
138
  const name = safeGetOwnProperty(block, "name");
131
139
  if (typeof name !== "string" || name.length === 0) continue;
140
+ const input = safeGetOwnProperty(block, "input");
132
141
  events.push({
133
142
  id: `${String(log.id)}-anthropic-tool-${String(events.length)}`,
134
143
  logId: log.id,
135
144
  index: events.length,
136
145
  provider: "anthropic",
137
146
  name,
138
- argumentsPreview: previewValue(safeGetOwnProperty(block, "input")),
147
+ argumentsText: copyValue(input),
148
+ argumentsPreview: previewValue(input),
139
149
  });
140
150
  }
141
151
  return events;
@@ -155,13 +165,15 @@ function extractOpenAIToolTraceEvents(log: CapturedLog): ToolTraceEvent[] {
155
165
  const fn = safeGetOwnProperty(call, "function");
156
166
  const name = safeGetOwnProperty(fn, "name");
157
167
  if (typeof name !== "string" || name.length === 0) continue;
168
+ const args = safeGetOwnProperty(fn, "arguments");
158
169
  events.push({
159
170
  id: `${String(log.id)}-openai-tool-${String(events.length)}`,
160
171
  logId: log.id,
161
172
  index: events.length,
162
173
  provider: "openai",
163
174
  name,
164
- argumentsPreview: previewValue(safeGetOwnProperty(fn, "arguments")),
175
+ argumentsText: copyValue(args),
176
+ argumentsPreview: previewValue(args),
165
177
  });
166
178
  }
167
179
  }
@@ -1,4 +1,12 @@
1
- import type { KnowledgeCandidate } from "./types";
1
+ import type { KnowledgeCandidate, KnowledgeCandidateType } from "./types";
2
+ import { redactCandidate } from "./redactor";
3
+
4
+ export type CandidateDraftUpdate = {
5
+ type?: KnowledgeCandidateType;
6
+ title?: string;
7
+ content?: string;
8
+ tags?: string[];
9
+ };
2
10
 
3
11
  const candidates = new Map<string, KnowledgeCandidate>();
4
12
 
@@ -58,6 +66,29 @@ export function markCandidateFailed(id: string, error: string): KnowledgeCandida
58
66
  return updated;
59
67
  }
60
68
 
69
+ export function updateCandidateDraft(
70
+ id: string,
71
+ updates: CandidateDraftUpdate,
72
+ ): KnowledgeCandidate | null {
73
+ const existing = candidates.get(id);
74
+ if (existing === undefined) return null;
75
+ if (existing.status === "promoted") return null;
76
+
77
+ const updated = redactCandidate({
78
+ ...existing,
79
+ type: updates.type ?? existing.type,
80
+ title: updates.title ?? existing.title,
81
+ content: updates.content ?? existing.content,
82
+ tags: updates.tags ?? existing.tags,
83
+ status: "draft",
84
+ error: null,
85
+ openClawMemoryId: null,
86
+ updatedAt: new Date().toISOString(),
87
+ });
88
+ candidates.set(id, updated);
89
+ return updated;
90
+ }
91
+
61
92
  export function _resetCandidatesForTests(): void {
62
93
  candidates.clear();
63
94
  }