@skyhook-io/radar-app 1.14.1 → 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
@@ -1,3904 +1,26 @@
1
- // Presentational pieces of the Diagnose surface — pure + prop-driven (no
2
- // persistence, no app/routing knowledge) so they lift cleanly into k8s-ui later
3
- // and Cloud can reuse them. The stateful controller lives in DiagnoseContext;
4
- // the run logic in InvestigationView.
5
- import {
6
- useEffect,
7
- useId,
8
- useMemo,
9
- useRef,
10
- useState,
11
- type ReactNode,
12
- } from "react";
13
- import {
14
- Loader2,
15
- CheckCircle2,
16
- AlertTriangle,
17
- Copy,
18
- Check,
19
- ShieldCheck,
20
- ChevronDown,
21
- Wrench,
22
- Sparkles,
23
- RefreshCw,
24
- Maximize2,
25
- HelpCircle,
26
- FileSearch,
27
- Search,
28
- ListChecks,
29
- } from "lucide-react";
30
- import { stringify as toYaml } from "yaml";
31
- import { codeToHtml } from "shiki";
32
- import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
33
- import { parseContextName } from "../../utils/context-name";
34
- import {
35
- TRANSITION_MENU,
36
- overlayExitMs,
37
- overlayTransitionStyle,
38
- } from "../../utils/animation";
39
- import { useAnimatedUnmount } from "../../hooks/useAnimatedUnmount";
40
- import { useTheme } from "../../context/ThemeContext";
41
- import { type DiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
42
- import {
43
- type Diagnosis,
44
- type DiagnoseStep,
45
- type AgentInfo,
46
- type ApplyMutationOutcome,
47
- type ExecutionProfile,
48
- type DiagnoseStreamEvent,
49
- type MCPServerStatus,
50
- } from "../../api/diagnose";
51
- import { Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
52
- import { Markdown } from "../ui/Markdown";
53
- import { Tooltip } from "../ui/Tooltip";
54
- import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
55
- import {
56
- highlightRelatedEvidence,
57
- locateSourceExcerpt,
58
- } from "./investigationSourceFocus";
59
- import {
60
- investigationActivitySourceDomId,
61
- investigationEvidenceSourceId,
62
- type InvestigationRootCauseEvidenceResolution,
63
- type InvestigationEvidenceSource,
64
- } from "./investigationEvidence";
65
- import type {
66
- InvestigationCaseItem,
67
- InvestigationCaseResolution,
68
- } from "./investigationCase";
69
- import { AgentClaimNote } from "./AgentCase";
70
- import type { InvestigationHealthSignal } from "./investigationState";
71
- import { Badge } from "@skyhook-io/k8s-ui";
72
- import { diagnosisHasStoryShape, storyPlainText } from "./investigationStory";
73
-
74
- import { useDisclosureReveal } from "./useDisclosureReveal";
75
-
76
- const CURSOR_FULL_LOCAL_WARNING =
77
- "Radar passes Cursor --force, which auto-approves its built-in tools and every MCP server it loads, including your global servers. Cursor’s sandbox does not reliably confine those tools to Radar’s temporary workspace.";
78
-
79
- // Segmented two-or-more-way selector — shared shape for the agent and execution
80
- // profile pickers.
81
- function Segmented<T extends string | boolean>({
82
- label,
83
- options,
84
- value,
85
- onChange,
86
- }: {
87
- label?: string;
88
- options: { value: T; label: string }[];
89
- value: T;
90
- onChange: (v: T) => void;
91
- }) {
92
- return (
93
- <div>
94
- {label && (
95
- <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
96
- {label}
97
- </div>
98
- )}
99
- <div className="flex gap-1 rounded-lg border border-theme-border bg-theme-base p-1">
100
- {options.map((o) => (
101
- <button
102
- key={String(o.value)}
103
- onClick={() => onChange(o.value)}
104
- className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
105
- o.value === value
106
- ? "selection-strong selection-text selection-ring"
107
- : "text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
108
- }`}
109
- >
110
- {o.label}
111
- </button>
112
- ))}
113
- </div>
114
- </div>
115
- );
116
- }
117
-
118
- type Option = { value: string; label: string; description?: string };
119
-
120
- // Claude Code's --model takes version-stable ALIASES that always resolve to the
121
- // user's installed latest of that tier (per `claude --help`), so this list never
122
- // rots across model updates. "" = the agent's own default. Descriptions mirror
123
- // Claude Code's own /model picker so the tradeoff is legible.
124
- const CLAUDE_MODEL_OPTIONS: Option[] = [
125
- {
126
- value: "",
127
- label: "Default",
128
- description: "Use Claude Code's configured model",
129
- },
130
- {
131
- value: "opus",
132
- label: "Opus",
133
- description: "Most capable — best for complex problems",
134
- },
135
- {
136
- value: "sonnet",
137
- label: "Sonnet",
138
- description: "Balanced — efficient for routine work",
139
- },
140
- { value: "haiku", label: "Haiku", description: "Fastest — quick checks" },
141
- ];
142
- // Codex has no stable alias set and no way to enumerate models, and slugs change
143
- // across versions — so we take a free-text override rather than a list that rots.
144
- const EFFORT_OPTIONS: Option[] = [
145
- {
146
- value: "",
147
- label: "Default",
148
- description: "Recommended — Radar's default (medium)",
149
- },
150
- { value: "low", label: "Low", description: "Fastest, least reasoning" },
151
- { value: "medium", label: "Medium", description: "Balanced depth" },
152
- { value: "high", label: "High", description: "Most thorough, slowest" },
153
- ];
154
-
155
- function TextField({
156
- label,
157
- value,
158
- placeholder,
159
- onChange,
160
- hint,
161
- }: {
162
- label: string;
163
- value: string;
164
- placeholder?: string;
165
- onChange: (v: string) => void;
166
- hint?: string;
167
- }) {
168
- return (
169
- <div>
170
- <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
171
- {label}
172
- </div>
173
- <input
174
- type="text"
175
- value={value}
176
- placeholder={placeholder}
177
- onChange={(e) => onChange(e.target.value)}
178
- className="w-full rounded-md border border-theme-border bg-theme-base px-2 py-1.5 text-xs text-theme-text-primary placeholder:text-theme-text-tertiary"
179
- />
180
- {hint && (
181
- <p className="mt-1 text-[11px] leading-snug text-theme-text-tertiary">
182
- {hint}
183
- </p>
184
- )}
185
- </div>
186
- );
187
- }
188
-
189
- // SelectMenu is a themed dropdown (button + popover list) matching the app's other
190
- // custom dropdowns — unlike a native <select> it renders option descriptions and
191
- // stays on-theme in both light/dark.
192
- function SelectMenu({
193
- label,
194
- value,
195
- options,
196
- onChange,
197
- hint,
198
- }: {
199
- label: string;
200
- value: string;
201
- options: Option[];
202
- onChange: (v: string) => void;
203
- hint?: string;
204
- }) {
205
- const [open, setOpen] = useState(false);
206
- // Presence outlives `open` by the menu exit so the list can fade out; the
207
- // click-away backdrop only exists while logically open.
208
- const { shouldRender, isOpen } = useAnimatedUnmount(
209
- open,
210
- overlayExitMs("menu"),
211
- );
212
- const current = options.find((o) => o.value === value) ?? options[0];
213
- return (
214
- <div>
215
- <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
216
- {label}
217
- </div>
218
- <div className="relative">
219
- <button
220
- onClick={() => setOpen((v) => !v)}
221
- aria-haspopup="listbox"
222
- aria-expanded={open}
223
- className="flex w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-base px-2.5 py-1.5 text-left text-xs text-theme-text-primary hover:bg-theme-hover"
224
- >
225
- <span className="truncate">{current?.label}</span>
226
- <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
227
- </button>
228
- {open && (
229
- <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
230
- )}
231
- {shouldRender && (
232
- <ul
233
- role="listbox"
234
- inert={!open || undefined}
235
- className={`absolute left-0 right-0 z-20 mt-1 max-h-72 origin-top overflow-y-auto rounded-md border border-theme-border bg-theme-surface py-1 shadow-theme-lg ${TRANSITION_MENU} ${
236
- isOpen
237
- ? "opacity-100 translate-y-0 scale-100"
238
- : "opacity-0 -translate-y-1 scale-[0.97]"
239
- } ${open ? "" : "pointer-events-none"}`}
240
- style={overlayTransitionStyle(isOpen, "menu")}
241
- >
242
- {options.map((o) => {
243
- const sel = o.value === value;
244
- return (
245
- <li key={o.value}>
246
- <button
247
- role="option"
248
- aria-selected={sel}
249
- onClick={() => {
250
- onChange(o.value);
251
- setOpen(false);
252
- }}
253
- className="flex w-full items-start gap-2 px-2.5 py-1.5 text-left hover:bg-theme-hover"
254
- >
255
- <Check
256
- className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${sel ? "text-accent" : "opacity-0"}`}
257
- />
258
- <span className="min-w-0">
259
- <span className="block text-xs font-medium text-theme-text-primary">
260
- {o.label}
261
- </span>
262
- {o.description && (
263
- <span className="block text-[11px] leading-snug text-theme-text-tertiary">
264
- {o.description}
265
- </span>
266
- )}
267
- </span>
268
- </button>
269
- </li>
270
- );
271
- })}
272
- </ul>
273
- )}
274
- </div>
275
- {hint && (
276
- <p className="mt-1 text-[11px] leading-snug text-theme-text-tertiary">
277
- {hint}
278
- </p>
279
- )}
280
- </div>
281
- );
282
- }
283
-
284
- // AgentControls is the full AI investigation config block (agent, execution profile, model,
285
- // effort) — pure + prop-driven. It lives in Settings, not the investigation panel,
286
- // since these are set-once preferences rather than per-run knobs.
287
- export function AgentControls({
288
- agents,
289
- selectedAgent,
290
- onSelectAgent,
291
- profile,
292
- onSetProfile,
293
- model,
294
- onSetModel,
295
- effort,
296
- onSetEffort,
297
- }: {
298
- agents: AgentInfo[];
299
- selectedAgent: string;
300
- onSelectAgent: (name: string) => void;
301
- profile: ExecutionProfile;
302
- onSetProfile: (v: ExecutionProfile) => void;
303
- model: string;
304
- onSetModel: (v: string) => void;
305
- effort: string;
306
- onSetEffort: (v: string) => void;
307
- }) {
308
- const isCodex = selectedAgent === "codex";
309
- const isClaude = selectedAgent === "claude";
310
- const isCursor = selectedAgent === "cursor-agent";
311
- const selectedAgentInfo = agents.find((a) => a.name === selectedAgent);
312
- const selectedAgentLabel =
313
- selectedAgentInfo?.label || selectedAgent || "agent";
314
- const profiles = selectedAgentInfo?.profiles ?? [];
315
- const shownProfile = profiles.includes(profile)
316
- ? profile
317
- : (profiles[0] ?? profile);
318
- const profileLabels: Record<ExecutionProfile, string> = {
319
- safeguarded: "Radar safeguards",
320
- "full-local": `Your ${selectedAgentLabel} setup`,
321
- };
322
- return (
323
- <div className="space-y-3">
324
- {agents.length >= 2 && (
325
- <Segmented
326
- label="Agent"
327
- value={selectedAgent}
328
- onChange={onSelectAgent}
329
- options={agents.map((a) => ({
330
- value: a.name,
331
- label: a.label || a.name,
332
- }))}
333
- />
334
- )}
335
- {profiles.length > 0 && (
336
- <div>
337
- {profiles.length > 1 ? (
338
- <>
339
- <Segmented<ExecutionProfile>
340
- label="How Radar runs it"
341
- value={shownProfile}
342
- onChange={onSetProfile}
343
- options={profiles.map((value) => ({
344
- value,
345
- label: profileLabels[value],
346
- }))}
347
- />
348
- {shownProfile === "safeguarded" ? (
349
- <p className="mt-1.5 text-[11px] leading-snug text-theme-text-tertiary">
350
- {isClaude
351
- ? "Claude’s built-in tools are disabled, and MCP access is limited to Radar’s read-only investigation tools. Your Claude settings, hooks, and CLAUDE.md instructions still apply and are outside Radar’s control."
352
- : isCodex
353
- ? "Radar excludes your Codex configuration and other MCP servers. Codex’s sandboxed shell can still read files on this machine; it cannot write or reach the network."
354
- : "Radar uses this agent’s safeguarded execution profile. Review the agent’s documented restrictions before continuing."}
355
- </p>
356
- ) : (
357
- <div className="mt-1.5 flex items-start gap-1.5 rounded border border-amber-500/40 bg-amber-500/10 p-2 text-[11px] leading-snug text-theme-text-secondary">
358
- <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
359
- <span>
360
- Uses your agent&apos;s normal configuration and other
361
- configured tools and MCP servers. Radar cannot constrain
362
- that external tooling; it may access local files or the
363
- network and may be able to change your cluster.{" "}
364
- {isCursor
365
- ? CURSOR_FULL_LOCAL_WARNING
366
- : isClaude
367
- ? "Claude uses the permissions from your setup; Radar does not override them."
368
- : "Radar still enables the agent CLI’s own sandbox, but that sandbox does not constrain external MCP servers."}{" "}
369
- Choose this only when you need that setup.
370
- </span>
371
- </div>
372
- )}
373
- </>
374
- ) : shownProfile === "safeguarded" ? (
375
- <div className="flex items-start gap-1.5 rounded border border-theme-border bg-theme-base p-2 text-[11px] leading-snug text-theme-text-secondary">
376
- <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0 text-accent" />
377
- <span>
378
- Radar always runs this agent with safeguards.
379
- {isClaude
380
- ? " Claude’s built-in tools are disabled, and MCP access is limited to Radar’s read-only investigation tools. Your Claude settings, hooks, and CLAUDE.md instructions still apply and are outside Radar’s control."
381
- : isCodex
382
- ? " Your Codex configuration and other MCP servers are excluded. Codex’s sandboxed shell can still read files on this machine; it cannot write or reach the network."
383
- : " Review the agent’s documented restrictions before continuing."}
384
- </span>
385
- </div>
386
- ) : (
387
- <div className="flex items-start gap-1.5 rounded border border-amber-500/40 bg-amber-500/10 p-2 text-[11px] leading-snug text-theme-text-secondary">
388
- <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
389
- <span>
390
- Radar must use this agent&apos;s normal setup. Radar cannot
391
- constrain its external tools or MCP servers; they may access
392
- local files or the network and may be able to change your
393
- cluster.{" "}
394
- {isCursor ? (
395
- CURSOR_FULL_LOCAL_WARNING
396
- ) : (
397
- <>
398
- Radar still enables the agent CLI&apos;s own sandbox, but
399
- that sandbox does not constrain external MCP servers.
400
- </>
401
- )}
402
- </span>
403
- </div>
404
- )}
405
- </div>
406
- )}
407
- {isClaude ? (
408
- <SelectMenu
409
- label="Model"
410
- value={model}
411
- options={CLAUDE_MODEL_OPTIONS}
412
- onChange={onSetModel}
413
- hint="Aliases always resolve to the latest of that tier."
414
- />
415
- ) : isCodex || isCursor ? (
416
- <TextField
417
- label="Model"
418
- value={model}
419
- placeholder={
420
- isCursor
421
- ? "Default (e.g. auto, gpt-5.2, composer-2.5)"
422
- : "Default (e.g. gpt-5-codex, o3)"
423
- }
424
- onChange={onSetModel}
425
- hint={
426
- isCursor
427
- ? "Leave empty for your Cursor default, or enter a model slug Cursor supports."
428
- : shownProfile === "full-local"
429
- ? "Your Codex setup uses its configured model; set a slug here to override it."
430
- : "Leave empty for Codex's default, or enter a model your Codex version supports."
431
- }
432
- />
433
- ) : (
434
- <TextField
435
- label="Model"
436
- value={model}
437
- placeholder="Default"
438
- onChange={onSetModel}
439
- hint="Leave empty for the agent's default, or enter a model identifier it supports."
440
- />
441
- )}
442
- {isCodex && (
443
- <SelectMenu
444
- label="Reasoning effort"
445
- value={effort}
446
- options={EFFORT_OPTIONS}
447
- onChange={onSetEffort}
448
- />
449
- )}
450
- </div>
451
- );
452
- }
453
-
454
- // Turn is one round of the conversation: the initial investigation (no question)
455
- // or a follow-up, each with its own transcript + result.
456
- export type Turn = {
457
- resultSequence?: number;
458
- explainAssessment?: number;
459
- question?: string;
460
- actor?: string;
461
- timeline: TimelineItem[];
462
- diagnosis: Diagnosis | null;
463
- error: string | null;
464
- status: "running" | "done" | "error";
465
- // apply turns execute the recommended fix (write tools) — they report an
466
- // outcome, not a root cause, so the UI frames them differently.
467
- apply?: boolean;
468
- // Set from the apply turn's terminal stream event. Green success is reserved
469
- // for an explicit producer-confirmed mutation.
470
- applyOutcome?: ApplyMutationOutcome;
471
- // Verification turns are structurally a fresh health assessment, even though
472
- // they carry a question. Keeping the bit explicit prevents the UI from
473
- // misclassifying them as ordinary conversational follow-ups on replay.
474
- verify?: boolean;
475
- // Set from the replay/live boundary when the terminal event arrives. Historical
476
- // conclusions render immediately; conclusions observed live enter smoothly.
477
- animateResult?: boolean;
478
- // The latest startup phase the server reported before the agent's first
479
- // message. It drives the pending status line only; it is never a transcript item.
480
- startup?: StartupSignal;
481
- };
482
-
483
- export type StartupSignal = {
484
- phase: "investigating" | "connected" | "ready";
485
- model?: string;
486
- toolCount?: number;
487
- mcpServers?: MCPServerStatus[];
488
- };
489
-
490
- const STARTUP_PHASE_RANK: Record<StartupSignal["phase"], number> = {
491
- investigating: 0,
492
- connected: 1,
493
- ready: 2,
494
- };
495
-
496
- // Folds a phase event into the turn's startup signal. The handshake and the
497
- // CLI's init line are reported by different goroutines, so a later event can
498
- // name an earlier phase; the furthest phase wins and the init facts are kept.
499
- export function mergeStartupSignal(
500
- prev: StartupSignal | undefined,
501
- event: Pick<
502
- DiagnoseStreamEvent,
503
- "phase" | "model" | "toolCount" | "mcpServers"
504
- >,
505
- ): StartupSignal | undefined {
506
- const phase = event.phase;
507
- if (phase !== "investigating" && phase !== "connected" && phase !== "ready")
508
- return prev;
509
- const facts =
510
- phase === "ready"
511
- ? {
512
- model: event.model,
513
- toolCount: event.toolCount,
514
- mcpServers: event.mcpServers,
515
- }
516
- : {};
517
- if (prev && STARTUP_PHASE_RANK[prev.phase] >= STARTUP_PHASE_RANK[phase]) {
518
- return phase === "ready" ? { ...prev, ...facts } : prev;
519
- }
520
- return { ...prev, ...facts, phase };
521
- }
522
-
523
- function startupLabel(startup: StartupSignal, agentLabel: string): string {
524
- switch (startup.phase) {
525
- case "investigating":
526
- return `${agentLabel} starting…`;
527
- case "connected":
528
- return "Connected to Radar's tools";
529
- case "ready": {
530
- const parts = [`${agentLabel} ready`];
531
- if (startup.model) parts.push(startup.model);
532
- if (startup.toolCount !== undefined)
533
- parts.push(
534
- `${startup.toolCount} Radar ${startup.toolCount === 1 ? "tool" : "tools"}`,
535
- );
536
- return parts.join(" · ");
537
- }
538
- }
539
- }
540
-
541
- // TimelineItem is one ordered transcript entry: agent reasoning, or a tool call.
542
- export type TimelineItem =
543
- | { kind: "thinking"; text: string; animate?: boolean }
544
- | {
545
- kind: "tool";
546
- id: string;
547
- tool: string;
548
- status: string;
549
- ms?: number;
550
- summary?: string;
551
- result?: string;
552
- evidenceRef?: string;
553
- radarEvidence?: boolean;
554
- truncated?: boolean;
555
- // Tri-state by design: false = producer confirmed success, true = producer
556
- // confirmed failure, undefined = this replay cannot establish the outcome.
557
- isError?: boolean;
558
- // Arrival motion is event-local so a replayed running turn can keep receiving
559
- // live tool calls without reanimating the history reconstructed before it.
560
- animate?: boolean;
561
- };
562
-
563
- export function appendThinking(
564
- prev: TimelineItem[],
565
- text: string,
566
- animate = true,
567
- ): TimelineItem[] {
568
- const normalized = text.replace(/\*\*\r?\n(?=\*\*)/g, "**\n\n");
569
- // Agent CLIs commonly emit each short bold planning update as a complete
570
- // stream event. Preserve those as discrete chronological beats instead of
571
- // producing invalid/run-on Markdown such as `**one****two**`. Ordinary token
572
- // chunks still concatenate into their current beat exactly as emitted.
573
- const blocks = normalized.split(/\n{2,}(?=\s*\*\*)/);
574
- const next = [...prev];
575
- for (const block of blocks) {
576
- if (!block) continue;
577
- const last = next[next.length - 1];
578
- const beginsBeat = /^\s*\*\*/.test(block);
579
- const priorBeatComplete =
580
- last?.kind === "thinking" && /\*\*\s*$/.test(last.text);
581
- // Some agents repeat a bold phase heading at stream boundaries. Suppress
582
- // only that presentation artifact; identical ordinary lines can be real
583
- // evidence/reasoning and must survive both replay and live chunking.
584
- if (
585
- last?.kind === "thinking" &&
586
- beginsBeat &&
587
- priorBeatComplete &&
588
- last.text.trim() === block.trim()
589
- )
590
- continue;
591
- if (last?.kind === "thinking" && !(beginsBeat && priorBeatComplete)) {
592
- next[next.length - 1] = {
593
- ...last,
594
- text: (last.text + block).slice(-4000),
595
- animate: last.animate === true || animate,
596
- };
597
- continue;
598
- }
599
- next.push({
600
- kind: "thinking",
601
- text: block.trimStart(),
602
- animate,
603
- });
604
- }
605
- return next;
606
- }
607
-
608
- export function upsertTool(
609
- prev: TimelineItem[],
610
- step: DiagnoseStep,
611
- animate = true,
612
- ): TimelineItem[] {
613
- const i = prev.findIndex((it) => it.kind === "tool" && it.id === step.id);
614
- if (i >= 0) {
615
- const next = [...prev];
616
- const cur = next[i] as Extract<TimelineItem, { kind: "tool" }>;
617
- // The `done` event omits the tool name + input; keep them from `running`.
618
- next[i] = {
619
- ...cur,
620
- ...step,
621
- kind: "tool",
622
- tool: step.tool || cur.tool,
623
- summary: step.summary || cur.summary,
624
- animate: cur.animate === true || animate,
625
- };
626
- return next;
627
- }
628
- return [...prev, { kind: "tool", ...step, animate }];
629
- }
630
-
631
- export function TurnView({
632
- turn,
633
- agentLabel,
634
- onApply,
635
- onViewExplanation,
636
- onCheckStatus,
637
- onRetryDiagnosis,
638
- hideConclusion = false,
639
- assessment = false,
640
- explanation,
641
- turnIndex,
642
- evidenceStepIds,
643
- onViewEvidence,
644
- sourceRevealRequest,
645
- assessmentSources,
646
- }: {
647
- turn: Turn;
648
- agentLabel?: string;
649
- onApply?: (fix: string) => void;
650
- onViewExplanation?: () => void;
651
- onCheckStatus?: () => void;
652
- onRetryDiagnosis?: () => void;
653
- /** Sources and agent items an answer turn cited; answers otherwise show none. */
654
- assessmentSources?: ReactNode;
655
- // The current assessment is Findings; the transcript keeps a pointer to it
656
- // rather than a second copy (reasoning + tool calls still show).
657
- hideConclusion?: boolean;
658
- /** This turn is (or was) an assessment: render its full verdict, never as a conversational answer. */
659
- assessment?: boolean;
660
- /** A saved plain-language explanation of an earlier assessment, shown with it here. */
661
- explanation?: AssessmentExplanation;
662
- turnIndex?: number;
663
- evidenceStepIds?: ReadonlySet<string>;
664
- onViewEvidence?: (sourceId: string) => void;
665
- sourceRevealRequest?: {
666
- sourceId: string;
667
- requestId: number;
668
- excerpt?: InvestigationSourceExcerpt;
669
- };
670
- }) {
671
- // A follow-up (a turn the user asked a question on) is a conversational reply,
672
- // not a fresh diagnosis — render it as a plain answer, never the root-cause
673
- // anchor or a remediation card — unless its verdict revised the assessment.
674
- const followup =
675
- !!turn.question && !turn.apply && !turn.verify && !assessment;
676
- // Whether the done turn has anything for ResultCard to render — mirrors its
677
- // branch order exactly (apply → followup → structured/healthy), since a followup
678
- // ONLY ever renders FollowupAnswer (report/rootCause), never the remediation list.
679
- // When false, TurnView shows the narration or an explicit empty note, not a blank.
680
- const dx = turn.diagnosis;
681
- const hasResult = dx
682
- ? followup
683
- ? !!(dx.report?.trim() || dx.rootCause?.trim()) // FollowupAnswer
684
- : dx.healthy && !dx.rootCause
685
- ? true // AllClearCard
686
- : dx.inconclusive && !dx.rootCause
687
- ? true // InconclusiveCard
688
- : !!dx.rootCause ||
689
- (dx.remediation?.length ?? 0) > 0 ||
690
- !!dx.report?.trim()
691
- : false;
692
- return (
693
- <div className="space-y-2">
694
- {turn.explainAssessment ? (
695
- <button
696
- type="button"
697
- onClick={onViewExplanation}
698
- className="flex items-center gap-1.5 rounded-md py-2 text-xs text-accent-text hover:underline"
699
- >
700
- <HelpCircle className="h-3.5 w-3.5" />
701
- {turn.status === "running"
702
- ? "Explaining assessment…"
703
- : turn.status === "error"
704
- ? "Explanation failed"
705
- : "Plain-language explanation"}
706
- <span className="text-theme-text-tertiary">· View in Findings</span>
707
- </button>
708
- ) : (
709
- turn.question &&
710
- (turn.verify ? (
711
- <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">
712
- <RefreshCw className="h-3.5 w-3.5 shrink-0 text-accent" />
713
- <span className="font-medium text-theme-text-primary">
714
- Automatic verification
715
- </span>
716
- <span className="text-theme-text-tertiary">
717
- Re-checking after apply
718
- </span>
719
- </div>
720
- ) : (
721
- <div className="flex justify-end">
722
- <div className="max-w-[85%]">
723
- {turn.actor && (
724
- <div className="mb-0.5 text-right text-[10px] text-theme-text-tertiary">
725
- {turn.actor}
726
- </div>
727
- )}
728
- <div className="rounded-lg rounded-br-sm bg-accent/10 px-3 py-1.5 text-sm text-theme-text-primary [overflow-wrap:anywhere]">
729
- {turn.question}
730
- </div>
731
- </div>
732
- </div>
733
- ))
734
- )}
735
- <Timeline
736
- items={turn.timeline}
737
- running={turn.status === "running"}
738
- applyMode={turn.apply}
739
- followup={followup}
740
- startup={turn.startup}
741
- agentLabel={agentLabel}
742
- turnIndex={turnIndex}
743
- evidenceStepIds={evidenceStepIds}
744
- onViewEvidence={onViewEvidence}
745
- sourceRevealRequest={sourceRevealRequest}
746
- />
747
- {!turn.explainAssessment &&
748
- turn.status === "done" &&
749
- (turn.apply ? (
750
- <ApplyOutcomeCard
751
- diagnosis={turn.diagnosis}
752
- applyOutcome={turn.applyOutcome}
753
- onCheckStatus={onCheckStatus}
754
- animate={turn.animateResult !== false}
755
- />
756
- ) : hideConclusion && hasResult ? (
757
- assessment ? (
758
- <p
759
- data-turn-assessment-pointer
760
- className="flex items-center gap-1.5 text-xs text-theme-text-tertiary"
761
- >
762
- <Sparkles className="h-3.5 w-3.5 text-accent" aria-hidden />
763
- Assessment · shown in Findings
764
- </p>
765
- ) : null
766
- ) : hasResult ? (
767
- <ResultCard
768
- diagnosis={turn.diagnosis!}
769
- onApply={onApply}
770
- followup={followup}
771
- onCheckStatus={onCheckStatus}
772
- animate={turn.animateResult !== false}
773
- assessmentSources={assessmentSources}
774
- explanation={explanation}
775
- storyInline={assessment}
776
- readOnlyAssessment={assessment}
777
- />
778
- ) : (
779
- <EmptyResult animate={turn.animateResult !== false} />
780
- ))}
781
- {!turn.explainAssessment &&
782
- !turn.apply &&
783
- turn.status === "done" &&
784
- turn.diagnosis?.notes ? (
785
- <WorkingNotes notes={turn.diagnosis.notes} />
786
- ) : null}
787
- {turn.explainAssessment ? null : turn.status === "error" && turn.apply ? (
788
- <ApplyOutcomeCard
789
- diagnosis={turn.diagnosis}
790
- error={turn.error}
791
- applyOutcome={turn.applyOutcome}
792
- onCheckStatus={onCheckStatus}
793
- animate={turn.animateResult !== false}
794
- />
795
- ) : turn.status === "error" && turn.error ? (
796
- <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">
797
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
798
- <div className="flex min-w-0 flex-col gap-2">
799
- <span className="whitespace-pre-wrap break-words">
800
- {turn.error}
801
- </span>
802
- {onRetryDiagnosis && (
803
- <button
804
- type="button"
805
- onClick={onRetryDiagnosis}
806
- className="btn-brand self-start px-3 py-1 text-xs"
807
- >
808
- Retry investigation
809
- </button>
810
- )}
811
- {onCheckStatus && (
812
- <button
813
- type="button"
814
- onClick={onCheckStatus}
815
- className="btn-brand self-start px-3 py-1 text-xs"
816
- >
817
- Check current status
818
- </button>
819
- )}
820
- </div>
821
- </div>
822
- ) : null}
823
- </div>
824
- );
825
- }
826
-
827
- // ConsentCardShell owns the card chrome (icon, layout, settings link, Approve/
828
- // Cancel) so every copy tier — the OSS default, the hosted fallback, and a host
829
- // override — feeds the same frame instead of duplicating it.
830
- function ConsentCardShell({
831
- title,
832
- body,
833
- bullets,
834
- settingsLabel,
835
- approveLabel = "Approve & investigate",
836
- warning = false,
837
- error,
838
- onOpenSettings,
839
- onApprove,
840
- onCancel,
841
- }: DiagnoseConsentCopy & {
842
- warning?: boolean;
843
- // Why the last approval failed, straight from the server. The card stays up on
844
- // failure so retrying works in place, which means this is the ONLY chance to
845
- // say why — a host that records consent above the individual refuses the
846
- // wrong person here, and only its message knows who to ask.
847
- error?: string | null;
848
- onOpenSettings?: () => void;
849
- onApprove: () => void;
850
- onCancel: () => void;
851
- }) {
852
- // `settingsLabel: null` hides the link outright — a host with one fixed agent
853
- // has nothing behind it. `undefined` keeps the OSS default label.
854
- const resolvedSettingsLabel =
855
- settingsLabel === undefined
856
- ? "Change the agent and how it runs in Settings"
857
- : settingsLabel;
858
- return (
859
- <div
860
- className={
861
- warning
862
- ? "rounded-lg border border-amber-500/40 bg-amber-500/10 p-4"
863
- : "rounded-lg border border-theme-border bg-theme-elevated p-4"
864
- }
865
- >
866
- <div className="mb-2 flex items-center gap-2">
867
- {warning ? (
868
- <AlertTriangle className="h-4 w-4 text-amber-500" />
869
- ) : (
870
- <ShieldCheck className="h-4 w-4 text-accent" />
871
- )}
872
- <div className="text-sm font-medium text-theme-text-primary">
873
- {title}
874
- </div>
875
- </div>
876
- <div className="text-sm leading-relaxed text-theme-text-secondary">
877
- {body}
878
- </div>
879
- {bullets && bullets.length > 0 && (
880
- <ul className="mt-2 space-y-1 text-xs text-theme-text-tertiary">
881
- {bullets.map((b, i) => (
882
- <li key={i}>• {b}</li>
883
- ))}
884
- </ul>
885
- )}
886
- {onOpenSettings && resolvedSettingsLabel && (
887
- <button
888
- onClick={onOpenSettings}
889
- className="mt-3 text-xs text-accent hover:underline"
890
- >
891
- {resolvedSettingsLabel}
892
- </button>
893
- )}
894
- {/* Red, not amber: the full-local card is itself amber, so an amber box on
895
- it reads as another paragraph of body copy. role="alert" because nothing
896
- else moves on failure — focus stays on Approve, so without it a screen
897
- reader says nothing and the user re-presses a button that cannot succeed. */}
898
- {error && (
899
- <div
900
- role="alert"
901
- className="mt-3 flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-2 text-xs text-theme-text-primary"
902
- >
903
- <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-400" />
904
- <span>{error}</span>
905
- </div>
906
- )}
907
- <div className="mt-4 flex gap-2">
908
- <button
909
- onClick={onCancel}
910
- className="flex-1 rounded-lg border border-theme-border py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
911
- >
912
- Cancel
913
- </button>
914
- <button
915
- onClick={onApprove}
916
- className="flex-1 rounded-lg btn-brand py-1.5 text-sm"
917
- >
918
- {approveLabel}
919
- </button>
920
- </div>
921
- </div>
922
- );
923
- }
924
-
925
- // The first-run consent + trust card. The copy is checkable fact about a data
926
- // flow — not marketing — and the wrong claim is a lie, not a typo. It resolves
927
- // in two tiers:
928
- //
929
- // 1. `copy` — the embedding host tells its own trust story. Only the host
930
- // knows where its agent runs, whose key pays, and where transcripts live,
931
- // so any host that runs the agent somewhere other than the OSS local CLI
932
- // MUST override rather than let Radar assert the local story over its flow.
933
- // 2. No `copy` — the OSS bring-your-own-local-CLI default, the only tier where
934
- // "on your machine / no Radar cloud / your account" actually holds.
935
- export function ConsentCard({
936
- agentName,
937
- agent,
938
- profile,
939
- copy,
940
- error,
941
- onOpenSettings,
942
- onApprove,
943
- onCancel,
944
- }: {
945
- agentName: string;
946
- agent?: string;
947
- profile: ExecutionProfile;
948
- copy?: DiagnoseConsentCopy;
949
- error?: string | null;
950
- onOpenSettings?: () => void;
951
- onApprove: () => void;
952
- onCancel: () => void;
953
- }) {
954
- const chrome = { onOpenSettings, onApprove, onCancel, error };
955
-
956
- // Tier 1: a host (e.g. radar-hub-web) supplied its own copy — use it verbatim.
957
- if (copy) return <ConsentCardShell {...copy} {...chrome} />;
958
-
959
- return (
960
- <ConsentCardShell
961
- {...chrome}
962
- warning={profile === "full-local"}
963
- approveLabel={
964
- profile === "full-local"
965
- ? "Continue with my agent setup"
966
- : "Approve & investigate"
967
- }
968
- title={
969
- profile === "safeguarded"
970
- ? "Run an AI investigation with Radar safeguards?"
971
- : `Run using your ${agentName} setup?`
972
- }
973
- body={
974
- <>
975
- This runs{" "}
976
- <span className="font-medium text-theme-text-primary">
977
- your own {agentName}
978
- </span>{" "}
979
- on your machine — no Radar cloud, no API key, no account. Radar sends
980
- this resource&apos;s spec, recent events, and pod logs to it (and on
981
- to its model provider under your account, not to Radar). Transcripts
982
- are kept in your local Radar history on this machine until cleared.
983
- {profile === "safeguarded" && (
984
- <>
985
- {" "}
986
- Radar&apos;s investigation tools can only{" "}
987
- <span className="font-medium">read</span> your cluster.
988
- </>
989
- )}
990
- </>
991
- }
992
- bullets={
993
- profile === "safeguarded"
994
- ? [
995
- agent === "claude" ? (
996
- <>
997
- Radar safeguards disable Claude&apos;s built-in tools and
998
- limit MCP access to Radar&apos;s read-only investigation
999
- tools. Your Claude settings, hooks, and CLAUDE.md instructions
1000
- still apply and are outside Radar&apos;s control.
1001
- </>
1002
- ) : agent === "codex" ? (
1003
- <>
1004
- Radar safeguards exclude your Codex configuration and other
1005
- MCP servers. Codex&apos;s sandboxed shell can still read files
1006
- on this machine; it cannot write or reach the network.
1007
- </>
1008
- ) : (
1009
- <>
1010
- Radar uses this agent&apos;s safeguarded execution profile.
1011
- Review the agent&apos;s documented restrictions before
1012
- continuing.
1013
- </>
1014
- ),
1015
- ]
1016
- : [
1017
- <>
1018
- Radar cannot constrain the agent&apos;s other configured tools
1019
- or MCP servers. They may access local files or the network and
1020
- may be able to change your cluster.
1021
- </>,
1022
- agent === "cursor-agent" ? (
1023
- CURSOR_FULL_LOCAL_WARNING
1024
- ) : agent === "claude" ? (
1025
- <>
1026
- Claude uses the permissions from your setup; Radar does not
1027
- override them.
1028
- </>
1029
- ) : (
1030
- <>
1031
- Radar still enables the agent CLI&apos;s own sandbox, but that
1032
- sandbox does not constrain external MCP servers.
1033
- </>
1034
- ),
1035
- ]
1036
- }
1037
- />
1038
- );
1039
- }
1040
-
1041
- // The Apply confirmation — wider than a generic confirm so the recommended fix
1042
- // (rendered markdown) is legible, making it unambiguous what the one click does.
1043
- export function ApplyDialog({
1044
- open,
1045
- onClose,
1046
- onConfirm,
1047
- agentLabel,
1048
- resourceLabel,
1049
- context,
1050
- fix,
1051
- reason,
1052
- precondition,
1053
- managedBy,
1054
- confidence,
1055
- }: {
1056
- open: boolean;
1057
- onClose: () => void;
1058
- onConfirm: () => void;
1059
- agentLabel: string;
1060
- resourceLabel: string;
1061
- context: string;
1062
- fix?: string;
1063
- /** The agent's one-clause case for this step, repeated at the decision. */
1064
- reason?: string;
1065
- /** The condition the agent attached to this step; shown before the operator confirms. */
1066
- precondition?: string;
1067
- managedBy?: string; // GitOps/Helm owner of the resource, if any
1068
- confidence?: number;
1069
- }) {
1070
- const fixText = fix?.trim();
1071
- const lowConfidence = confidence != null && confidence < 0.5;
1072
- // A GitOps/Helm-managed resource needs an explicit acknowledgment before applying
1073
- // a direct change — it's the canonical footgun (the controller reverts it). Gating
1074
- // (not just warning) makes the user opt into "yes, I know this may be undone."
1075
- // TODO(SKY-1075): once Radar can connect the user's SCM (GitHub/GitLab/…), replace
1076
- // direct apply on managed resources with "open a PR against the Git source"
1077
- // instead — the durable fix. See Linear SKY-1075.
1078
- const [acked, setAcked] = useState(false);
1079
- useEffect(() => {
1080
- if (open) setAcked(false);
1081
- }, [open]);
1082
- const applyBlocked = !!managedBy && !acked;
1083
- return (
1084
- <DialogPortal
1085
- open={open}
1086
- onClose={onClose}
1087
- className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden"
1088
- >
1089
- <div className="flex shrink-0 items-start gap-3 border-b border-theme-border p-4">
1090
- <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20">
1091
- <AlertTriangle className="h-5 w-5 text-amber-500" />
1092
- </div>
1093
- <div className="min-w-0 flex-1">
1094
- <h3 className="text-lg font-semibold text-theme-text-primary">
1095
- Apply this fix?
1096
- </h3>
1097
- <p className="mt-1 text-sm text-theme-text-secondary">
1098
- Let {agentLabel} apply the recommended change to{" "}
1099
- <span className="font-medium text-theme-text-primary">
1100
- {resourceLabel}
1101
- </span>
1102
- .
1103
- </p>
1104
- <p className="mt-2 text-sm text-theme-text-secondary">
1105
- Cluster:{" "}
1106
- <span className="font-medium text-theme-text-primary">
1107
- {parseContextName(context).clusterName}
1108
- </span>
1109
- </p>
1110
- {context !== parseContextName(context).clusterName && (
1111
- <p className="mt-0.5 break-all font-mono text-xs text-theme-text-tertiary">
1112
- {context}
1113
- </p>
1114
- )}
1115
- </div>
1116
- </div>
1117
-
1118
- <div className="min-h-0 overflow-y-auto">
1119
- {fixText && (
1120
- <div className="border-b border-theme-border p-4">
1121
- <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent">
1122
- <Sparkles className="h-3.5 w-3.5" />
1123
- Proposed change
1124
- </div>
1125
- <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-primary [&_pre]:my-1.5 [&_pre]:whitespace-pre-wrap [&_pre_code]:whitespace-pre-wrap">
1126
- {fixText}
1127
- </AIMarkdown>
1128
- {reason ? (
1129
- <p
1130
- data-apply-reason
1131
- className="mt-2 text-sm text-theme-text-secondary"
1132
- >
1133
- <span className="font-medium text-theme-text-primary">
1134
- Why this step:
1135
- </span>{" "}
1136
- {reason}
1137
- </p>
1138
- ) : null}
1139
- {precondition ? (
1140
- <p
1141
- data-apply-precondition
1142
- className="mt-2 flex items-start gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/5 px-2.5 py-1.5 text-xs text-theme-text-primary"
1143
- >
1144
- <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
1145
- <span>
1146
- <span className="font-medium">
1147
- The agent said this applies only if:
1148
- </span>{" "}
1149
- {precondition}. Radar has not checked that condition.
1150
- </span>
1151
- </p>
1152
- ) : null}
1153
- </div>
1154
- )}
1155
- </div>
1156
- <div className="shrink-0 space-y-2 p-4">
1157
- {/* The star warning: when we KNOW a controller owns this resource, a live
1158
- change reverts on the next reconcile — say so authoritatively. */}
1159
- {managedBy && (
1160
- <div className="space-y-2 rounded border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-theme-text-primary">
1161
- <div className="flex items-start gap-2">
1162
- <RefreshCw className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
1163
- <span>
1164
- <span className="font-medium">Managed by {managedBy}.</span>{" "}
1165
- Unless you turn off auto-sync, a direct change here will be
1166
- undone within minutes when {managedBy} re-syncs from Git — the
1167
- durable fix is to change it in Git (the {managedBy} source).
1168
- </span>
1169
- </div>
1170
- <label className="flex cursor-pointer items-center gap-2 pl-6 text-xs text-theme-text-secondary">
1171
- <input
1172
- type="checkbox"
1173
- checked={acked}
1174
- onChange={(e) => setAcked(e.target.checked)}
1175
- className="h-3.5 w-3.5 accent-amber-500"
1176
- />
1177
- I understand {managedBy} may revert this — apply anyway.
1178
- </label>
1179
- </div>
1180
- )}
1181
- {lowConfidence && (
1182
- <div className="flex items-start gap-2 rounded border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
1183
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
1184
- <span>
1185
- The agent had <span className="font-medium">low confidence</span>{" "}
1186
- in this conclusion — consider asking a follow-up to verify before
1187
- applying.
1188
- </span>
1189
- </div>
1190
- )}
1191
- <div className="flex items-start gap-2 rounded border border-theme-border bg-theme-base/50 p-3 text-sm text-theme-text-secondary">
1192
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
1193
- <span>
1194
- {agentLabel} will change your cluster using your kubeconfig
1195
- credentials. Review the change above; if you&apos;re not sure, ask a
1196
- follow-up first.
1197
- </span>
1198
- </div>
1199
- </div>
1200
- <div className="flex shrink-0 items-center justify-end gap-3 border-t border-theme-border p-4">
1201
- <button
1202
- onClick={onClose}
1203
- className="rounded-lg px-4 py-2 text-sm font-medium text-theme-text-secondary transition-colors hover:bg-theme-elevated hover:text-theme-text-primary"
1204
- >
1205
- Cancel
1206
- </button>
1207
- <button
1208
- onClick={onConfirm}
1209
- disabled={applyBlocked}
1210
- className="flex items-center gap-1.5 rounded-lg btn-brand px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
1211
- >
1212
- <Wrench className="h-4 w-4" />
1213
- Apply fix
1214
- </button>
1215
- </div>
1216
- </DialogPortal>
1217
- );
1218
- }
1219
-
1220
- export function Timeline({
1221
- items,
1222
- running,
1223
- applyMode,
1224
- followup,
1225
- startup,
1226
- agentLabel = "Agent",
1227
- turnIndex,
1228
- evidenceStepIds,
1229
- onViewEvidence,
1230
- sourceRevealRequest,
1231
- }: {
1232
- items: TimelineItem[];
1233
- running: boolean;
1234
- applyMode?: boolean;
1235
- followup?: boolean;
1236
- startup?: StartupSignal;
1237
- agentLabel?: string;
1238
- turnIndex?: number;
1239
- evidenceStepIds?: ReadonlySet<string>;
1240
- onViewEvidence?: (sourceId: string) => void;
1241
- sourceRevealRequest?: {
1242
- sourceId: string;
1243
- requestId: number;
1244
- excerpt?: InvestigationSourceExcerpt;
1245
- };
1246
- }) {
1247
- const heading = applyMode
1248
- ? "Applying fix"
1249
- : followup
1250
- ? "Working"
1251
- : "Investigation";
1252
- // The live status verb tracks the running tool ("Reading logs…") so the wait is
1253
- // informative, not a generic spinner; before the first item it reports the
1254
- // startup phases the server actually observed, never a timer-based guess.
1255
- const activeTool = [...items]
1256
- .reverse()
1257
- .find((it) => it.kind === "tool" && it.status !== "done") as
1258
- Extract<TimelineItem, { kind: "tool" }> | undefined;
1259
- const runningLabel = applyMode
1260
- ? "Applying the fix…"
1261
- : activeTool
1262
- ? toolActivity(activeTool.tool)
1263
- : items.length > 0
1264
- ? "Working…"
1265
- : startup
1266
- ? startupLabel(startup, agentLabel)
1267
- : followup
1268
- ? "Thinking…"
1269
- : "Starting investigation…";
1270
- const failedServers = (startup?.mcpServers ?? []).filter(
1271
- (server) => server.status !== "connected",
1272
- );
1273
- const radarServer = failedServers.find((server) => server.name === "radar");
1274
- const allDefiniteFailures = failedServers.every((server) =>
1275
- mcpStatusIsFailure(server.status),
1276
- );
1277
- return (
1278
- <div className="space-y-1.5">
1279
- {items.length > 0 && (
1280
- <div className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
1281
- {heading}
1282
- </div>
1283
- )}
1284
- {failedServers.length > 0 && (
1285
- <div
1286
- role="status"
1287
- 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"
1288
- >
1289
- <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-semantic-warning" />
1290
- <span>
1291
- {failedServers.map((server, i) => (
1292
- <span key={server.name}>
1293
- {i > 0 ? "; " : ""}
1294
- MCP server{" "}
1295
- <span className="font-medium text-theme-text-primary">
1296
- {server.name}
1297
- </span>{" "}
1298
- {mcpStatusPhrase(server.status)}
1299
- </span>
1300
- ))}
1301
- {radarServer
1302
- ? mcpStatusIsFailure(radarServer.status)
1303
- ? ` at startup. ${agentLabel} had no Radar tools this turn, so it could not use Radar's cluster evidence.`
1304
- : ` at startup. Radar's tools may not have been available to ${agentLabel} this turn.`
1305
- : allDefiniteFailures
1306
- ? ` at startup. ${agentLabel} ran this turn without those tools.`
1307
- : ` at startup. Those tools may not have been available to ${agentLabel} this turn.`}
1308
- </span>
1309
- </div>
1310
- )}
1311
- {items.map((it, i) => {
1312
- if (it.kind === "thinking") {
1313
- return (
1314
- <ThinkingBlock
1315
- key={i}
1316
- text={it.text}
1317
- animate={it.animate !== false}
1318
- live={running && i === items.length - 1}
1319
- />
1320
- );
1321
- }
1322
- const sourceId =
1323
- turnIndex === undefined
1324
- ? undefined
1325
- : investigationEvidenceSourceId(turnIndex, it.id);
1326
- return (
1327
- <ToolRow
1328
- key={it.id}
1329
- step={it}
1330
- sourceId={sourceId}
1331
- hasEvidence={evidenceStepIds?.has(it.id) ?? false}
1332
- onViewEvidence={onViewEvidence}
1333
- revealRequestId={
1334
- sourceRevealRequest && sourceId === sourceRevealRequest.sourceId
1335
- ? sourceRevealRequest.requestId
1336
- : undefined
1337
- }
1338
- sourceExcerpt={
1339
- sourceRevealRequest && sourceRevealRequest.sourceId === sourceId
1340
- ? sourceRevealRequest.excerpt
1341
- : undefined
1342
- }
1343
- animate={it.animate !== false}
1344
- />
1345
- );
1346
- })}
1347
- {running && <RunningStatus label={runningLabel} />}
1348
- </div>
1349
- );
1350
- }
1351
-
1352
- // The model's reasoning between tool calls — muted + subordinate to the tool
1353
- // rows, and clamped once its beat is over so the chronology stays scannable.
1354
- // The final beat remains fully visible while it is streaming; completed prose
1355
- // is still available through an overflow-aware disclosure.
1356
- function ThinkingBlock({
1357
- text,
1358
- animate,
1359
- live,
1360
- }: {
1361
- text: string;
1362
- animate: boolean;
1363
- live: boolean;
1364
- }) {
1365
- const [expanded, setExpanded] = useState(false);
1366
- const [contentHeight, setContentHeight] = useState<number>();
1367
- const contentRef = useRef<HTMLDivElement>(null);
1368
- const contentId = useId();
1369
- const reveal = useDisclosureReveal<HTMLDivElement>();
1370
- // Two lines of 12px/19.5px prose plus 8px paragraph/container spacing. Unlike a disclosure
1371
- // this starts with a visible preview, so animate measured height using the
1372
- // same 200ms ease-out/reduced-motion treatment as Collapse.
1373
- const previewHeight = 47;
1374
- const clamped = !live && !expanded;
1375
- useEffect(() => {
1376
- const content = contentRef.current;
1377
- if (!content) return;
1378
- const measure = () => setContentHeight(content.scrollHeight);
1379
- measure();
1380
- if (typeof ResizeObserver === "undefined") return;
1381
- const observer = new ResizeObserver(measure);
1382
- observer.observe(content);
1383
- return () => observer.disconnect();
1384
- }, [text]);
1385
-
1386
- return (
1387
- <div
1388
- ref={reveal.elementRef}
1389
- className={animate ? "animate-transcript-enter" : ""}
1390
- >
1391
- <div
1392
- id={contentId}
1393
- className="overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none"
1394
- style={{
1395
- height: clamped
1396
- ? Math.min(contentHeight ?? previewHeight, previewHeight)
1397
- : contentHeight,
1398
- }}
1399
- >
1400
- <div ref={contentRef}>
1401
- <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">
1402
- {text}
1403
- </AIMarkdown>
1404
- </div>
1405
- </div>
1406
- {!live && ((contentHeight ?? 0) > previewHeight || expanded) ? (
1407
- <button
1408
- type="button"
1409
- aria-controls={contentId}
1410
- aria-expanded={expanded}
1411
- onClick={() => {
1412
- setExpanded(!expanded);
1413
- reveal.revealAfterToggle(!expanded);
1414
- }}
1415
- className="text-[11px] font-medium text-theme-text-tertiary hover:text-accent-text"
1416
- >
1417
- {expanded ? "Show less" : "Show reasoning"}
1418
- </button>
1419
- ) : null}
1420
- </div>
1421
- );
1422
- }
1423
-
1424
- // The live "working" line: spinner + shimmering activity verb, plus an elapsed
1425
- // counter and — if the same activity sits with no update for a while — a soft
1426
- // "still working" reassurance, so a long investigation reads as progress and a
1427
- // genuine hang is at least legible (a non-expert can't otherwise tell them apart).
1428
- // Self-contained: counts from when this line mounts; the stall timer resets each
1429
- // time the label changes (i.e. whenever the agent moves to a new tool/phase).
1430
- function RunningStatus({ label }: { label: string }) {
1431
- const [elapsed, setElapsed] = useState(0);
1432
- const elapsedRef = useRef(0);
1433
- const lastChangeRef = useRef(0);
1434
- const prevLabelRef = useRef(label);
1435
- // Reset the stall timer synchronously when the label changes (i.e. the agent moved
1436
- // to a new tool/phase) — doing it during render, not in an effect, so an already-
1437
- // stalled line never flashes "no update for Ns" for a tick before resetting.
1438
- if (prevLabelRef.current !== label) {
1439
- prevLabelRef.current = label;
1440
- lastChangeRef.current = elapsedRef.current;
1441
- }
1442
- useEffect(() => {
1443
- const id = setInterval(() => {
1444
- elapsedRef.current += 1;
1445
- setElapsed(elapsedRef.current);
1446
- }, 1000);
1447
- return () => clearInterval(id);
1448
- }, []);
1449
- const sinceChange = elapsed - lastChangeRef.current;
1450
- const stalled = elapsed >= 30 && sinceChange >= 30;
1451
- const counter = runningElapsedLabel(elapsed);
1452
- return (
1453
- <div className="flex items-center gap-2 pt-1 text-xs">
1454
- <Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
1455
- <span className="ai-shimmer min-w-0 truncate">{label}</span>
1456
- {stalled && (
1457
- <span className="shrink-0 text-theme-text-tertiary">
1458
- · still working — no update for {sinceChange}s
1459
- </span>
1460
- )}
1461
- {counter && (
1462
- <span className="ml-auto shrink-0 tabular-nums text-theme-text-tertiary">
1463
- {counter}
1464
- </span>
1465
- )}
1466
- </div>
1467
- );
1468
- }
1469
-
1470
- // The wait the operator feels is the whole turn's, so the counter is a
1471
- // row-level figure set apart from the label: "Connected to Radar's tools"
1472
- // followed by "10s" read as if the handshake took that long.
1473
- export function runningElapsedLabel(elapsed: number): string | undefined {
1474
- return elapsed >= 3 ? `${elapsed}s elapsed` : undefined;
1475
- }
1476
-
1477
- // Claude Code reports each MCP server as connected, failed, needs-auth, or
1478
- // pending. Only the first two are definite outcomes; anything else is left as
1479
- // the CLI's own word so the warning never claims more than it knows.
1480
- function mcpStatusIsFailure(status: string): boolean {
1481
- return status === "failed" || status === "needs-auth";
1482
- }
1483
-
1484
- function mcpStatusPhrase(status: string): string {
1485
- if (status === "failed") return "failed to connect";
1486
- if (status === "needs-auth") return "needs authentication";
1487
- return `is ${status}`;
1488
- }
1489
-
1490
- // Maps a running tool to a human verb so the status line reads as activity, not
1491
- // machinery. Falls back to the prettified tool name for anything unmapped.
1492
- function toolActivity(tool: string): string {
1493
- const t = tool.toLowerCase();
1494
- if (t.includes("log")) return "Reading logs…";
1495
- if (t.includes("event")) return "Checking recent events…";
1496
- if (t.includes("list")) return "Scanning related resources…";
1497
- if (t.includes("describe") || t.includes("get_resource"))
1498
- return "Inspecting the resource…";
1499
- if (t.includes("resource")) return "Inspecting the resource…";
1500
- if (t.includes("metric") || t.includes("top")) return "Checking metrics…";
1501
- if (t.includes("topology") || t.includes("graph"))
1502
- return "Tracing dependencies…";
1503
- return `${prettyTool(tool)}…`;
1504
- }
1505
-
1506
- function ToolRow({
1507
- step,
1508
- sourceId,
1509
- hasEvidence,
1510
- onViewEvidence,
1511
- revealRequestId,
1512
- sourceExcerpt,
1513
- animate,
1514
- }: {
1515
- step: Extract<TimelineItem, { kind: "tool" }>;
1516
- sourceId?: string;
1517
- hasEvidence?: boolean;
1518
- onViewEvidence?: (sourceId: string) => void;
1519
- revealRequestId?: number;
1520
- sourceExcerpt?: InvestigationSourceExcerpt;
1521
- animate: boolean;
1522
- }) {
1523
- const [open, setOpen] = useState(revealRequestId !== undefined);
1524
- const [showFull, setShowFull] = useState(false);
1525
- const [argumentsOpen, setArgumentsOpen] = useState(false);
1526
- const toolReveal = useDisclosureReveal<HTMLDivElement>();
1527
- const argumentsReveal = useDisclosureReveal<HTMLDivElement>();
1528
- const argumentsId = useId();
1529
- const argumentsPreview = step.summary ? compactArgs(step.summary) : "";
1530
- const inlineArguments =
1531
- argumentsPreview.length <= 240 && !argumentsPreview.includes("\n");
1532
- const detailId = `investigation-tool-detail-${useId().replaceAll(":", "")}`;
1533
- const hasDetail = !!(step.summary || step.result);
1534
- useEffect(() => {
1535
- if (revealRequestId !== undefined && hasDetail) setOpen(true);
1536
- }, [hasDetail, revealRequestId]);
1537
- // Offer the rich dialog when the result is structured or non-trivial in size.
1538
- const richResult =
1539
- !!step.result && (isJsonPayload(step.result) || step.result.length > 200);
1540
- const done = step.status === "done";
1541
- const durationLabel = toolDurationLabel(step.ms);
1542
- const errorReason =
1543
- step.isError === true ? toolErrorReason(step.result) : undefined;
1544
- const outcomeLabel = !done
1545
- ? "Running"
1546
- : step.isError === true
1547
- ? "Tool failed"
1548
- : step.isError === false
1549
- ? "Tool completed"
1550
- : "Tool finished; outcome not recorded";
1551
- const rowContent = (
1552
- <>
1553
- {!done ? (
1554
- <Loader2
1555
- aria-label={outcomeLabel}
1556
- className="h-3.5 w-3.5 shrink-0 animate-spin text-accent"
1557
- />
1558
- ) : step.isError === true ? (
1559
- <AlertTriangle
1560
- aria-label={outcomeLabel}
1561
- className="h-3.5 w-3.5 shrink-0 text-red-400"
1562
- />
1563
- ) : step.isError === false ? (
1564
- <CheckCircle2
1565
- aria-label={outcomeLabel}
1566
- className="h-3.5 w-3.5 shrink-0 text-emerald-400"
1567
- />
1568
- ) : (
1569
- <HelpCircle
1570
- aria-label={outcomeLabel}
1571
- className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
1572
- />
1573
- )}
1574
- <span className="shrink-0 font-mono text-xs text-theme-text-secondary">
1575
- {prettyTool(step.tool)}
1576
- </span>
1577
- {step.summary && !open && (
1578
- <span className="min-w-0 flex-1 shrink-[4] truncate font-mono text-[11px] text-theme-text-tertiary">
1579
- {argumentsPreview}
1580
- </span>
1581
- )}
1582
- {errorReason && !open && (
1583
- // The arguments give way first: they are still readable expanded,
1584
- // while the reason is the one thing this row exists to say. It still
1585
- // clips at the row's edge; the full text is a hover and a click away.
1586
- <Tooltip content={errorReason} wrapperClassName="min-w-0 truncate">
1587
- <span className="investigation-tool-reason block min-w-0 truncate text-[11px] text-semantic-error">
1588
- {middleTruncate(errorReason)}
1589
- </span>
1590
- </Tooltip>
1591
- )}
1592
- {durationLabel && (
1593
- <span className="ml-auto shrink-0 text-[11px] text-theme-text-tertiary">
1594
- {durationLabel}
1595
- </span>
1596
- )}
1597
- {hasDetail && <CollapseChevron open={open} className="h-3.5 w-3.5" />}
1598
- </>
1599
- );
1600
- return (
1601
- <div
1602
- onMouseEnter={(event) =>
1603
- highlightRelatedEvidence(event.currentTarget, sourceId)
1604
- }
1605
- onMouseLeave={(event) => highlightRelatedEvidence(event.currentTarget)}
1606
- ref={toolReveal.elementRef}
1607
- id={sourceId ? investigationActivitySourceDomId(sourceId) : undefined}
1608
- tabIndex={sourceId ? -1 : undefined}
1609
- role={sourceId ? "group" : undefined}
1610
- aria-label={
1611
- sourceId
1612
- ? `${prettyTool(step.tool)} ${outcomeLabel.toLowerCase()}`
1613
- : undefined
1614
- }
1615
- 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`}
1616
- >
1617
- <div className="flex min-w-0 items-stretch">
1618
- {hasDetail ? (
1619
- <button
1620
- type="button"
1621
- onClick={() => {
1622
- setOpen(!open);
1623
- toolReveal.revealAfterToggle(!open);
1624
- }}
1625
- aria-expanded={open}
1626
- aria-controls={detailId}
1627
- className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm hover:bg-theme-hover"
1628
- >
1629
- {rowContent}
1630
- </button>
1631
- ) : (
1632
- <div className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm">
1633
- {rowContent}
1634
- </div>
1635
- )}
1636
- {hasEvidence && sourceId && onViewEvidence ? (
1637
- <button
1638
- type="button"
1639
- onClick={() => onViewEvidence(sourceId)}
1640
- aria-label={`View evidence from ${prettyTool(step.tool)}`}
1641
- className="investigation-evidence-jump shrink-0 px-2 text-[11px] font-medium text-accent-text hover:bg-theme-hover"
1642
- >
1643
- Show evidence
1644
- </button>
1645
- ) : null}
1646
- </div>
1647
- {hasDetail && (
1648
- <div id={detailId}>
1649
- <Collapse open={open}>
1650
- <div className="space-y-2 border-t border-theme-border/60 px-2 py-2">
1651
- {step.summary &&
1652
- (inlineArguments ? (
1653
- <div
1654
- className="break-words font-mono text-[11px] leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]"
1655
- aria-label="Tool arguments"
1656
- >
1657
- {argumentsPreview}
1658
- </div>
1659
- ) : (
1660
- <div ref={argumentsReveal.elementRef}>
1661
- <button
1662
- type="button"
1663
- aria-expanded={argumentsOpen}
1664
- aria-controls={argumentsId}
1665
- onClick={() => {
1666
- setArgumentsOpen(!argumentsOpen);
1667
- argumentsReveal.revealAfterToggle(!argumentsOpen);
1668
- }}
1669
- className="flex items-center gap-1 py-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
1670
- >
1671
- <CollapseChevron
1672
- open={argumentsOpen}
1673
- className="h-3.5 w-3.5"
1674
- />
1675
- {argumentsOpen ? "Hide arguments" : "Show arguments"}
1676
- </button>
1677
- <div id={argumentsId}>
1678
- <Collapse open={argumentsOpen} mountLazily>
1679
- <PayloadBlock label="Arguments" text={step.summary} />
1680
- </Collapse>
1681
- </div>
1682
- </div>
1683
- ))}
1684
- {step.result && (
1685
- <PayloadBlock
1686
- label="Original result"
1687
- detail={step.ms != null ? `${step.ms}ms` : undefined}
1688
- text={step.result}
1689
- sourceExcerpt={sourceExcerpt}
1690
- revealRequestId={revealRequestId}
1691
- truncated={step.truncated}
1692
- action={
1693
- richResult ? (
1694
- <button
1695
- onClick={() => setShowFull(true)}
1696
- className="flex items-center gap-1 text-[11px] text-accent hover:underline"
1697
- >
1698
- <Maximize2 className="h-3 w-3" />
1699
- View payload
1700
- </button>
1701
- ) : undefined
1702
- }
1703
- />
1704
- )}
1705
- </div>
1706
- </Collapse>
1707
- </div>
1708
- )}
1709
- {step.result && (
1710
- <ToolResultDialog
1711
- open={showFull}
1712
- onClose={() => setShowFull(false)}
1713
- title={prettyTool(step.tool)}
1714
- text={step.result}
1715
- truncated={step.truncated}
1716
- />
1717
- )}
1718
- </div>
1719
- );
1720
- }
1721
-
1722
- // Only outliers carry information on the collapsed row: a slow log fetch or a
1723
- // probe that hung. Sub-second calls stay silent; the exact figure lives in the
1724
- // expanded result header.
1725
- export function toolDurationLabel(ms: number | undefined): string | undefined {
1726
- if (ms == null || ms < 2000) return undefined;
1727
- return `${Math.round(ms / 1000)}s`;
1728
- }
1729
-
1730
- // The producer's own words for a failed call, reduced to one line. Radar's MCP
1731
- // errors are plain text; a JSON envelope with an `error` field is unwrapped.
1732
- // Radar's not-found errors append retry hints for the agent after an em dash;
1733
- // only the clause before it says what failed, so the hints are dropped.
1734
- export function toolErrorReason(
1735
- result: string | undefined,
1736
- ): string | undefined {
1737
- if (!result) return undefined;
1738
- let text = result;
1739
- try {
1740
- const parsed: unknown = JSON.parse(result);
1741
- if (
1742
- parsed &&
1743
- typeof parsed === "object" &&
1744
- typeof (parsed as { error?: unknown }).error === "string"
1745
- ) {
1746
- text = (parsed as { error: string }).error;
1747
- }
1748
- } catch {
1749
- // plain text
1750
- }
1751
- const line = text
1752
- .split("\n")
1753
- .map((part) => part.trim())
1754
- .find((part) => part.length > 0);
1755
- if (!line) return undefined;
1756
- const clause = line.split(" — ")[0].trim();
1757
- return clause || line;
1758
- }
1759
-
1760
- const COLLAPSED_REASON_MAX = 100;
1761
- const COLLAPSED_REASON_TAIL = 36;
1762
-
1763
- // Keeps both ends of a long reason: the leading words say what failed and the
1764
- // tail usually carries the identifier, which a plain end-truncation would lose.
1765
- export function middleTruncate(
1766
- text: string,
1767
- max = COLLAPSED_REASON_MAX,
1768
- tail = COLLAPSED_REASON_TAIL,
1769
- ): string {
1770
- if (text.length <= max) return text;
1771
- const head = Math.max(1, max - tail - 1);
1772
- return `${text.slice(0, head).trimEnd()}…${text.slice(-tail).trimStart()}`;
1773
- }
1774
-
1775
- // isJsonPayload / formatJson — a tool result is "structured" if it parses as JSON.
1776
- function isJsonPayload(text: string): boolean {
1777
- try {
1778
- JSON.parse(text);
1779
- return true;
1780
- } catch {
1781
- return false;
1782
- }
1783
- }
1784
- function formatJson(text: string): string | null {
1785
- try {
1786
- return JSON.stringify(JSON.parse(text), null, 2);
1787
- } catch {
1788
- return null;
1789
- }
1790
- }
1791
-
1792
- // PayloadBlock — compact inline view of a tool input/result: pretty JSON (scrolled
1793
- // to keep indentation) or wrapped text (logs/prose), with copy + optional action.
1794
- function PayloadBlock({
1795
- label,
1796
- detail,
1797
- text,
1798
- truncated,
1799
- action,
1800
- sourceExcerpt,
1801
- revealRequestId,
1802
- }: {
1803
- label: string;
1804
- detail?: string;
1805
- text: string;
1806
- truncated?: boolean;
1807
- action?: ReactNode;
1808
- sourceExcerpt?: InvestigationSourceExcerpt;
1809
- revealRequestId?: number;
1810
- }) {
1811
- const json = formatJson(text);
1812
- const display = json ?? text;
1813
- const range = locateSourceExcerpt(display, sourceExcerpt);
1814
- const rangeStart = range?.start;
1815
- const rangeEnd = range?.end;
1816
- const preRef = useRef<HTMLPreElement>(null);
1817
- const markRef = useRef<HTMLElement>(null);
1818
- useEffect(() => {
1819
- if (rangeStart === undefined || revealRequestId === undefined) return;
1820
- const frame = requestAnimationFrame(() => {
1821
- const pre = preRef.current,
1822
- mark = markRef.current;
1823
- if (pre && mark)
1824
- pre.scrollTop +=
1825
- mark.getBoundingClientRect().top -
1826
- pre.getBoundingClientRect().top -
1827
- pre.clientHeight / 3;
1828
- });
1829
- return () => cancelAnimationFrame(frame);
1830
- }, [revealRequestId, rangeStart, rangeEnd]);
1831
- return (
1832
- <div>
1833
- <div className="mb-0.5 flex items-center justify-between gap-2">
1834
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
1835
- {label}
1836
- {detail && (
1837
- <span className="ml-1.5 normal-case tracking-normal">
1838
- · {detail}
1839
- </span>
1840
- )}
1841
- </span>
1842
- <div className="flex items-center gap-2">
1843
- {action}
1844
- <CopyButton
1845
- text={json ?? text}
1846
- label={`Copy ${label.toLowerCase()}`}
1847
- />
1848
- </div>
1849
- </div>
1850
- <pre
1851
- ref={preRef}
1852
- 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]"}`}
1853
- >
1854
- {range ? (
1855
- <>
1856
- {display.slice(0, range.start)}
1857
- <mark
1858
- ref={markRef}
1859
- className="bg-accent-muted text-theme-text-primary ring-1 ring-accent/40"
1860
- >
1861
- {display.slice(range.start, range.end)}
1862
- </mark>
1863
- {display.slice(range.end)}
1864
- </>
1865
- ) : (
1866
- display
1867
- )}
1868
- </pre>
1869
- {truncated && (
1870
- <div className="mt-0.5 text-[10px] text-amber-500">
1871
- Capped at 32 KB — partial output.
1872
- </div>
1873
- )}
1874
- </div>
1875
- );
1876
- }
1877
-
1878
- // ToolResultDialog — the rich payload viewer: syntax-highlighted + searchable via
1879
- // CodeViewer, with a JSON⇄YAML toggle for structured results (YAML default — k8s
1880
- // reads better) and plain text for non-JSON (logs/prose).
1881
- function ToolResultDialog({
1882
- open,
1883
- onClose,
1884
- title,
1885
- text,
1886
- truncated,
1887
- }: {
1888
- open: boolean;
1889
- onClose: () => void;
1890
- title: string;
1891
- text: string;
1892
- truncated?: boolean;
1893
- }) {
1894
- const { theme } = useTheme();
1895
- const [fmt, setFmt] = useState<"yaml" | "json">("yaml");
1896
- const parsed = useMemo<{ ok: boolean; value?: unknown }>(() => {
1897
- try {
1898
- return { ok: true, value: JSON.parse(text) };
1899
- } catch {
1900
- return { ok: false };
1901
- }
1902
- }, [text]);
1903
-
1904
- const display = !parsed.ok
1905
- ? text
1906
- : fmt === "yaml"
1907
- ? safeYaml(parsed.value)
1908
- : JSON.stringify(parsed.value, null, 2);
1909
- const language = parsed.ok ? fmt : "text";
1910
-
1911
- // Progressive syntax highlighting: render the plain text instantly, then swap in
1912
- // shiki's highlighted HTML once it resolves. A slow/failed highlighter load never
1913
- // blocks the payload (unlike CodeViewer's "Loading…" gate) — worst case it stays
1914
- // plain. Native browser find still works on the rendered text.
1915
- const [html, setHtml] = useState<string | null>(null);
1916
- useEffect(() => {
1917
- if (!open) return;
1918
- setHtml(null);
1919
- let alive = true;
1920
- codeToHtml(display, {
1921
- lang: language,
1922
- theme: theme === "light" ? "github-light" : "github-dark",
1923
- })
1924
- .then((h) => alive && setHtml(h))
1925
- .catch(() => {}); // keep the plain pre on failure
1926
- return () => {
1927
- alive = false;
1928
- };
1929
- }, [open, display, language, theme]);
1930
- return (
1931
- <DialogPortal open={open} onClose={onClose} className="w-[min(90vw,820px)]">
1932
- <div className="flex items-center justify-between gap-3 border-b border-theme-border p-3">
1933
- <div className="min-w-0">
1934
- <div className="truncate font-mono text-sm text-theme-text-primary">
1935
- {title}
1936
- </div>
1937
- {truncated && (
1938
- <div className="text-[11px] text-amber-500">
1939
- Capped at 32 KB — partial output.
1940
- </div>
1941
- )}
1942
- </div>
1943
- <div className="flex shrink-0 items-center gap-2">
1944
- {parsed.ok && (
1945
- <div className="w-32">
1946
- <Segmented<"yaml" | "json">
1947
- value={fmt}
1948
- onChange={setFmt}
1949
- options={[
1950
- { value: "yaml", label: "YAML" },
1951
- { value: "json", label: "JSON" },
1952
- ]}
1953
- />
1954
- </div>
1955
- )}
1956
- <CopyButton text={display} label="Copy tool result" />
1957
- </div>
1958
- </div>
1959
- {html ? (
1960
- <div
1961
- 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"
1962
- dangerouslySetInnerHTML={{ __html: html }}
1963
- />
1964
- ) : (
1965
- <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">
1966
- {display}
1967
- </pre>
1968
- )}
1969
- </DialogPortal>
1970
- );
1971
- }
1972
-
1973
- function safeYaml(value: unknown): string {
1974
- try {
1975
- return toYaml(value, { lineWidth: 0 });
1976
- } catch {
1977
- return JSON.stringify(value, null, 2);
1978
- }
1979
- }
1980
-
1981
- function compactArgs(raw: string): string {
1982
- try {
1983
- const o = JSON.parse(raw);
1984
- return Object.entries(o)
1985
- .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
1986
- .join(" ");
1987
- } catch {
1988
- return raw;
1989
- }
1990
- }
1991
-
1992
- export function ResultCard({
1993
- diagnosis,
1994
- onApply,
1995
- explanation,
1996
- apply,
1997
- applyOutcome,
1998
- followup,
1999
- section = "full",
2000
- onCheckStatus,
2001
- animate = true,
2002
- showDisclaimer = true,
2003
- coverageLimited = false,
2004
- evidenceConflict = false,
2005
- evidenceConflictExplainedBy,
2006
- compactActions = false,
2007
- assessmentAction,
2008
- assessmentSources,
2009
- actionNotice,
2010
- storyInline = false,
2011
- readOnlyAssessment = false,
2012
- revisedAfter,
2013
- assessmentLimits,
2014
- assessmentCopy,
2015
- healthSignals,
2016
- onRevealSource,
2017
- }: {
2018
- diagnosis: Diagnosis;
2019
- onApply?: (fix: string) => void;
2020
- explanation?: AssessmentExplanation;
2021
- apply?: boolean;
2022
- applyOutcome?: ApplyMutationOutcome;
2023
- followup?: boolean;
2024
- section?: "full" | "conclusion" | "actions";
2025
- /**
2026
- * Render the story as plain prose inside this card. Findings renders the
2027
- * story itself, with placed cards, so it leaves this off; Activity's
2028
- * read-only copies of earlier assessments turn it on.
2029
- */
2030
- storyInline?: boolean;
2031
- /** An earlier assessment shown for the record: no Apply, labelled as such. */
2032
- readOnlyAssessment?: boolean;
2033
- /** The question that produced this revised assessment, when it replaced an earlier one. */
2034
- revisedAfter?: string;
2035
- /** Reads Radar could not complete for this assessment; listed under Still open. */
2036
- assessmentLimits?: string[];
2037
- assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
2038
- healthSignals?: InvestigationHealthSignal[];
2039
- onRevealSource?: (sourceId: string) => void;
2040
- onCheckStatus?: () => void;
2041
- animate?: boolean;
2042
- /** Additional navigation placed in the assessment action row. */
2043
- assessmentAction?: ReactNode;
2044
- assessmentSources?: ReactNode;
2045
- /** Hide the repeated disclaimer when the enclosing surface provides context. */
2046
- showDisclaimer?: boolean;
2047
- /** Qualifies a healthy assessment when structured evidence is absent or partial. */
2048
- coverageLimited?: boolean;
2049
- /** Marks a healthy agent assessment that conflicts with same-turn Key evidence. */
2050
- evidenceConflict?: boolean;
2051
- /**
2052
- * Titles of the conflicting cards when the agent placed a "not a problem"
2053
- * note on every one of them; the banner then points at the agent's reasons
2054
- * rather than accusing evidence it already addressed.
2055
- */
2056
- evidenceConflictExplainedBy?: string[];
2057
- /** Show only the recommended (or first) action until the user asks for more. */
2058
- compactActions?: boolean;
2059
- actionNotice?: string;
2060
- }) {
2061
- // Apply turns report mutation truth, not a diagnosis. The outcome-specific
2062
- // card decides whether that truth is confirmed, failed, or still unknown.
2063
- if (apply)
2064
- return (
2065
- <ApplyOutcomeCard
2066
- diagnosis={diagnosis}
2067
- applyOutcome={applyOutcome}
2068
- onCheckStatus={onCheckStatus}
2069
- animate={animate}
2070
- />
2071
- );
2072
- // A question remains conversational even if the model happens to set a health
2073
- // flag in its structured envelope. Never promote an ordinary answer into an
2074
- // authoritative investigation conclusion.
2075
- if (followup)
2076
- return (
2077
- <>
2078
- <FollowupAnswer diagnosis={diagnosis} animate={animate} />
2079
- {assessmentSources ? (
2080
- <AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
2081
- ) : null}
2082
- </>
2083
- );
2084
- if (diagnosis.healthy && !diagnosis.rootCause)
2085
- return section === "actions" ? null : (
2086
- <AllClearCard
2087
- diagnosis={diagnosis}
2088
- animate={animate}
2089
- showDisclaimer={showDisclaimer}
2090
- coverageLimited={coverageLimited}
2091
- evidenceConflict={evidenceConflict}
2092
- evidenceConflictExplainedBy={evidenceConflictExplainedBy}
2093
- assessmentAction={assessmentAction}
2094
- assessmentSources={assessmentSources}
2095
- storyInline={storyInline}
2096
- revisedAfter={revisedAfter}
2097
- assessmentLimits={assessmentLimits}
2098
- assessmentCopy={assessmentCopy}
2099
- healthSignals={healthSignals}
2100
- onRevealSource={onRevealSource}
2101
- />
2102
- );
2103
- // Couldn't-determine is its own honest state — never a confident all-clear, never
2104
- // the alarming root-cause anchor. With typed steps it can still carry the
2105
- // next check, which the actions section renders like any other step.
2106
- if (diagnosis.inconclusive && !diagnosis.rootCause) {
2107
- if (section === "actions")
2108
- return (diagnosis.steps?.length ?? 0) > 0 ? (
2109
- <DiagnosisResult
2110
- diagnosis={diagnosis}
2111
- onApply={readOnlyAssessment ? undefined : onApply}
2112
- section="actions"
2113
- animate={animate}
2114
- showDisclaimer={false}
2115
- compactActions={compactActions}
2116
- actionNotice={actionNotice}
2117
- readOnlyAssessment={readOnlyAssessment}
2118
- />
2119
- ) : null;
2120
- return (
2121
- <>
2122
- <InconclusiveCard
2123
- diagnosis={diagnosis}
2124
- animate={animate}
2125
- storyInline={storyInline}
2126
- revisedAfter={revisedAfter}
2127
- assessmentLimits={assessmentLimits}
2128
- assessmentCopy={assessmentCopy}
2129
- />
2130
- {section === "full" && (diagnosis.steps?.length ?? 0) > 0 ? (
2131
- <DiagnosisResult
2132
- diagnosis={diagnosis}
2133
- onApply={readOnlyAssessment ? undefined : onApply}
2134
- section="actions"
2135
- animate={false}
2136
- showDisclaimer={false}
2137
- compactActions={compactActions}
2138
- actionNotice={actionNotice}
2139
- readOnlyAssessment={readOnlyAssessment}
2140
- />
2141
- ) : null}
2142
- {assessmentSources ? (
2143
- <AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
2144
- ) : null}
2145
- {assessmentAction && (
2146
- <div className="mt-2 flex justify-end">{assessmentAction}</div>
2147
- )}
2148
- </>
2149
- );
2150
- }
2151
- // A turn with no structured root cause and no remediation (e.g. "looks healthy",
2152
- // or a clarifying question) is not a diagnosis — render it neutrally rather than
2153
- // forcing the alarming root-cause anchor onto a non-problem.
2154
- const structured =
2155
- !!diagnosis.rootCause || (diagnosis.remediation?.length ?? 0) > 0;
2156
- if (!structured)
2157
- return (
2158
- <>
2159
- <FollowupAnswer diagnosis={diagnosis} animate={animate} />
2160
- {assessmentSources ? (
2161
- <AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
2162
- ) : null}
2163
- {assessmentAction && (
2164
- <div className="mt-2 flex justify-end">{assessmentAction}</div>
2165
- )}
2166
- </>
2167
- );
2168
-
2169
- return (
2170
- <DiagnosisResult
2171
- diagnosis={diagnosis}
2172
- onApply={readOnlyAssessment ? undefined : onApply}
2173
- explanation={explanation}
2174
- section={section}
2175
- animate={animate}
2176
- showDisclaimer={showDisclaimer}
2177
- compactActions={compactActions}
2178
- assessmentAction={assessmentAction}
2179
- assessmentSources={assessmentSources}
2180
- actionNotice={actionNotice}
2181
- storyInline={storyInline}
2182
- readOnlyAssessment={readOnlyAssessment}
2183
- revisedAfter={revisedAfter}
2184
- assessmentLimits={assessmentLimits}
2185
- assessmentCopy={assessmentCopy}
2186
- />
2187
- );
2188
- }
2189
-
2190
- /**
2191
- * The whole assessment as text: headline, certainty, what is unresolved, the
2192
- * technical cause, the story without its markers, and the steps. Copying only
2193
- * the headline would ship the persuasive half without its caveats.
2194
- */
2195
- /** What Radar adds to the agent's verdict when the assessment is copied for a channel. */
2196
- interface AssessmentCopyRadar {
2197
- /** Reads Radar could not complete, as shown in Still open. */
2198
- limits?: readonly string[];
2199
- /** Adverse Radar cards the agent explained, as shown in Still open. */
2200
- signals?: readonly string[];
2201
- /** Adverse Radar cards nothing explains, as shown above a healthy verdict. */
2202
- flags?: readonly string[];
2203
- /** Where and when: the header line a reader in a channel needs first. */
2204
- context?: {
2205
- target: string;
2206
- cluster?: string;
2207
- startedAt?: string;
2208
- agent?: string;
2209
- };
2210
- /** The placed cards, as short excerpts: the receipts behind the headline. */
2211
- receipts?: readonly {
2212
- title: string;
2213
- role?: string;
2214
- lines: readonly string[];
2215
- /** The agent's statement of what this result does not cover. */
2216
- gap?: string;
2217
- }[];
2218
- }
2219
-
2220
- /**
2221
- * The assessment as Markdown for a channel or a ticket: context, headline,
2222
- * cause, the receipts, what is still open, the next step. The full story and
2223
- * every captured result stay in Radar; the text says so.
2224
- */
2225
- export function assessmentCopyText(
2226
- diagnosis: Diagnosis,
2227
- radar: AssessmentCopyRadar = {},
2228
- ): string {
2229
- const parts: string[] = [];
2230
- if (radar.context) {
2231
- const when = radar.context.startedAt
2232
- ? new Date(radar.context.startedAt).toLocaleString(undefined, {
2233
- dateStyle: "medium",
2234
- timeStyle: "short",
2235
- })
2236
- : undefined;
2237
- parts.push(
2238
- [
2239
- `**${radar.context.target}**`,
2240
- radar.context.cluster,
2241
- when,
2242
- radar.context.agent ? `via ${radar.context.agent}` : undefined,
2243
- ]
2244
- .filter(Boolean)
2245
- .join(" · "),
2246
- );
2247
- }
2248
- if (diagnosis.summary) {
2249
- const certainty = diagnosis.healthy
2250
- ? "Healthy"
2251
- : diagnosis.certainty
2252
- ? CERTAINTY_LABEL[diagnosis.certainty]
2253
- : undefined;
2254
- parts.push(
2255
- `**${diagnosis.summary.trim()}**${certainty ? ` — ${certainty} (agent's word)` : ""}`,
2256
- );
2257
- } else if (diagnosis.certainty) {
2258
- parts.push(`Certainty (agent): ${CERTAINTY_LABEL[diagnosis.certainty]}`);
2259
- }
2260
- // The pasted text must not read more confident than the screen: Radar's
2261
- // own qualifications travel with the agent's.
2262
- const flags = (radar.flags ?? []).filter((item) => item.trim());
2263
- if (flags.length > 0)
2264
- parts.push(
2265
- [
2266
- "Radar flagged:",
2267
- ...flags.map((item) => `- ${item}`),
2268
- "Read those cards before treating this as an all-clear.",
2269
- ].join("\n"),
2270
- );
2271
- const unresolved = [
2272
- ...(diagnosis.unresolved ?? []),
2273
- ...(radar.limits ?? []),
2274
- ...(radar.signals ?? []),
2275
- ].filter((item) => item.trim());
2276
- if (unresolved.length > 0)
2277
- parts.push(
2278
- ["Still open:", ...unresolved.map((item) => `- ${item}`)].join("\n"),
2279
- );
2280
- if (diagnosis.rootCause) parts.push(storyPlainText(diagnosis.rootCause));
2281
- const receipts = (radar.receipts ?? []).filter((receipt) => receipt.title);
2282
- if (receipts.length > 0) {
2283
- parts.push(
2284
- [
2285
- "Evidence (Radar):",
2286
- ...receipts.map((receipt) =>
2287
- [
2288
- `- **${receipt.title}**${receipt.role ? ` · ${receipt.role}` : ""}`,
2289
- ...receipt.lines
2290
- .filter((line) => line.trim())
2291
- .map((line) => ` > ${line.trim()}`),
2292
- ...(receipt.gap ? [` Not shown: ${receipt.gap}`] : []),
2293
- ].join("\n"),
2294
- ),
2295
- ].join("\n"),
2296
- );
2297
- } else if (!radar.context) {
2298
- const story = storyPlainText(diagnosis.report ?? "");
2299
- if (story) parts.push(story);
2300
- }
2301
- const steps = diagnosis.steps?.length
2302
- ? diagnosis.steps.map(
2303
- (step, index) =>
2304
- `${index + 1}. [${STEP_KIND_LABEL[step.kind]}] ${step.text}${step.precondition ? ` (only if ${step.precondition})` : ""}`,
2305
- )
2306
- : (diagnosis.remediation ?? []).map(
2307
- (text, index) => `${index + 1}. ${text}`,
2308
- );
2309
- if (steps.length > 0) {
2310
- if (radar.context) {
2311
- // A channel gets the recommended step; the rest wait in Radar.
2312
- const lead = diagnosis.recommendedIndex
2313
- ? diagnosis.recommendedIndex - 1
2314
- : 0;
2315
- const first = steps[lead] ?? steps[0];
2316
- const rest = steps.length - 1;
2317
- parts.push(
2318
- [
2319
- `Next step: ${first.replace(/^\d+\. /, "")}`,
2320
- diagnosis.recommendedReason
2321
- ? `Why: ${diagnosis.recommendedReason}`
2322
- : undefined,
2323
- rest > 0
2324
- ? `${rest} more ${rest === 1 ? "step" : "steps"} in Radar.`
2325
- : undefined,
2326
- ]
2327
- .filter(Boolean)
2328
- .join("\n"),
2329
- );
2330
- } else parts.push(["Next steps:", ...steps].join("\n"));
2331
- }
2332
- if (radar.context)
2333
- parts.push(
2334
- "Full analysis and every captured result: open the investigation in Radar.",
2335
- );
2336
- return parts.join("\n\n");
2337
- }
2338
-
2339
- const CERTAINTY_LABEL: Record<NonNullable<Diagnosis["certainty"]>, string> = {
2340
- established: "Established",
2341
- likely: "Likely",
2342
- suspected: "Suspected",
2343
- };
2344
-
2345
- // The word is the agent's; the clause says what kind of Radar evidence sits
2346
- // behind it, so a reader knows what the word licenses them to do.
2347
- const CERTAINTY_DEFINITION: Record<
2348
- NonNullable<Diagnosis["certainty"]>,
2349
- string
2350
- > = {
2351
- established:
2352
- "The agent's word. It found the cause stated outright in Radar's results — a log line, an event, a condition — not just consistent with them.",
2353
- likely:
2354
- "The agent's word. Radar's results fit this explanation, but none of them states the cause outright.",
2355
- suspected:
2356
- "The agent's word. A plausible reading on thin or indirect evidence; treat it as a lead, not a finding.",
2357
- };
2358
- const HEALTHY_DEFINITION: Record<
2359
- NonNullable<Diagnosis["certainty"]>,
2360
- string
2361
- > = {
2362
- established:
2363
- "The agent found no live problem, and Radar's results show that directly — the pod state, logs and events it read.",
2364
- likely:
2365
- "The agent found no live problem; Radar's results fit that reading but do not show it outright.",
2366
- suspected:
2367
- "The agent leans healthy on thin or indirect evidence; treat it as a lead, not a finding.",
2368
- };
2369
-
2370
- const STEP_KIND_LABEL: Record<
2371
- NonNullable<Diagnosis["steps"]>[number]["kind"],
2372
- string
2373
- > = {
2374
- mitigate: "Mitigate",
2375
- verify: "Verify",
2376
- investigate: "Investigate",
2377
- };
2378
-
2379
- /**
2380
- * The headline of an assessment that carries the story contract: the
2381
- * agent's plain-language summary with its own word for how sure it is, the
2382
- * technical cause beneath it, and what is still unresolved — which renders
2383
- * whenever the agent listed anything, and says so when it listed nothing,
2384
- * because a persuasive story needs its caveats above the fold, not inside it.
2385
- */
2386
- function AssessmentHeadline({
2387
- diagnosis,
2388
- tone,
2389
- revisedAfter,
2390
- limits = [],
2391
- signals,
2392
- flags,
2393
- copy,
2394
- }: {
2395
- diagnosis: Diagnosis;
2396
- tone: "cause" | "healthy" | "inconclusive";
2397
- revisedAfter?: string;
2398
- /** Reads Radar could not complete for this assessment, one line each. */
2399
- limits?: string[];
2400
- /** Adverse Radar cards the agent explained, with its position; first in Still open. */
2401
- signals?: { text: string; onReveal?: () => void }[];
2402
- /** Adverse Radar cards nothing explains; shown above a healthy verdict, copied with it. */
2403
- flags?: string[];
2404
- /** Context and receipts for the channel copy. */
2405
- copy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
2406
- }) {
2407
- const summary = diagnosis.summary?.trim();
2408
- if (!summary) return null;
2409
- // One block for everything that qualifies the answer: what the agent left
2410
- // open and what Radar could not read. The rest of the page states the
2411
- // answer; this is the only place it argues with itself.
2412
- const unresolved = [
2413
- ...(diagnosis.unresolved ?? []).filter((item) => item.trim()),
2414
- ...limits,
2415
- ];
2416
- const stillOpen = (signals ?? []).length > 0 || unresolved.length > 0;
2417
- return (
2418
- <div data-assessment-headline className="space-y-2">
2419
- <div className="flex items-start justify-between gap-2">
2420
- <p className="text-[15px] font-semibold leading-snug text-theme-text-primary [overflow-wrap:anywhere] [text-wrap:balance]">
2421
- {summary}
2422
- </p>
2423
- <CopyButton
2424
- text={assessmentCopyText(diagnosis, {
2425
- limits,
2426
- signals: (signals ?? []).map((signal) => signal.text),
2427
- flags,
2428
- ...copy,
2429
- })}
2430
- label="Copy for a channel"
2431
- />
2432
- </div>
2433
- <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-theme-text-secondary">
2434
- {diagnosis.certainty ? (
2435
- <Tooltip
2436
- content={
2437
- tone === "healthy"
2438
- ? HEALTHY_DEFINITION[diagnosis.certainty]
2439
- : CERTAINTY_DEFINITION[diagnosis.certainty]
2440
- }
2441
- >
2442
- <Badge tone="agent" size="sm">
2443
- <Sparkles className="h-2.5 w-2.5 shrink-0" aria-hidden />
2444
- {tone === "healthy"
2445
- ? "Healthy"
2446
- : CERTAINTY_LABEL[diagnosis.certainty]}
2447
- </Badge>
2448
- </Tooltip>
2449
- ) : null}
2450
- {revisedAfter ? (
2451
- <span className="min-w-0 text-theme-text-tertiary">
2452
- Revised after: &ldquo;
2453
- {revisedAfter.length > 110
2454
- ? `${revisedAfter.slice(0, 110).trimEnd()}…`
2455
- : revisedAfter}
2456
- &rdquo;
2457
- </span>
2458
- ) : null}
2459
- </div>
2460
- {tone === "cause" && diagnosis.rootCause ? (
2461
- <AIMarkdown className="text-[13px] leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-secondary">
2462
- {diagnosis.rootCause}
2463
- </AIMarkdown>
2464
- ) : null}
2465
- {stillOpen ? (
2466
- <div
2467
- data-assessment-unresolved
2468
- className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2"
2469
- >
2470
- <div className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-theme-text-secondary">
2471
- <HelpCircle className="h-3 w-3" aria-hidden />
2472
- {tone === "inconclusive"
2473
- ? "What blocked a conclusion"
2474
- : "Still open"}
2475
- </div>
2476
- <ul className="space-y-0.5 text-xs text-theme-text-primary">
2477
- {unresolved.map((item, index) => (
2478
- <li key={`open-${index}`} className="flex gap-1.5">
2479
- <span aria-hidden className="text-theme-text-tertiary">
2480
- –
2481
- </span>
2482
- <span className="[overflow-wrap:anywhere]">{item}</span>
2483
- </li>
2484
- ))}
2485
- {(signals ?? []).map((signal, index) => (
2486
- <li
2487
- key={`signal-${index}`}
2488
- className="flex gap-1.5"
2489
- data-health-signal
2490
- >
2491
- <span aria-hidden className="text-theme-text-tertiary">
2492
- –
2493
- </span>
2494
- {signal.onReveal ? (
2495
- <button
2496
- type="button"
2497
- onClick={signal.onReveal}
2498
- className="text-left [overflow-wrap:anywhere] hover:text-accent-text"
2499
- >
2500
- {signal.text}
2501
- </button>
2502
- ) : (
2503
- <span className="[overflow-wrap:anywhere]">
2504
- {signal.text}
2505
- </span>
2506
- )}
2507
- </li>
2508
- ))}
2509
- </ul>
2510
- </div>
2511
- ) : null}
2512
- </div>
2513
- );
2514
- }
2515
-
2516
- /**
2517
- * Sources the assessment cites: root-cause links first, then every source an
2518
- * agent item cites. Each row shows the roles the agent gave that source; a
2519
- * claim the pane could not pin to one observation is shown here in full.
2520
- */
2521
- export function assessmentSourceRows(
2522
- resolution: InvestigationRootCauseEvidenceResolution | undefined,
2523
- investigationCase: InvestigationCaseResolution | undefined,
2524
- ): Array<{
2525
- source: InvestigationEvidenceSource;
2526
- items: InvestigationCaseItem[];
2527
- }> {
2528
- const rows = new Map<
2529
- string,
2530
- { source: InvestigationEvidenceSource; items: InvestigationCaseItem[] }
2531
- >();
2532
- if (resolution?.status === "linked") {
2533
- for (const link of resolution.links) {
2534
- rows.set(link.source.id, { source: link.source, items: [] });
2535
- }
2536
- }
2537
- for (const item of investigationCase?.items ?? []) {
2538
- const row = rows.get(item.source.id) ?? { source: item.source, items: [] };
2539
- row.items.push(item);
2540
- rows.set(item.source.id, row);
2541
- }
2542
- return [...rows.values()];
2543
- }
2544
-
2545
- function joinTitles(titles: string[]): string {
2546
- if (titles.length <= 1) return titles[0] ?? "";
2547
- if (titles.length === 2) return `${titles[0]} and ${titles[1]}`;
2548
- return `${titles.slice(0, -1).join(", ")}, and ${titles[titles.length - 1]}`;
2549
- }
2550
-
2551
- /**
2552
- * Provenance for one assessment: the exact tool results it cited, each with
2553
- * its source, and under a source only the agent notes that are not already
2554
- * shown on a card. Notes that live on cards are counted, not repeated; an
2555
- * earlier assessment no longer annotates the Evidence pane, so all of its
2556
- * notes are listed here instead of being lost.
2557
- */
2558
- export function AssessmentSources({
2559
- resolution,
2560
- investigationCase,
2561
- unlinkedEvidence = 0,
2562
- evidenceMalformed = false,
2563
- readOnly = false,
2564
- renderedGroupIds,
2565
- onViewSource,
2566
- }: {
2567
- resolution?: InvestigationRootCauseEvidenceResolution;
2568
- investigationCase?: InvestigationCaseResolution;
2569
- /**
2570
- * Notes the agent wrote that could not be tied to a Radar result: a
2571
- * reference that named nothing, a role Radar does not know, a sentence over
2572
- * the length limit. Radar does not repair them, because repairing one means
2573
- * deciding what the agent meant, so it says how many were lost instead.
2574
- */
2575
- unlinkedEvidence?: number;
2576
- /**
2577
- * The agent's notes were not a list at all, so none of them could be read
2578
- * and no count describes how many were lost.
2579
- */
2580
- evidenceMalformed?: boolean;
2581
- readOnly?: boolean;
2582
- /**
2583
- * Groups the Evidence pane actually rendered. A card-placed note whose card
2584
- * was withheld is shown here instead of being counted as visible elsewhere;
2585
- * without this the note renders in neither place. Omitted by hosts that do
2586
- * not know, which keeps the original counting.
2587
- */
2588
- renderedGroupIds?: ReadonlySet<string>;
2589
- onViewSource: (sourceId: string) => void;
2590
- }) {
2591
- const rows = assessmentSourceRows(resolution, investigationCase);
2592
- if (rows.length === 0 && unlinkedEvidence === 0 && !evidenceMalformed)
2593
- return null;
2594
- return (
2595
- <div className="mt-3 border-t border-theme-border/60 pt-2">
2596
- <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2597
- Sources used for this assessment
2598
- <span className="ml-1.5 font-medium normal-case tracking-normal text-theme-text-tertiary">
2599
- {rows.length}
2600
- </span>
2601
- </h4>
2602
- <ul className="mt-1 divide-y divide-theme-border/50">
2603
- {rows.map(({ source, items }) => {
2604
- const shownOnCard = (item: InvestigationCaseItem) =>
2605
- !!item.claim &&
2606
- item.placement !== "source" &&
2607
- (!renderedGroupIds ||
2608
- (!!item.groupId && renderedGroupIds.has(item.groupId)));
2609
- const onCards = items.filter(shownOnCard).length;
2610
- const notes = items.filter(
2611
- (item) => item.claim && (readOnly || !shownOnCard(item)),
2612
- );
2613
- return (
2614
- <li key={source.id} className="py-1.5">
2615
- <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2 px-2">
2616
- <FileSearch
2617
- className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
2618
- aria-hidden
2619
- />
2620
- <div className="min-w-0 text-xs">
2621
- <div className="font-medium text-theme-text-primary">
2622
- {prettyTool(source.tool)}
2623
- </div>
2624
- <CitedSourceScope source={source} />
2625
- {!readOnly && onCards > 0 ? (
2626
- <div className="mt-0.5 text-[11px] text-theme-text-tertiary">
2627
- {onCards === 1
2628
- ? "1 agent note on an evidence card"
2629
- : `${onCards} agent notes on evidence cards`}
2630
- </div>
2631
- ) : null}
2632
- </div>
2633
- <button
2634
- type="button"
2635
- aria-label={`View ${prettyTool(source.tool)} result used for this assessment`}
2636
- onClick={() => onViewSource(source.id)}
2637
- className="inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
2638
- >
2639
- View result
2640
- </button>
2641
- </div>
2642
- {notes.length > 0 ? (
2643
- <div
2644
- className="ml-[1.9rem] mr-2 mt-1.5 space-y-1.5"
2645
- data-source-placed-claims
2646
- >
2647
- <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2648
- {readOnly
2649
- ? "Notes from this assessment"
2650
- : "Notes not shown on a card"}
2651
- </div>
2652
- {notes.map((item) => (
2653
- <AgentClaimNote
2654
- key={item.index}
2655
- claim={item.claim}
2656
- role={item.role}
2657
- subject={
2658
- item.placement === "source"
2659
- ? undefined
2660
- : item.observation?.title
2661
- }
2662
- className="border-t-0"
2663
- />
2664
- ))}
2665
- </div>
2666
- ) : null}
2667
- </li>
2668
- );
2669
- })}
2670
- </ul>
2671
- {evidenceMalformed ? (
2672
- <p className="mt-2 text-[11px] text-theme-text-tertiary">
2673
- The agent&apos;s notes could not be read, so none are shown.
2674
- </p>
2675
- ) : null}
2676
- {unlinkedEvidence > 0 ? (
2677
- <p className="mt-2 text-[11px] text-theme-text-tertiary">
2678
- {unlinkedEvidence === 1
2679
- ? "1 agent note could not be linked to a Radar result and is not shown."
2680
- : `${unlinkedEvidence} agent notes could not be linked to Radar results and are not shown.`}
2681
- </p>
2682
- ) : null}
2683
- </div>
2684
- );
2685
- }
2686
-
2687
- function WorkingNotes({ notes }: { notes: string }) {
2688
- const [open, setOpen] = useState(false);
2689
- const id = useId();
2690
- const reveal = useDisclosureReveal<HTMLDivElement>();
2691
- return (
2692
- <div ref={reveal.elementRef} data-turn-working-notes>
2693
- <button
2694
- type="button"
2695
- aria-expanded={open}
2696
- aria-controls={id}
2697
- onClick={() => {
2698
- setOpen(!open);
2699
- reveal.revealAfterToggle(!open);
2700
- }}
2701
- className="flex items-center gap-1.5 rounded-md py-1 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
2702
- >
2703
- <CollapseChevron open={open} className="h-3.5 w-3.5" />
2704
- Evidence considered
2705
- </button>
2706
- <div id={id}>
2707
- <Collapse open={open}>
2708
- <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">
2709
- {notes}
2710
- </AIMarkdown>
2711
- </Collapse>
2712
- </div>
2713
- </div>
2714
- );
2715
- }
2716
-
2717
- function AssessmentSourceDetails({ children }: { children: ReactNode }) {
2718
- const [open, setOpen] = useState(false);
2719
- const id = useId();
2720
- const reveal = useDisclosureReveal<HTMLDivElement>();
2721
- return (
2722
- <div ref={reveal.elementRef}>
2723
- <button
2724
- type="button"
2725
- aria-expanded={open}
2726
- aria-controls={id}
2727
- onClick={() => {
2728
- setOpen(!open);
2729
- reveal.revealAfterToggle(!open);
2730
- }}
2731
- className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
2732
- >
2733
- <CollapseChevron open={open} className="h-3.5 w-3.5" />
2734
- Assessment details
2735
- </button>
2736
- <div id={id}>
2737
- <Collapse open={open}>{children}</Collapse>
2738
- </div>
2739
- </div>
2740
- );
2741
- }
2742
-
2743
- function CitedSourceScope({ source }: { source: InvestigationEvidenceSource }) {
2744
- let input: unknown;
2745
- try {
2746
- input = JSON.parse(source.args ?? "");
2747
- } catch {
2748
- return null;
2749
- }
2750
- if (!input || typeof input !== "object" || Array.isArray(input)) return null;
2751
- const args = input as Record<string, unknown>;
2752
- const fields = ["query", "kind", "namespace", "name", "filter"].flatMap(
2753
- (key) =>
2754
- typeof args[key] === "string" && args[key].trim()
2755
- ? [`${key === "query" ? "" : `${key}: `}${args[key]}`]
2756
- : [],
2757
- );
2758
- return fields.length > 0 ? (
2759
- <span className="mt-0.5 block break-words text-theme-text-secondary [overflow-wrap:anywhere]">
2760
- {fields.join(" · ")}
2761
- </span>
2762
- ) : null;
2763
- }
2764
-
2765
- export type AssessmentExplanation = {
2766
- status: "idle" | "running" | "done" | "error";
2767
- text?: string;
2768
- error?: string;
2769
- onGenerate?: () => void;
2770
- openRequest?: number;
2771
- };
2772
-
2773
- // The diagnosis result: likely cause + remediation + the
2774
- // agent's full analysis on demand.
2775
- function DiagnosisResult({
2776
- diagnosis,
2777
- onApply,
2778
- explanation,
2779
- section = "full",
2780
- animate,
2781
- showDisclaimer,
2782
- compactActions,
2783
- assessmentAction,
2784
- assessmentSources,
2785
- actionNotice,
2786
- storyInline = false,
2787
- readOnlyAssessment = false,
2788
- revisedAfter,
2789
- assessmentLimits,
2790
- assessmentCopy,
2791
- }: {
2792
- diagnosis: Diagnosis;
2793
- onApply?: (fix: string) => void;
2794
- explanation?: AssessmentExplanation;
2795
- section?: "full" | "conclusion" | "actions";
2796
- animate: boolean;
2797
- showDisclaimer: boolean;
2798
- compactActions: boolean;
2799
- assessmentAction?: ReactNode;
2800
- assessmentSources?: ReactNode;
2801
- actionNotice?: string;
2802
- storyInline?: boolean;
2803
- readOnlyAssessment?: boolean;
2804
- revisedAfter?: string;
2805
- /** Reads Radar could not complete for this assessment; listed under Still open. */
2806
- assessmentLimits?: string[];
2807
- assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
2808
- }) {
2809
- // The story contract: a summary headline above, the story rendered by the
2810
- // host (or inline as plain prose), typed steps below.
2811
- const storyShape = diagnosisHasStoryShape(diagnosis);
2812
- const analysisText =
2813
- storyShape && storyInline
2814
- ? storyPlainText(diagnosis.report)
2815
- : diagnosis.report;
2816
- const showAnalysisDisclosure = !storyShape || storyInline;
2817
- const [detail, setDetail] = useState<"analysis" | "explanation" | null>(
2818
- explanation?.status === "running" ? "explanation" : null,
2819
- );
2820
- const showAnalysis = detail === "analysis";
2821
- const analysisReveal = useDisclosureReveal<HTMLDivElement>();
2822
- const { elementRef: analysisElementRef, revealAfterToggle: revealAnalysis } =
2823
- analysisReveal;
2824
- useEffect(() => {
2825
- if (explanation?.openRequest) {
2826
- setDetail("explanation");
2827
- revealAnalysis(true);
2828
- }
2829
- }, [explanation?.openRequest, revealAnalysis]);
2830
- useEffect(() => {
2831
- if (
2832
- detail === "explanation" &&
2833
- (explanation?.status === "done" || explanation?.status === "error")
2834
- ) {
2835
- const element = analysisElementRef.current;
2836
- const scroller = element?.closest("[data-investigation-findings-scroll]");
2837
- if (element && scroller) {
2838
- const top = element.getBoundingClientRect().top;
2839
- const viewport = scroller.getBoundingClientRect();
2840
- if (top >= viewport.top && top < viewport.bottom) revealAnalysis(true);
2841
- }
2842
- }
2843
- }, [explanation?.status, detail, analysisElementRef, revealAnalysis]);
2844
- const [showAllSteps, setShowAllSteps] = useState(false);
2845
- const stepsId = useId();
2846
- const stepsReveal = useDisclosureReveal<HTMLDivElement>();
2847
- const analysisId = useId();
2848
- // Only a real structured cause anchors the amber card; the full prose lives in
2849
- // "Full analysis" (never relabel the report as a causal assessment).
2850
- const rootCause = diagnosis.rootCause;
2851
- const remediation = diagnosis.remediation || [];
2852
- const steps = diagnosis.steps ?? [];
2853
- const typedSteps = steps.length === remediation.length && steps.length > 0;
2854
- const hasRemediation = remediation.length > 0;
2855
- const recIdx = diagnosis.recommendedIndex;
2856
- const recValid =
2857
- recIdx != null &&
2858
- recIdx >= 1 &&
2859
- recIdx <= remediation.length &&
2860
- (!typedSteps || steps[recIdx - 1].kind === "mitigate");
2861
- // Apply is offered ONLY when the agent pointed at a safe step (recommended_index).
2862
- // When it returns 0 / none ("needs human judgement"), we honor that and don't
2863
- // offer one-click apply — the steps stay copy-only with a note.
2864
- const canApply = !!onApply && recValid;
2865
- const showConclusion = section !== "actions";
2866
- const showActions = section !== "conclusion";
2867
- const remediationEntries = remediation.map((text, index) => ({
2868
- text,
2869
- index,
2870
- }));
2871
- const primaryActionIndex = recValid ? recIdx! - 1 : 0;
2872
- const hiddenStepCount = remediationEntries.length - 1;
2873
- const renderRemediationStep = ({
2874
- text: r,
2875
- index: i,
2876
- }: {
2877
- text: string;
2878
- index: number;
2879
- }) => {
2880
- const isRec = recValid && i === recIdx! - 1;
2881
- const step = typedSteps ? steps[i] : undefined;
2882
- return (
2883
- <div
2884
- key={i}
2885
- data-step-kind={step?.kind}
2886
- className={
2887
- isRec ? "rounded-lg border border-accent/40 bg-accent/5 p-2.5" : ""
2888
- }
2889
- >
2890
- <div className="flex items-start gap-2">
2891
- <span
2892
- className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] ${
2893
- isRec
2894
- ? "bg-accent/20 text-accent"
2895
- : "bg-theme-base text-theme-text-tertiary"
2896
- }`}
2897
- >
2898
- {step ? (
2899
- step.kind === "mitigate" ? (
2900
- <Wrench className="h-2.5 w-2.5" aria-hidden />
2901
- ) : step.kind === "verify" ? (
2902
- <ListChecks className="h-2.5 w-2.5" aria-hidden />
2903
- ) : (
2904
- <Search className="h-2.5 w-2.5" aria-hidden />
2905
- )
2906
- ) : (
2907
- i + 1
2908
- )}
2909
- </span>
2910
- <div className="min-w-0 flex-1">
2911
- {/* Labels, then the action cluster pushed to the row's end, then
2912
- the reason on its own line — so the step text below spans the
2913
- card instead of wrapping beside the buttons. */}
2914
- <div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
2915
- {(isRec || step) && (
2916
- <>
2917
- {step ? (
2918
- <span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2919
- {STEP_KIND_LABEL[step.kind]}
2920
- </span>
2921
- ) : null}
2922
- {isRec && (
2923
- <span className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-accent">
2924
- <Sparkles className="h-3 w-3" />
2925
- Recommended
2926
- </span>
2927
- )}
2928
- </>
2929
- )}
2930
- <div className="ml-auto flex shrink-0 items-center gap-0.5">
2931
- {canApply && isRec && (
2932
- <button
2933
- onClick={() => onApply!(r)}
2934
- className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/10 px-2 py-1 text-xs font-medium text-accent transition-colors hover:bg-accent/20"
2935
- >
2936
- <Wrench className="h-3 w-3" />
2937
- Apply…
2938
- </button>
2939
- )}
2940
- {remediationCommands(r).map((command, c, all) => (
2941
- <CopyButton
2942
- key={c}
2943
- text={command}
2944
- label={
2945
- all.length > 1
2946
- ? `Copy command ${c + 1} of step ${i + 1}`
2947
- : `Copy command from step ${i + 1}`
2948
- }
2949
- />
2950
- ))}
2951
- </div>
2952
- </div>
2953
- {/* The condition and the reason are read before the command is
2954
- copied, so they precede it at a size that is meant to be read. */}
2955
- {step?.precondition ? (
2956
- <p
2957
- data-step-precondition
2958
- className="mb-1.5 text-[13px] leading-snug text-warning-text"
2959
- >
2960
- Only if {step.precondition.replace(/^(if|when|once)\s+/i, "")}
2961
- </p>
2962
- ) : null}
2963
- {isRec && diagnosis.recommendedReason && (
2964
- <p
2965
- data-recommended-reason
2966
- className="mb-1.5 text-[13px] leading-snug text-theme-text-secondary"
2967
- >
2968
- {diagnosis.recommendedReason}
2969
- </p>
2970
- )}
2971
- <AIMarkdown className="max-w-[100ch] text-sm [overflow-wrap:anywhere] [&_p]:my-0 [&_pre]:my-1.5">
2972
- {r}
2973
- </AIMarkdown>
2974
- </div>
2975
- </div>
2976
- </div>
2977
- );
2978
- };
2979
- return (
2980
- <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
2981
- {showConclusion && storyShape && (
2982
- <div
2983
- className={
2984
- section === "conclusion"
2985
- ? ""
2986
- : "rounded-lg border border-theme-border bg-theme-surface p-3"
2987
- }
2988
- >
2989
- {readOnlyAssessment ? (
2990
- <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2991
- Earlier assessment
2992
- </div>
2993
- ) : null}
2994
- <AssessmentHeadline
2995
- diagnosis={diagnosis}
2996
- tone="cause"
2997
- revisedAfter={revisedAfter}
2998
- limits={assessmentLimits}
2999
- copy={assessmentCopy}
3000
- />
3001
- </div>
3002
- )}
3003
- {/* Likely cause — agent-authored, visually prominent without claiming proof. */}
3004
- {showConclusion && !storyShape && rootCause && (
3005
- <div
3006
- className={
3007
- section === "conclusion"
3008
- ? "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2"
3009
- : "rounded-lg border border-theme-border bg-theme-surface p-3"
3010
- }
3011
- >
3012
- <div
3013
- className={
3014
- section === "conclusion"
3015
- ? "col-start-2 row-start-1"
3016
- : "mb-1 flex items-center justify-between gap-2"
3017
- }
3018
- >
3019
- {section !== "conclusion" && (
3020
- <div className="text-xs font-medium text-theme-text-secondary">
3021
- Likely cause
3022
- </div>
3023
- )}
3024
- <div className="flex items-center gap-2">
3025
- <CopyButton text={rootCause} label="Copy likely cause" />
3026
- </div>
3027
- </div>
3028
- <AIMarkdown
3029
- className={`${section === "conclusion" ? "col-start-1 row-start-1 max-w-[100ch]" : "max-w-prose"} text-sm leading-relaxed text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-primary`}
3030
- >
3031
- {rootCause}
3032
- </AIMarkdown>
3033
- </div>
3034
- )}
3035
-
3036
- {/* Remediation — every step is copyable. Only the explicitly recommended
3037
- step can be applied, and only when the caller enables apply. */}
3038
- {showActions && hasRemediation && (
3039
- <div
3040
- ref={stepsReveal.elementRef}
3041
- className={
3042
- section === "actions"
3043
- ? "space-y-2"
3044
- : "rounded-lg border border-theme-border bg-theme-surface p-3"
3045
- }
3046
- >
3047
- {section !== "actions" && (
3048
- <div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary">
3049
- <Wrench className="h-3.5 w-3.5 text-accent" />
3050
- {typedSteps ? "Next steps" : "Remediation"}
3051
- </div>
3052
- )}
3053
- {actionNotice && (
3054
- <p className="text-xs text-theme-text-secondary">{actionNotice}</p>
3055
- )}
3056
- <div id={stepsId}>
3057
- <ol>
3058
- {remediationEntries.map((entry) => {
3059
- const expanded =
3060
- !compactActions ||
3061
- showAllSteps ||
3062
- entry.index === primaryActionIndex;
3063
- const step = typedSteps ? steps[entry.index] : undefined;
3064
- return (
3065
- <li key={entry.index} value={entry.index + 1}>
3066
- <Collapse open={expanded} mountLazily>
3067
- <div className="pt-2">{renderRemediationStep(entry)}</div>
3068
- </Collapse>
3069
- {/* A folded step still shows what it is: its kind and
3070
- first line, one row each, so the alternatives are
3071
- readable before anyone opens them. */}
3072
- {!expanded ? (
3073
- <button
3074
- type="button"
3075
- data-step-folded={step?.kind ?? "step"}
3076
- onClick={() => {
3077
- setShowAllSteps(true);
3078
- stepsReveal.revealAfterToggle(true);
3079
- }}
3080
- className="mt-1.5 flex w-full min-w-0 items-baseline gap-2 rounded-md px-2 py-1 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
3081
- >
3082
- {step ? (
3083
- <span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
3084
- {STEP_KIND_LABEL[step.kind]}
3085
- </span>
3086
- ) : (
3087
- <span className="shrink-0 text-[10px] font-semibold text-theme-text-tertiary">
3088
- {entry.index + 1}
3089
- </span>
3090
- )}
3091
- <span className="min-w-0 flex-1 truncate">
3092
- {remediationHeadline(entry.text)}
3093
- </span>
3094
- </button>
3095
- ) : null}
3096
- </li>
3097
- );
3098
- })}
3099
- </ol>
3100
- </div>
3101
- {compactActions && remediation.length > 1 ? (
3102
- <button
3103
- type="button"
3104
- aria-expanded={showAllSteps}
3105
- aria-controls={stepsId}
3106
- onClick={() => {
3107
- setShowAllSteps(!showAllSteps);
3108
- stepsReveal.revealAfterToggle(!showAllSteps);
3109
- }}
3110
- className="mt-2 inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
3111
- >
3112
- <CollapseChevron open={showAllSteps} className="h-3.5 w-3.5" />
3113
- {showAllSteps
3114
- ? recValid
3115
- ? "Show only recommended step"
3116
- : "Show only first step"
3117
- : hiddenStepCount === 1
3118
- ? "Expand the other step"
3119
- : "Expand all steps"}
3120
- </button>
3121
- ) : null}
3122
- {!actionNotice && !recValid && (
3123
- <p className="mt-2 flex items-start gap-1.5 text-[11px] leading-snug text-theme-text-tertiary">
3124
- <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
3125
- No one-click fix is available. Review these steps and apply them
3126
- manually, or ask the agent to continue.
3127
- </p>
3128
- )}
3129
- </div>
3130
- )}
3131
-
3132
- {/* Full analysis — the agent's detailed evidence, on demand. Under the
3133
- story contract Findings renders the story itself, so only the
3134
- explanation and sources remain here. */}
3135
- {showConclusion &&
3136
- ((showAnalysisDisclosure && diagnosis.report) ||
3137
- (showAnalysisDisclosure && diagnosis.confidence != null) ||
3138
- explanation ||
3139
- assessmentAction ||
3140
- assessmentSources) && (
3141
- <div>
3142
- <div
3143
- className="flex flex-wrap items-center gap-x-3 gap-y-1 pt-2"
3144
- data-assessment-actions
3145
- >
3146
- {((showAnalysisDisclosure && diagnosis.report) ||
3147
- (showAnalysisDisclosure && diagnosis.confidence != null) ||
3148
- assessmentSources) && (
3149
- <button
3150
- type="button"
3151
- aria-expanded={showAnalysis}
3152
- aria-controls={`${analysisId}-analysis`}
3153
- onClick={() => {
3154
- setDetail(showAnalysis ? null : "analysis");
3155
- analysisReveal.revealAfterToggle(!showAnalysis);
3156
- }}
3157
- className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
3158
- >
3159
- <CollapseChevron
3160
- open={showAnalysis}
3161
- className="h-3.5 w-3.5"
3162
- />
3163
- {showAnalysisDisclosure && diagnosis.report
3164
- ? "Full analysis"
3165
- : "Assessment details"}
3166
- </button>
3167
- )}
3168
- {explanation && (
3169
- <Tooltip
3170
- content={
3171
- explanation.status === "idle"
3172
- ? explanation.onGenerate
3173
- ? "Ask the agent to explain this assessment and its proposed next steps in plain language."
3174
- : "Wait for the current agent request to finish before requesting an explanation."
3175
- : explanation.status === "running"
3176
- ? "The agent is preparing an explanation. You can close this and return while it runs."
3177
- : explanation.status === "error"
3178
- ? "View the explanation error and retry when the agent is available."
3179
- : "Show the saved plain-language explanation. No new request is needed."
3180
- }
3181
- >
3182
- <button
3183
- type="button"
3184
- aria-expanded={detail === "explanation"}
3185
- aria-controls={`${analysisId}-explanation`}
3186
- disabled={
3187
- explanation.status === "idle" && !explanation.onGenerate
3188
- }
3189
- onClick={() => {
3190
- const open = detail !== "explanation";
3191
- setDetail(open ? "explanation" : null);
3192
- analysisReveal.revealAfterToggle(open);
3193
- if (open && explanation.status === "idle")
3194
- explanation.onGenerate?.();
3195
- }}
3196
- className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:opacity-50"
3197
- >
3198
- <HelpCircle className="h-3 w-3" />
3199
- Explain simply
3200
- </button>
3201
- </Tooltip>
3202
- )}
3203
- {assessmentAction && (
3204
- <div className="ml-auto">{assessmentAction}</div>
3205
- )}
3206
- </div>
3207
- <div id={analysisId} ref={analysisReveal.elementRef}>
3208
- <div id={`${analysisId}-analysis`}>
3209
- <Collapse open={detail === "analysis"} mountLazily>
3210
- <div className="border-t border-theme-border/60 px-3 py-2">
3211
- {showAnalysisDisclosure ? (
3212
- <>
3213
- <p className="mb-2 text-xs text-theme-text-tertiary">
3214
- Agent confidence:{" "}
3215
- {diagnosis.confidence != null
3216
- ? confidenceLabel(diagnosis.confidence)
3217
- : "not stated"}
3218
- {diagnosis.confidence != null
3219
- ? " · self-reported"
3220
- : ""}
3221
- </p>
3222
- <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_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">
3223
- {analysisText}
3224
- </AIMarkdown>
3225
- </>
3226
- ) : null}
3227
- {assessmentSources}
3228
- </div>
3229
- </Collapse>
3230
- </div>
3231
- <div id={`${analysisId}-explanation`}>
3232
- <Collapse open={detail === "explanation"} mountLazily>
3233
- <div className="border-t border-theme-border/60 px-3 py-2">
3234
- {explanation?.status === "running" ? (
3235
- <div
3236
- role="status"
3237
- className="flex items-center gap-2 py-2 text-sm text-theme-text-secondary"
3238
- >
3239
- <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
3240
- Explaining this assessment…
3241
- </div>
3242
- ) : explanation?.status === "done" ? (
3243
- <div className="flex items-start gap-2">
3244
- <AIMarkdown className="min-w-0 flex-1 text-sm text-theme-text-secondary [overflow-wrap:anywhere]">
3245
- {explanation.text || ""}
3246
- </AIMarkdown>
3247
- <CopyButton
3248
- text={explanation.text || ""}
3249
- label="Copy explanation"
3250
- />
3251
- </div>
3252
- ) : explanation?.status === "error" ? (
3253
- <div
3254
- role="alert"
3255
- className="flex flex-wrap items-center gap-2 py-2 text-sm text-theme-text-secondary"
3256
- >
3257
- <span>
3258
- {explanation.error ||
3259
- "The agent did not return an explanation."}
3260
- </span>
3261
- {explanation.onGenerate && (
3262
- <button
3263
- type="button"
3264
- onClick={explanation.onGenerate}
3265
- className="rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover"
3266
- >
3267
- Try again
3268
- </button>
3269
- )}
3270
- </div>
3271
- ) : null}
3272
- </div>
3273
- </Collapse>
3274
- </div>
3275
- </div>
3276
- </div>
3277
- )}
3278
-
3279
- {showConclusion && showDisclaimer && (
3280
- <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
3281
- <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
3282
- <span>AI-generated — review before applying</span>
3283
- </div>
3284
- )}
3285
- </div>
3286
- );
3287
- }
3288
-
3289
- /** The banner line and the clipboard line for one adverse Radar card must be the same words. */
3290
- function healthFlagSentence(flag: {
3291
- status: string;
3292
- title: string;
3293
- role?: string;
3294
- }): string {
3295
- return flag.status === "contradiction"
3296
- ? `The agent calls ${flag.title} a ${flag.role} and still reports healthy`
3297
- : `Radar flagged ${flag.title} · no explanation is linked to it`;
3298
- }
3299
-
3300
- function AllClearCard({
3301
- diagnosis,
3302
- animate,
3303
- showDisclaimer,
3304
- coverageLimited,
3305
- evidenceConflict,
3306
- evidenceConflictExplainedBy,
3307
- assessmentAction,
3308
- assessmentSources,
3309
- storyInline = false,
3310
- revisedAfter,
3311
- assessmentLimits,
3312
- assessmentCopy,
3313
- healthSignals,
3314
- onRevealSource,
3315
- }: {
3316
- diagnosis: Diagnosis;
3317
- animate: boolean;
3318
- showDisclaimer: boolean;
3319
- coverageLimited: boolean;
3320
- evidenceConflict: boolean;
3321
- evidenceConflictExplainedBy?: string[];
3322
- assessmentAction?: ReactNode;
3323
- assessmentSources?: ReactNode;
3324
- storyInline?: boolean;
3325
- revisedAfter?: string;
3326
- /** Reads Radar could not complete for this assessment; listed under Still open. */
3327
- assessmentLimits?: string[];
3328
- assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
3329
- /** Adverse Radar cards with the agent's position on each (story shape). */
3330
- healthSignals?: InvestigationHealthSignal[];
3331
- onRevealSource?: (sourceId: string) => void;
3332
- }) {
3333
- const storyShape = diagnosisHasStoryShape(diagnosis);
3334
- const [showAnalysis, setShowAnalysis] = useState(false);
3335
- const analysisReveal = useDisclosureReveal<HTMLDivElement>();
3336
- const analysisId = useId();
3337
- const report =
3338
- (storyShape && !storyInline ? "" : storyPlainText(diagnosis.report)) ||
3339
- (storyShape
3340
- ? ""
3341
- : "The agent did not identify a problem in the evidence it checked.");
3342
- const detailed = report.length > 320 || report.split("\n").length > 2;
3343
- const summary = storyShape
3344
- ? ""
3345
- : detailed
3346
- ? "The agent found no active problem in the evidence it reviewed."
3347
- : report;
3348
- const explained =
3349
- evidenceConflict &&
3350
- !!evidenceConflictExplainedBy &&
3351
- evidenceConflictExplainedBy.length > 0;
3352
- const unexplainedConflict = evidenceConflict && !explained;
3353
- // Story shape: the summary and the certainty word are the verdict; adverse
3354
- // Radar cards the agent explained become lines in Still open, and only a
3355
- // card the agent's verdict never addressed (or contradicts) keeps a header
3356
- // that names it — so the reader knows exactly how it relates to the answer.
3357
- const signals = healthSignals ?? [];
3358
- const flagged = signals.filter(
3359
- (signal) =>
3360
- signal.status === "unaddressed" || signal.status === "contradiction",
3361
- );
3362
- const stillOpenSignals = signals
3363
- .filter(
3364
- (signal) => signal.status === "explained" || signal.status === "related",
3365
- )
3366
- .map((signal) => ({
3367
- text:
3368
- signal.status === "explained"
3369
- ? `Radar flagged ${signal.title} · the agent looked at it and reads it as not a live problem: ${signal.claim}`
3370
- : `Radar flagged ${signal.title} · the agent reads it as related, but not what matters here: ${signal.claim}`,
3371
- onReveal:
3372
- signal.sourceId && onRevealSource
3373
- ? () => onRevealSource(signal.sourceId!)
3374
- : undefined,
3375
- }));
3376
- // The analysis disclosure (Activity's read-only copy of an earlier healthy
3377
- // assessment carries its story inline as plain prose), the action slot and
3378
- // the disclaimer are the same in both shapes.
3379
- const trailing = (
3380
- <>
3381
- {detailed || assessmentAction || assessmentSources ? (
3382
- <div>
3383
- <div
3384
- className="flex flex-wrap items-center gap-3 pt-2"
3385
- data-assessment-actions
3386
- >
3387
- {(detailed || assessmentSources) && (
3388
- <button
3389
- type="button"
3390
- aria-expanded={showAnalysis}
3391
- aria-controls={analysisId}
3392
- onClick={() => {
3393
- setShowAnalysis(!showAnalysis);
3394
- analysisReveal.revealAfterToggle(!showAnalysis);
3395
- }}
3396
- className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
3397
- >
3398
- <CollapseChevron open={showAnalysis} className="h-3.5 w-3.5" />
3399
- {detailed ? "Full analysis" : "Assessment details"}
3400
- </button>
3401
- )}
3402
- {assessmentAction && (
3403
- <div className="ml-auto">{assessmentAction}</div>
3404
- )}
3405
- </div>
3406
- <div id={analysisId} ref={analysisReveal.elementRef}>
3407
- <Collapse open={showAnalysis}>
3408
- <div className="border-t border-theme-border/60 px-3 py-2">
3409
- <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_p]:my-1.5 [&_p]:text-theme-text-secondary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
3410
- {detailed ? report : ""}
3411
- </AIMarkdown>
3412
- {assessmentSources}
3413
- </div>
3414
- </Collapse>
3415
- </div>
3416
- </div>
3417
- ) : null}
3418
- {showDisclaimer ? (
3419
- <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
3420
- <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
3421
- <span>AI-generated — verify if symptoms persist</span>
3422
- </div>
3423
- ) : null}
3424
- </>
3425
- );
3426
- if (storyShape) {
3427
- return (
3428
- <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
3429
- {flagged.map((flag) => (
3430
- <div
3431
- key={`${flag.groupId ?? flag.title}-${flag.status}`}
3432
- data-health-flag={flag.status}
3433
- className="rounded-md border border-amber-500/40 bg-amber-500/5 px-2.5 py-2 text-xs text-theme-text-primary"
3434
- >
3435
- <div className="flex items-center gap-1.5 font-semibold text-amber-500">
3436
- <AlertTriangle className="h-3.5 w-3.5" aria-hidden />
3437
- {healthFlagSentence(flag)}
3438
- </div>
3439
- <p className="mt-1 text-theme-text-secondary">
3440
- {flag.status === "contradiction"
3441
- ? "Read the card before treating this as an all-clear."
3442
- : "It may be unrelated to what you asked, or missed. Open it before treating this as an all-clear."}
3443
- {flag.sourceId && onRevealSource ? (
3444
- <>
3445
- {" "}
3446
- <button
3447
- type="button"
3448
- onClick={() => onRevealSource(flag.sourceId!)}
3449
- className="font-medium text-accent-text hover:underline"
3450
- >
3451
- View the card
3452
- </button>
3453
- </>
3454
- ) : null}
3455
- </p>
3456
- </div>
3457
- ))}
3458
- <AssessmentHeadline
3459
- diagnosis={diagnosis}
3460
- tone="healthy"
3461
- revisedAfter={revisedAfter}
3462
- flags={flagged.map(healthFlagSentence)}
3463
- limits={assessmentLimits}
3464
- copy={assessmentCopy}
3465
- signals={stillOpenSignals}
3466
- />
3467
- {trailing}
3468
- </div>
3469
- );
3470
- }
3471
- return (
3472
- <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
3473
- <div
3474
- className={`rounded-lg border p-3 ${
3475
- unexplainedConflict || explained
3476
- ? "border-amber-500/40 bg-amber-500/5"
3477
- : coverageLimited
3478
- ? "border-amber-500/30 bg-amber-500/5"
3479
- : "border-emerald-500/30 bg-emerald-500/5"
3480
- }`}
3481
- >
3482
- <div className="mb-1 flex items-center justify-between gap-2">
3483
- <div
3484
- className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${
3485
- unexplainedConflict || explained || coverageLimited
3486
- ? "text-amber-500"
3487
- : "text-emerald-500"
3488
- }`}
3489
- >
3490
- {unexplainedConflict || explained || coverageLimited ? (
3491
- <AlertTriangle className="h-3.5 w-3.5" />
3492
- ) : (
3493
- <CheckCircle2 className="h-3.5 w-3.5" />
3494
- )}
3495
- {unexplainedConflict
3496
- ? "Assessment conflicts with captured evidence"
3497
- : explained
3498
- ? "Agent reports no active problem; adverse evidence remains"
3499
- : coverageLimited
3500
- ? "No problem identified in available evidence"
3501
- : "No problem found in checked evidence"}
3502
- </div>
3503
- <CopyButton text={report} label="Copy assessment" />
3504
- </div>
3505
- <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
3506
- {summary}
3507
- </AIMarkdown>
3508
- {unexplainedConflict ? (
3509
- <p className="mt-2 text-xs text-theme-text-secondary">
3510
- Radar also captured evidence of an active problem. Review that
3511
- evidence before treating the agent&apos;s conclusion as an
3512
- all-clear.
3513
- </p>
3514
- ) : explained ? (
3515
- <p className="mt-2 text-xs text-theme-text-secondary">
3516
- Radar captured evidence of an active problem. The agent explains its
3517
- interpretation in the note on{" "}
3518
- {joinTitles(evidenceConflictExplainedBy!)}.
3519
- {coverageLimited
3520
- ? " Evidence coverage is also limited — review the limitations in Evidence."
3521
- : ""}
3522
- </p>
3523
- ) : coverageLimited ? (
3524
- <p className="mt-2 text-xs text-theme-text-secondary">
3525
- Evidence coverage is limited. Review the limitations in Evidence
3526
- before treating this as an all-clear.
3527
- </p>
3528
- ) : null}
3529
- </div>
3530
- {trailing}
3531
- </div>
3532
- );
3533
- }
3534
-
3535
- // The agent investigated but couldn't determine an answer. A distinct, honest
3536
- // state — neutral (not the alarming amber root cause, not the reassuring emerald
3537
- // all-clear) — so "I couldn't tell" never reads as "you're fine."
3538
- function InconclusiveCard({
3539
- diagnosis,
3540
- animate,
3541
- storyInline = false,
3542
- revisedAfter,
3543
- assessmentLimits,
3544
- assessmentCopy,
3545
- }: {
3546
- diagnosis: Diagnosis;
3547
- animate: boolean;
3548
- storyInline?: boolean;
3549
- revisedAfter?: string;
3550
- /** Reads Radar could not complete for this assessment; listed under Still open. */
3551
- assessmentLimits?: string[];
3552
- assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
3553
- }) {
3554
- const storyShape = diagnosisHasStoryShape(diagnosis);
3555
- const text =
3556
- (storyShape && !storyInline ? "" : storyPlainText(diagnosis.report)) ||
3557
- (storyShape
3558
- ? ""
3559
- : "The investigation couldn't reach a clear conclusion — some information was unavailable or the evidence was ambiguous.");
3560
- return (
3561
- <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
3562
- <div className="rounded-lg border border-theme-border bg-theme-elevated p-3">
3563
- <div className="mb-1 flex items-center justify-between gap-2">
3564
- <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">
3565
- <HelpCircle className="h-3.5 w-3.5" />
3566
- Couldn&apos;t determine
3567
- </div>
3568
- {storyShape ? null : (
3569
- <CopyButton text={text} label="Copy assessment" />
3570
- )}
3571
- </div>
3572
- {storyShape ? (
3573
- <AssessmentHeadline
3574
- diagnosis={diagnosis}
3575
- tone="inconclusive"
3576
- revisedAfter={revisedAfter}
3577
- limits={assessmentLimits}
3578
- copy={assessmentCopy}
3579
- />
3580
- ) : null}
3581
- {text ? (
3582
- <AIMarkdown className="mt-2 text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
3583
- {text}
3584
- </AIMarkdown>
3585
- ) : null}
3586
- </div>
3587
- <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
3588
- <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
3589
- <span>
3590
- Try a follow-up with more context, or investigate again after
3591
- addressing any errors shown in Activity.
3592
- </span>
3593
- </div>
3594
- </div>
3595
- );
3596
- }
3597
-
3598
- // A follow-up reply: the agent answering a question, not re-diagnosing. Plain
3599
- // neutral block — no root-cause anchor, no remediation/apply.
3600
- function FollowupAnswer({
3601
- diagnosis,
3602
- animate,
3603
- }: {
3604
- diagnosis: Diagnosis;
3605
- animate: boolean;
3606
- }) {
3607
- const text = diagnosis.report || diagnosis.rootCause;
3608
- if (!text) return null;
3609
- return (
3610
- <div
3611
- className={`mt-1 rounded-lg border border-theme-border bg-theme-elevated p-3 ${animate ? "animate-result-in" : ""}`}
3612
- >
3613
- <div className="mb-1.5 flex items-center justify-between gap-2">
3614
- <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
3615
- <Sparkles className="h-3.5 w-3.5 text-accent" />
3616
- Answer
3617
- </div>
3618
- <CopyButton text={text} label="Copy answer" />
3619
- </div>
3620
- <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">
3621
- {text}
3622
- </AIMarkdown>
3623
- </div>
3624
- );
3625
- }
3626
-
3627
- // A done turn that produced no renderable result at all (empty diagnosis, no
3628
- // narration). Without this the turn would render blank — which reads as "the tool
3629
- // broke." Make the dead-end explicit and point at the recovery (a follow-up).
3630
- function EmptyResult({ animate }: { animate: boolean }) {
3631
- return (
3632
- <div
3633
- 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" : ""}`}
3634
- >
3635
- <ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
3636
- <span>
3637
- The investigation finished without a clear conclusion. Try a follow-up
3638
- question, or investigate again.
3639
- </span>
3640
- </div>
3641
- );
3642
- }
3643
-
3644
- // The result of an apply turn. Mutation truth comes from Radar write-tool
3645
- // results, not the agent's prose or process exit. Missing outcome metadata is
3646
- // therefore fail-closed as unknown; only explicit confirmation renders green.
3647
- function ApplyOutcomeCard({
3648
- diagnosis,
3649
- error,
3650
- applyOutcome,
3651
- onCheckStatus,
3652
- animate,
3653
- }: {
3654
- diagnosis: Diagnosis | null;
3655
- error?: string | null;
3656
- applyOutcome?: ApplyMutationOutcome;
3657
- onCheckStatus?: () => void;
3658
- animate: boolean;
3659
- }) {
3660
- const outcome = diagnosis?.report || diagnosis?.rootCause;
3661
- const status = applyOutcome ?? "unknown";
3662
- const confirmed = status === "confirmed";
3663
- const failed = status === "failed";
3664
- const heading = confirmed
3665
- ? "Applied"
3666
- : failed
3667
- ? "Not applied"
3668
- : "Outcome unknown";
3669
- const detail = error || outcome;
3670
- const fallbackDetail = confirmed
3671
- ? "Radar confirmed that the change was applied. Check the current state to confirm its effect."
3672
- : failed
3673
- ? "Radar could not confirm that the change was applied. Review Activity before retrying."
3674
- : "Radar could not confirm whether the change was applied. Check the current state before retrying.";
3675
- const containerClass = confirmed
3676
- ? "border-emerald-500/30 bg-emerald-500/5"
3677
- : failed
3678
- ? "border-red-500/30 bg-red-500/5"
3679
- : "border-amber-500/40 bg-amber-500/10";
3680
- const accentClass = confirmed
3681
- ? "text-emerald-500"
3682
- : failed
3683
- ? "text-red-400"
3684
- : "text-amber-400";
3685
- const buttonClass = confirmed
3686
- ? "border-emerald-500/40 text-emerald-500 hover:bg-emerald-500/10"
3687
- : "border-amber-500/40 text-amber-400 hover:bg-amber-500/10";
3688
- const provenance = confirmed
3689
- ? error
3690
- ? "Radar confirmed the change — the agent report is incomplete"
3691
- : "Radar confirmed the change was applied"
3692
- : failed
3693
- ? "Radar did not confirm that the change was applied"
3694
- : "Radar cannot confirm whether the change was applied";
3695
- return (
3696
- <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
3697
- <div className={`rounded-lg border p-3 ${containerClass}`}>
3698
- <div className="mb-1 flex items-center justify-between gap-2">
3699
- <div
3700
- className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${accentClass}`}
3701
- >
3702
- {confirmed ? (
3703
- <CheckCircle2 className="h-3.5 w-3.5" />
3704
- ) : (
3705
- <AlertTriangle className="h-3.5 w-3.5" />
3706
- )}
3707
- {heading}
3708
- </div>
3709
- {detail && <CopyButton text={detail} label="Copy apply result" />}
3710
- </div>
3711
- <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
3712
- {detail || fallbackDetail}
3713
- </AIMarkdown>
3714
- {!confirmed && !failed && (
3715
- <div className="mt-2 flex items-start gap-1.5 text-xs font-medium text-amber-300">
3716
- <RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
3717
- <span>
3718
- Check the current state before trying to apply this change again.
3719
- </span>
3720
- </div>
3721
- )}
3722
- {onCheckStatus && !failed && (
3723
- <button
3724
- type="button"
3725
- onClick={onCheckStatus}
3726
- className={`mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border py-2 text-sm font-medium ${buttonClass}`}
3727
- >
3728
- <RefreshCw className="h-4 w-4" />
3729
- Check current status
3730
- </button>
3731
- )}
3732
- </div>
3733
- <div className="flex items-center gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
3734
- <ShieldCheck className="h-3 w-3 shrink-0" />
3735
- <span className="truncate">{provenance}</span>
3736
- </div>
3737
- </div>
3738
- );
3739
- }
3740
-
3741
- const COMMAND_BINARIES = new Set([
3742
- "kubectl",
3743
- "helm",
3744
- "argocd",
3745
- "flux",
3746
- "kustomize",
3747
- "docker",
3748
- "gcloud",
3749
- "aws",
3750
- "az",
3751
- "mongosh",
3752
- "psql",
3753
- "redis-cli",
3754
- "curl",
3755
- "git",
3756
- "istioctl",
3757
- "velero",
3758
- "cilium",
3759
- "calicoctl",
3760
- "terraform",
3761
- "kn",
3762
- "oc",
3763
- "k9s",
3764
- "skyhook",
3765
- ]);
3766
-
3767
- /** The first sentence of a step, without its markdown, for a one-line row. */
3768
- export function remediationHeadline(step: string): string {
3769
- const firstLine =
3770
- step
3771
- .split(/\r?\n/)
3772
- .map((line) => line.trim())
3773
- .find((line) => line && !line.startsWith("```")) ?? "";
3774
- const sentence = firstLine.split(/(?<=[.!?:])\s/)[0] ?? firstLine;
3775
- // A step written as "Confirm recovery:" expects its command to follow; as a
3776
- // folded title the colon dangles.
3777
- return sentence.replace(/`/g, "").replace(/\*\*/g, "").replace(/:$/, "");
3778
- }
3779
-
3780
- /**
3781
- * The commands inside a remediation step, in order: every fenced block and
3782
- * every inline code span that reads as a shell invocation. A step's prose is
3783
- * never worth copying; a command is. The prompt asks the agent to wrap
3784
- * commands in backticks, so this is the seam to read them from.
3785
- */
3786
- export function remediationCommands(step: string): string[] {
3787
- const commands: string[] = [];
3788
- // One pass in reading order, so button N is the Nth command in the text.
3789
- const code = /```[a-zA-Z]*\n([\s\S]*?)```|`([^`\n]+)`/g;
3790
- let match: RegExpExecArray | null;
3791
- while ((match = code.exec(step))) {
3792
- if (match[1] !== undefined) {
3793
- const body = match[1].trim();
3794
- if (body) commands.push(body);
3795
- continue;
3796
- }
3797
- const span = match[2].trim();
3798
- if (looksLikeCommand(span)) commands.push(span);
3799
- }
3800
- return commands;
3801
- }
3802
-
3803
- function looksLikeCommand(span: string): boolean {
3804
- if (!/\s/.test(span)) return false;
3805
- const first = span.split(/\s+/)[0];
3806
- if (COMMAND_BINARIES.has(first)) return true;
3807
- // An unknown binary still reads as a command when it takes flags.
3808
- return /^[a-z][a-z0-9._-]*$/.test(first) && /(^|\s)--?[a-zA-Z]/.test(span);
3809
- }
3810
-
3811
- function CopyButton({ text, label }: { text: string; label: string }) {
3812
- const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
3813
- const copied = state === "copied";
3814
- return (
3815
- <Tooltip
3816
- content={
3817
- state === "copied"
3818
- ? "Copied"
3819
- : state === "failed"
3820
- ? "Copy failed"
3821
- : label
3822
- }
3823
- delay={100}
3824
- wrapperClassName="shrink-0"
3825
- >
3826
- <button
3827
- onClick={async () => {
3828
- // Report what happened, not what was attempted: the write can be
3829
- // denied or unavailable, and "Copied" over an empty clipboard is worse
3830
- // than no button.
3831
- try {
3832
- if (!navigator.clipboard) throw new Error("clipboard unavailable");
3833
- await navigator.clipboard.writeText(text);
3834
- setState("copied");
3835
- } catch {
3836
- setState("failed");
3837
- }
3838
- setTimeout(() => setState("idle"), 1200);
3839
- }}
3840
- className="shrink-0 rounded p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
3841
- aria-label={
3842
- state === "copied"
3843
- ? `${label} — copied`
3844
- : state === "failed"
3845
- ? `${label} — copy failed`
3846
- : label
3847
- }
3848
- aria-live="polite"
3849
- >
3850
- {copied ? (
3851
- <Check className="h-3.5 w-3.5 text-emerald-400" />
3852
- ) : state === "failed" ? (
3853
- <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
3854
- ) : (
3855
- <Copy className="h-3.5 w-3.5" />
3856
- )}
3857
- </button>
3858
- </Tooltip>
3859
- );
3860
- }
3861
-
3862
- export function prettyTool(tool: string): string {
3863
- return tool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
3864
- }
3865
-
3866
- // A coarse band, not a precise %: a two-sig-fig confidence on an LLM judgement
3867
- // reads as calibrated when it isn't.
3868
- function confidenceLabel(c: number): string {
3869
- if (c >= 0.8) return "High";
3870
- if (c >= 0.5) return "Medium";
3871
- return "Low";
3872
- }
3873
-
3874
- // LLMs occasionally open a ```fence mid-line ("run this: ```bash kubectl …") or
3875
- // put the command on the same line as the ```lang marker. GFM then won't parse
3876
- // it as a fence — it leaks the literal ``` and renders an empty code box. Coerce
3877
- // fence markers onto their own lines and push trailing content off the opener so
3878
- // the block renders. (Well-formed markdown is unaffected.)
3879
- function tidyFences(md: string): string {
3880
- if (!md || !md.includes("```")) return md;
3881
- return md
3882
- .replace(/([^\n])```/g, "$1\n\n```") // opener/closer must start a line
3883
- .replace(/```([A-Za-z0-9_-]*)[ \t]+(\S)/g, "```$1\n$2"); // content off the opener line
3884
- }
3885
-
3886
- // Diagnosis output is dense with inline `code`; the shared chip's brand tint is
3887
- // too loud at that density, so neutralize it (border/bg only) for this surface.
3888
- const SOFT_INLINE_CODE =
3889
- "[&_.inline-code]:border-theme-border/60 [&_.inline-code]:bg-theme-base [&_.inline-code]:font-normal";
3890
-
3891
- // Markdown for agent-generated text — normalizes flaky fences + softens code.
3892
- function AIMarkdown({
3893
- className,
3894
- children,
3895
- }: {
3896
- className?: string;
3897
- children: string;
3898
- }) {
3899
- return (
3900
- <Markdown className={`${SOFT_INLINE_CODE} ${className ?? ""}`}>
3901
- {tidyFences(children)}
3902
- </Markdown>
3903
- );
3904
- }
1
+ // The public names of the assessment, next steps, Activity and agent controls,
2
+ // kept together so their consumers import from one place.
3
+ export { AgentControls, ConsentCard } from "./AgentControls";
4
+ export {
5
+ mergeStartupSignal,
6
+ appendThinking,
7
+ upsertTool,
8
+ TurnView,
9
+ Timeline,
10
+ runningElapsedLabel,
11
+ toolDurationLabel,
12
+ toolErrorReason,
13
+ middleTruncate,
14
+ } from "./ActivityTurn";
15
+ export type { Turn, StartupSignal, TimelineItem } from "./ActivityTurn";
16
+ export { ApplyDialog } from "./ApplyDialog";
17
+ export {
18
+ ResultCard,
19
+ assessmentSourceRows,
20
+ AssessmentSources,
21
+ remediationHeadline,
22
+ remediationCommands,
23
+ } from "./AssessmentCard";
24
+ export type { AssessmentExplanation } from "./AssessmentCard";
25
+ export { assessmentCopyText } from "./assessmentCopy";
26
+ export { prettyTool } from "./toolCallLabel";