@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
@@ -0,0 +1,1560 @@
1
+ import { useEffect, useId, useState, type ReactNode } from "react";
2
+ import {
3
+ Loader2,
4
+ CheckCircle2,
5
+ AlertTriangle,
6
+ ShieldCheck,
7
+ Wrench,
8
+ Sparkles,
9
+ HelpCircle,
10
+ FileSearch,
11
+ Search,
12
+ ListChecks,
13
+ } from "lucide-react";
14
+ import type { Diagnosis, ApplyMutationOutcome } from "../../api/diagnose";
15
+ import { Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
16
+ import { Tooltip } from "../ui/Tooltip";
17
+ import type {
18
+ InvestigationRootCauseEvidenceResolution,
19
+ InvestigationEvidenceSource,
20
+ } from "./investigationEvidence";
21
+ import type {
22
+ InvestigationCaseItem,
23
+ InvestigationCaseResolution,
24
+ } from "./investigationCase";
25
+ import { AgentClaimNote } from "./AgentCase";
26
+ import type { InvestigationHealthSignal } from "./investigationState";
27
+ import { Badge } from "@skyhook-io/k8s-ui";
28
+ import { diagnosisHasStoryShape, storyPlainText } from "./investigationStory";
29
+ import { useDisclosureReveal } from "./useDisclosureReveal";
30
+ import { FollowupAnswer } from "./ActivityTurn";
31
+ import { ApplyOutcomeCard } from "./ApplyDialog";
32
+ import {
33
+ type AssessmentCopyRadar,
34
+ CERTAINTY_LABEL,
35
+ STEP_KIND_LABEL,
36
+ assessmentCopyText,
37
+ } from "./assessmentCopy";
38
+ import { AIMarkdown, CopyButton } from "./AIMarkdown";
39
+ import { prettyTool } from "./toolCallLabel";
40
+
41
+ export function ResultCard({
42
+ diagnosis,
43
+ onApply,
44
+ explanation,
45
+ apply,
46
+ applyOutcome,
47
+ followup,
48
+ section = "full",
49
+ onCheckStatus,
50
+ animate = true,
51
+ showDisclaimer = true,
52
+ coverageLimited = false,
53
+ evidenceConflict = false,
54
+ evidenceConflictExplainedBy,
55
+ compactActions = false,
56
+ assessmentAction,
57
+ assessmentSources,
58
+ actionNotice,
59
+ storyInline = false,
60
+ readOnlyAssessment = false,
61
+ revisedAfter,
62
+ assessmentLimits,
63
+ assessmentCopy,
64
+ healthSignals,
65
+ onRevealSource,
66
+ }: {
67
+ diagnosis: Diagnosis;
68
+ onApply?: (fix: string) => void;
69
+ explanation?: AssessmentExplanation;
70
+ apply?: boolean;
71
+ applyOutcome?: ApplyMutationOutcome;
72
+ followup?: boolean;
73
+ section?: "full" | "conclusion" | "actions";
74
+ /**
75
+ * Render the story as plain prose inside this card. Findings renders the
76
+ * story itself, with placed cards, so it leaves this off; Activity's
77
+ * read-only copies of earlier assessments turn it on.
78
+ */
79
+ storyInline?: boolean;
80
+ /** An earlier assessment shown for the record: no Apply, labelled as such. */
81
+ readOnlyAssessment?: boolean;
82
+ /** The question that produced this revised assessment, when it replaced an earlier one. */
83
+ revisedAfter?: string;
84
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
85
+ assessmentLimits?: string[];
86
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
87
+ healthSignals?: InvestigationHealthSignal[];
88
+ onRevealSource?: (sourceId: string) => void;
89
+ onCheckStatus?: () => void;
90
+ animate?: boolean;
91
+ /** Additional navigation placed in the assessment action row. */
92
+ assessmentAction?: ReactNode;
93
+ assessmentSources?: ReactNode;
94
+ /** Hide the repeated disclaimer when the enclosing surface provides context. */
95
+ showDisclaimer?: boolean;
96
+ /** Qualifies a healthy assessment when structured evidence is absent or partial. */
97
+ coverageLimited?: boolean;
98
+ /** Marks a healthy agent assessment that conflicts with same-turn Key evidence. */
99
+ evidenceConflict?: boolean;
100
+ /**
101
+ * Titles of the conflicting cards when the agent placed a "not a problem"
102
+ * note on every one of them; the banner then points at the agent's reasons
103
+ * rather than accusing evidence it already addressed.
104
+ */
105
+ evidenceConflictExplainedBy?: string[];
106
+ /** Show only the recommended (or first) action until the user asks for more. */
107
+ compactActions?: boolean;
108
+ actionNotice?: string;
109
+ }) {
110
+ // Apply turns report mutation truth, not a diagnosis. The outcome-specific
111
+ // card decides whether that truth is confirmed, failed, or still unknown.
112
+ if (apply)
113
+ return (
114
+ <ApplyOutcomeCard
115
+ diagnosis={diagnosis}
116
+ applyOutcome={applyOutcome}
117
+ onCheckStatus={onCheckStatus}
118
+ animate={animate}
119
+ />
120
+ );
121
+ // A question remains conversational even if the model happens to set a health
122
+ // flag in its structured envelope. Never promote an ordinary answer into an
123
+ // authoritative investigation conclusion.
124
+ if (followup)
125
+ return (
126
+ <>
127
+ <FollowupAnswer diagnosis={diagnosis} animate={animate} />
128
+ {assessmentSources ? (
129
+ <AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
130
+ ) : null}
131
+ </>
132
+ );
133
+ if (diagnosis.healthy && !diagnosis.rootCause)
134
+ return section === "actions" ? null : (
135
+ <AllClearCard
136
+ diagnosis={diagnosis}
137
+ animate={animate}
138
+ showDisclaimer={showDisclaimer}
139
+ coverageLimited={coverageLimited}
140
+ evidenceConflict={evidenceConflict}
141
+ evidenceConflictExplainedBy={evidenceConflictExplainedBy}
142
+ assessmentAction={assessmentAction}
143
+ assessmentSources={assessmentSources}
144
+ storyInline={storyInline}
145
+ revisedAfter={revisedAfter}
146
+ assessmentLimits={assessmentLimits}
147
+ assessmentCopy={assessmentCopy}
148
+ healthSignals={healthSignals}
149
+ onRevealSource={onRevealSource}
150
+ />
151
+ );
152
+ // Couldn't-determine is its own honest state — never a confident all-clear, never
153
+ // the alarming root-cause anchor. With typed steps it can still carry the
154
+ // next check, which the actions section renders like any other step.
155
+ if (diagnosis.inconclusive && !diagnosis.rootCause) {
156
+ if (section === "actions")
157
+ return (diagnosis.steps?.length ?? 0) > 0 ? (
158
+ <DiagnosisResult
159
+ diagnosis={diagnosis}
160
+ onApply={readOnlyAssessment ? undefined : onApply}
161
+ section="actions"
162
+ animate={animate}
163
+ showDisclaimer={false}
164
+ compactActions={compactActions}
165
+ actionNotice={actionNotice}
166
+ readOnlyAssessment={readOnlyAssessment}
167
+ />
168
+ ) : null;
169
+ return (
170
+ <>
171
+ <InconclusiveCard
172
+ diagnosis={diagnosis}
173
+ animate={animate}
174
+ storyInline={storyInline}
175
+ revisedAfter={revisedAfter}
176
+ assessmentLimits={assessmentLimits}
177
+ assessmentCopy={assessmentCopy}
178
+ />
179
+ {section === "full" && (diagnosis.steps?.length ?? 0) > 0 ? (
180
+ <DiagnosisResult
181
+ diagnosis={diagnosis}
182
+ onApply={readOnlyAssessment ? undefined : onApply}
183
+ section="actions"
184
+ animate={false}
185
+ showDisclaimer={false}
186
+ compactActions={compactActions}
187
+ actionNotice={actionNotice}
188
+ readOnlyAssessment={readOnlyAssessment}
189
+ />
190
+ ) : null}
191
+ {assessmentSources ? (
192
+ <AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
193
+ ) : null}
194
+ {assessmentAction && (
195
+ <div className="mt-2 flex justify-end">{assessmentAction}</div>
196
+ )}
197
+ </>
198
+ );
199
+ }
200
+ // A turn with no structured root cause and no remediation (e.g. "looks healthy",
201
+ // or a clarifying question) is not a diagnosis — render it neutrally rather than
202
+ // forcing the alarming root-cause anchor onto a non-problem.
203
+ const structured =
204
+ !!diagnosis.rootCause || (diagnosis.remediation?.length ?? 0) > 0;
205
+ if (!structured)
206
+ return (
207
+ <>
208
+ <FollowupAnswer diagnosis={diagnosis} animate={animate} />
209
+ {assessmentSources ? (
210
+ <AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
211
+ ) : null}
212
+ {assessmentAction && (
213
+ <div className="mt-2 flex justify-end">{assessmentAction}</div>
214
+ )}
215
+ </>
216
+ );
217
+
218
+ return (
219
+ <DiagnosisResult
220
+ diagnosis={diagnosis}
221
+ onApply={readOnlyAssessment ? undefined : onApply}
222
+ explanation={explanation}
223
+ section={section}
224
+ animate={animate}
225
+ showDisclaimer={showDisclaimer}
226
+ compactActions={compactActions}
227
+ assessmentAction={assessmentAction}
228
+ assessmentSources={assessmentSources}
229
+ actionNotice={actionNotice}
230
+ storyInline={storyInline}
231
+ readOnlyAssessment={readOnlyAssessment}
232
+ revisedAfter={revisedAfter}
233
+ assessmentLimits={assessmentLimits}
234
+ assessmentCopy={assessmentCopy}
235
+ />
236
+ );
237
+ }
238
+
239
+ // The word is the agent's; the clause says what kind of Radar evidence sits
240
+ // behind it, so a reader knows what the word licenses them to do.
241
+ const CERTAINTY_DEFINITION: Record<
242
+ NonNullable<Diagnosis["certainty"]>,
243
+ string
244
+ > = {
245
+ established:
246
+ "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.",
247
+ likely:
248
+ "The agent's word. Radar's results fit this explanation, but none of them states the cause outright.",
249
+ suspected:
250
+ "The agent's word. A plausible reading on thin or indirect evidence; treat it as a lead, not a finding.",
251
+ };
252
+
253
+ const HEALTHY_DEFINITION: Record<
254
+ NonNullable<Diagnosis["certainty"]>,
255
+ string
256
+ > = {
257
+ established:
258
+ "The agent found no live problem, and Radar's results show that directly — the pod state, logs and events it read.",
259
+ likely:
260
+ "The agent found no live problem; Radar's results fit that reading but do not show it outright.",
261
+ suspected:
262
+ "The agent leans healthy on thin or indirect evidence; treat it as a lead, not a finding.",
263
+ };
264
+
265
+ /**
266
+ * The headline of an assessment that carries the story contract: the
267
+ * agent's plain-language summary with its own word for how sure it is, the
268
+ * technical cause beneath it, and what is still unresolved — which renders
269
+ * whenever the agent listed anything, and says so when it listed nothing,
270
+ * because a persuasive story needs its caveats above the fold, not inside it.
271
+ */
272
+ function AssessmentHeadline({
273
+ diagnosis,
274
+ tone,
275
+ revisedAfter,
276
+ limits = [],
277
+ signals,
278
+ flags,
279
+ copy,
280
+ }: {
281
+ diagnosis: Diagnosis;
282
+ tone: "cause" | "healthy" | "inconclusive";
283
+ revisedAfter?: string;
284
+ /** Reads Radar could not complete for this assessment, one line each. */
285
+ limits?: string[];
286
+ /** Adverse Radar cards the agent explained, with its position; first in Still open. */
287
+ signals?: { text: string; onReveal?: () => void }[];
288
+ /** Adverse Radar cards nothing explains; shown above a healthy verdict, copied with it. */
289
+ flags?: string[];
290
+ /** Context and receipts for the channel copy. */
291
+ copy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
292
+ }) {
293
+ const summary = diagnosis.summary?.trim();
294
+ if (!summary) return null;
295
+ // One block for everything that qualifies the answer: what the agent left
296
+ // open and what Radar could not read. The rest of the page states the
297
+ // answer; this is the only place it argues with itself.
298
+ const unresolved = [
299
+ ...(diagnosis.unresolved ?? []).filter((item) => item.trim()),
300
+ ...limits,
301
+ ];
302
+ const stillOpen = (signals ?? []).length > 0 || unresolved.length > 0;
303
+ return (
304
+ <div data-assessment-headline className="space-y-2">
305
+ <div className="flex items-start justify-between gap-2">
306
+ <p className="text-[15px] font-semibold leading-snug text-theme-text-primary [overflow-wrap:anywhere] [text-wrap:balance]">
307
+ {summary}
308
+ </p>
309
+ <CopyButton
310
+ text={assessmentCopyText(diagnosis, {
311
+ limits,
312
+ signals: (signals ?? []).map((signal) => signal.text),
313
+ flags,
314
+ ...copy,
315
+ })}
316
+ label="Copy for a channel"
317
+ />
318
+ </div>
319
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-theme-text-secondary">
320
+ {diagnosis.certainty ? (
321
+ <Tooltip
322
+ content={
323
+ tone === "healthy"
324
+ ? HEALTHY_DEFINITION[diagnosis.certainty]
325
+ : CERTAINTY_DEFINITION[diagnosis.certainty]
326
+ }
327
+ >
328
+ <Badge tone="agent" size="sm">
329
+ <Sparkles className="h-2.5 w-2.5 shrink-0" aria-hidden />
330
+ {tone === "healthy"
331
+ ? "Healthy"
332
+ : CERTAINTY_LABEL[diagnosis.certainty]}
333
+ </Badge>
334
+ </Tooltip>
335
+ ) : null}
336
+ {revisedAfter ? (
337
+ <span className="min-w-0 text-theme-text-tertiary">
338
+ Revised after: &ldquo;
339
+ {revisedAfter.length > 110
340
+ ? `${revisedAfter.slice(0, 110).trimEnd()}…`
341
+ : revisedAfter}
342
+ &rdquo;
343
+ </span>
344
+ ) : null}
345
+ </div>
346
+ {tone === "cause" && diagnosis.rootCause ? (
347
+ <AIMarkdown className="text-[13px] leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-secondary">
348
+ {diagnosis.rootCause}
349
+ </AIMarkdown>
350
+ ) : null}
351
+ {stillOpen ? (
352
+ <div
353
+ data-assessment-unresolved
354
+ className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2"
355
+ >
356
+ <div className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-theme-text-secondary">
357
+ <HelpCircle className="h-3 w-3" aria-hidden />
358
+ {tone === "inconclusive"
359
+ ? "What blocked a conclusion"
360
+ : "Still open"}
361
+ </div>
362
+ <ul className="space-y-0.5 text-xs text-theme-text-primary">
363
+ {unresolved.map((item, index) => (
364
+ <li key={`open-${index}`} className="flex gap-1.5">
365
+ <span aria-hidden className="text-theme-text-tertiary">
366
+ –
367
+ </span>
368
+ <span className="[overflow-wrap:anywhere]">{item}</span>
369
+ </li>
370
+ ))}
371
+ {(signals ?? []).map((signal, index) => (
372
+ <li
373
+ key={`signal-${index}`}
374
+ className="flex gap-1.5"
375
+ data-health-signal
376
+ >
377
+ <span aria-hidden className="text-theme-text-tertiary">
378
+ –
379
+ </span>
380
+ {signal.onReveal ? (
381
+ <button
382
+ type="button"
383
+ onClick={signal.onReveal}
384
+ className="text-left [overflow-wrap:anywhere] hover:text-accent-text"
385
+ >
386
+ {signal.text}
387
+ </button>
388
+ ) : (
389
+ <span className="[overflow-wrap:anywhere]">
390
+ {signal.text}
391
+ </span>
392
+ )}
393
+ </li>
394
+ ))}
395
+ </ul>
396
+ </div>
397
+ ) : null}
398
+ </div>
399
+ );
400
+ }
401
+
402
+ /**
403
+ * Sources the assessment cites: root-cause links first, then every source an
404
+ * agent item cites. Each row shows the roles the agent gave that source; a
405
+ * claim the pane could not pin to one observation is shown here in full.
406
+ */
407
+ export function assessmentSourceRows(
408
+ resolution: InvestigationRootCauseEvidenceResolution | undefined,
409
+ investigationCase: InvestigationCaseResolution | undefined,
410
+ ): Array<{
411
+ source: InvestigationEvidenceSource;
412
+ items: InvestigationCaseItem[];
413
+ }> {
414
+ const rows = new Map<
415
+ string,
416
+ { source: InvestigationEvidenceSource; items: InvestigationCaseItem[] }
417
+ >();
418
+ if (resolution?.status === "linked") {
419
+ for (const link of resolution.links) {
420
+ rows.set(link.source.id, { source: link.source, items: [] });
421
+ }
422
+ }
423
+ for (const item of investigationCase?.items ?? []) {
424
+ const row = rows.get(item.source.id) ?? { source: item.source, items: [] };
425
+ row.items.push(item);
426
+ rows.set(item.source.id, row);
427
+ }
428
+ return [...rows.values()];
429
+ }
430
+
431
+ function joinTitles(titles: string[]): string {
432
+ if (titles.length <= 1) return titles[0] ?? "";
433
+ if (titles.length === 2) return `${titles[0]} and ${titles[1]}`;
434
+ return `${titles.slice(0, -1).join(", ")}, and ${titles[titles.length - 1]}`;
435
+ }
436
+
437
+ /**
438
+ * Provenance for one assessment: the exact tool results it cited, each with
439
+ * its source, and under a source only the agent notes that are not already
440
+ * shown on a card. Notes that live on cards are counted, not repeated; an
441
+ * earlier assessment no longer annotates the Evidence pane, so all of its
442
+ * notes are listed here instead of being lost.
443
+ */
444
+ export function AssessmentSources({
445
+ resolution,
446
+ investigationCase,
447
+ unlinkedEvidence = 0,
448
+ evidenceMalformed = false,
449
+ readOnly = false,
450
+ renderedGroupIds,
451
+ onViewSource,
452
+ }: {
453
+ resolution?: InvestigationRootCauseEvidenceResolution;
454
+ investigationCase?: InvestigationCaseResolution;
455
+ /**
456
+ * Notes the agent wrote that could not be tied to a Radar result: a
457
+ * reference that named nothing, a role Radar does not know, a sentence over
458
+ * the length limit. Radar does not repair them, because repairing one means
459
+ * deciding what the agent meant, so it says how many were lost instead.
460
+ */
461
+ unlinkedEvidence?: number;
462
+ /**
463
+ * The agent's notes were not a list at all, so none of them could be read
464
+ * and no count describes how many were lost.
465
+ */
466
+ evidenceMalformed?: boolean;
467
+ readOnly?: boolean;
468
+ /**
469
+ * Groups the Evidence pane actually rendered. A card-placed note whose card
470
+ * was withheld is shown here instead of being counted as visible elsewhere;
471
+ * without this the note renders in neither place. Omitted by hosts that do
472
+ * not know, which keeps the original counting.
473
+ */
474
+ renderedGroupIds?: ReadonlySet<string>;
475
+ onViewSource: (sourceId: string) => void;
476
+ }) {
477
+ const rows = assessmentSourceRows(resolution, investigationCase);
478
+ if (rows.length === 0 && unlinkedEvidence === 0 && !evidenceMalformed)
479
+ return null;
480
+ return (
481
+ <div className="mt-3 border-t border-theme-border/60 pt-2">
482
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
483
+ Sources used for this assessment
484
+ <span className="ml-1.5 font-medium normal-case tracking-normal text-theme-text-tertiary">
485
+ {rows.length}
486
+ </span>
487
+ </h4>
488
+ <ul className="mt-1 divide-y divide-theme-border/50">
489
+ {rows.map(({ source, items }) => {
490
+ const shownOnCard = (item: InvestigationCaseItem) =>
491
+ !!item.claim &&
492
+ item.placement !== "source" &&
493
+ (!renderedGroupIds ||
494
+ (!!item.groupId && renderedGroupIds.has(item.groupId)));
495
+ const onCards = items.filter(shownOnCard).length;
496
+ const notes = items.filter(
497
+ (item) => item.claim && (readOnly || !shownOnCard(item)),
498
+ );
499
+ return (
500
+ <li key={source.id} className="py-1.5">
501
+ <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2 px-2">
502
+ <FileSearch
503
+ className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
504
+ aria-hidden
505
+ />
506
+ <div className="min-w-0 text-xs">
507
+ <div className="font-medium text-theme-text-primary">
508
+ {prettyTool(source.tool)}
509
+ </div>
510
+ <CitedSourceScope source={source} />
511
+ {!readOnly && onCards > 0 ? (
512
+ <div className="mt-0.5 text-[11px] text-theme-text-tertiary">
513
+ {onCards === 1
514
+ ? "1 agent note on an evidence card"
515
+ : `${onCards} agent notes on evidence cards`}
516
+ </div>
517
+ ) : null}
518
+ </div>
519
+ <button
520
+ type="button"
521
+ aria-label={`View ${prettyTool(source.tool)} result used for this assessment`}
522
+ onClick={() => onViewSource(source.id)}
523
+ 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"
524
+ >
525
+ View result
526
+ </button>
527
+ </div>
528
+ {notes.length > 0 ? (
529
+ <div
530
+ className="ml-[1.9rem] mr-2 mt-1.5 space-y-1.5"
531
+ data-source-placed-claims
532
+ >
533
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
534
+ {readOnly
535
+ ? "Notes from this assessment"
536
+ : "Notes not shown on a card"}
537
+ </div>
538
+ {notes.map((item) => (
539
+ <AgentClaimNote
540
+ key={item.index}
541
+ claim={item.claim}
542
+ role={item.role}
543
+ subject={
544
+ item.placement === "source"
545
+ ? undefined
546
+ : item.observation?.title
547
+ }
548
+ className="border-t-0"
549
+ />
550
+ ))}
551
+ </div>
552
+ ) : null}
553
+ </li>
554
+ );
555
+ })}
556
+ </ul>
557
+ {evidenceMalformed ? (
558
+ <p className="mt-2 text-[11px] text-theme-text-tertiary">
559
+ The agent&apos;s notes could not be read, so none are shown.
560
+ </p>
561
+ ) : null}
562
+ {unlinkedEvidence > 0 ? (
563
+ <p className="mt-2 text-[11px] text-theme-text-tertiary">
564
+ {unlinkedEvidence === 1
565
+ ? "1 agent note could not be linked to a Radar result and is not shown."
566
+ : `${unlinkedEvidence} agent notes could not be linked to Radar results and are not shown.`}
567
+ </p>
568
+ ) : null}
569
+ </div>
570
+ );
571
+ }
572
+
573
+ export function WorkingNotes({ notes }: { notes: string }) {
574
+ const [open, setOpen] = useState(false);
575
+ const id = useId();
576
+ const reveal = useDisclosureReveal<HTMLDivElement>();
577
+ return (
578
+ <div ref={reveal.elementRef} data-turn-working-notes>
579
+ <button
580
+ type="button"
581
+ aria-expanded={open}
582
+ aria-controls={id}
583
+ onClick={() => {
584
+ setOpen(!open);
585
+ reveal.revealAfterToggle(!open);
586
+ }}
587
+ className="flex items-center gap-1.5 rounded-md py-1 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
588
+ >
589
+ <CollapseChevron open={open} className="h-3.5 w-3.5" />
590
+ Evidence considered
591
+ </button>
592
+ <div id={id}>
593
+ <Collapse open={open}>
594
+ <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">
595
+ {notes}
596
+ </AIMarkdown>
597
+ </Collapse>
598
+ </div>
599
+ </div>
600
+ );
601
+ }
602
+
603
+ function AssessmentSourceDetails({ children }: { children: ReactNode }) {
604
+ const [open, setOpen] = useState(false);
605
+ const id = useId();
606
+ const reveal = useDisclosureReveal<HTMLDivElement>();
607
+ return (
608
+ <div ref={reveal.elementRef}>
609
+ <button
610
+ type="button"
611
+ aria-expanded={open}
612
+ aria-controls={id}
613
+ onClick={() => {
614
+ setOpen(!open);
615
+ reveal.revealAfterToggle(!open);
616
+ }}
617
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
618
+ >
619
+ <CollapseChevron open={open} className="h-3.5 w-3.5" />
620
+ Assessment details
621
+ </button>
622
+ <div id={id}>
623
+ <Collapse open={open}>{children}</Collapse>
624
+ </div>
625
+ </div>
626
+ );
627
+ }
628
+
629
+ function CitedSourceScope({ source }: { source: InvestigationEvidenceSource }) {
630
+ let input: unknown;
631
+ try {
632
+ input = JSON.parse(source.args ?? "");
633
+ } catch {
634
+ return null;
635
+ }
636
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
637
+ const args = input as Record<string, unknown>;
638
+ const fields = ["query", "kind", "namespace", "name", "filter"].flatMap(
639
+ (key) =>
640
+ typeof args[key] === "string" && args[key].trim()
641
+ ? [`${key === "query" ? "" : `${key}: `}${args[key]}`]
642
+ : [],
643
+ );
644
+ return fields.length > 0 ? (
645
+ <span className="mt-0.5 block break-words text-theme-text-secondary [overflow-wrap:anywhere]">
646
+ {fields.join(" · ")}
647
+ </span>
648
+ ) : null;
649
+ }
650
+
651
+ export type AssessmentExplanation = {
652
+ status: "idle" | "running" | "done" | "error";
653
+ text?: string;
654
+ error?: string;
655
+ onGenerate?: () => void;
656
+ openRequest?: number;
657
+ };
658
+
659
+ // The diagnosis result: likely cause + remediation + the
660
+ // agent's full analysis on demand.
661
+ function DiagnosisResult({
662
+ diagnosis,
663
+ onApply,
664
+ explanation,
665
+ section = "full",
666
+ animate,
667
+ showDisclaimer,
668
+ compactActions,
669
+ assessmentAction,
670
+ assessmentSources,
671
+ actionNotice,
672
+ storyInline = false,
673
+ readOnlyAssessment = false,
674
+ revisedAfter,
675
+ assessmentLimits,
676
+ assessmentCopy,
677
+ }: {
678
+ diagnosis: Diagnosis;
679
+ onApply?: (fix: string) => void;
680
+ explanation?: AssessmentExplanation;
681
+ section?: "full" | "conclusion" | "actions";
682
+ animate: boolean;
683
+ showDisclaimer: boolean;
684
+ compactActions: boolean;
685
+ assessmentAction?: ReactNode;
686
+ assessmentSources?: ReactNode;
687
+ actionNotice?: string;
688
+ storyInline?: boolean;
689
+ readOnlyAssessment?: boolean;
690
+ revisedAfter?: string;
691
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
692
+ assessmentLimits?: string[];
693
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
694
+ }) {
695
+ // The story contract: a summary headline above, the story rendered by the
696
+ // host (or inline as plain prose), typed steps below.
697
+ const storyShape = diagnosisHasStoryShape(diagnosis);
698
+ const analysisText =
699
+ storyShape && storyInline
700
+ ? storyPlainText(diagnosis.report)
701
+ : diagnosis.report;
702
+ const showAnalysisDisclosure = !storyShape || storyInline;
703
+ const [detail, setDetail] = useState<"analysis" | "explanation" | null>(
704
+ explanation?.status === "running" ? "explanation" : null,
705
+ );
706
+ const showAnalysis = detail === "analysis";
707
+ const analysisReveal = useDisclosureReveal<HTMLDivElement>();
708
+ const { elementRef: analysisElementRef, revealAfterToggle: revealAnalysis } =
709
+ analysisReveal;
710
+ useEffect(() => {
711
+ if (explanation?.openRequest) {
712
+ setDetail("explanation");
713
+ revealAnalysis(true);
714
+ }
715
+ }, [explanation?.openRequest, revealAnalysis]);
716
+ useEffect(() => {
717
+ if (
718
+ detail === "explanation" &&
719
+ (explanation?.status === "done" || explanation?.status === "error")
720
+ ) {
721
+ const element = analysisElementRef.current;
722
+ const scroller = element?.closest("[data-investigation-findings-scroll]");
723
+ if (element && scroller) {
724
+ const top = element.getBoundingClientRect().top;
725
+ const viewport = scroller.getBoundingClientRect();
726
+ if (top >= viewport.top && top < viewport.bottom) revealAnalysis(true);
727
+ }
728
+ }
729
+ }, [explanation?.status, detail, analysisElementRef, revealAnalysis]);
730
+ const [showAllSteps, setShowAllSteps] = useState(false);
731
+ const stepsId = useId();
732
+ const stepsReveal = useDisclosureReveal<HTMLDivElement>();
733
+ const analysisId = useId();
734
+ // Only a real structured cause anchors the amber card; the full prose lives in
735
+ // "Full analysis" (never relabel the report as a causal assessment).
736
+ const rootCause = diagnosis.rootCause;
737
+ const remediation = diagnosis.remediation || [];
738
+ const steps = diagnosis.steps ?? [];
739
+ const typedSteps = steps.length === remediation.length && steps.length > 0;
740
+ const hasRemediation = remediation.length > 0;
741
+ const recIdx = diagnosis.recommendedIndex;
742
+ const recValid =
743
+ recIdx != null &&
744
+ recIdx >= 1 &&
745
+ recIdx <= remediation.length &&
746
+ (!typedSteps || steps[recIdx - 1].kind === "mitigate");
747
+ // Apply is offered ONLY when the agent pointed at a safe step (recommended_index).
748
+ // When it returns 0 / none ("needs human judgement"), we honor that and don't
749
+ // offer one-click apply — the steps stay copy-only with a note.
750
+ const canApply = !!onApply && recValid;
751
+ const showConclusion = section !== "actions";
752
+ const showActions = section !== "conclusion";
753
+ const remediationEntries = remediation.map((text, index) => ({
754
+ text,
755
+ index,
756
+ }));
757
+ const primaryActionIndex = recValid ? recIdx! - 1 : 0;
758
+ const hiddenStepCount = remediationEntries.length - 1;
759
+ const renderRemediationStep = ({
760
+ text: r,
761
+ index: i,
762
+ }: {
763
+ text: string;
764
+ index: number;
765
+ }) => {
766
+ const isRec = recValid && i === recIdx! - 1;
767
+ const step = typedSteps ? steps[i] : undefined;
768
+ return (
769
+ <div
770
+ key={i}
771
+ data-step-kind={step?.kind}
772
+ className={
773
+ isRec ? "rounded-lg border border-accent/40 bg-accent/5 p-2.5" : ""
774
+ }
775
+ >
776
+ <div className="flex items-start gap-2">
777
+ <span
778
+ className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] ${
779
+ isRec
780
+ ? "bg-accent/20 text-accent"
781
+ : "bg-theme-base text-theme-text-tertiary"
782
+ }`}
783
+ >
784
+ {step ? (
785
+ step.kind === "mitigate" ? (
786
+ <Wrench className="h-2.5 w-2.5" aria-hidden />
787
+ ) : step.kind === "verify" ? (
788
+ <ListChecks className="h-2.5 w-2.5" aria-hidden />
789
+ ) : (
790
+ <Search className="h-2.5 w-2.5" aria-hidden />
791
+ )
792
+ ) : (
793
+ i + 1
794
+ )}
795
+ </span>
796
+ <div className="min-w-0 flex-1">
797
+ {/* Labels, then the action cluster pushed to the row's end, then
798
+ the reason on its own line — so the step text below spans the
799
+ card instead of wrapping beside the buttons. */}
800
+ <div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
801
+ {(isRec || step) && (
802
+ <>
803
+ {step ? (
804
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
805
+ {STEP_KIND_LABEL[step.kind]}
806
+ </span>
807
+ ) : null}
808
+ {isRec && (
809
+ <span className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-accent">
810
+ <Sparkles className="h-3 w-3" />
811
+ Recommended
812
+ </span>
813
+ )}
814
+ </>
815
+ )}
816
+ <div className="ml-auto flex shrink-0 items-center gap-0.5">
817
+ {canApply && isRec && (
818
+ <button
819
+ onClick={() => onApply!(r)}
820
+ 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"
821
+ >
822
+ <Wrench className="h-3 w-3" />
823
+ Apply…
824
+ </button>
825
+ )}
826
+ {remediationCommands(r).map((command, c, all) => (
827
+ <CopyButton
828
+ key={c}
829
+ text={command}
830
+ label={
831
+ all.length > 1
832
+ ? `Copy command ${c + 1} of step ${i + 1}`
833
+ : `Copy command from step ${i + 1}`
834
+ }
835
+ />
836
+ ))}
837
+ </div>
838
+ </div>
839
+ {/* The condition and the reason are read before the command is
840
+ copied, so they precede it at a size that is meant to be read. */}
841
+ {step?.precondition ? (
842
+ <p
843
+ data-step-precondition
844
+ className="mb-1.5 text-[13px] leading-snug text-warning-text"
845
+ >
846
+ Only if {step.precondition.replace(/^(if|when|once)\s+/i, "")}
847
+ </p>
848
+ ) : null}
849
+ {isRec && diagnosis.recommendedReason && (
850
+ <p
851
+ data-recommended-reason
852
+ className="mb-1.5 text-[13px] leading-snug text-theme-text-secondary"
853
+ >
854
+ {diagnosis.recommendedReason}
855
+ </p>
856
+ )}
857
+ <AIMarkdown className="max-w-[100ch] text-sm [overflow-wrap:anywhere] [&_p]:my-0 [&_pre]:my-1.5">
858
+ {r}
859
+ </AIMarkdown>
860
+ </div>
861
+ </div>
862
+ </div>
863
+ );
864
+ };
865
+ return (
866
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
867
+ {showConclusion && storyShape && (
868
+ <div
869
+ className={
870
+ section === "conclusion"
871
+ ? ""
872
+ : "rounded-lg border border-theme-border bg-theme-surface p-3"
873
+ }
874
+ >
875
+ {readOnlyAssessment ? (
876
+ <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
877
+ Earlier assessment
878
+ </div>
879
+ ) : null}
880
+ <AssessmentHeadline
881
+ diagnosis={diagnosis}
882
+ tone="cause"
883
+ revisedAfter={revisedAfter}
884
+ limits={assessmentLimits}
885
+ copy={assessmentCopy}
886
+ />
887
+ </div>
888
+ )}
889
+ {/* Likely cause — agent-authored, visually prominent without claiming proof. */}
890
+ {showConclusion && !storyShape && rootCause && (
891
+ <div
892
+ className={
893
+ section === "conclusion"
894
+ ? "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2"
895
+ : "rounded-lg border border-theme-border bg-theme-surface p-3"
896
+ }
897
+ >
898
+ <div
899
+ className={
900
+ section === "conclusion"
901
+ ? "col-start-2 row-start-1"
902
+ : "mb-1 flex items-center justify-between gap-2"
903
+ }
904
+ >
905
+ {section !== "conclusion" && (
906
+ <div className="text-xs font-medium text-theme-text-secondary">
907
+ Likely cause
908
+ </div>
909
+ )}
910
+ <div className="flex items-center gap-2">
911
+ <CopyButton text={rootCause} label="Copy likely cause" />
912
+ </div>
913
+ </div>
914
+ <AIMarkdown
915
+ 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`}
916
+ >
917
+ {rootCause}
918
+ </AIMarkdown>
919
+ </div>
920
+ )}
921
+
922
+ {/* Remediation — every step is copyable. Only the explicitly recommended
923
+ step can be applied, and only when the caller enables apply. */}
924
+ {showActions && hasRemediation && (
925
+ <div
926
+ ref={stepsReveal.elementRef}
927
+ className={
928
+ section === "actions"
929
+ ? "space-y-2"
930
+ : "rounded-lg border border-theme-border bg-theme-surface p-3"
931
+ }
932
+ >
933
+ {section !== "actions" && (
934
+ <div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary">
935
+ <Wrench className="h-3.5 w-3.5 text-accent" />
936
+ {typedSteps ? "Next steps" : "Remediation"}
937
+ </div>
938
+ )}
939
+ {actionNotice && (
940
+ <p className="text-xs text-theme-text-secondary">{actionNotice}</p>
941
+ )}
942
+ <div id={stepsId}>
943
+ <ol>
944
+ {remediationEntries.map((entry) => {
945
+ const expanded =
946
+ !compactActions ||
947
+ showAllSteps ||
948
+ entry.index === primaryActionIndex;
949
+ const step = typedSteps ? steps[entry.index] : undefined;
950
+ return (
951
+ <li key={entry.index} value={entry.index + 1}>
952
+ <Collapse open={expanded} mountLazily>
953
+ <div className="pt-2">{renderRemediationStep(entry)}</div>
954
+ </Collapse>
955
+ {/* A folded step still shows what it is: its kind and
956
+ first line, one row each, so the alternatives are
957
+ readable before anyone opens them. */}
958
+ {!expanded ? (
959
+ <button
960
+ type="button"
961
+ data-step-folded={step?.kind ?? "step"}
962
+ onClick={() => {
963
+ setShowAllSteps(true);
964
+ stepsReveal.revealAfterToggle(true);
965
+ }}
966
+ 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"
967
+ >
968
+ {step ? (
969
+ <span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
970
+ {STEP_KIND_LABEL[step.kind]}
971
+ </span>
972
+ ) : (
973
+ <span className="shrink-0 text-[10px] font-semibold text-theme-text-tertiary">
974
+ {entry.index + 1}
975
+ </span>
976
+ )}
977
+ <span className="min-w-0 flex-1 truncate">
978
+ {remediationHeadline(entry.text)}
979
+ </span>
980
+ </button>
981
+ ) : null}
982
+ </li>
983
+ );
984
+ })}
985
+ </ol>
986
+ </div>
987
+ {compactActions && remediation.length > 1 ? (
988
+ <button
989
+ type="button"
990
+ aria-expanded={showAllSteps}
991
+ aria-controls={stepsId}
992
+ onClick={() => {
993
+ setShowAllSteps(!showAllSteps);
994
+ stepsReveal.revealAfterToggle(!showAllSteps);
995
+ }}
996
+ 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"
997
+ >
998
+ <CollapseChevron open={showAllSteps} className="h-3.5 w-3.5" />
999
+ {showAllSteps
1000
+ ? recValid
1001
+ ? "Show only recommended step"
1002
+ : "Show only first step"
1003
+ : hiddenStepCount === 1
1004
+ ? "Expand the other step"
1005
+ : "Expand all steps"}
1006
+ </button>
1007
+ ) : null}
1008
+ {!actionNotice && !recValid && (
1009
+ <p className="mt-2 flex items-start gap-1.5 text-[11px] leading-snug text-theme-text-tertiary">
1010
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1011
+ No one-click fix is available. Review these steps and apply them
1012
+ manually, or ask the agent to continue.
1013
+ </p>
1014
+ )}
1015
+ </div>
1016
+ )}
1017
+
1018
+ {/* Full analysis — the agent's detailed evidence, on demand. Under the
1019
+ story contract Findings renders the story itself, so only the
1020
+ explanation and sources remain here. */}
1021
+ {showConclusion &&
1022
+ ((showAnalysisDisclosure && diagnosis.report) ||
1023
+ (showAnalysisDisclosure && diagnosis.confidence != null) ||
1024
+ explanation ||
1025
+ assessmentAction ||
1026
+ assessmentSources) && (
1027
+ <div>
1028
+ <div
1029
+ className="flex flex-wrap items-center gap-x-3 gap-y-1 pt-2"
1030
+ data-assessment-actions
1031
+ >
1032
+ {((showAnalysisDisclosure && diagnosis.report) ||
1033
+ (showAnalysisDisclosure && diagnosis.confidence != null) ||
1034
+ assessmentSources) && (
1035
+ <button
1036
+ type="button"
1037
+ aria-expanded={showAnalysis}
1038
+ aria-controls={`${analysisId}-analysis`}
1039
+ onClick={() => {
1040
+ setDetail(showAnalysis ? null : "analysis");
1041
+ analysisReveal.revealAfterToggle(!showAnalysis);
1042
+ }}
1043
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
1044
+ >
1045
+ <CollapseChevron
1046
+ open={showAnalysis}
1047
+ className="h-3.5 w-3.5"
1048
+ />
1049
+ {showAnalysisDisclosure && diagnosis.report
1050
+ ? "Full analysis"
1051
+ : "Assessment details"}
1052
+ </button>
1053
+ )}
1054
+ {explanation && (
1055
+ <Tooltip
1056
+ content={
1057
+ explanation.status === "idle"
1058
+ ? explanation.onGenerate
1059
+ ? "Ask the agent to explain this assessment and its proposed next steps in plain language."
1060
+ : "Wait for the current agent request to finish before requesting an explanation."
1061
+ : explanation.status === "running"
1062
+ ? "The agent is preparing an explanation. You can close this and return while it runs."
1063
+ : explanation.status === "error"
1064
+ ? "View the explanation error and retry when the agent is available."
1065
+ : "Show the saved plain-language explanation. No new request is needed."
1066
+ }
1067
+ >
1068
+ <button
1069
+ type="button"
1070
+ aria-expanded={detail === "explanation"}
1071
+ aria-controls={`${analysisId}-explanation`}
1072
+ disabled={
1073
+ explanation.status === "idle" && !explanation.onGenerate
1074
+ }
1075
+ onClick={() => {
1076
+ const open = detail !== "explanation";
1077
+ setDetail(open ? "explanation" : null);
1078
+ analysisReveal.revealAfterToggle(open);
1079
+ if (open && explanation.status === "idle")
1080
+ explanation.onGenerate?.();
1081
+ }}
1082
+ 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"
1083
+ >
1084
+ <HelpCircle className="h-3 w-3" />
1085
+ Explain simply
1086
+ </button>
1087
+ </Tooltip>
1088
+ )}
1089
+ {assessmentAction && (
1090
+ <div className="ml-auto">{assessmentAction}</div>
1091
+ )}
1092
+ </div>
1093
+ <div id={analysisId} ref={analysisReveal.elementRef}>
1094
+ <div id={`${analysisId}-analysis`}>
1095
+ <Collapse open={detail === "analysis"} mountLazily>
1096
+ <div className="border-t border-theme-border/60 px-3 py-2">
1097
+ {showAnalysisDisclosure ? (
1098
+ <>
1099
+ <p className="mb-2 text-xs text-theme-text-tertiary">
1100
+ Agent confidence:{" "}
1101
+ {diagnosis.confidence != null
1102
+ ? confidenceLabel(diagnosis.confidence)
1103
+ : "not stated"}
1104
+ {diagnosis.confidence != null
1105
+ ? " · self-reported"
1106
+ : ""}
1107
+ </p>
1108
+ <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">
1109
+ {analysisText}
1110
+ </AIMarkdown>
1111
+ </>
1112
+ ) : null}
1113
+ {assessmentSources}
1114
+ </div>
1115
+ </Collapse>
1116
+ </div>
1117
+ <div id={`${analysisId}-explanation`}>
1118
+ <Collapse open={detail === "explanation"} mountLazily>
1119
+ <div className="border-t border-theme-border/60 px-3 py-2">
1120
+ {explanation?.status === "running" ? (
1121
+ <div
1122
+ role="status"
1123
+ className="flex items-center gap-2 py-2 text-sm text-theme-text-secondary"
1124
+ >
1125
+ <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
1126
+ Explaining this assessment…
1127
+ </div>
1128
+ ) : explanation?.status === "done" ? (
1129
+ <div className="flex items-start gap-2">
1130
+ <AIMarkdown className="min-w-0 flex-1 text-sm text-theme-text-secondary [overflow-wrap:anywhere]">
1131
+ {explanation.text || ""}
1132
+ </AIMarkdown>
1133
+ <CopyButton
1134
+ text={explanation.text || ""}
1135
+ label="Copy explanation"
1136
+ />
1137
+ </div>
1138
+ ) : explanation?.status === "error" ? (
1139
+ <div
1140
+ role="alert"
1141
+ className="flex flex-wrap items-center gap-2 py-2 text-sm text-theme-text-secondary"
1142
+ >
1143
+ <span>
1144
+ {explanation.error ||
1145
+ "The agent did not return an explanation."}
1146
+ </span>
1147
+ {explanation.onGenerate && (
1148
+ <button
1149
+ type="button"
1150
+ onClick={explanation.onGenerate}
1151
+ className="rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover"
1152
+ >
1153
+ Try again
1154
+ </button>
1155
+ )}
1156
+ </div>
1157
+ ) : null}
1158
+ </div>
1159
+ </Collapse>
1160
+ </div>
1161
+ </div>
1162
+ </div>
1163
+ )}
1164
+
1165
+ {showConclusion && showDisclaimer && (
1166
+ <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1167
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1168
+ <span>AI-generated — review before applying</span>
1169
+ </div>
1170
+ )}
1171
+ </div>
1172
+ );
1173
+ }
1174
+
1175
+ /** The banner line and the clipboard line for one adverse Radar card must be the same words. */
1176
+ function healthFlagSentence(flag: {
1177
+ status: string;
1178
+ title: string;
1179
+ role?: string;
1180
+ }): string {
1181
+ return flag.status === "contradiction"
1182
+ ? `The agent calls ${flag.title} a ${flag.role} and still reports healthy`
1183
+ : `Radar flagged ${flag.title} · no explanation is linked to it`;
1184
+ }
1185
+
1186
+ export function AllClearCard({
1187
+ diagnosis,
1188
+ animate,
1189
+ showDisclaimer,
1190
+ coverageLimited,
1191
+ evidenceConflict,
1192
+ evidenceConflictExplainedBy,
1193
+ assessmentAction,
1194
+ assessmentSources,
1195
+ storyInline = false,
1196
+ revisedAfter,
1197
+ assessmentLimits,
1198
+ assessmentCopy,
1199
+ healthSignals,
1200
+ onRevealSource,
1201
+ }: {
1202
+ diagnosis: Diagnosis;
1203
+ animate: boolean;
1204
+ showDisclaimer: boolean;
1205
+ coverageLimited: boolean;
1206
+ evidenceConflict: boolean;
1207
+ evidenceConflictExplainedBy?: string[];
1208
+ assessmentAction?: ReactNode;
1209
+ assessmentSources?: ReactNode;
1210
+ storyInline?: boolean;
1211
+ revisedAfter?: string;
1212
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
1213
+ assessmentLimits?: string[];
1214
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
1215
+ /** Adverse Radar cards with the agent's position on each (story shape). */
1216
+ healthSignals?: InvestigationHealthSignal[];
1217
+ onRevealSource?: (sourceId: string) => void;
1218
+ }) {
1219
+ const storyShape = diagnosisHasStoryShape(diagnosis);
1220
+ const [showAnalysis, setShowAnalysis] = useState(false);
1221
+ const analysisReveal = useDisclosureReveal<HTMLDivElement>();
1222
+ const analysisId = useId();
1223
+ const report =
1224
+ (storyShape && !storyInline ? "" : storyPlainText(diagnosis.report)) ||
1225
+ (storyShape
1226
+ ? ""
1227
+ : "The agent did not identify a problem in the evidence it checked.");
1228
+ const detailed = report.length > 320 || report.split("\n").length > 2;
1229
+ const summary = storyShape
1230
+ ? ""
1231
+ : detailed
1232
+ ? "The agent found no active problem in the evidence it reviewed."
1233
+ : report;
1234
+ const explained =
1235
+ evidenceConflict &&
1236
+ !!evidenceConflictExplainedBy &&
1237
+ evidenceConflictExplainedBy.length > 0;
1238
+ const unexplainedConflict = evidenceConflict && !explained;
1239
+ // Story shape: the summary and the certainty word are the verdict; adverse
1240
+ // Radar cards the agent explained become lines in Still open, and only a
1241
+ // card the agent's verdict never addressed (or contradicts) keeps a header
1242
+ // that names it — so the reader knows exactly how it relates to the answer.
1243
+ const signals = healthSignals ?? [];
1244
+ const flagged = signals.filter(
1245
+ (signal) =>
1246
+ signal.status === "unaddressed" || signal.status === "contradiction",
1247
+ );
1248
+ const stillOpenSignals = signals
1249
+ .filter(
1250
+ (signal) => signal.status === "explained" || signal.status === "related",
1251
+ )
1252
+ .map((signal) => ({
1253
+ text:
1254
+ signal.status === "explained"
1255
+ ? `Radar flagged ${signal.title} · the agent looked at it and reads it as not a live problem: ${signal.claim}`
1256
+ : `Radar flagged ${signal.title} · the agent reads it as related, but not what matters here: ${signal.claim}`,
1257
+ onReveal:
1258
+ signal.sourceId && onRevealSource
1259
+ ? () => onRevealSource(signal.sourceId!)
1260
+ : undefined,
1261
+ }));
1262
+ // The analysis disclosure (Activity's read-only copy of an earlier healthy
1263
+ // assessment carries its story inline as plain prose), the action slot and
1264
+ // the disclaimer are the same in both shapes.
1265
+ const trailing = (
1266
+ <>
1267
+ {detailed || assessmentAction || assessmentSources ? (
1268
+ <div>
1269
+ <div
1270
+ className="flex flex-wrap items-center gap-3 pt-2"
1271
+ data-assessment-actions
1272
+ >
1273
+ {(detailed || assessmentSources) && (
1274
+ <button
1275
+ type="button"
1276
+ aria-expanded={showAnalysis}
1277
+ aria-controls={analysisId}
1278
+ onClick={() => {
1279
+ setShowAnalysis(!showAnalysis);
1280
+ analysisReveal.revealAfterToggle(!showAnalysis);
1281
+ }}
1282
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
1283
+ >
1284
+ <CollapseChevron open={showAnalysis} className="h-3.5 w-3.5" />
1285
+ {detailed ? "Full analysis" : "Assessment details"}
1286
+ </button>
1287
+ )}
1288
+ {assessmentAction && (
1289
+ <div className="ml-auto">{assessmentAction}</div>
1290
+ )}
1291
+ </div>
1292
+ <div id={analysisId} ref={analysisReveal.elementRef}>
1293
+ <Collapse open={showAnalysis}>
1294
+ <div className="border-t border-theme-border/60 px-3 py-2">
1295
+ <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">
1296
+ {detailed ? report : ""}
1297
+ </AIMarkdown>
1298
+ {assessmentSources}
1299
+ </div>
1300
+ </Collapse>
1301
+ </div>
1302
+ </div>
1303
+ ) : null}
1304
+ {showDisclaimer ? (
1305
+ <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1306
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1307
+ <span>AI-generated — verify if symptoms persist</span>
1308
+ </div>
1309
+ ) : null}
1310
+ </>
1311
+ );
1312
+ if (storyShape) {
1313
+ return (
1314
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
1315
+ {flagged.map((flag) => (
1316
+ <div
1317
+ key={`${flag.groupId ?? flag.title}-${flag.status}`}
1318
+ data-health-flag={flag.status}
1319
+ className="rounded-md border border-amber-500/40 bg-amber-500/5 px-2.5 py-2 text-xs text-theme-text-primary"
1320
+ >
1321
+ <div className="flex items-center gap-1.5 font-semibold text-amber-500">
1322
+ <AlertTriangle className="h-3.5 w-3.5" aria-hidden />
1323
+ {healthFlagSentence(flag)}
1324
+ </div>
1325
+ <p className="mt-1 text-theme-text-secondary">
1326
+ {flag.status === "contradiction"
1327
+ ? "Read the card before treating this as an all-clear."
1328
+ : "It may be unrelated to what you asked, or missed. Open it before treating this as an all-clear."}
1329
+ {flag.sourceId && onRevealSource ? (
1330
+ <>
1331
+ {" "}
1332
+ <button
1333
+ type="button"
1334
+ onClick={() => onRevealSource(flag.sourceId!)}
1335
+ className="font-medium text-accent-text hover:underline"
1336
+ >
1337
+ View the card
1338
+ </button>
1339
+ </>
1340
+ ) : null}
1341
+ </p>
1342
+ </div>
1343
+ ))}
1344
+ <AssessmentHeadline
1345
+ diagnosis={diagnosis}
1346
+ tone="healthy"
1347
+ revisedAfter={revisedAfter}
1348
+ flags={flagged.map(healthFlagSentence)}
1349
+ limits={assessmentLimits}
1350
+ copy={assessmentCopy}
1351
+ signals={stillOpenSignals}
1352
+ />
1353
+ {trailing}
1354
+ </div>
1355
+ );
1356
+ }
1357
+ return (
1358
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
1359
+ <div
1360
+ className={`rounded-lg border p-3 ${
1361
+ unexplainedConflict || explained
1362
+ ? "border-amber-500/40 bg-amber-500/5"
1363
+ : coverageLimited
1364
+ ? "border-amber-500/30 bg-amber-500/5"
1365
+ : "border-emerald-500/30 bg-emerald-500/5"
1366
+ }`}
1367
+ >
1368
+ <div className="mb-1 flex items-center justify-between gap-2">
1369
+ <div
1370
+ className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${
1371
+ unexplainedConflict || explained || coverageLimited
1372
+ ? "text-amber-500"
1373
+ : "text-emerald-500"
1374
+ }`}
1375
+ >
1376
+ {unexplainedConflict || explained || coverageLimited ? (
1377
+ <AlertTriangle className="h-3.5 w-3.5" />
1378
+ ) : (
1379
+ <CheckCircle2 className="h-3.5 w-3.5" />
1380
+ )}
1381
+ {unexplainedConflict
1382
+ ? "Assessment conflicts with captured evidence"
1383
+ : explained
1384
+ ? "Agent reports no active problem; adverse evidence remains"
1385
+ : coverageLimited
1386
+ ? "No problem identified in available evidence"
1387
+ : "No problem found in checked evidence"}
1388
+ </div>
1389
+ <CopyButton text={report} label="Copy assessment" />
1390
+ </div>
1391
+ <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">
1392
+ {summary}
1393
+ </AIMarkdown>
1394
+ {unexplainedConflict ? (
1395
+ <p className="mt-2 text-xs text-theme-text-secondary">
1396
+ Radar also captured evidence of an active problem. Review that
1397
+ evidence before treating the agent&apos;s conclusion as an
1398
+ all-clear.
1399
+ </p>
1400
+ ) : explained ? (
1401
+ <p className="mt-2 text-xs text-theme-text-secondary">
1402
+ Radar captured evidence of an active problem. The agent explains its
1403
+ interpretation in the note on{" "}
1404
+ {joinTitles(evidenceConflictExplainedBy!)}.
1405
+ {coverageLimited
1406
+ ? " Evidence coverage is also limited — review the limitations in Evidence."
1407
+ : ""}
1408
+ </p>
1409
+ ) : coverageLimited ? (
1410
+ <p className="mt-2 text-xs text-theme-text-secondary">
1411
+ Evidence coverage is limited. Review the limitations in Evidence
1412
+ before treating this as an all-clear.
1413
+ </p>
1414
+ ) : null}
1415
+ </div>
1416
+ {trailing}
1417
+ </div>
1418
+ );
1419
+ }
1420
+
1421
+ // The agent investigated but couldn't determine an answer. A distinct, honest
1422
+ // state — neutral (not the alarming amber root cause, not the reassuring emerald
1423
+ // all-clear) — so "I couldn't tell" never reads as "you're fine."
1424
+ export function InconclusiveCard({
1425
+ diagnosis,
1426
+ animate,
1427
+ storyInline = false,
1428
+ revisedAfter,
1429
+ assessmentLimits,
1430
+ assessmentCopy,
1431
+ }: {
1432
+ diagnosis: Diagnosis;
1433
+ animate: boolean;
1434
+ storyInline?: boolean;
1435
+ revisedAfter?: string;
1436
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
1437
+ assessmentLimits?: string[];
1438
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
1439
+ }) {
1440
+ const storyShape = diagnosisHasStoryShape(diagnosis);
1441
+ const text =
1442
+ (storyShape && !storyInline ? "" : storyPlainText(diagnosis.report)) ||
1443
+ (storyShape
1444
+ ? ""
1445
+ : "The investigation couldn't reach a clear conclusion — some information was unavailable or the evidence was ambiguous.");
1446
+ return (
1447
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
1448
+ <div className="rounded-lg border border-theme-border bg-theme-elevated p-3">
1449
+ <div className="mb-1 flex items-center justify-between gap-2">
1450
+ <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">
1451
+ <HelpCircle className="h-3.5 w-3.5" />
1452
+ Couldn&apos;t determine
1453
+ </div>
1454
+ {storyShape ? null : (
1455
+ <CopyButton text={text} label="Copy assessment" />
1456
+ )}
1457
+ </div>
1458
+ {storyShape ? (
1459
+ <AssessmentHeadline
1460
+ diagnosis={diagnosis}
1461
+ tone="inconclusive"
1462
+ revisedAfter={revisedAfter}
1463
+ limits={assessmentLimits}
1464
+ copy={assessmentCopy}
1465
+ />
1466
+ ) : null}
1467
+ {text ? (
1468
+ <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">
1469
+ {text}
1470
+ </AIMarkdown>
1471
+ ) : null}
1472
+ </div>
1473
+ <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1474
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1475
+ <span>
1476
+ Try a follow-up with more context, or investigate again after
1477
+ addressing any errors shown in Activity.
1478
+ </span>
1479
+ </div>
1480
+ </div>
1481
+ );
1482
+ }
1483
+
1484
+ const COMMAND_BINARIES = new Set([
1485
+ "kubectl",
1486
+ "helm",
1487
+ "argocd",
1488
+ "flux",
1489
+ "kustomize",
1490
+ "docker",
1491
+ "gcloud",
1492
+ "aws",
1493
+ "az",
1494
+ "mongosh",
1495
+ "psql",
1496
+ "redis-cli",
1497
+ "curl",
1498
+ "git",
1499
+ "istioctl",
1500
+ "velero",
1501
+ "cilium",
1502
+ "calicoctl",
1503
+ "terraform",
1504
+ "kn",
1505
+ "oc",
1506
+ "k9s",
1507
+ "skyhook",
1508
+ ]);
1509
+
1510
+ /** The first sentence of a step, without its markdown, for a one-line row. */
1511
+ export function remediationHeadline(step: string): string {
1512
+ const firstLine =
1513
+ step
1514
+ .split(/\r?\n/)
1515
+ .map((line) => line.trim())
1516
+ .find((line) => line && !line.startsWith("```")) ?? "";
1517
+ const sentence = firstLine.split(/(?<=[.!?:])\s/)[0] ?? firstLine;
1518
+ // A step written as "Confirm recovery:" expects its command to follow; as a
1519
+ // folded title the colon dangles.
1520
+ return sentence.replace(/`/g, "").replace(/\*\*/g, "").replace(/:$/, "");
1521
+ }
1522
+
1523
+ /**
1524
+ * The commands inside a remediation step, in order: every fenced block and
1525
+ * every inline code span that reads as a shell invocation. A step's prose is
1526
+ * never worth copying; a command is. The prompt asks the agent to wrap
1527
+ * commands in backticks, so this is the seam to read them from.
1528
+ */
1529
+ export function remediationCommands(step: string): string[] {
1530
+ const commands: string[] = [];
1531
+ // One pass in reading order, so button N is the Nth command in the text.
1532
+ const code = /```[a-zA-Z]*\n([\s\S]*?)```|`([^`\n]+)`/g;
1533
+ let match: RegExpExecArray | null;
1534
+ while ((match = code.exec(step))) {
1535
+ if (match[1] !== undefined) {
1536
+ const body = match[1].trim();
1537
+ if (body) commands.push(body);
1538
+ continue;
1539
+ }
1540
+ const span = match[2].trim();
1541
+ if (looksLikeCommand(span)) commands.push(span);
1542
+ }
1543
+ return commands;
1544
+ }
1545
+
1546
+ function looksLikeCommand(span: string): boolean {
1547
+ if (!/\s/.test(span)) return false;
1548
+ const first = span.split(/\s+/)[0];
1549
+ if (COMMAND_BINARIES.has(first)) return true;
1550
+ // An unknown binary still reads as a command when it takes flags.
1551
+ return /^[a-z][a-z0-9._-]*$/.test(first) && /(^|\s)--?[a-zA-Z]/.test(span);
1552
+ }
1553
+
1554
+ // A coarse band, not a precise %: a two-sig-fig confidence on an LLM judgement
1555
+ // reads as calibrated when it isn't.
1556
+ function confidenceLabel(c: number): string {
1557
+ if (c >= 0.8) return "High";
1558
+ if (c >= 0.5) return "Medium";
1559
+ return "Low";
1560
+ }