@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,28 +1,90 @@
1
1
  import { useCallback, useMemo, useState, type JSX } from "react";
2
- import { AlertTriangle, Brain, Clock, Loader2, MessageSquare, Wrench, Zap } from "lucide-react";
2
+ import {
3
+ AlertTriangle,
4
+ Brain,
5
+ ChevronDown,
6
+ ChevronRight,
7
+ CircleCheck,
8
+ Clock,
9
+ FileSearch,
10
+ Loader2,
11
+ MessageSquare,
12
+ Pencil,
13
+ RefreshCw,
14
+ Save,
15
+ ShieldCheck,
16
+ UploadCloud,
17
+ Wrench,
18
+ X,
19
+ XCircle,
20
+ Zap,
21
+ } from "lucide-react";
3
22
  import { z } from "zod";
4
- import { formatTokens } from "../../lib/utils";
23
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
24
+ import { formatTimestampRange } from "../../lib/timeDisplay";
25
+ import { cn, formatTokens } from "../../lib/utils";
5
26
  import { parseJsonResponse, readApiError } from "../../lib/apiClient";
6
- import { KnowledgeCandidateSchema, type KnowledgeCandidate } from "../../knowledge/types";
27
+ import {
28
+ KnowledgeCandidateSchema,
29
+ type KnowledgeCandidate,
30
+ type KnowledgeCandidateStatus,
31
+ } from "../../knowledge/types";
7
32
  import type { CapturedLog } from "../../proxy/schemas";
8
33
  import { Badge } from "../ui/badge";
9
34
  import { Button } from "../ui/button";
10
- import { buildTraceSummary } from "./viewerState";
35
+ import { buildTraceSummary, extractToolTraceEvents, type TraceSummary } from "./viewerState";
11
36
 
12
37
  const CandidateResponseSchema = z.object({
13
38
  candidates: z.array(KnowledgeCandidateSchema),
14
39
  });
15
40
 
41
+ const CandidatePromotionResponseSchema = z.object({
42
+ candidate: KnowledgeCandidateSchema,
43
+ });
44
+
45
+ const CandidatePromotionFailureResponseSchema = z.object({
46
+ error: z.string().optional(),
47
+ candidate: KnowledgeCandidateSchema.optional(),
48
+ });
49
+
50
+ const CandidateUpdateResponseSchema = z.object({
51
+ candidate: KnowledgeCandidateSchema,
52
+ });
53
+
54
+ type CandidateDraftUpdate = {
55
+ type: KnowledgeCandidate["type"];
56
+ title: string;
57
+ content: string;
58
+ tags: string[];
59
+ };
60
+
16
61
  type CandidateLoadState =
17
62
  | { status: "idle"; error: null }
18
63
  | { status: "loading"; error: null }
19
64
  | { status: "ready"; error: null }
20
65
  | { status: "failed"; error: string };
21
66
 
67
+ type TraceInsightKind = "session" | "tool" | "slow" | "failure" | "candidate";
68
+
69
+ type TraceInsight = {
70
+ kind: TraceInsightKind;
71
+ title: string;
72
+ detail: string;
73
+ logId: number | null;
74
+ };
75
+
76
+ const CANDIDATE_STATUS_CLASSES: Record<KnowledgeCandidateStatus, string> = {
77
+ draft: "border-amber-500/30 bg-amber-500/10 text-amber-500",
78
+ promoted: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500",
79
+ failed: "border-destructive/30 bg-destructive/10 text-destructive",
80
+ };
81
+
22
82
  type AgentTraceSummaryProps = {
23
83
  logs: CapturedLog[];
24
84
  scopeId: string;
25
85
  slowResponseThresholdSeconds: number;
86
+ showRollupMetrics: boolean;
87
+ timeDisplayFormat: TimeDisplayFormat;
26
88
  };
27
89
 
28
90
  function formatElapsed(ms: number | null): string {
@@ -31,60 +93,492 @@ function formatElapsed(ms: number | null): string {
31
93
  return `${(ms / 1000).toFixed(1)}s`;
32
94
  }
33
95
 
34
- function formatTimeRange(startedAt: string | null, endedAt: string | null): string | null {
96
+ function formatTimeRange(
97
+ startedAt: string | null,
98
+ endedAt: string | null,
99
+ timeDisplayFormat: TimeDisplayFormat,
100
+ ): string | null {
35
101
  if (startedAt === null || endedAt === null) return null;
36
- const format = (iso: string): string =>
37
- new Date(iso).toLocaleTimeString([], {
38
- hour: "2-digit",
39
- minute: "2-digit",
40
- second: "2-digit",
41
- });
42
- return `${format(startedAt)} - ${format(endedAt)}`;
102
+ return formatTimestampRange(startedAt, endedAt, timeDisplayFormat);
103
+ }
104
+
105
+ function formatCandidateCount(count: number): string {
106
+ return `${String(count)} candidate${count === 1 ? "" : "s"}`;
107
+ }
108
+
109
+ function getLogAnchor(logId: number): string {
110
+ return `log-${String(logId)}`;
43
111
  }
44
112
 
45
- function scrollToLog(logId: number): void {
46
- const target = document.getElementById(`log-${String(logId)}`);
113
+ function jumpToLog(logId: number): void {
114
+ const anchor = getLogAnchor(logId);
115
+ const target = document.getElementById(anchor);
116
+ window.history.replaceState(null, "", `#${anchor}`);
47
117
  if (!(target instanceof HTMLElement)) return;
48
118
  target.scrollIntoView({ block: "center", behavior: "smooth" });
49
119
  target.focus({ preventScroll: true });
50
- if (target.getAttribute("data-nav-action") === "expand") {
51
- target.click();
120
+ }
121
+
122
+ function firstFailureLog(logs: CapturedLog[]): CapturedLog | null {
123
+ for (const log of logs) {
124
+ if (log.responseStatus !== null && log.responseStatus >= 400) return log;
52
125
  }
126
+ return null;
53
127
  }
54
128
 
55
- function CandidateList({ candidates }: { candidates: KnowledgeCandidate[] }): JSX.Element | null {
56
- if (candidates.length === 0) return null;
129
+ function slowestLog(logs: CapturedLog[]): CapturedLog | null {
130
+ let current: CapturedLog | null = null;
131
+ let currentElapsed = -1;
132
+ for (const log of logs) {
133
+ if (log.elapsedMs === null) continue;
134
+ if (log.elapsedMs > currentElapsed) {
135
+ current = log;
136
+ currentElapsed = log.elapsedMs;
137
+ }
138
+ }
139
+ return current;
140
+ }
141
+
142
+ function firstToolLog(logs: CapturedLog[]): CapturedLog | null {
143
+ for (const log of logs) {
144
+ if (extractToolTraceEvents(log).length > 0) return log;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function buildTraceInsights(input: {
150
+ logs: CapturedLog[];
151
+ scopeId: string;
152
+ summary: TraceSummary;
153
+ candidates: KnowledgeCandidate[];
154
+ }): TraceInsight[] {
155
+ const insights: TraceInsight[] = [
156
+ {
157
+ kind: "session",
158
+ title: `${String(input.summary.llmCallCount)} LLM call${
159
+ input.summary.llmCallCount === 1 ? "" : "s"
160
+ }`,
161
+ detail: `Scope ${input.scopeId}`,
162
+ logId: input.logs[0]?.id ?? null,
163
+ },
164
+ ];
165
+
166
+ const toolLog = firstToolLog(input.logs);
167
+ if (input.summary.toolCallCount > 0 && toolLog !== null) {
168
+ insights.push({
169
+ kind: "tool",
170
+ title: `${String(input.summary.toolCallCount)} tool call${
171
+ input.summary.toolCallCount === 1 ? "" : "s"
172
+ }`,
173
+ detail: `First tool evidence at #${String(toolLog.id)}`,
174
+ logId: toolLog.id,
175
+ });
176
+ }
177
+
178
+ const failure = firstFailureLog(input.logs);
179
+ if (failure !== null) {
180
+ insights.push({
181
+ kind: "failure",
182
+ title: `Failure #${String(failure.responseStatus)}`,
183
+ detail: `First failed request is #${String(failure.id)}`,
184
+ logId: failure.id,
185
+ });
186
+ }
187
+
188
+ const slowest = slowestLog(input.logs);
189
+ if (slowest !== null && input.summary.maxElapsedMs !== null) {
190
+ insights.push({
191
+ kind: "slow",
192
+ title: `Slowest ${formatElapsed(input.summary.maxElapsedMs)}`,
193
+ detail: `Max latency observed at #${String(slowest.id)}`,
194
+ logId: slowest.id,
195
+ });
196
+ }
197
+
198
+ if (input.candidates.length > 0) {
199
+ const draftCount = input.candidates.filter((candidate) => candidate.status === "draft").length;
200
+ const promotedCount = input.candidates.filter(
201
+ (candidate) => candidate.status === "promoted",
202
+ ).length;
203
+ insights.push({
204
+ kind: "candidate",
205
+ title: `${String(input.candidates.length)} memory candidate${
206
+ input.candidates.length === 1 ? "" : "s"
207
+ }`,
208
+ detail: `${String(draftCount)} draft / ${String(promotedCount)} promoted`,
209
+ logId: input.candidates[0]?.logIds[0] ?? null,
210
+ });
211
+ }
212
+
213
+ return insights;
214
+ }
215
+
216
+ function insightIcon(kind: TraceInsightKind): JSX.Element {
217
+ switch (kind) {
218
+ case "session":
219
+ return <MessageSquare className="size-3.5 text-blue-400" />;
220
+ case "tool":
221
+ return <Wrench className="size-3.5 text-sky-400/70" />;
222
+ case "slow":
223
+ return <Clock className="size-3.5 text-amber-400" />;
224
+ case "failure":
225
+ return <XCircle className="size-3.5 text-destructive" />;
226
+ case "candidate":
227
+ return <Brain className="size-3.5 text-emerald-400" />;
228
+ }
229
+ }
230
+
231
+ function TraceInsights({ insights }: { insights: TraceInsight[] }): JSX.Element | null {
232
+ if (insights.length === 0) return null;
57
233
  return (
58
- <div className="mt-2 grid gap-1.5">
59
- {candidates.map((candidate) => (
60
- <div
61
- key={candidate.id}
62
- className="rounded-md border border-border/80 bg-background/60 px-2.5 py-2"
234
+ <div className="mt-2 grid gap-1.5 border-t border-border/70 pt-2 md:grid-cols-2 xl:grid-cols-3">
235
+ {insights.map((insight) => (
236
+ <button
237
+ key={`${insight.kind}-${insight.title}`}
238
+ type="button"
239
+ className={cn(
240
+ "flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs",
241
+ "text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground",
242
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
243
+ )}
244
+ onClick={() => {
245
+ if (insight.logId !== null) jumpToLog(insight.logId);
246
+ }}
247
+ disabled={insight.logId === null}
63
248
  >
64
- <div className="flex min-w-0 items-center gap-2">
65
- <Badge variant="outline" className="h-5 px-1.5 text-[10px] font-mono">
66
- {candidate.type}
67
- </Badge>
68
- <span className="min-w-0 flex-1 truncate text-xs font-medium" title={candidate.title}>
69
- {candidate.title}
70
- </span>
71
- <span className="shrink-0 font-mono text-[10px] text-muted-foreground">
72
- {candidate.status}
73
- </span>
249
+ {insightIcon(insight.kind)}
250
+ <span className="min-w-0">
251
+ <span className="block truncate font-medium text-foreground/90">{insight.title}</span>
252
+ <span className="block truncate font-mono text-[10px]">{insight.detail}</span>
253
+ </span>
254
+ </button>
255
+ ))}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ function candidateStatusLabel(status: KnowledgeCandidateStatus): string {
261
+ switch (status) {
262
+ case "draft":
263
+ return "Draft";
264
+ case "promoted":
265
+ return "Promoted";
266
+ case "failed":
267
+ return "Failed";
268
+ }
269
+ }
270
+
271
+ function candidatePromoteLabel(candidate: KnowledgeCandidate, promoting: boolean): string {
272
+ if (promoting) return "Promoting";
273
+ switch (candidate.status) {
274
+ case "draft":
275
+ return "Promote";
276
+ case "failed":
277
+ return "Retry";
278
+ case "promoted":
279
+ return "Promoted";
280
+ }
281
+ }
282
+
283
+ function redactionLabel(candidate: KnowledgeCandidate): string {
284
+ if (!candidate.redaction.redacted) return "No sensitive pattern matched";
285
+ return `Redacted ${candidate.redaction.patterns.join(", ")}`;
286
+ }
287
+
288
+ function previewText(value: string, maxLength: number): string {
289
+ const normalized = value.replace(/\s+/g, " ").trim();
290
+ if (normalized.length <= maxLength) return normalized;
291
+ return `${normalized.slice(0, maxLength - 3)}...`;
292
+ }
293
+
294
+ function logRangeLabel(logIds: number[]): string {
295
+ const first = logIds[0];
296
+ const last = logIds[logIds.length - 1];
297
+ if (first === undefined) return "No evidence logs";
298
+ if (last === undefined || first === last) return `#${String(first)}`;
299
+ return `#${String(first)}-#${String(last)}`;
300
+ }
301
+
302
+ function tagsToText(tags: string[]): string {
303
+ return tags.join(", ");
304
+ }
305
+
306
+ function textToTags(value: string): string[] {
307
+ return value
308
+ .split(",")
309
+ .map((tag) => tag.trim())
310
+ .filter((tag) => tag.length > 0);
311
+ }
312
+
313
+ function CandidateItem({
314
+ candidate,
315
+ isPromoting,
316
+ isUpdating,
317
+ onPromoteCandidate,
318
+ onUpdateCandidate,
319
+ }: {
320
+ candidate: KnowledgeCandidate;
321
+ isPromoting: boolean;
322
+ isUpdating: boolean;
323
+ onPromoteCandidate: (candidateId: string) => void;
324
+ onUpdateCandidate: (candidateId: string, update: CandidateDraftUpdate) => Promise<boolean>;
325
+ }): JSX.Element {
326
+ const [editing, setEditing] = useState(false);
327
+ const [draftType, setDraftType] = useState<KnowledgeCandidate["type"]>(candidate.type);
328
+ const [draftTitle, setDraftTitle] = useState(candidate.title);
329
+ const [draftContent, setDraftContent] = useState(candidate.content);
330
+ const [draftTags, setDraftTags] = useState(tagsToText(candidate.tags));
331
+ const [localError, setLocalError] = useState<string | null>(null);
332
+ const canPromote = candidate.status !== "promoted";
333
+ const canEdit = candidate.status !== "promoted";
334
+
335
+ const resetDraft = useCallback(() => {
336
+ setDraftType(candidate.type);
337
+ setDraftTitle(candidate.title);
338
+ setDraftContent(candidate.content);
339
+ setDraftTags(tagsToText(candidate.tags));
340
+ setLocalError(null);
341
+ }, [candidate.content, candidate.tags, candidate.title, candidate.type]);
342
+
343
+ const saveDraft = useCallback(async () => {
344
+ const title = draftTitle.trim();
345
+ const content = draftContent.trim();
346
+ const tags = textToTags(draftTags);
347
+ if (title.length === 0) {
348
+ setLocalError("Title is required.");
349
+ return;
350
+ }
351
+ if (content.length === 0) {
352
+ setLocalError("Content is required.");
353
+ return;
354
+ }
355
+ if (tags.length === 0) {
356
+ setLocalError("At least one tag is required.");
357
+ return;
358
+ }
359
+ setLocalError(null);
360
+ const saved = await onUpdateCandidate(candidate.id, {
361
+ type: draftType,
362
+ title,
363
+ content,
364
+ tags,
365
+ });
366
+ if (saved) setEditing(false);
367
+ }, [candidate.id, draftContent, draftTags, draftTitle, draftType, onUpdateCandidate]);
368
+
369
+ if (editing) {
370
+ return (
371
+ <div className="rounded-md border border-border/80 bg-background/60 px-2.5 py-2">
372
+ <div className="grid gap-2">
373
+ <div className="flex flex-wrap items-center gap-2">
374
+ <select
375
+ value={draftType}
376
+ disabled={isUpdating}
377
+ onChange={(event) => {
378
+ const parsed = KnowledgeCandidateSchema.shape.type.safeParse(
379
+ event.currentTarget.value,
380
+ );
381
+ if (parsed.success) setDraftType(parsed.data);
382
+ }}
383
+ className="h-7 rounded-md border border-input bg-background px-2 text-xs"
384
+ aria-label="Candidate type"
385
+ >
386
+ <option value="episode">episode</option>
387
+ <option value="procedure">procedure</option>
388
+ <option value="preference">preference</option>
389
+ <option value="project-fact">project-fact</option>
390
+ </select>
391
+ <input
392
+ value={draftTitle}
393
+ disabled={isUpdating}
394
+ onChange={(event) => setDraftTitle(event.currentTarget.value)}
395
+ className="h-7 min-w-[220px] flex-1 rounded-md border border-input bg-background px-2 text-xs"
396
+ aria-label="Candidate title"
397
+ />
74
398
  </div>
75
- <div className="mt-1 flex flex-wrap items-center gap-1.5">
76
- {candidate.logIds.map((logId) => (
77
- <button
78
- key={logId}
79
- type="button"
80
- onClick={() => scrollToLog(logId)}
81
- className="rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-blue-400 transition-colors hover:bg-muted hover:text-blue-300"
82
- >
83
- #{logId}
84
- </button>
85
- ))}
399
+ <textarea
400
+ value={draftContent}
401
+ disabled={isUpdating}
402
+ onChange={(event) => setDraftContent(event.currentTarget.value)}
403
+ className="min-h-28 rounded-md border border-input bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed"
404
+ aria-label="Candidate content"
405
+ />
406
+ <input
407
+ value={draftTags}
408
+ disabled={isUpdating}
409
+ onChange={(event) => setDraftTags(event.currentTarget.value)}
410
+ className="h-7 rounded-md border border-input bg-background px-2 text-xs"
411
+ aria-label="Candidate tags"
412
+ />
413
+ <div className="flex flex-wrap items-center gap-2">
414
+ <span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
415
+ <ShieldCheck className="size-3" />
416
+ Saving reruns Inspector redaction before promotion.
417
+ </span>
418
+ <span className="flex-1" />
419
+ {localError !== null && (
420
+ <span className="text-[10px] text-destructive">{localError}</span>
421
+ )}
422
+ <Button
423
+ type="button"
424
+ variant="ghost"
425
+ size="sm"
426
+ className="h-7 gap-1.5 px-2 text-xs"
427
+ disabled={isUpdating}
428
+ onClick={() => {
429
+ resetDraft();
430
+ setEditing(false);
431
+ }}
432
+ >
433
+ <X className="size-3.5" />
434
+ Cancel
435
+ </Button>
436
+ <Button
437
+ type="button"
438
+ variant="outline"
439
+ size="sm"
440
+ className="h-7 gap-1.5 px-2 text-xs"
441
+ disabled={isUpdating}
442
+ onClick={() => {
443
+ void saveDraft();
444
+ }}
445
+ >
446
+ {isUpdating ? (
447
+ <RefreshCw className="size-3.5 animate-spin" />
448
+ ) : (
449
+ <Save className="size-3.5" />
450
+ )}
451
+ Save
452
+ </Button>
86
453
  </div>
87
454
  </div>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ return (
460
+ <div className="rounded-md border border-border/80 bg-background/60 px-2.5 py-2">
461
+ <div className="flex min-w-0 items-center gap-2">
462
+ <Badge variant="outline" className="h-5 px-1.5 text-[10px] font-mono">
463
+ {candidate.type}
464
+ </Badge>
465
+ <span className="min-w-0 flex-1 truncate text-xs font-medium" title={candidate.title}>
466
+ {candidate.title}
467
+ </span>
468
+ <Badge
469
+ variant="outline"
470
+ className={cn(
471
+ "h-5 shrink-0 px-1.5 text-[10px] font-mono",
472
+ CANDIDATE_STATUS_CLASSES[candidate.status],
473
+ )}
474
+ >
475
+ {candidateStatusLabel(candidate.status)}
476
+ </Badge>
477
+ </div>
478
+ <p
479
+ className="mt-1 text-[11px] leading-relaxed text-muted-foreground"
480
+ title={candidate.content}
481
+ >
482
+ {previewText(candidate.content, 360)}
483
+ </p>
484
+ <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted-foreground">
485
+ <span className="inline-flex items-center gap-1">
486
+ <FileSearch className="size-3" />
487
+ {logRangeLabel(candidate.logIds)}
488
+ </span>
489
+ <span className="inline-flex items-center gap-1">
490
+ <ShieldCheck className="size-3" />
491
+ {redactionLabel(candidate)}
492
+ </span>
493
+ {candidate.status === "promoted" && (
494
+ <span className="inline-flex items-center gap-1 text-emerald-500">
495
+ <CircleCheck className="size-3" />
496
+ {candidate.openClawMemoryId ?? "OpenClaw"}
497
+ </span>
498
+ )}
499
+ {candidate.status === "failed" && candidate.error !== null && (
500
+ <span className="inline-flex items-center gap-1 text-destructive">
501
+ <XCircle className="size-3" />
502
+ {candidate.error}
503
+ </span>
504
+ )}
505
+ </div>
506
+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
507
+ {candidate.logIds.map((logId) => (
508
+ <a
509
+ key={logId}
510
+ href={`#${getLogAnchor(logId)}`}
511
+ onClick={(event) => {
512
+ event.preventDefault();
513
+ jumpToLog(logId);
514
+ }}
515
+ className="rounded border border-blue-400/25 px-1.5 py-0.5 font-mono text-[10px] text-blue-400 underline-offset-2 transition-colors hover:bg-blue-400/10 hover:text-blue-300 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
516
+ aria-label={`Jump to evidence log ${String(logId)}`}
517
+ >
518
+ #{logId}
519
+ </a>
520
+ ))}
521
+ <span className="flex-1" />
522
+ {canEdit && (
523
+ <Button
524
+ type="button"
525
+ variant="ghost"
526
+ size="sm"
527
+ className="h-6 gap-1.5 px-2 text-[10px]"
528
+ disabled={isUpdating || isPromoting}
529
+ onClick={() => setEditing(true)}
530
+ >
531
+ <Pencil className="size-3" />
532
+ Review
533
+ </Button>
534
+ )}
535
+ <Button
536
+ type="button"
537
+ variant={canPromote ? "outline" : "ghost"}
538
+ size="sm"
539
+ className="h-6 gap-1.5 px-2 text-[10px]"
540
+ onClick={() => onPromoteCandidate(candidate.id)}
541
+ disabled={!canPromote || isPromoting || isUpdating}
542
+ >
543
+ {isPromoting ? (
544
+ <RefreshCw className="size-3 animate-spin" />
545
+ ) : canPromote ? (
546
+ <UploadCloud className="size-3" />
547
+ ) : (
548
+ <CircleCheck className="size-3" />
549
+ )}
550
+ {candidatePromoteLabel(candidate, isPromoting)}
551
+ </Button>
552
+ </div>
553
+ </div>
554
+ );
555
+ }
556
+
557
+ function CandidateList({
558
+ candidates,
559
+ promotingCandidateIds,
560
+ updatingCandidateIds,
561
+ onPromoteCandidate,
562
+ onUpdateCandidate,
563
+ }: {
564
+ candidates: KnowledgeCandidate[];
565
+ promotingCandidateIds: ReadonlySet<string>;
566
+ updatingCandidateIds: ReadonlySet<string>;
567
+ onPromoteCandidate: (candidateId: string) => void;
568
+ onUpdateCandidate: (candidateId: string, update: CandidateDraftUpdate) => Promise<boolean>;
569
+ }): JSX.Element | null {
570
+ if (candidates.length === 0) return null;
571
+ return (
572
+ <div className="mt-2 grid gap-1.5">
573
+ {candidates.map((candidate) => (
574
+ <CandidateItem
575
+ key={candidate.id}
576
+ candidate={candidate}
577
+ isPromoting={promotingCandidateIds.has(candidate.id)}
578
+ isUpdating={updatingCandidateIds.has(candidate.id)}
579
+ onPromoteCandidate={onPromoteCandidate}
580
+ onUpdateCandidate={onUpdateCandidate}
581
+ />
88
582
  ))}
89
583
  </div>
90
584
  );
@@ -94,19 +588,34 @@ export function AgentTraceSummary({
94
588
  logs,
95
589
  scopeId,
96
590
  slowResponseThresholdSeconds,
591
+ showRollupMetrics,
592
+ timeDisplayFormat,
97
593
  }: AgentTraceSummaryProps): JSX.Element | null {
98
594
  const [candidates, setCandidates] = useState<KnowledgeCandidate[]>([]);
595
+ const [candidatesExpanded, setCandidatesExpanded] = useState(true);
99
596
  const [candidateState, setCandidateState] = useState<CandidateLoadState>({
100
597
  status: "idle",
101
598
  error: null,
102
599
  });
600
+ const [promotingCandidateIds, setPromotingCandidateIds] = useState<ReadonlySet<string>>(
601
+ () => new Set(),
602
+ );
603
+ const [updatingCandidateIds, setUpdatingCandidateIds] = useState<ReadonlySet<string>>(
604
+ () => new Set(),
605
+ );
606
+ const hasCandidates = candidates.length > 0;
103
607
  const summary = useMemo(
104
608
  () => buildTraceSummary(logs, slowResponseThresholdSeconds, candidates.length),
105
609
  [candidates.length, logs, slowResponseThresholdSeconds],
106
610
  );
611
+ const traceInsights = useMemo(
612
+ () => buildTraceInsights({ logs, scopeId, summary, candidates }),
613
+ [candidates, logs, scopeId, summary],
614
+ );
615
+ const showElapsedSummary = showRollupMetrics || summary.maxElapsedMs !== null;
107
616
  const timeRange = useMemo(
108
- () => formatTimeRange(summary.startedAt, summary.endedAt),
109
- [summary.endedAt, summary.startedAt],
617
+ () => formatTimeRange(summary.startedAt, summary.endedAt, timeDisplayFormat),
618
+ [summary.endedAt, summary.startedAt, timeDisplayFormat],
110
619
  );
111
620
 
112
621
  const createCandidates = useCallback(() => {
@@ -128,7 +637,12 @@ export function AgentTraceSummary({
128
637
  }
129
638
  const parsed = await parseJsonResponse(response, CandidateResponseSchema);
130
639
  setCandidates(parsed.candidates);
131
- setCandidateState({ status: "ready", error: null });
640
+ setCandidatesExpanded(parsed.candidates.length > 0);
641
+ setCandidateState(
642
+ parsed.candidates.length > 0
643
+ ? { status: "ready", error: null }
644
+ : { status: "failed", error: "No candidate was generated for this trace scope." },
645
+ );
132
646
  } catch (error) {
133
647
  setCandidateState({
134
648
  status: "failed",
@@ -138,44 +652,159 @@ export function AgentTraceSummary({
138
652
  })();
139
653
  }, [candidateState.status, logs.length, scopeId]);
140
654
 
655
+ const promoteCandidate = useCallback((candidateId: string) => {
656
+ setPromotingCandidateIds((current) => {
657
+ if (current.has(candidateId)) return current;
658
+ const next = new Set(current);
659
+ next.add(candidateId);
660
+ return next;
661
+ });
662
+
663
+ void (async () => {
664
+ try {
665
+ const response = await fetch(
666
+ `/api/knowledge/candidates/${encodeURIComponent(candidateId)}/promote`,
667
+ { method: "POST" },
668
+ );
669
+ const raw: unknown = await response.json().catch(() => null);
670
+ const parsed = CandidatePromotionResponseSchema.safeParse(raw);
671
+ if (response.ok && parsed.success) {
672
+ setCandidates((current) =>
673
+ current.map((candidate) =>
674
+ candidate.id === parsed.data.candidate.id ? parsed.data.candidate : candidate,
675
+ ),
676
+ );
677
+ setCandidateState({ status: "ready", error: null });
678
+ return;
679
+ }
680
+
681
+ const failure = CandidatePromotionFailureResponseSchema.safeParse(raw);
682
+ if (failure.success && failure.data.candidate !== undefined) {
683
+ setCandidates((current) =>
684
+ current.map((candidate) =>
685
+ candidate.id === failure.data.candidate?.id ? failure.data.candidate : candidate,
686
+ ),
687
+ );
688
+ }
689
+ setCandidateState({
690
+ status: "failed",
691
+ error: failure.success
692
+ ? (failure.data.error ?? "Candidate promotion failed")
693
+ : `Candidate promotion failed with ${String(response.status)}`,
694
+ });
695
+ } catch (error) {
696
+ setCandidateState({
697
+ status: "failed",
698
+ error: error instanceof Error ? error.message : "Candidate promotion failed",
699
+ });
700
+ } finally {
701
+ setPromotingCandidateIds((current) => {
702
+ const next = new Set(current);
703
+ next.delete(candidateId);
704
+ return next;
705
+ });
706
+ }
707
+ })();
708
+ }, []);
709
+
710
+ const updateCandidate = useCallback(
711
+ async (candidateId: string, update: CandidateDraftUpdate): Promise<boolean> => {
712
+ setUpdatingCandidateIds((current) => {
713
+ if (current.has(candidateId)) return current;
714
+ const next = new Set(current);
715
+ next.add(candidateId);
716
+ return next;
717
+ });
718
+
719
+ try {
720
+ const response = await fetch(
721
+ `/api/knowledge/candidates/${encodeURIComponent(candidateId)}`,
722
+ {
723
+ method: "PATCH",
724
+ headers: { "Content-Type": "application/json" },
725
+ body: JSON.stringify(update),
726
+ },
727
+ );
728
+ if (!response.ok) {
729
+ const message = await readApiError(
730
+ response,
731
+ `Candidate update failed with ${String(response.status)}`,
732
+ );
733
+ setCandidateState({ status: "failed", error: message });
734
+ return false;
735
+ }
736
+
737
+ const parsed = await parseJsonResponse(response, CandidateUpdateResponseSchema);
738
+ setCandidates((current) =>
739
+ current.map((candidate) =>
740
+ candidate.id === parsed.candidate.id ? parsed.candidate : candidate,
741
+ ),
742
+ );
743
+ setCandidateState({ status: "ready", error: null });
744
+ return true;
745
+ } catch (error) {
746
+ setCandidateState({
747
+ status: "failed",
748
+ error: error instanceof Error ? error.message : "Candidate update failed",
749
+ });
750
+ return false;
751
+ } finally {
752
+ setUpdatingCandidateIds((current) => {
753
+ const next = new Set(current);
754
+ next.delete(candidateId);
755
+ return next;
756
+ });
757
+ }
758
+ },
759
+ [],
760
+ );
761
+
141
762
  if (logs.length === 0) return null;
142
763
 
143
764
  return (
144
765
  <section className="mb-2 rounded-lg border border-border bg-muted/10 px-3 py-2">
145
766
  <div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs">
146
- <span className="inline-flex items-center gap-1.5 font-semibold text-foreground">
147
- <MessageSquare className="size-3.5 text-blue-400" />
148
- {summary.llmCallCount} LLM
149
- </span>
767
+ {showRollupMetrics && (
768
+ <span className="inline-flex items-center gap-1.5 font-semibold text-foreground">
769
+ <MessageSquare className="size-3.5 text-blue-400" />
770
+ {summary.llmCallCount} LLM
771
+ </span>
772
+ )}
150
773
  <span className="inline-flex items-center gap-1.5 text-muted-foreground">
151
- <Wrench className="size-3.5 text-amber-400" />
774
+ <Wrench className="size-3.5 text-sky-400/70" />
152
775
  {summary.toolCallCount} tools
153
776
  </span>
154
- <span className="inline-flex items-center gap-1.5 text-muted-foreground">
155
- <Zap className="size-3.5 text-emerald-400" />
156
- <span className="font-mono">
157
- {formatTokens(summary.totalInputTokens)} / {formatTokens(summary.totalOutputTokens)}
777
+ {showRollupMetrics && (
778
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
779
+ <Zap className="size-3.5 text-emerald-400" />
780
+ <span className="font-mono">
781
+ {formatTokens(summary.totalInputTokens)} / {formatTokens(summary.totalOutputTokens)}
782
+ </span>
158
783
  </span>
159
- </span>
784
+ )}
160
785
  {(summary.totalCacheCreationInputTokens > 0 || summary.totalCacheReadInputTokens > 0) && (
161
786
  <span className="inline-flex items-center gap-1.5 text-muted-foreground">
162
787
  <Zap className="size-3.5 text-purple-400" />
163
788
  <span className="font-mono">
164
- +{formatTokens(summary.totalCacheCreationInputTokens)} / ~
789
+ KV Cache +{formatTokens(summary.totalCacheCreationInputTokens)} / ~
165
790
  {formatTokens(summary.totalCacheReadInputTokens)}
166
791
  </span>
167
792
  </span>
168
793
  )}
169
- <span className="inline-flex items-center gap-1.5 text-muted-foreground">
170
- <Clock className="size-3.5" />
171
- <span className="font-mono">{formatElapsed(summary.totalElapsedMs)}</span>
172
- {summary.maxElapsedMs !== null && (
173
- <span className="font-mono text-muted-foreground/70">
174
- max {formatElapsed(summary.maxElapsedMs)}
175
- </span>
176
- )}
177
- </span>
178
- {timeRange !== null && (
794
+ {showElapsedSummary && (
795
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
796
+ <Clock className="size-3.5" />
797
+ {showRollupMetrics && (
798
+ <span className="font-mono">{formatElapsed(summary.totalElapsedMs)}</span>
799
+ )}
800
+ {summary.maxElapsedMs !== null && (
801
+ <span className="font-mono text-muted-foreground/70">
802
+ max {formatElapsed(summary.maxElapsedMs)}
803
+ </span>
804
+ )}
805
+ </span>
806
+ )}
807
+ {showRollupMetrics && timeRange !== null && (
179
808
  <span className="font-mono text-muted-foreground/70">{timeRange}</span>
180
809
  )}
181
810
  {(summary.failedCallCount > 0 ||
@@ -190,9 +819,11 @@ export function AgentTraceSummary({
190
819
  </span>
191
820
  )}
192
821
  <span className="flex-1" />
193
- <Badge variant="outline" className="h-6 px-2 text-[10px] font-mono">
194
- {summary.knowledgeCandidateCount} memory
195
- </Badge>
822
+ {hasCandidates && (
823
+ <Badge variant="outline" className="h-6 px-2 text-[10px] font-mono">
824
+ {formatCandidateCount(summary.knowledgeCandidateCount)}
825
+ </Badge>
826
+ )}
196
827
  <Button
197
828
  type="button"
198
829
  variant="outline"
@@ -208,11 +839,39 @@ export function AgentTraceSummary({
208
839
  )}
209
840
  Candidate
210
841
  </Button>
842
+ {hasCandidates && (
843
+ <Button
844
+ type="button"
845
+ variant="ghost"
846
+ size="icon"
847
+ className="size-7 text-muted-foreground"
848
+ onClick={() => setCandidatesExpanded((value) => !value)}
849
+ aria-expanded={candidatesExpanded}
850
+ aria-label={
851
+ candidatesExpanded ? "Collapse memory candidates" : "Expand memory candidates"
852
+ }
853
+ >
854
+ {candidatesExpanded ? (
855
+ <ChevronDown className="size-3.5" />
856
+ ) : (
857
+ <ChevronRight className="size-3.5" />
858
+ )}
859
+ </Button>
860
+ )}
211
861
  </div>
212
862
  {candidateState.status === "failed" && (
213
863
  <p className="mt-2 text-xs text-destructive">{candidateState.error}</p>
214
864
  )}
215
- <CandidateList candidates={candidates} />
865
+ <TraceInsights insights={traceInsights} />
866
+ {candidatesExpanded && (
867
+ <CandidateList
868
+ candidates={candidates}
869
+ promotingCandidateIds={promotingCandidateIds}
870
+ updatingCandidateIds={updatingCandidateIds}
871
+ onPromoteCandidate={promoteCandidate}
872
+ onUpdateCandidate={updateCandidate}
873
+ />
874
+ )}
216
875
  </section>
217
876
  );
218
877
  }