@skyhook-io/radar-app 1.14.2 → 1.14.3

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 (74) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +30 -0
  3. package/src/components/CloudConnectFlow.tsx +21 -7
  4. package/src/components/CloudFunnelButton.tsx +85 -8
  5. package/src/components/cloudConnectHandoff.test.ts +10 -1
  6. package/src/components/cloudConnectHandoff.ts +15 -1
  7. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  8. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  9. package/src/components/diagnose/AgentControls.tsx +603 -0
  10. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  11. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  12. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  13. package/src/components/diagnose/AssessmentCard.tsx +1560 -0
  14. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  15. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  16. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  17. package/src/components/diagnose/Home.test.tsx +1 -3
  18. package/src/components/diagnose/Home.tsx +2 -1
  19. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  20. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  21. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  22. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  23. package/src/components/diagnose/InvestigationView.tsx +11 -28
  24. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  25. package/src/components/diagnose/assessmentCopy.ts +169 -0
  26. package/src/components/diagnose/investigationCase.test.tsx +631 -2
  27. package/src/components/diagnose/investigationCase.ts +132 -24
  28. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  31. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  38. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  39. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  40. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  41. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  48. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  49. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  50. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  51. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  52. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  53. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  54. package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
  55. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  57. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  58. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  59. package/src/components/diagnose/parts.test.tsx +3 -1
  60. package/src/components/diagnose/parts.tsx +26 -3904
  61. package/src/components/diagnose/target.ts +9 -3
  62. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  63. package/src/components/diagnose/toolCallLabel.ts +41 -2
  64. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  65. package/src/components/home/MCPSetupDialog.tsx +1 -1
  66. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  67. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  68. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  69. package/src/components/resources/PodFilePreview.tsx +1 -1
  70. package/src/components/ui/CommandPalette.tsx +12 -8
  71. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  72. package/src/components/ui/Omnibar.tsx +33 -10
  73. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  74. package/src/components/ui/Disclosure.tsx +0 -47
@@ -0,0 +1,1244 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { TW_EASE_UI } from "../../utils/animation";
10
+ import {
11
+ Loader2,
12
+ CheckCircle2,
13
+ AlertTriangle,
14
+ ShieldCheck,
15
+ Sparkles,
16
+ RefreshCw,
17
+ Maximize2,
18
+ HelpCircle,
19
+ } from "lucide-react";
20
+ import { stringify as toYaml } from "yaml";
21
+ import { codeToHtml } from "shiki";
22
+ import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
23
+ import { useTheme } from "../../context/ThemeContext";
24
+ import type {
25
+ Diagnosis,
26
+ DiagnoseStep,
27
+ ApplyMutationOutcome,
28
+ DiagnoseStreamEvent,
29
+ MCPServerStatus,
30
+ } from "../../api/diagnose";
31
+ import { Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
32
+ import { Tooltip } from "../ui/Tooltip";
33
+ import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
34
+ import {
35
+ highlightRelatedEvidence,
36
+ locateSourceExcerpt,
37
+ } from "./investigationSourceFocus";
38
+ import {
39
+ investigationActivitySourceDomId,
40
+ investigationEvidenceSourceId,
41
+ } from "./investigationEvidence";
42
+ import { useDisclosureReveal } from "./useDisclosureReveal";
43
+ import { Segmented } from "./AgentControls";
44
+ import { ApplyOutcomeCard } from "./ApplyDialog";
45
+ import {
46
+ type AssessmentExplanation,
47
+ ResultCard,
48
+ WorkingNotes,
49
+ } from "./AssessmentCard";
50
+ import { AIMarkdown, CopyButton } from "./AIMarkdown";
51
+ import { prettyTool } from "./toolCallLabel";
52
+
53
+ // Turn is one round of the conversation: the initial investigation (no question)
54
+ // or a follow-up, each with its own transcript + result.
55
+ export type Turn = {
56
+ resultSequence?: number;
57
+ explainAssessment?: number;
58
+ question?: string;
59
+ actor?: string;
60
+ timeline: TimelineItem[];
61
+ diagnosis: Diagnosis | null;
62
+ error: string | null;
63
+ status: "running" | "done" | "error";
64
+ // apply turns execute the recommended fix (write tools) — they report an
65
+ // outcome, not a root cause, so the UI frames them differently.
66
+ apply?: boolean;
67
+ // Set from the apply turn's terminal stream event. Green success is reserved
68
+ // for an explicit producer-confirmed mutation.
69
+ applyOutcome?: ApplyMutationOutcome;
70
+ // Verification turns are structurally a fresh health assessment, even though
71
+ // they carry a question. Keeping the bit explicit prevents the UI from
72
+ // misclassifying them as ordinary conversational follow-ups on replay.
73
+ verify?: boolean;
74
+ // Set from the replay/live boundary when the terminal event arrives. Historical
75
+ // conclusions render immediately; conclusions observed live enter smoothly.
76
+ animateResult?: boolean;
77
+ // The latest startup phase the server reported before the agent's first
78
+ // message. It drives the pending status line only; it is never a transcript item.
79
+ startup?: StartupSignal;
80
+ };
81
+
82
+ export type StartupSignal = {
83
+ phase: "investigating" | "connected" | "ready";
84
+ model?: string;
85
+ toolCount?: number;
86
+ mcpServers?: MCPServerStatus[];
87
+ };
88
+
89
+ const STARTUP_PHASE_RANK: Record<StartupSignal["phase"], number> = {
90
+ investigating: 0,
91
+ connected: 1,
92
+ ready: 2,
93
+ };
94
+
95
+ // Folds a phase event into the turn's startup signal. The handshake and the
96
+ // CLI's init line are reported by different goroutines, so a later event can
97
+ // name an earlier phase; the furthest phase wins and the init facts are kept.
98
+ export function mergeStartupSignal(
99
+ prev: StartupSignal | undefined,
100
+ event: Pick<
101
+ DiagnoseStreamEvent,
102
+ "phase" | "model" | "toolCount" | "mcpServers"
103
+ >,
104
+ ): StartupSignal | undefined {
105
+ const phase = event.phase;
106
+ if (phase !== "investigating" && phase !== "connected" && phase !== "ready")
107
+ return prev;
108
+ const facts =
109
+ phase === "ready"
110
+ ? {
111
+ model: event.model,
112
+ toolCount: event.toolCount,
113
+ mcpServers: event.mcpServers,
114
+ }
115
+ : {};
116
+ if (prev && STARTUP_PHASE_RANK[prev.phase] >= STARTUP_PHASE_RANK[phase]) {
117
+ return phase === "ready" ? { ...prev, ...facts } : prev;
118
+ }
119
+ return { ...prev, ...facts, phase };
120
+ }
121
+
122
+ function startupLabel(startup: StartupSignal, agentLabel: string): string {
123
+ switch (startup.phase) {
124
+ case "investigating":
125
+ return `${agentLabel} starting…`;
126
+ case "connected":
127
+ return "Connected to Radar's tools";
128
+ case "ready": {
129
+ const parts = [`${agentLabel} ready`];
130
+ if (startup.model) parts.push(startup.model);
131
+ if (startup.toolCount !== undefined)
132
+ parts.push(
133
+ `${startup.toolCount} Radar ${startup.toolCount === 1 ? "tool" : "tools"}`,
134
+ );
135
+ return parts.join(" · ");
136
+ }
137
+ }
138
+ }
139
+
140
+ // TimelineItem is one ordered transcript entry: agent reasoning, or a tool call.
141
+ export type TimelineItem =
142
+ | { kind: "thinking"; text: string; animate?: boolean }
143
+ | {
144
+ kind: "tool";
145
+ id: string;
146
+ tool: string;
147
+ status: string;
148
+ ms?: number;
149
+ summary?: string;
150
+ result?: string;
151
+ evidenceRef?: string;
152
+ radarEvidence?: boolean;
153
+ truncated?: boolean;
154
+ // Tri-state by design: false = producer confirmed success, true = producer
155
+ // confirmed failure, undefined = this replay cannot establish the outcome.
156
+ isError?: boolean;
157
+ // Arrival motion is event-local so a replayed running turn can keep receiving
158
+ // live tool calls without reanimating the history reconstructed before it.
159
+ animate?: boolean;
160
+ };
161
+
162
+ export function appendThinking(
163
+ prev: TimelineItem[],
164
+ text: string,
165
+ animate = true,
166
+ ): TimelineItem[] {
167
+ const normalized = text.replace(/\*\*\r?\n(?=\*\*)/g, "**\n\n");
168
+ // Agent CLIs commonly emit each short bold planning update as a complete
169
+ // stream event. Preserve those as discrete chronological beats instead of
170
+ // producing invalid/run-on Markdown such as `**one****two**`. Ordinary token
171
+ // chunks still concatenate into their current beat exactly as emitted.
172
+ const blocks = normalized.split(/\n{2,}(?=\s*\*\*)/);
173
+ const next = [...prev];
174
+ for (const block of blocks) {
175
+ if (!block) continue;
176
+ const last = next[next.length - 1];
177
+ const beginsBeat = /^\s*\*\*/.test(block);
178
+ const priorBeatComplete =
179
+ last?.kind === "thinking" && /\*\*\s*$/.test(last.text);
180
+ // Some agents repeat a bold phase heading at stream boundaries. Suppress
181
+ // only that presentation artifact; identical ordinary lines can be real
182
+ // evidence/reasoning and must survive both replay and live chunking.
183
+ if (
184
+ last?.kind === "thinking" &&
185
+ beginsBeat &&
186
+ priorBeatComplete &&
187
+ last.text.trim() === block.trim()
188
+ )
189
+ continue;
190
+ if (last?.kind === "thinking" && !(beginsBeat && priorBeatComplete)) {
191
+ next[next.length - 1] = {
192
+ ...last,
193
+ text: (last.text + block).slice(-4000),
194
+ animate: last.animate === true || animate,
195
+ };
196
+ continue;
197
+ }
198
+ next.push({
199
+ kind: "thinking",
200
+ text: block.trimStart(),
201
+ animate,
202
+ });
203
+ }
204
+ return next;
205
+ }
206
+
207
+ export function upsertTool(
208
+ prev: TimelineItem[],
209
+ step: DiagnoseStep,
210
+ animate = true,
211
+ ): TimelineItem[] {
212
+ const i = prev.findIndex((it) => it.kind === "tool" && it.id === step.id);
213
+ if (i >= 0) {
214
+ const next = [...prev];
215
+ const cur = next[i] as Extract<TimelineItem, { kind: "tool" }>;
216
+ // The `done` event omits the tool name + input; keep them from `running`.
217
+ next[i] = {
218
+ ...cur,
219
+ ...step,
220
+ kind: "tool",
221
+ tool: step.tool || cur.tool,
222
+ summary: step.summary || cur.summary,
223
+ animate: cur.animate === true || animate,
224
+ };
225
+ return next;
226
+ }
227
+ return [...prev, { kind: "tool", ...step, animate }];
228
+ }
229
+
230
+ export function TurnView({
231
+ turn,
232
+ agentLabel,
233
+ onApply,
234
+ onViewExplanation,
235
+ onCheckStatus,
236
+ onRetryDiagnosis,
237
+ hideConclusion = false,
238
+ assessment = false,
239
+ explanation,
240
+ turnIndex,
241
+ evidenceStepIds,
242
+ onViewEvidence,
243
+ sourceRevealRequest,
244
+ assessmentSources,
245
+ }: {
246
+ turn: Turn;
247
+ agentLabel?: string;
248
+ onApply?: (fix: string) => void;
249
+ onViewExplanation?: () => void;
250
+ onCheckStatus?: () => void;
251
+ onRetryDiagnosis?: () => void;
252
+ /** Sources and agent items an answer turn cited; answers otherwise show none. */
253
+ assessmentSources?: ReactNode;
254
+ // The current assessment is Findings; the transcript keeps a pointer to it
255
+ // rather than a second copy (reasoning + tool calls still show).
256
+ hideConclusion?: boolean;
257
+ /** This turn is (or was) an assessment: render its full verdict, never as a conversational answer. */
258
+ assessment?: boolean;
259
+ /** A saved plain-language explanation of an earlier assessment, shown with it here. */
260
+ explanation?: AssessmentExplanation;
261
+ turnIndex?: number;
262
+ evidenceStepIds?: ReadonlySet<string>;
263
+ onViewEvidence?: (sourceId: string) => void;
264
+ sourceRevealRequest?: {
265
+ sourceId: string;
266
+ requestId: number;
267
+ excerpt?: InvestigationSourceExcerpt;
268
+ };
269
+ }) {
270
+ // A follow-up (a turn the user asked a question on) is a conversational reply,
271
+ // not a fresh diagnosis — render it as a plain answer, never the root-cause
272
+ // anchor or a remediation card — unless its verdict revised the assessment.
273
+ const followup =
274
+ !!turn.question && !turn.apply && !turn.verify && !assessment;
275
+ // Whether the done turn has anything for ResultCard to render — mirrors its
276
+ // branch order exactly (apply → followup → structured/healthy), since a followup
277
+ // ONLY ever renders FollowupAnswer (report/rootCause), never the remediation list.
278
+ // When false, TurnView shows the narration or an explicit empty note, not a blank.
279
+ const dx = turn.diagnosis;
280
+ const hasResult = dx
281
+ ? followup
282
+ ? !!(dx.report?.trim() || dx.rootCause?.trim()) // FollowupAnswer
283
+ : dx.healthy && !dx.rootCause
284
+ ? true // AllClearCard
285
+ : dx.inconclusive && !dx.rootCause
286
+ ? true // InconclusiveCard
287
+ : !!dx.rootCause ||
288
+ (dx.remediation?.length ?? 0) > 0 ||
289
+ !!dx.report?.trim()
290
+ : false;
291
+ return (
292
+ <div className="space-y-2">
293
+ {turn.explainAssessment ? (
294
+ <button
295
+ type="button"
296
+ onClick={onViewExplanation}
297
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs text-accent-text hover:underline"
298
+ >
299
+ <HelpCircle className="h-3.5 w-3.5" />
300
+ {turn.status === "running"
301
+ ? "Explaining assessment…"
302
+ : turn.status === "error"
303
+ ? "Explanation failed"
304
+ : "Plain-language explanation"}
305
+ <span className="text-theme-text-tertiary">· View in Findings</span>
306
+ </button>
307
+ ) : (
308
+ turn.question &&
309
+ (turn.verify ? (
310
+ <div className="flex items-center gap-2 rounded-md border border-theme-border/60 bg-theme-base/40 px-2.5 py-2 text-xs text-theme-text-secondary">
311
+ <RefreshCw className="h-3.5 w-3.5 shrink-0 text-accent" />
312
+ <span className="font-medium text-theme-text-primary">
313
+ Automatic verification
314
+ </span>
315
+ <span className="text-theme-text-tertiary">
316
+ Re-checking after apply
317
+ </span>
318
+ </div>
319
+ ) : (
320
+ <div className="flex justify-end">
321
+ <div className="max-w-[85%]">
322
+ {turn.actor && (
323
+ <div className="mb-0.5 text-right text-[10px] text-theme-text-tertiary">
324
+ {turn.actor}
325
+ </div>
326
+ )}
327
+ <div className="rounded-lg rounded-br-sm bg-accent/10 px-3 py-1.5 text-sm text-theme-text-primary [overflow-wrap:anywhere]">
328
+ {turn.question}
329
+ </div>
330
+ </div>
331
+ </div>
332
+ ))
333
+ )}
334
+ <Timeline
335
+ items={turn.timeline}
336
+ running={turn.status === "running"}
337
+ applyMode={turn.apply}
338
+ followup={followup}
339
+ startup={turn.startup}
340
+ agentLabel={agentLabel}
341
+ turnIndex={turnIndex}
342
+ evidenceStepIds={evidenceStepIds}
343
+ onViewEvidence={onViewEvidence}
344
+ sourceRevealRequest={sourceRevealRequest}
345
+ />
346
+ {!turn.explainAssessment &&
347
+ turn.status === "done" &&
348
+ (turn.apply ? (
349
+ <ApplyOutcomeCard
350
+ diagnosis={turn.diagnosis}
351
+ applyOutcome={turn.applyOutcome}
352
+ onCheckStatus={onCheckStatus}
353
+ animate={turn.animateResult !== false}
354
+ />
355
+ ) : hideConclusion && hasResult ? (
356
+ assessment ? (
357
+ <p
358
+ data-turn-assessment-pointer
359
+ className="flex items-center gap-1.5 text-xs text-theme-text-tertiary"
360
+ >
361
+ <Sparkles className="h-3.5 w-3.5 text-accent" aria-hidden />
362
+ Assessment · shown in Findings
363
+ </p>
364
+ ) : null
365
+ ) : hasResult ? (
366
+ <ResultCard
367
+ diagnosis={turn.diagnosis!}
368
+ onApply={onApply}
369
+ followup={followup}
370
+ onCheckStatus={onCheckStatus}
371
+ animate={turn.animateResult !== false}
372
+ assessmentSources={assessmentSources}
373
+ explanation={explanation}
374
+ storyInline={assessment}
375
+ readOnlyAssessment={assessment}
376
+ />
377
+ ) : (
378
+ <EmptyResult animate={turn.animateResult !== false} />
379
+ ))}
380
+ {!turn.explainAssessment &&
381
+ !turn.apply &&
382
+ turn.status === "done" &&
383
+ turn.diagnosis?.notes ? (
384
+ <WorkingNotes notes={turn.diagnosis.notes} />
385
+ ) : null}
386
+ {turn.explainAssessment ? null : turn.status === "error" && turn.apply ? (
387
+ <ApplyOutcomeCard
388
+ diagnosis={turn.diagnosis}
389
+ error={turn.error}
390
+ applyOutcome={turn.applyOutcome}
391
+ onCheckStatus={onCheckStatus}
392
+ animate={turn.animateResult !== false}
393
+ />
394
+ ) : turn.status === "error" && turn.error ? (
395
+ <div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
396
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
397
+ <div className="flex min-w-0 flex-col gap-2">
398
+ <span className="whitespace-pre-wrap break-words">
399
+ {turn.error}
400
+ </span>
401
+ {onRetryDiagnosis && (
402
+ <button
403
+ type="button"
404
+ onClick={onRetryDiagnosis}
405
+ className="btn-brand self-start px-3 py-1 text-xs"
406
+ >
407
+ Retry investigation
408
+ </button>
409
+ )}
410
+ {onCheckStatus && (
411
+ <button
412
+ type="button"
413
+ onClick={onCheckStatus}
414
+ className="btn-brand self-start px-3 py-1 text-xs"
415
+ >
416
+ Check current status
417
+ </button>
418
+ )}
419
+ </div>
420
+ </div>
421
+ ) : null}
422
+ </div>
423
+ );
424
+ }
425
+
426
+ export function Timeline({
427
+ items,
428
+ running,
429
+ applyMode,
430
+ followup,
431
+ startup,
432
+ agentLabel = "Agent",
433
+ turnIndex,
434
+ evidenceStepIds,
435
+ onViewEvidence,
436
+ sourceRevealRequest,
437
+ }: {
438
+ items: TimelineItem[];
439
+ running: boolean;
440
+ applyMode?: boolean;
441
+ followup?: boolean;
442
+ startup?: StartupSignal;
443
+ agentLabel?: string;
444
+ turnIndex?: number;
445
+ evidenceStepIds?: ReadonlySet<string>;
446
+ onViewEvidence?: (sourceId: string) => void;
447
+ sourceRevealRequest?: {
448
+ sourceId: string;
449
+ requestId: number;
450
+ excerpt?: InvestigationSourceExcerpt;
451
+ };
452
+ }) {
453
+ const heading = applyMode
454
+ ? "Applying fix"
455
+ : followup
456
+ ? "Working"
457
+ : "Investigation";
458
+ // The live status verb tracks the running tool ("Reading logs…") so the wait is
459
+ // informative, not a generic spinner; before the first item it reports the
460
+ // startup phases the server actually observed, never a timer-based guess.
461
+ const activeTool = [...items]
462
+ .reverse()
463
+ .find((it) => it.kind === "tool" && it.status !== "done") as
464
+ Extract<TimelineItem, { kind: "tool" }> | undefined;
465
+ const runningLabel = applyMode
466
+ ? "Applying the fix…"
467
+ : activeTool
468
+ ? toolActivity(activeTool.tool)
469
+ : items.length > 0
470
+ ? "Working…"
471
+ : startup
472
+ ? startupLabel(startup, agentLabel)
473
+ : followup
474
+ ? "Thinking…"
475
+ : "Starting investigation…";
476
+ const failedServers = (startup?.mcpServers ?? []).filter(
477
+ (server) => server.status !== "connected",
478
+ );
479
+ const radarServer = failedServers.find((server) => server.name === "radar");
480
+ const allDefiniteFailures = failedServers.every((server) =>
481
+ mcpStatusIsFailure(server.status),
482
+ );
483
+ return (
484
+ <div className="space-y-1.5">
485
+ {items.length > 0 && (
486
+ <div className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
487
+ {heading}
488
+ </div>
489
+ )}
490
+ {failedServers.length > 0 && (
491
+ <div
492
+ role="status"
493
+ className="flex items-start gap-1.5 rounded border border-semantic-warning/40 bg-semantic-warning/10 p-2 text-[11px] leading-snug text-theme-text-secondary"
494
+ >
495
+ <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-semantic-warning" />
496
+ <span>
497
+ {failedServers.map((server, i) => (
498
+ <span key={server.name}>
499
+ {i > 0 ? "; " : ""}
500
+ MCP server{" "}
501
+ <span className="font-medium text-theme-text-primary">
502
+ {server.name}
503
+ </span>{" "}
504
+ {mcpStatusPhrase(server.status)}
505
+ </span>
506
+ ))}
507
+ {radarServer
508
+ ? mcpStatusIsFailure(radarServer.status)
509
+ ? ` at startup. ${agentLabel} had no Radar tools this turn, so it could not use Radar's cluster evidence.`
510
+ : ` at startup. Radar's tools may not have been available to ${agentLabel} this turn.`
511
+ : allDefiniteFailures
512
+ ? ` at startup. ${agentLabel} ran this turn without those tools.`
513
+ : ` at startup. Those tools may not have been available to ${agentLabel} this turn.`}
514
+ </span>
515
+ </div>
516
+ )}
517
+ {items.map((it, i) => {
518
+ if (it.kind === "thinking") {
519
+ return (
520
+ <ThinkingBlock
521
+ key={i}
522
+ text={it.text}
523
+ animate={it.animate !== false}
524
+ live={running && i === items.length - 1}
525
+ />
526
+ );
527
+ }
528
+ const sourceId =
529
+ turnIndex === undefined
530
+ ? undefined
531
+ : investigationEvidenceSourceId(turnIndex, it.id);
532
+ return (
533
+ <ToolRow
534
+ key={it.id}
535
+ step={it}
536
+ sourceId={sourceId}
537
+ hasEvidence={evidenceStepIds?.has(it.id) ?? false}
538
+ onViewEvidence={onViewEvidence}
539
+ revealRequestId={
540
+ sourceRevealRequest && sourceId === sourceRevealRequest.sourceId
541
+ ? sourceRevealRequest.requestId
542
+ : undefined
543
+ }
544
+ sourceExcerpt={
545
+ sourceRevealRequest && sourceRevealRequest.sourceId === sourceId
546
+ ? sourceRevealRequest.excerpt
547
+ : undefined
548
+ }
549
+ animate={it.animate !== false}
550
+ />
551
+ );
552
+ })}
553
+ {running && <RunningStatus label={runningLabel} />}
554
+ </div>
555
+ );
556
+ }
557
+
558
+ // The model's reasoning between tool calls — muted + subordinate to the tool
559
+ // rows, and clamped once its beat is over so the chronology stays scannable.
560
+ // The final beat remains fully visible while it is streaming; completed prose
561
+ // is still available through an overflow-aware disclosure.
562
+ function ThinkingBlock({
563
+ text,
564
+ animate,
565
+ live,
566
+ }: {
567
+ text: string;
568
+ animate: boolean;
569
+ live: boolean;
570
+ }) {
571
+ const [expanded, setExpanded] = useState(false);
572
+ const [contentHeight, setContentHeight] = useState<number>();
573
+ const contentRef = useRef<HTMLDivElement>(null);
574
+ const contentId = useId();
575
+ const reveal = useDisclosureReveal<HTMLDivElement>();
576
+ // Two lines of 12px/19.5px prose plus 8px paragraph/container spacing. Unlike a disclosure
577
+ // this starts with a visible preview, so animate measured height on the
578
+ // same clock as Collapse (DURATION_DISCLOSURE + the one curve).
579
+ const previewHeight = 47;
580
+ const clamped = !live && !expanded;
581
+ useEffect(() => {
582
+ const content = contentRef.current;
583
+ if (!content) return;
584
+ const measure = () => setContentHeight(content.scrollHeight);
585
+ measure();
586
+ if (typeof ResizeObserver === "undefined") return;
587
+ const observer = new ResizeObserver(measure);
588
+ observer.observe(content);
589
+ return () => observer.disconnect();
590
+ }, [text]);
591
+
592
+ return (
593
+ <div
594
+ ref={reveal.elementRef}
595
+ className={animate ? "animate-transcript-enter" : ""}
596
+ >
597
+ <div
598
+ id={contentId}
599
+ className={`overflow-hidden transition-[height] duration-300 ${TW_EASE_UI} motion-reduce:transition-none`}
600
+ style={{
601
+ height: clamped
602
+ ? Math.min(contentHeight ?? previewHeight, previewHeight)
603
+ : contentHeight,
604
+ }}
605
+ >
606
+ <div ref={contentRef}>
607
+ <AIMarkdown className="py-0.5 text-xs leading-relaxed text-theme-text-tertiary [overflow-wrap:anywhere] [&_li]:text-theme-text-tertiary [&_p]:my-0.5 [&_strong]:font-medium [&_strong]:text-theme-text-secondary">
608
+ {text}
609
+ </AIMarkdown>
610
+ </div>
611
+ </div>
612
+ {!live && ((contentHeight ?? 0) > previewHeight || expanded) ? (
613
+ <button
614
+ type="button"
615
+ aria-controls={contentId}
616
+ aria-expanded={expanded}
617
+ onClick={() => {
618
+ setExpanded(!expanded);
619
+ reveal.revealAfterToggle(!expanded);
620
+ }}
621
+ className="text-[11px] font-medium text-theme-text-tertiary hover:text-accent-text"
622
+ >
623
+ {expanded ? "Show less" : "Show reasoning"}
624
+ </button>
625
+ ) : null}
626
+ </div>
627
+ );
628
+ }
629
+
630
+ // The live "working" line: spinner + shimmering activity verb, plus an elapsed
631
+ // counter and — if the same activity sits with no update for a while — a soft
632
+ // "still working" reassurance, so a long investigation reads as progress and a
633
+ // genuine hang is at least legible (a non-expert can't otherwise tell them apart).
634
+ // Self-contained: counts from when this line mounts; the stall timer resets each
635
+ // time the label changes (i.e. whenever the agent moves to a new tool/phase).
636
+ function RunningStatus({ label }: { label: string }) {
637
+ const [elapsed, setElapsed] = useState(0);
638
+ const elapsedRef = useRef(0);
639
+ const lastChangeRef = useRef(0);
640
+ const prevLabelRef = useRef(label);
641
+ // Reset the stall timer synchronously when the label changes (i.e. the agent moved
642
+ // to a new tool/phase) — doing it during render, not in an effect, so an already-
643
+ // stalled line never flashes "no update for Ns" for a tick before resetting.
644
+ if (prevLabelRef.current !== label) {
645
+ prevLabelRef.current = label;
646
+ lastChangeRef.current = elapsedRef.current;
647
+ }
648
+ useEffect(() => {
649
+ const id = setInterval(() => {
650
+ elapsedRef.current += 1;
651
+ setElapsed(elapsedRef.current);
652
+ }, 1000);
653
+ return () => clearInterval(id);
654
+ }, []);
655
+ const sinceChange = elapsed - lastChangeRef.current;
656
+ const stalled = elapsed >= 30 && sinceChange >= 30;
657
+ const counter = runningElapsedLabel(elapsed);
658
+ return (
659
+ <div className="flex items-center gap-2 pt-1 text-xs">
660
+ <Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
661
+ <span className="ai-shimmer min-w-0 truncate">{label}</span>
662
+ {stalled && (
663
+ <span className="shrink-0 text-theme-text-tertiary">
664
+ · still working — no update for {sinceChange}s
665
+ </span>
666
+ )}
667
+ {counter && (
668
+ <span className="ml-auto shrink-0 tabular-nums text-theme-text-tertiary">
669
+ {counter}
670
+ </span>
671
+ )}
672
+ </div>
673
+ );
674
+ }
675
+
676
+ // The wait the operator feels is the whole turn's, so the counter is a
677
+ // row-level figure set apart from the label: "Connected to Radar's tools"
678
+ // followed by "10s" read as if the handshake took that long.
679
+ export function runningElapsedLabel(elapsed: number): string | undefined {
680
+ return elapsed >= 3 ? `${elapsed}s elapsed` : undefined;
681
+ }
682
+
683
+ // Claude Code reports each MCP server as connected, failed, needs-auth, or
684
+ // pending. Only the first two are definite outcomes; anything else is left as
685
+ // the CLI's own word so the warning never claims more than it knows.
686
+ function mcpStatusIsFailure(status: string): boolean {
687
+ return status === "failed" || status === "needs-auth";
688
+ }
689
+
690
+ function mcpStatusPhrase(status: string): string {
691
+ if (status === "failed") return "failed to connect";
692
+ if (status === "needs-auth") return "needs authentication";
693
+ return `is ${status}`;
694
+ }
695
+
696
+ // Maps a running tool to a human verb so the status line reads as activity, not
697
+ // machinery. Falls back to the prettified tool name for anything unmapped.
698
+ function toolActivity(tool: string): string {
699
+ const t = tool.toLowerCase();
700
+ if (t.includes("log")) return "Reading logs…";
701
+ if (t.includes("event")) return "Checking recent events…";
702
+ if (t.includes("list")) return "Scanning related resources…";
703
+ if (t.includes("describe") || t.includes("get_resource"))
704
+ return "Inspecting the resource…";
705
+ if (t.includes("resource")) return "Inspecting the resource…";
706
+ if (t.includes("metric") || t.includes("top")) return "Checking metrics…";
707
+ if (t.includes("topology") || t.includes("graph"))
708
+ return "Tracing dependencies…";
709
+ return `${prettyTool(tool)}…`;
710
+ }
711
+
712
+ function ToolRow({
713
+ step,
714
+ sourceId,
715
+ hasEvidence,
716
+ onViewEvidence,
717
+ revealRequestId,
718
+ sourceExcerpt,
719
+ animate,
720
+ }: {
721
+ step: Extract<TimelineItem, { kind: "tool" }>;
722
+ sourceId?: string;
723
+ hasEvidence?: boolean;
724
+ onViewEvidence?: (sourceId: string) => void;
725
+ revealRequestId?: number;
726
+ sourceExcerpt?: InvestigationSourceExcerpt;
727
+ animate: boolean;
728
+ }) {
729
+ const [open, setOpen] = useState(revealRequestId !== undefined);
730
+ const [showFull, setShowFull] = useState(false);
731
+ const [argumentsOpen, setArgumentsOpen] = useState(false);
732
+ const toolReveal = useDisclosureReveal<HTMLDivElement>();
733
+ const argumentsReveal = useDisclosureReveal<HTMLDivElement>();
734
+ const argumentsId = useId();
735
+ const argumentsPreview = step.summary ? compactArgs(step.summary) : "";
736
+ const inlineArguments =
737
+ argumentsPreview.length <= 240 && !argumentsPreview.includes("\n");
738
+ const detailId = `investigation-tool-detail-${useId().replaceAll(":", "")}`;
739
+ const hasDetail = !!(step.summary || step.result);
740
+ useEffect(() => {
741
+ if (revealRequestId !== undefined && hasDetail) setOpen(true);
742
+ }, [hasDetail, revealRequestId]);
743
+ // Offer the rich dialog when the result is structured or non-trivial in size.
744
+ const richResult =
745
+ !!step.result && (isJsonPayload(step.result) || step.result.length > 200);
746
+ const done = step.status === "done";
747
+ const durationLabel = toolDurationLabel(step.ms);
748
+ const errorReason =
749
+ step.isError === true ? toolErrorReason(step.result) : undefined;
750
+ const outcomeLabel = !done
751
+ ? "Running"
752
+ : step.isError === true
753
+ ? "Tool failed"
754
+ : step.isError === false
755
+ ? "Tool completed"
756
+ : "Tool finished; outcome not recorded";
757
+ const rowContent = (
758
+ <>
759
+ {!done ? (
760
+ <Loader2
761
+ aria-label={outcomeLabel}
762
+ className="h-3.5 w-3.5 shrink-0 animate-spin text-accent"
763
+ />
764
+ ) : step.isError === true ? (
765
+ <AlertTriangle
766
+ aria-label={outcomeLabel}
767
+ className="h-3.5 w-3.5 shrink-0 text-red-400"
768
+ />
769
+ ) : step.isError === false ? (
770
+ <CheckCircle2
771
+ aria-label={outcomeLabel}
772
+ className="h-3.5 w-3.5 shrink-0 text-emerald-400"
773
+ />
774
+ ) : (
775
+ <HelpCircle
776
+ aria-label={outcomeLabel}
777
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
778
+ />
779
+ )}
780
+ <span className="shrink-0 font-mono text-xs text-theme-text-secondary">
781
+ {prettyTool(step.tool)}
782
+ </span>
783
+ {step.summary && !open && (
784
+ <span className="min-w-0 flex-1 shrink-[4] truncate font-mono text-[11px] text-theme-text-tertiary">
785
+ {argumentsPreview}
786
+ </span>
787
+ )}
788
+ {errorReason && !open && (
789
+ // The arguments give way first: they are still readable expanded,
790
+ // while the reason is the one thing this row exists to say. It still
791
+ // clips at the row's edge; the full text is a hover and a click away.
792
+ <Tooltip content={errorReason} wrapperClassName="min-w-0 truncate">
793
+ <span className="investigation-tool-reason block min-w-0 truncate text-[11px] text-semantic-error">
794
+ {middleTruncate(errorReason)}
795
+ </span>
796
+ </Tooltip>
797
+ )}
798
+ {durationLabel && (
799
+ <span className="ml-auto shrink-0 text-[11px] text-theme-text-tertiary">
800
+ {durationLabel}
801
+ </span>
802
+ )}
803
+ {hasDetail && <CollapseChevron open={open} className="h-3.5 w-3.5" />}
804
+ </>
805
+ );
806
+ return (
807
+ <div
808
+ onMouseEnter={(event) =>
809
+ highlightRelatedEvidence(event.currentTarget, sourceId)
810
+ }
811
+ onMouseLeave={(event) => highlightRelatedEvidence(event.currentTarget)}
812
+ ref={toolReveal.elementRef}
813
+ id={sourceId ? investigationActivitySourceDomId(sourceId) : undefined}
814
+ tabIndex={sourceId ? -1 : undefined}
815
+ role={sourceId ? "group" : undefined}
816
+ aria-label={
817
+ sourceId
818
+ ? `${prettyTool(step.tool)} ${outcomeLabel.toLowerCase()}`
819
+ : undefined
820
+ }
821
+ className={`${animate ? "animate-transcript-enter" : ""} scroll-mt-3 rounded-md border border-theme-border/60 bg-theme-base/40 outline-none focus:ring-2 focus:ring-accent/50`}
822
+ >
823
+ <div className="flex min-w-0 items-stretch">
824
+ {hasDetail ? (
825
+ <button
826
+ type="button"
827
+ onClick={() => {
828
+ setOpen(!open);
829
+ toolReveal.revealAfterToggle(!open);
830
+ }}
831
+ aria-expanded={open}
832
+ aria-controls={detailId}
833
+ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm hover:bg-theme-hover"
834
+ >
835
+ {rowContent}
836
+ </button>
837
+ ) : (
838
+ <div className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm">
839
+ {rowContent}
840
+ </div>
841
+ )}
842
+ {hasEvidence && sourceId && onViewEvidence ? (
843
+ <button
844
+ type="button"
845
+ onClick={() => onViewEvidence(sourceId)}
846
+ aria-label={`View evidence from ${prettyTool(step.tool)}`}
847
+ className="investigation-evidence-jump shrink-0 px-2 text-[11px] font-medium text-accent-text hover:bg-theme-hover"
848
+ >
849
+ Show evidence
850
+ </button>
851
+ ) : null}
852
+ </div>
853
+ {hasDetail && (
854
+ <div id={detailId}>
855
+ <Collapse open={open}>
856
+ <div className="space-y-2 border-t border-theme-border/60 px-2 py-2">
857
+ {step.summary &&
858
+ (inlineArguments ? (
859
+ <div
860
+ className="break-words font-mono text-[11px] leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]"
861
+ aria-label="Tool arguments"
862
+ >
863
+ {argumentsPreview}
864
+ </div>
865
+ ) : (
866
+ <div ref={argumentsReveal.elementRef}>
867
+ <button
868
+ type="button"
869
+ aria-expanded={argumentsOpen}
870
+ aria-controls={argumentsId}
871
+ onClick={() => {
872
+ setArgumentsOpen(!argumentsOpen);
873
+ argumentsReveal.revealAfterToggle(!argumentsOpen);
874
+ }}
875
+ className="flex items-center gap-1 py-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
876
+ >
877
+ <CollapseChevron
878
+ open={argumentsOpen}
879
+ className="h-3.5 w-3.5"
880
+ />
881
+ {argumentsOpen ? "Hide arguments" : "Show arguments"}
882
+ </button>
883
+ <div id={argumentsId}>
884
+ <Collapse open={argumentsOpen} mountLazily>
885
+ <PayloadBlock label="Arguments" text={step.summary} />
886
+ </Collapse>
887
+ </div>
888
+ </div>
889
+ ))}
890
+ {step.result && (
891
+ <PayloadBlock
892
+ label="Original result"
893
+ detail={step.ms != null ? `${step.ms}ms` : undefined}
894
+ text={step.result}
895
+ sourceExcerpt={sourceExcerpt}
896
+ revealRequestId={revealRequestId}
897
+ truncated={step.truncated}
898
+ action={
899
+ richResult ? (
900
+ <button
901
+ onClick={() => setShowFull(true)}
902
+ className="flex items-center gap-1 text-[11px] text-accent hover:underline"
903
+ >
904
+ <Maximize2 className="h-3 w-3" />
905
+ View payload
906
+ </button>
907
+ ) : undefined
908
+ }
909
+ />
910
+ )}
911
+ </div>
912
+ </Collapse>
913
+ </div>
914
+ )}
915
+ {step.result && (
916
+ <ToolResultDialog
917
+ open={showFull}
918
+ onClose={() => setShowFull(false)}
919
+ title={prettyTool(step.tool)}
920
+ text={step.result}
921
+ truncated={step.truncated}
922
+ />
923
+ )}
924
+ </div>
925
+ );
926
+ }
927
+
928
+ // Only outliers carry information on the collapsed row: a slow log fetch or a
929
+ // probe that hung. Sub-second calls stay silent; the exact figure lives in the
930
+ // expanded result header.
931
+ export function toolDurationLabel(ms: number | undefined): string | undefined {
932
+ if (ms == null || ms < 2000) return undefined;
933
+ return `${Math.round(ms / 1000)}s`;
934
+ }
935
+
936
+ // The producer's own words for a failed call, reduced to one line. Radar's MCP
937
+ // errors are plain text; a JSON envelope with an `error` field is unwrapped.
938
+ // Radar's not-found errors append retry hints for the agent after an em dash;
939
+ // only the clause before it says what failed, so the hints are dropped.
940
+ export function toolErrorReason(
941
+ result: string | undefined,
942
+ ): string | undefined {
943
+ if (!result) return undefined;
944
+ let text = result;
945
+ try {
946
+ const parsed: unknown = JSON.parse(result);
947
+ if (
948
+ parsed &&
949
+ typeof parsed === "object" &&
950
+ typeof (parsed as { error?: unknown }).error === "string"
951
+ ) {
952
+ text = (parsed as { error: string }).error;
953
+ }
954
+ } catch {
955
+ // plain text
956
+ }
957
+ const line = text
958
+ .split("\n")
959
+ .map((part) => part.trim())
960
+ .find((part) => part.length > 0);
961
+ if (!line) return undefined;
962
+ const clause = line.split(" — ")[0].trim();
963
+ return clause || line;
964
+ }
965
+
966
+ const COLLAPSED_REASON_MAX = 100;
967
+
968
+ const COLLAPSED_REASON_TAIL = 36;
969
+
970
+ // Keeps both ends of a long reason: the leading words say what failed and the
971
+ // tail usually carries the identifier, which a plain end-truncation would lose.
972
+ export function middleTruncate(
973
+ text: string,
974
+ max = COLLAPSED_REASON_MAX,
975
+ tail = COLLAPSED_REASON_TAIL,
976
+ ): string {
977
+ if (text.length <= max) return text;
978
+ const head = Math.max(1, max - tail - 1);
979
+ return `${text.slice(0, head).trimEnd()}…${text.slice(-tail).trimStart()}`;
980
+ }
981
+
982
+ // isJsonPayload / formatJson — a tool result is "structured" if it parses as JSON.
983
+ function isJsonPayload(text: string): boolean {
984
+ try {
985
+ JSON.parse(text);
986
+ return true;
987
+ } catch {
988
+ return false;
989
+ }
990
+ }
991
+
992
+ function formatJson(text: string): string | null {
993
+ try {
994
+ return JSON.stringify(JSON.parse(text), null, 2);
995
+ } catch {
996
+ return null;
997
+ }
998
+ }
999
+
1000
+ // PayloadBlock — compact inline view of a tool input/result: pretty JSON (scrolled
1001
+ // to keep indentation) or wrapped text (logs/prose), with copy + optional action.
1002
+ function PayloadBlock({
1003
+ label,
1004
+ detail,
1005
+ text,
1006
+ truncated,
1007
+ action,
1008
+ sourceExcerpt,
1009
+ revealRequestId,
1010
+ }: {
1011
+ label: string;
1012
+ detail?: string;
1013
+ text: string;
1014
+ truncated?: boolean;
1015
+ action?: ReactNode;
1016
+ sourceExcerpt?: InvestigationSourceExcerpt;
1017
+ revealRequestId?: number;
1018
+ }) {
1019
+ const json = formatJson(text);
1020
+ const display = json ?? text;
1021
+ const range = locateSourceExcerpt(display, sourceExcerpt);
1022
+ const rangeStart = range?.start;
1023
+ const rangeEnd = range?.end;
1024
+ const preRef = useRef<HTMLPreElement>(null);
1025
+ const markRef = useRef<HTMLElement>(null);
1026
+ useEffect(() => {
1027
+ if (rangeStart === undefined || revealRequestId === undefined) return;
1028
+ const frame = requestAnimationFrame(() => {
1029
+ const pre = preRef.current,
1030
+ mark = markRef.current;
1031
+ if (pre && mark)
1032
+ pre.scrollTop +=
1033
+ mark.getBoundingClientRect().top -
1034
+ pre.getBoundingClientRect().top -
1035
+ pre.clientHeight / 3;
1036
+ });
1037
+ return () => cancelAnimationFrame(frame);
1038
+ }, [revealRequestId, rangeStart, rangeEnd]);
1039
+ return (
1040
+ <div>
1041
+ <div className="mb-0.5 flex items-center justify-between gap-2">
1042
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
1043
+ {label}
1044
+ {detail && (
1045
+ <span className="ml-1.5 normal-case tracking-normal">
1046
+ · {detail}
1047
+ </span>
1048
+ )}
1049
+ </span>
1050
+ <div className="flex items-center gap-2">
1051
+ {action}
1052
+ <CopyButton
1053
+ text={json ?? text}
1054
+ label={`Copy ${label.toLowerCase()}`}
1055
+ />
1056
+ </div>
1057
+ </div>
1058
+ <pre
1059
+ ref={preRef}
1060
+ className={`max-h-64 overflow-auto rounded bg-theme-elevated p-1.5 font-mono text-[11px] text-theme-text-secondary ${json && !range ? "" : "whitespace-pre-wrap [overflow-wrap:anywhere]"}`}
1061
+ >
1062
+ {range ? (
1063
+ <>
1064
+ {display.slice(0, range.start)}
1065
+ <mark
1066
+ ref={markRef}
1067
+ className="bg-accent-muted text-theme-text-primary ring-1 ring-accent/40"
1068
+ >
1069
+ {display.slice(range.start, range.end)}
1070
+ </mark>
1071
+ {display.slice(range.end)}
1072
+ </>
1073
+ ) : (
1074
+ display
1075
+ )}
1076
+ </pre>
1077
+ {truncated && (
1078
+ <div className="mt-0.5 text-[10px] text-amber-500">
1079
+ Capped at 32 KB — partial output.
1080
+ </div>
1081
+ )}
1082
+ </div>
1083
+ );
1084
+ }
1085
+
1086
+ // ToolResultDialog — the rich payload viewer: syntax-highlighted + searchable via
1087
+ // CodeViewer, with a JSON⇄YAML toggle for structured results (YAML default — k8s
1088
+ // reads better) and plain text for non-JSON (logs/prose).
1089
+ function ToolResultDialog({
1090
+ open,
1091
+ onClose,
1092
+ title,
1093
+ text,
1094
+ truncated,
1095
+ }: {
1096
+ open: boolean;
1097
+ onClose: () => void;
1098
+ title: string;
1099
+ text: string;
1100
+ truncated?: boolean;
1101
+ }) {
1102
+ const { theme } = useTheme();
1103
+ const [fmt, setFmt] = useState<"yaml" | "json">("yaml");
1104
+ const parsed = useMemo<{ ok: boolean; value?: unknown }>(() => {
1105
+ try {
1106
+ return { ok: true, value: JSON.parse(text) };
1107
+ } catch {
1108
+ return { ok: false };
1109
+ }
1110
+ }, [text]);
1111
+
1112
+ const display = !parsed.ok
1113
+ ? text
1114
+ : fmt === "yaml"
1115
+ ? safeYaml(parsed.value)
1116
+ : JSON.stringify(parsed.value, null, 2);
1117
+ const language = parsed.ok ? fmt : "text";
1118
+
1119
+ // Progressive syntax highlighting: render the plain text instantly, then swap in
1120
+ // shiki's highlighted HTML once it resolves. A slow/failed highlighter load never
1121
+ // blocks the payload (unlike CodeViewer's "Loading…" gate) — worst case it stays
1122
+ // plain. Native browser find still works on the rendered text.
1123
+ const [html, setHtml] = useState<string | null>(null);
1124
+ useEffect(() => {
1125
+ if (!open) return;
1126
+ setHtml(null);
1127
+ let alive = true;
1128
+ codeToHtml(display, {
1129
+ lang: language,
1130
+ theme: theme === "light" ? "github-light" : "github-dark",
1131
+ })
1132
+ .then((h) => alive && setHtml(h))
1133
+ .catch(() => {}); // keep the plain pre on failure
1134
+ return () => {
1135
+ alive = false;
1136
+ };
1137
+ }, [open, display, language, theme]);
1138
+ return (
1139
+ <DialogPortal open={open} onClose={onClose} className="w-[min(90vw,820px)]">
1140
+ <div className="flex items-center justify-between gap-3 border-b border-theme-border p-3">
1141
+ <div className="min-w-0">
1142
+ <div className="truncate font-mono text-sm text-theme-text-primary">
1143
+ {title}
1144
+ </div>
1145
+ {truncated && (
1146
+ <div className="text-[11px] text-amber-500">
1147
+ Capped at 32 KB — partial output.
1148
+ </div>
1149
+ )}
1150
+ </div>
1151
+ <div className="flex shrink-0 items-center gap-2">
1152
+ {parsed.ok && (
1153
+ <div className="w-32">
1154
+ <Segmented<"yaml" | "json">
1155
+ value={fmt}
1156
+ onChange={setFmt}
1157
+ options={[
1158
+ { value: "yaml", label: "YAML" },
1159
+ { value: "json", label: "JSON" },
1160
+ ]}
1161
+ />
1162
+ </div>
1163
+ )}
1164
+ <CopyButton text={display} label="Copy tool result" />
1165
+ </div>
1166
+ </div>
1167
+ {html ? (
1168
+ <div
1169
+ className="animate-code-colorize m-3 max-h-[60vh] overflow-auto rounded-md border border-theme-border bg-theme-base p-3 text-xs leading-relaxed [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:font-mono [&_pre]:!text-xs [&_pre]:!leading-relaxed"
1170
+ dangerouslySetInnerHTML={{ __html: html }}
1171
+ />
1172
+ ) : (
1173
+ <pre className="m-3 max-h-[60vh] overflow-auto rounded-md border border-theme-border bg-theme-base p-3 font-mono text-xs leading-relaxed text-theme-text-secondary">
1174
+ {display}
1175
+ </pre>
1176
+ )}
1177
+ </DialogPortal>
1178
+ );
1179
+ }
1180
+
1181
+ function safeYaml(value: unknown): string {
1182
+ try {
1183
+ return toYaml(value, { lineWidth: 0 });
1184
+ } catch {
1185
+ return JSON.stringify(value, null, 2);
1186
+ }
1187
+ }
1188
+
1189
+ function compactArgs(raw: string): string {
1190
+ try {
1191
+ const o = JSON.parse(raw);
1192
+ return Object.entries(o)
1193
+ .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
1194
+ .join(" ");
1195
+ } catch {
1196
+ return raw;
1197
+ }
1198
+ }
1199
+
1200
+ // A follow-up reply: the agent answering a question, not re-diagnosing. Plain
1201
+ // neutral block — no root-cause anchor, no remediation/apply.
1202
+ export function FollowupAnswer({
1203
+ diagnosis,
1204
+ animate,
1205
+ }: {
1206
+ diagnosis: Diagnosis;
1207
+ animate: boolean;
1208
+ }) {
1209
+ const text = diagnosis.report || diagnosis.rootCause;
1210
+ if (!text) return null;
1211
+ return (
1212
+ <div
1213
+ className={`mt-1 rounded-lg border border-theme-border bg-theme-elevated p-3 ${animate ? "animate-result-in" : ""}`}
1214
+ >
1215
+ <div className="mb-1.5 flex items-center justify-between gap-2">
1216
+ <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1217
+ <Sparkles className="h-3.5 w-3.5 text-accent" />
1218
+ Answer
1219
+ </div>
1220
+ <CopyButton text={text} label="Copy answer" />
1221
+ </div>
1222
+ <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_code]:font-normal [&_h2:first-child]:mt-0 [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h2]:text-xs [&_h2]:font-semibold [&_h2]:uppercase [&_h2]:tracking-wide [&_h2]:text-theme-text-tertiary [&_h3]:text-sm [&_li]:text-theme-text-secondary [&_p]:my-1.5 [&_p]:text-theme-text-secondary [&_p:first-child]:mt-0">
1223
+ {text}
1224
+ </AIMarkdown>
1225
+ </div>
1226
+ );
1227
+ }
1228
+
1229
+ // A done turn that produced no renderable result at all (empty diagnosis, no
1230
+ // narration). Without this the turn would render blank — which reads as "the tool
1231
+ // broke." Make the dead-end explicit and point at the recovery (a follow-up).
1232
+ function EmptyResult({ animate }: { animate: boolean }) {
1233
+ return (
1234
+ <div
1235
+ className={`mt-1 flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary ${animate ? "animate-result-in" : ""}`}
1236
+ >
1237
+ <ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
1238
+ <span>
1239
+ The investigation finished without a clear conclusion. Try a follow-up
1240
+ question, or investigate again.
1241
+ </span>
1242
+ </div>
1243
+ );
1244
+ }