@skyhook-io/radar-app 1.14.2 → 1.14.4

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 (83) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +32 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/CloudConnectFlow.tsx +21 -7
  5. package/src/components/CloudFunnelButton.tsx +85 -8
  6. package/src/components/cloudConnectHandoff.test.ts +10 -1
  7. package/src/components/cloudConnectHandoff.ts +15 -1
  8. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  9. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  10. package/src/components/diagnose/AgentControls.tsx +603 -0
  11. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  12. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  13. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  14. package/src/components/diagnose/AssessmentCard.tsx +1579 -0
  15. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  16. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  17. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  18. package/src/components/diagnose/Home.test.tsx +1 -3
  19. package/src/components/diagnose/Home.tsx +2 -1
  20. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  21. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  22. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  23. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  24. package/src/components/diagnose/InvestigationView.tsx +39 -51
  25. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  26. package/src/components/diagnose/assessmentCopy.ts +169 -0
  27. package/src/components/diagnose/investigationCase.test.tsx +658 -2
  28. package/src/components/diagnose/investigationCase.ts +135 -26
  29. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  31. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  32. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  34. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  41. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  47. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  48. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  49. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  50. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  51. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  52. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  53. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  54. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  55. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  57. package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
  58. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  59. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  60. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  61. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  62. package/src/components/diagnose/investigationState.test.ts +2 -2
  63. package/src/components/diagnose/investigationState.ts +2 -2
  64. package/src/components/diagnose/investigationStory.ts +1 -1
  65. package/src/components/diagnose/parts.test.tsx +15 -1
  66. package/src/components/diagnose/parts.tsx +26 -3904
  67. package/src/components/diagnose/target.ts +9 -3
  68. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  69. package/src/components/diagnose/toolCallLabel.ts +41 -2
  70. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  71. package/src/components/home/MCPSetupDialog.tsx +1 -1
  72. package/src/components/home/mcpToolCatalog.ts +27 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  74. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  75. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  76. package/src/components/resources/PodFilePreview.tsx +1 -1
  77. package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
  78. package/src/components/ui/CommandPalette.tsx +12 -8
  79. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  80. package/src/components/ui/Omnibar.tsx +96 -60
  81. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  82. package/src/components/workload/WorkloadView.tsx +7 -1
  83. package/src/components/ui/Disclosure.tsx +0 -47
@@ -0,0 +1,1579 @@
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
+ omittedEntries = 0,
450
+ readOnly = false,
451
+ renderedGroupIds,
452
+ onViewSource,
453
+ }: {
454
+ resolution?: InvestigationRootCauseEvidenceResolution;
455
+ investigationCase?: InvestigationCaseResolution;
456
+ /**
457
+ * Notes the agent wrote that could not be tied to a Radar result: a
458
+ * reference that named nothing, a role Radar does not know, a sentence over
459
+ * the length limit. Radar does not repair them, because repairing one means
460
+ * deciding what the agent meant, so it says how many were lost instead.
461
+ */
462
+ unlinkedEvidence?: number;
463
+ /**
464
+ * The agent's notes were not a list at all, so none of them could be read
465
+ * and no count describes how many were lost.
466
+ */
467
+ evidenceMalformed?: boolean;
468
+ /**
469
+ * Next steps, open items and ruled-out hypotheses the server left out of
470
+ * the record because the list ran past the page's limit. They were valid;
471
+ * the reader learns the list was longer than what is shown.
472
+ */
473
+ omittedEntries?: number;
474
+ readOnly?: boolean;
475
+ /**
476
+ * Groups the Evidence pane actually rendered. A card-placed note whose card
477
+ * was withheld is shown here instead of being counted as visible elsewhere;
478
+ * without this the note renders in neither place. Omitted by hosts that do
479
+ * not know, which keeps the original counting.
480
+ */
481
+ renderedGroupIds?: ReadonlySet<string>;
482
+ onViewSource: (sourceId: string) => void;
483
+ }) {
484
+ const rows = assessmentSourceRows(resolution, investigationCase);
485
+ if (
486
+ rows.length === 0 &&
487
+ unlinkedEvidence === 0 &&
488
+ !evidenceMalformed &&
489
+ omittedEntries === 0
490
+ )
491
+ return null;
492
+ return (
493
+ <div className="mt-3 border-t border-theme-border/60 pt-2">
494
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
495
+ Sources used for this assessment
496
+ <span className="ml-1.5 font-medium normal-case tracking-normal text-theme-text-tertiary">
497
+ {rows.length}
498
+ </span>
499
+ </h4>
500
+ <ul className="mt-1 divide-y divide-theme-border/50">
501
+ {rows.map(({ source, items }) => {
502
+ const shownOnCard = (item: InvestigationCaseItem) =>
503
+ !!item.claim &&
504
+ item.placement !== "source" &&
505
+ (!renderedGroupIds ||
506
+ (!!item.groupId && renderedGroupIds.has(item.groupId)));
507
+ const onCards = items.filter(shownOnCard).length;
508
+ const notes = items.filter(
509
+ (item) => item.claim && (readOnly || !shownOnCard(item)),
510
+ );
511
+ return (
512
+ <li key={source.id} className="py-1.5">
513
+ <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2 px-2">
514
+ <FileSearch
515
+ className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
516
+ aria-hidden
517
+ />
518
+ <div className="min-w-0 text-xs">
519
+ <div className="font-medium text-theme-text-primary">
520
+ {prettyTool(source.tool)}
521
+ </div>
522
+ <CitedSourceScope source={source} />
523
+ {!readOnly && onCards > 0 ? (
524
+ <div className="mt-0.5 text-[11px] text-theme-text-tertiary">
525
+ {onCards === 1
526
+ ? "1 agent note on an evidence card"
527
+ : `${onCards} agent notes on evidence cards`}
528
+ </div>
529
+ ) : null}
530
+ </div>
531
+ <button
532
+ type="button"
533
+ aria-label={`View ${prettyTool(source.tool)} result used for this assessment`}
534
+ onClick={() => onViewSource(source.id)}
535
+ 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"
536
+ >
537
+ View result
538
+ </button>
539
+ </div>
540
+ {notes.length > 0 ? (
541
+ <div
542
+ className="ml-[1.9rem] mr-2 mt-1.5 space-y-1.5"
543
+ data-source-placed-claims
544
+ >
545
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
546
+ {readOnly
547
+ ? "Notes from this assessment"
548
+ : "Notes not shown on a card"}
549
+ </div>
550
+ {notes.map((item) => (
551
+ <AgentClaimNote
552
+ key={item.index}
553
+ claim={item.claim}
554
+ role={item.role}
555
+ subject={
556
+ item.placement === "source"
557
+ ? undefined
558
+ : item.observation?.title
559
+ }
560
+ className="border-t-0"
561
+ />
562
+ ))}
563
+ </div>
564
+ ) : null}
565
+ </li>
566
+ );
567
+ })}
568
+ </ul>
569
+ {evidenceMalformed ? (
570
+ <p className="mt-2 text-[11px] text-theme-text-tertiary">
571
+ The agent&apos;s notes could not be read, so none are shown.
572
+ </p>
573
+ ) : null}
574
+ {unlinkedEvidence > 0 ? (
575
+ <p className="mt-2 text-[11px] text-theme-text-tertiary">
576
+ {unlinkedEvidence === 1
577
+ ? "1 agent note could not be linked to a Radar result and is not shown."
578
+ : `${unlinkedEvidence} agent notes could not be linked to Radar results and are not shown.`}
579
+ </p>
580
+ ) : null}
581
+ {omittedEntries > 0 ? (
582
+ <p className="mt-2 text-[11px] text-theme-text-tertiary">
583
+ {omittedEntries === 1
584
+ ? "1 next step, open item or ruled-out hypothesis went past the page's limit and is not shown."
585
+ : `${omittedEntries} next steps, open items or ruled-out hypotheses went past the page's limits and are not shown.`}
586
+ </p>
587
+ ) : null}
588
+ </div>
589
+ );
590
+ }
591
+
592
+ export function WorkingNotes({ notes }: { notes: string }) {
593
+ const [open, setOpen] = useState(false);
594
+ const id = useId();
595
+ const reveal = useDisclosureReveal<HTMLDivElement>();
596
+ return (
597
+ <div ref={reveal.elementRef} data-turn-working-notes>
598
+ <button
599
+ type="button"
600
+ aria-expanded={open}
601
+ aria-controls={id}
602
+ onClick={() => {
603
+ setOpen(!open);
604
+ reveal.revealAfterToggle(!open);
605
+ }}
606
+ className="flex items-center gap-1.5 rounded-md py-1 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
607
+ >
608
+ <CollapseChevron open={open} className="h-3.5 w-3.5" />
609
+ Evidence considered
610
+ </button>
611
+ <div id={id}>
612
+ <Collapse open={open}>
613
+ <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">
614
+ {notes}
615
+ </AIMarkdown>
616
+ </Collapse>
617
+ </div>
618
+ </div>
619
+ );
620
+ }
621
+
622
+ function AssessmentSourceDetails({ children }: { children: ReactNode }) {
623
+ const [open, setOpen] = useState(false);
624
+ const id = useId();
625
+ const reveal = useDisclosureReveal<HTMLDivElement>();
626
+ return (
627
+ <div ref={reveal.elementRef}>
628
+ <button
629
+ type="button"
630
+ aria-expanded={open}
631
+ aria-controls={id}
632
+ onClick={() => {
633
+ setOpen(!open);
634
+ reveal.revealAfterToggle(!open);
635
+ }}
636
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
637
+ >
638
+ <CollapseChevron open={open} className="h-3.5 w-3.5" />
639
+ Assessment details
640
+ </button>
641
+ <div id={id}>
642
+ <Collapse open={open}>{children}</Collapse>
643
+ </div>
644
+ </div>
645
+ );
646
+ }
647
+
648
+ function CitedSourceScope({ source }: { source: InvestigationEvidenceSource }) {
649
+ let input: unknown;
650
+ try {
651
+ input = JSON.parse(source.args ?? "");
652
+ } catch {
653
+ return null;
654
+ }
655
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
656
+ const args = input as Record<string, unknown>;
657
+ const fields = ["query", "kind", "namespace", "name", "filter"].flatMap(
658
+ (key) =>
659
+ typeof args[key] === "string" && args[key].trim()
660
+ ? [`${key === "query" ? "" : `${key}: `}${args[key]}`]
661
+ : [],
662
+ );
663
+ return fields.length > 0 ? (
664
+ <span className="mt-0.5 block break-words text-theme-text-secondary [overflow-wrap:anywhere]">
665
+ {fields.join(" · ")}
666
+ </span>
667
+ ) : null;
668
+ }
669
+
670
+ export type AssessmentExplanation = {
671
+ status: "idle" | "running" | "done" | "error";
672
+ text?: string;
673
+ error?: string;
674
+ onGenerate?: () => void;
675
+ openRequest?: number;
676
+ };
677
+
678
+ // The diagnosis result: likely cause + remediation + the
679
+ // agent's full analysis on demand.
680
+ function DiagnosisResult({
681
+ diagnosis,
682
+ onApply,
683
+ explanation,
684
+ section = "full",
685
+ animate,
686
+ showDisclaimer,
687
+ compactActions,
688
+ assessmentAction,
689
+ assessmentSources,
690
+ actionNotice,
691
+ storyInline = false,
692
+ readOnlyAssessment = false,
693
+ revisedAfter,
694
+ assessmentLimits,
695
+ assessmentCopy,
696
+ }: {
697
+ diagnosis: Diagnosis;
698
+ onApply?: (fix: string) => void;
699
+ explanation?: AssessmentExplanation;
700
+ section?: "full" | "conclusion" | "actions";
701
+ animate: boolean;
702
+ showDisclaimer: boolean;
703
+ compactActions: boolean;
704
+ assessmentAction?: ReactNode;
705
+ assessmentSources?: ReactNode;
706
+ actionNotice?: string;
707
+ storyInline?: boolean;
708
+ readOnlyAssessment?: boolean;
709
+ revisedAfter?: string;
710
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
711
+ assessmentLimits?: string[];
712
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
713
+ }) {
714
+ // The story contract: a summary headline above, the story rendered by the
715
+ // host (or inline as plain prose), typed steps below.
716
+ const storyShape = diagnosisHasStoryShape(diagnosis);
717
+ const analysisText =
718
+ storyShape && storyInline
719
+ ? storyPlainText(diagnosis.report)
720
+ : diagnosis.report;
721
+ const showAnalysisDisclosure = !storyShape || storyInline;
722
+ const [detail, setDetail] = useState<"analysis" | "explanation" | null>(
723
+ explanation?.status === "running" ? "explanation" : null,
724
+ );
725
+ const showAnalysis = detail === "analysis";
726
+ const analysisReveal = useDisclosureReveal<HTMLDivElement>();
727
+ const { elementRef: analysisElementRef, revealAfterToggle: revealAnalysis } =
728
+ analysisReveal;
729
+ useEffect(() => {
730
+ if (explanation?.openRequest) {
731
+ setDetail("explanation");
732
+ revealAnalysis(true);
733
+ }
734
+ }, [explanation?.openRequest, revealAnalysis]);
735
+ useEffect(() => {
736
+ if (
737
+ detail === "explanation" &&
738
+ (explanation?.status === "done" || explanation?.status === "error")
739
+ ) {
740
+ const element = analysisElementRef.current;
741
+ const scroller = element?.closest("[data-investigation-findings-scroll]");
742
+ if (element && scroller) {
743
+ const top = element.getBoundingClientRect().top;
744
+ const viewport = scroller.getBoundingClientRect();
745
+ if (top >= viewport.top && top < viewport.bottom) revealAnalysis(true);
746
+ }
747
+ }
748
+ }, [explanation?.status, detail, analysisElementRef, revealAnalysis]);
749
+ const [showAllSteps, setShowAllSteps] = useState(false);
750
+ const stepsId = useId();
751
+ const stepsReveal = useDisclosureReveal<HTMLDivElement>();
752
+ const analysisId = useId();
753
+ // Only a real structured cause anchors the amber card; the full prose lives in
754
+ // "Full analysis" (never relabel the report as a causal assessment).
755
+ const rootCause = diagnosis.rootCause;
756
+ const remediation = diagnosis.remediation || [];
757
+ const steps = diagnosis.steps ?? [];
758
+ const typedSteps = steps.length === remediation.length && steps.length > 0;
759
+ const hasRemediation = remediation.length > 0;
760
+ const recIdx = diagnosis.recommendedIndex;
761
+ const recValid =
762
+ recIdx != null &&
763
+ recIdx >= 1 &&
764
+ recIdx <= remediation.length &&
765
+ (!typedSteps || steps[recIdx - 1].kind === "mitigate");
766
+ // Apply is offered ONLY when the agent pointed at a safe step (recommended_index).
767
+ // When it returns 0 / none ("needs human judgement"), we honor that and don't
768
+ // offer one-click apply — the steps stay copy-only with a note.
769
+ const canApply = !!onApply && recValid;
770
+ const showConclusion = section !== "actions";
771
+ const showActions = section !== "conclusion";
772
+ const remediationEntries = remediation.map((text, index) => ({
773
+ text,
774
+ index,
775
+ }));
776
+ const primaryActionIndex = recValid ? recIdx! - 1 : 0;
777
+ const hiddenStepCount = remediationEntries.length - 1;
778
+ const renderRemediationStep = ({
779
+ text: r,
780
+ index: i,
781
+ }: {
782
+ text: string;
783
+ index: number;
784
+ }) => {
785
+ const isRec = recValid && i === recIdx! - 1;
786
+ const step = typedSteps ? steps[i] : undefined;
787
+ return (
788
+ <div
789
+ key={i}
790
+ data-step-kind={step?.kind}
791
+ className={
792
+ isRec ? "rounded-lg border border-accent/40 bg-accent/5 p-2.5" : ""
793
+ }
794
+ >
795
+ <div className="flex items-start gap-2">
796
+ <span
797
+ className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] ${
798
+ isRec
799
+ ? "bg-accent/20 text-accent"
800
+ : "bg-theme-base text-theme-text-tertiary"
801
+ }`}
802
+ >
803
+ {step ? (
804
+ step.kind === "mitigate" ? (
805
+ <Wrench className="h-2.5 w-2.5" aria-hidden />
806
+ ) : step.kind === "verify" ? (
807
+ <ListChecks className="h-2.5 w-2.5" aria-hidden />
808
+ ) : (
809
+ <Search className="h-2.5 w-2.5" aria-hidden />
810
+ )
811
+ ) : (
812
+ i + 1
813
+ )}
814
+ </span>
815
+ <div className="min-w-0 flex-1">
816
+ {/* Labels, then the action cluster pushed to the row's end, then
817
+ the reason on its own line — so the step text below spans the
818
+ card instead of wrapping beside the buttons. */}
819
+ <div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
820
+ {(isRec || step) && (
821
+ <>
822
+ {step ? (
823
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
824
+ {STEP_KIND_LABEL[step.kind]}
825
+ </span>
826
+ ) : null}
827
+ {isRec && (
828
+ <span className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-accent">
829
+ <Sparkles className="h-3 w-3" />
830
+ Recommended
831
+ </span>
832
+ )}
833
+ </>
834
+ )}
835
+ <div className="ml-auto flex shrink-0 items-center gap-0.5">
836
+ {canApply && isRec && (
837
+ <button
838
+ onClick={() => onApply!(r)}
839
+ 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"
840
+ >
841
+ <Wrench className="h-3 w-3" />
842
+ Apply…
843
+ </button>
844
+ )}
845
+ {remediationCommands(r).map((command, c, all) => (
846
+ <CopyButton
847
+ key={c}
848
+ text={command}
849
+ label={
850
+ all.length > 1
851
+ ? `Copy command ${c + 1} of step ${i + 1}`
852
+ : `Copy command from step ${i + 1}`
853
+ }
854
+ />
855
+ ))}
856
+ </div>
857
+ </div>
858
+ {/* The condition and the reason are read before the command is
859
+ copied, so they precede it at a size that is meant to be read. */}
860
+ {step?.precondition ? (
861
+ <p
862
+ data-step-precondition
863
+ className="mb-1.5 text-[13px] leading-snug text-warning-text"
864
+ >
865
+ Only if {step.precondition.replace(/^(if|when|once)\s+/i, "")}
866
+ </p>
867
+ ) : null}
868
+ {isRec && diagnosis.recommendedReason && (
869
+ <p
870
+ data-recommended-reason
871
+ className="mb-1.5 text-[13px] leading-snug text-theme-text-secondary"
872
+ >
873
+ {diagnosis.recommendedReason}
874
+ </p>
875
+ )}
876
+ <AIMarkdown className="max-w-[100ch] text-sm [overflow-wrap:anywhere] [&_p]:my-0 [&_pre]:my-1.5">
877
+ {r}
878
+ </AIMarkdown>
879
+ </div>
880
+ </div>
881
+ </div>
882
+ );
883
+ };
884
+ return (
885
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
886
+ {showConclusion && storyShape && (
887
+ <div
888
+ className={
889
+ section === "conclusion"
890
+ ? ""
891
+ : "rounded-lg border border-theme-border bg-theme-surface p-3"
892
+ }
893
+ >
894
+ {readOnlyAssessment ? (
895
+ <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
896
+ Earlier assessment
897
+ </div>
898
+ ) : null}
899
+ <AssessmentHeadline
900
+ diagnosis={diagnosis}
901
+ tone="cause"
902
+ revisedAfter={revisedAfter}
903
+ limits={assessmentLimits}
904
+ copy={assessmentCopy}
905
+ />
906
+ </div>
907
+ )}
908
+ {/* Likely cause — agent-authored, visually prominent without claiming proof. */}
909
+ {showConclusion && !storyShape && rootCause && (
910
+ <div
911
+ className={
912
+ section === "conclusion"
913
+ ? "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2"
914
+ : "rounded-lg border border-theme-border bg-theme-surface p-3"
915
+ }
916
+ >
917
+ <div
918
+ className={
919
+ section === "conclusion"
920
+ ? "col-start-2 row-start-1"
921
+ : "mb-1 flex items-center justify-between gap-2"
922
+ }
923
+ >
924
+ {section !== "conclusion" && (
925
+ <div className="text-xs font-medium text-theme-text-secondary">
926
+ Likely cause
927
+ </div>
928
+ )}
929
+ <div className="flex items-center gap-2">
930
+ <CopyButton text={rootCause} label="Copy likely cause" />
931
+ </div>
932
+ </div>
933
+ <AIMarkdown
934
+ 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`}
935
+ >
936
+ {rootCause}
937
+ </AIMarkdown>
938
+ </div>
939
+ )}
940
+
941
+ {/* Remediation — every step is copyable. Only the explicitly recommended
942
+ step can be applied, and only when the caller enables apply. */}
943
+ {showActions && hasRemediation && (
944
+ <div
945
+ ref={stepsReveal.elementRef}
946
+ className={
947
+ section === "actions"
948
+ ? "space-y-2"
949
+ : "rounded-lg border border-theme-border bg-theme-surface p-3"
950
+ }
951
+ >
952
+ {section !== "actions" && (
953
+ <div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary">
954
+ <Wrench className="h-3.5 w-3.5 text-accent" />
955
+ {typedSteps ? "Next steps" : "Remediation"}
956
+ </div>
957
+ )}
958
+ {actionNotice && (
959
+ <p className="text-xs text-theme-text-secondary">{actionNotice}</p>
960
+ )}
961
+ <div id={stepsId}>
962
+ <ol>
963
+ {remediationEntries.map((entry) => {
964
+ const expanded =
965
+ !compactActions ||
966
+ showAllSteps ||
967
+ entry.index === primaryActionIndex;
968
+ const step = typedSteps ? steps[entry.index] : undefined;
969
+ return (
970
+ <li key={entry.index} value={entry.index + 1}>
971
+ <Collapse open={expanded} mountLazily>
972
+ <div className="pt-2">{renderRemediationStep(entry)}</div>
973
+ </Collapse>
974
+ {/* A folded step still shows what it is: its kind and
975
+ first line, one row each, so the alternatives are
976
+ readable before anyone opens them. */}
977
+ {!expanded ? (
978
+ <button
979
+ type="button"
980
+ data-step-folded={step?.kind ?? "step"}
981
+ onClick={() => {
982
+ setShowAllSteps(true);
983
+ stepsReveal.revealAfterToggle(true);
984
+ }}
985
+ 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"
986
+ >
987
+ {step ? (
988
+ <span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
989
+ {STEP_KIND_LABEL[step.kind]}
990
+ </span>
991
+ ) : (
992
+ <span className="shrink-0 text-[10px] font-semibold text-theme-text-tertiary">
993
+ {entry.index + 1}
994
+ </span>
995
+ )}
996
+ <span className="min-w-0 flex-1 truncate">
997
+ {remediationHeadline(entry.text)}
998
+ </span>
999
+ </button>
1000
+ ) : null}
1001
+ </li>
1002
+ );
1003
+ })}
1004
+ </ol>
1005
+ </div>
1006
+ {compactActions && remediation.length > 1 ? (
1007
+ <button
1008
+ type="button"
1009
+ aria-expanded={showAllSteps}
1010
+ aria-controls={stepsId}
1011
+ onClick={() => {
1012
+ setShowAllSteps(!showAllSteps);
1013
+ stepsReveal.revealAfterToggle(!showAllSteps);
1014
+ }}
1015
+ 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"
1016
+ >
1017
+ <CollapseChevron open={showAllSteps} className="h-3.5 w-3.5" />
1018
+ {showAllSteps
1019
+ ? recValid
1020
+ ? "Show only recommended step"
1021
+ : "Show only first step"
1022
+ : hiddenStepCount === 1
1023
+ ? "Expand the other step"
1024
+ : "Expand all steps"}
1025
+ </button>
1026
+ ) : null}
1027
+ {!actionNotice && !recValid && (
1028
+ <p className="mt-2 flex items-start gap-1.5 text-[11px] leading-snug text-theme-text-tertiary">
1029
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1030
+ No one-click fix is available. Review these steps and apply them
1031
+ manually, or ask the agent to continue.
1032
+ </p>
1033
+ )}
1034
+ </div>
1035
+ )}
1036
+
1037
+ {/* Full analysis — the agent's detailed evidence, on demand. Under the
1038
+ story contract Findings renders the story itself, so only the
1039
+ explanation and sources remain here. */}
1040
+ {showConclusion &&
1041
+ ((showAnalysisDisclosure && diagnosis.report) ||
1042
+ (showAnalysisDisclosure && diagnosis.confidence != null) ||
1043
+ explanation ||
1044
+ assessmentAction ||
1045
+ assessmentSources) && (
1046
+ <div>
1047
+ <div
1048
+ className="flex flex-wrap items-center gap-x-3 gap-y-1 pt-2"
1049
+ data-assessment-actions
1050
+ >
1051
+ {((showAnalysisDisclosure && diagnosis.report) ||
1052
+ (showAnalysisDisclosure && diagnosis.confidence != null) ||
1053
+ assessmentSources) && (
1054
+ <button
1055
+ type="button"
1056
+ aria-expanded={showAnalysis}
1057
+ aria-controls={`${analysisId}-analysis`}
1058
+ onClick={() => {
1059
+ setDetail(showAnalysis ? null : "analysis");
1060
+ analysisReveal.revealAfterToggle(!showAnalysis);
1061
+ }}
1062
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
1063
+ >
1064
+ <CollapseChevron
1065
+ open={showAnalysis}
1066
+ className="h-3.5 w-3.5"
1067
+ />
1068
+ {showAnalysisDisclosure && diagnosis.report
1069
+ ? "Full analysis"
1070
+ : "Assessment details"}
1071
+ </button>
1072
+ )}
1073
+ {explanation && (
1074
+ <Tooltip
1075
+ content={
1076
+ explanation.status === "idle"
1077
+ ? explanation.onGenerate
1078
+ ? "Ask the agent to explain this assessment and its proposed next steps in plain language."
1079
+ : "Wait for the current agent request to finish before requesting an explanation."
1080
+ : explanation.status === "running"
1081
+ ? "The agent is preparing an explanation. You can close this and return while it runs."
1082
+ : explanation.status === "error"
1083
+ ? "View the explanation error and retry when the agent is available."
1084
+ : "Show the saved plain-language explanation. No new request is needed."
1085
+ }
1086
+ >
1087
+ <button
1088
+ type="button"
1089
+ aria-expanded={detail === "explanation"}
1090
+ aria-controls={`${analysisId}-explanation`}
1091
+ disabled={
1092
+ explanation.status === "idle" && !explanation.onGenerate
1093
+ }
1094
+ onClick={() => {
1095
+ const open = detail !== "explanation";
1096
+ setDetail(open ? "explanation" : null);
1097
+ analysisReveal.revealAfterToggle(open);
1098
+ if (open && explanation.status === "idle")
1099
+ explanation.onGenerate?.();
1100
+ }}
1101
+ 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"
1102
+ >
1103
+ <HelpCircle className="h-3 w-3" />
1104
+ Explain simply
1105
+ </button>
1106
+ </Tooltip>
1107
+ )}
1108
+ {assessmentAction && (
1109
+ <div className="ml-auto">{assessmentAction}</div>
1110
+ )}
1111
+ </div>
1112
+ <div id={analysisId} ref={analysisReveal.elementRef}>
1113
+ <div id={`${analysisId}-analysis`}>
1114
+ <Collapse open={detail === "analysis"} mountLazily>
1115
+ <div className="border-t border-theme-border/60 px-3 py-2">
1116
+ {showAnalysisDisclosure ? (
1117
+ <>
1118
+ <p className="mb-2 text-xs text-theme-text-tertiary">
1119
+ Agent confidence:{" "}
1120
+ {diagnosis.confidence != null
1121
+ ? confidenceLabel(diagnosis.confidence)
1122
+ : "not stated"}
1123
+ {diagnosis.confidence != null
1124
+ ? " · self-reported"
1125
+ : ""}
1126
+ </p>
1127
+ <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">
1128
+ {analysisText}
1129
+ </AIMarkdown>
1130
+ </>
1131
+ ) : null}
1132
+ {assessmentSources}
1133
+ </div>
1134
+ </Collapse>
1135
+ </div>
1136
+ <div id={`${analysisId}-explanation`}>
1137
+ <Collapse open={detail === "explanation"} mountLazily>
1138
+ <div className="border-t border-theme-border/60 px-3 py-2">
1139
+ {explanation?.status === "running" ? (
1140
+ <div
1141
+ role="status"
1142
+ className="flex items-center gap-2 py-2 text-sm text-theme-text-secondary"
1143
+ >
1144
+ <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
1145
+ Explaining this assessment…
1146
+ </div>
1147
+ ) : explanation?.status === "done" ? (
1148
+ <div className="flex items-start gap-2">
1149
+ <AIMarkdown className="min-w-0 flex-1 text-sm text-theme-text-secondary [overflow-wrap:anywhere]">
1150
+ {explanation.text || ""}
1151
+ </AIMarkdown>
1152
+ <CopyButton
1153
+ text={explanation.text || ""}
1154
+ label="Copy explanation"
1155
+ />
1156
+ </div>
1157
+ ) : explanation?.status === "error" ? (
1158
+ <div
1159
+ role="alert"
1160
+ className="flex flex-wrap items-center gap-2 py-2 text-sm text-theme-text-secondary"
1161
+ >
1162
+ <span>
1163
+ {explanation.error ||
1164
+ "The agent did not return an explanation."}
1165
+ </span>
1166
+ {explanation.onGenerate && (
1167
+ <button
1168
+ type="button"
1169
+ onClick={explanation.onGenerate}
1170
+ className="rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover"
1171
+ >
1172
+ Try again
1173
+ </button>
1174
+ )}
1175
+ </div>
1176
+ ) : null}
1177
+ </div>
1178
+ </Collapse>
1179
+ </div>
1180
+ </div>
1181
+ </div>
1182
+ )}
1183
+
1184
+ {showConclusion && showDisclaimer && (
1185
+ <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1186
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1187
+ <span>AI-generated — review before applying</span>
1188
+ </div>
1189
+ )}
1190
+ </div>
1191
+ );
1192
+ }
1193
+
1194
+ /** The banner line and the clipboard line for one adverse Radar card must be the same words. */
1195
+ function healthFlagSentence(flag: {
1196
+ status: string;
1197
+ title: string;
1198
+ role?: string;
1199
+ }): string {
1200
+ return flag.status === "contradiction"
1201
+ ? `The agent calls ${flag.title} a ${flag.role} and still reports healthy`
1202
+ : `Radar flagged ${flag.title} · no explanation is linked to it`;
1203
+ }
1204
+
1205
+ export function AllClearCard({
1206
+ diagnosis,
1207
+ animate,
1208
+ showDisclaimer,
1209
+ coverageLimited,
1210
+ evidenceConflict,
1211
+ evidenceConflictExplainedBy,
1212
+ assessmentAction,
1213
+ assessmentSources,
1214
+ storyInline = false,
1215
+ revisedAfter,
1216
+ assessmentLimits,
1217
+ assessmentCopy,
1218
+ healthSignals,
1219
+ onRevealSource,
1220
+ }: {
1221
+ diagnosis: Diagnosis;
1222
+ animate: boolean;
1223
+ showDisclaimer: boolean;
1224
+ coverageLimited: boolean;
1225
+ evidenceConflict: boolean;
1226
+ evidenceConflictExplainedBy?: string[];
1227
+ assessmentAction?: ReactNode;
1228
+ assessmentSources?: ReactNode;
1229
+ storyInline?: boolean;
1230
+ revisedAfter?: string;
1231
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
1232
+ assessmentLimits?: string[];
1233
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
1234
+ /** Adverse Radar cards with the agent's position on each (story shape). */
1235
+ healthSignals?: InvestigationHealthSignal[];
1236
+ onRevealSource?: (sourceId: string) => void;
1237
+ }) {
1238
+ const storyShape = diagnosisHasStoryShape(diagnosis);
1239
+ const [showAnalysis, setShowAnalysis] = useState(false);
1240
+ const analysisReveal = useDisclosureReveal<HTMLDivElement>();
1241
+ const analysisId = useId();
1242
+ const report =
1243
+ (storyShape && !storyInline ? "" : storyPlainText(diagnosis.report)) ||
1244
+ (storyShape
1245
+ ? ""
1246
+ : "The agent did not identify a problem in the evidence it checked.");
1247
+ const detailed = report.length > 320 || report.split("\n").length > 2;
1248
+ const summary = storyShape
1249
+ ? ""
1250
+ : detailed
1251
+ ? "The agent found no active problem in the evidence it reviewed."
1252
+ : report;
1253
+ const explained =
1254
+ evidenceConflict &&
1255
+ !!evidenceConflictExplainedBy &&
1256
+ evidenceConflictExplainedBy.length > 0;
1257
+ const unexplainedConflict = evidenceConflict && !explained;
1258
+ // Story shape: the summary and the certainty word are the verdict; adverse
1259
+ // Radar cards the agent explained become lines in Still open, and only a
1260
+ // card the agent's verdict never addressed (or contradicts) keeps a header
1261
+ // that names it — so the reader knows exactly how it relates to the answer.
1262
+ const signals = healthSignals ?? [];
1263
+ const flagged = signals.filter(
1264
+ (signal) =>
1265
+ signal.status === "unaddressed" || signal.status === "contradiction",
1266
+ );
1267
+ const stillOpenSignals = signals
1268
+ .filter(
1269
+ (signal) => signal.status === "explained" || signal.status === "related",
1270
+ )
1271
+ .map((signal) => ({
1272
+ text:
1273
+ signal.status === "explained"
1274
+ ? `Radar flagged ${signal.title} · the agent looked at it and reads it as not a live problem: ${signal.claim}`
1275
+ : `Radar flagged ${signal.title} · the agent reads it as related, but not what matters here: ${signal.claim}`,
1276
+ onReveal:
1277
+ signal.sourceId && onRevealSource
1278
+ ? () => onRevealSource(signal.sourceId!)
1279
+ : undefined,
1280
+ }));
1281
+ // The analysis disclosure (Activity's read-only copy of an earlier healthy
1282
+ // assessment carries its story inline as plain prose), the action slot and
1283
+ // the disclaimer are the same in both shapes.
1284
+ const trailing = (
1285
+ <>
1286
+ {detailed || assessmentAction || assessmentSources ? (
1287
+ <div>
1288
+ <div
1289
+ className="flex flex-wrap items-center gap-3 pt-2"
1290
+ data-assessment-actions
1291
+ >
1292
+ {(detailed || assessmentSources) && (
1293
+ <button
1294
+ type="button"
1295
+ aria-expanded={showAnalysis}
1296
+ aria-controls={analysisId}
1297
+ onClick={() => {
1298
+ setShowAnalysis(!showAnalysis);
1299
+ analysisReveal.revealAfterToggle(!showAnalysis);
1300
+ }}
1301
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
1302
+ >
1303
+ <CollapseChevron open={showAnalysis} className="h-3.5 w-3.5" />
1304
+ {detailed ? "Full analysis" : "Assessment details"}
1305
+ </button>
1306
+ )}
1307
+ {assessmentAction && (
1308
+ <div className="ml-auto">{assessmentAction}</div>
1309
+ )}
1310
+ </div>
1311
+ <div id={analysisId} ref={analysisReveal.elementRef}>
1312
+ <Collapse open={showAnalysis}>
1313
+ <div className="border-t border-theme-border/60 px-3 py-2">
1314
+ <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">
1315
+ {detailed ? report : ""}
1316
+ </AIMarkdown>
1317
+ {assessmentSources}
1318
+ </div>
1319
+ </Collapse>
1320
+ </div>
1321
+ </div>
1322
+ ) : null}
1323
+ {showDisclaimer ? (
1324
+ <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1325
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1326
+ <span>AI-generated — verify if symptoms persist</span>
1327
+ </div>
1328
+ ) : null}
1329
+ </>
1330
+ );
1331
+ if (storyShape) {
1332
+ return (
1333
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
1334
+ {flagged.map((flag) => (
1335
+ <div
1336
+ key={`${flag.groupId ?? flag.title}-${flag.status}`}
1337
+ data-health-flag={flag.status}
1338
+ className="rounded-md border border-amber-500/40 bg-amber-500/5 px-2.5 py-2 text-xs text-theme-text-primary"
1339
+ >
1340
+ <div className="flex items-center gap-1.5 font-semibold text-amber-500">
1341
+ <AlertTriangle className="h-3.5 w-3.5" aria-hidden />
1342
+ {healthFlagSentence(flag)}
1343
+ </div>
1344
+ <p className="mt-1 text-theme-text-secondary">
1345
+ {flag.status === "contradiction"
1346
+ ? "Read the card before treating this as an all-clear."
1347
+ : "It may be unrelated to what you asked, or missed. Open it before treating this as an all-clear."}
1348
+ {flag.sourceId && onRevealSource ? (
1349
+ <>
1350
+ {" "}
1351
+ <button
1352
+ type="button"
1353
+ onClick={() => onRevealSource(flag.sourceId!)}
1354
+ className="font-medium text-accent-text hover:underline"
1355
+ >
1356
+ View the card
1357
+ </button>
1358
+ </>
1359
+ ) : null}
1360
+ </p>
1361
+ </div>
1362
+ ))}
1363
+ <AssessmentHeadline
1364
+ diagnosis={diagnosis}
1365
+ tone="healthy"
1366
+ revisedAfter={revisedAfter}
1367
+ flags={flagged.map(healthFlagSentence)}
1368
+ limits={assessmentLimits}
1369
+ copy={assessmentCopy}
1370
+ signals={stillOpenSignals}
1371
+ />
1372
+ {trailing}
1373
+ </div>
1374
+ );
1375
+ }
1376
+ return (
1377
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
1378
+ <div
1379
+ className={`rounded-lg border p-3 ${
1380
+ unexplainedConflict || explained
1381
+ ? "border-amber-500/40 bg-amber-500/5"
1382
+ : coverageLimited
1383
+ ? "border-amber-500/30 bg-amber-500/5"
1384
+ : "border-emerald-500/30 bg-emerald-500/5"
1385
+ }`}
1386
+ >
1387
+ <div className="mb-1 flex items-center justify-between gap-2">
1388
+ <div
1389
+ className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${
1390
+ unexplainedConflict || explained || coverageLimited
1391
+ ? "text-amber-500"
1392
+ : "text-emerald-500"
1393
+ }`}
1394
+ >
1395
+ {unexplainedConflict || explained || coverageLimited ? (
1396
+ <AlertTriangle className="h-3.5 w-3.5" />
1397
+ ) : (
1398
+ <CheckCircle2 className="h-3.5 w-3.5" />
1399
+ )}
1400
+ {unexplainedConflict
1401
+ ? "Assessment conflicts with captured evidence"
1402
+ : explained
1403
+ ? "Agent reports no active problem; adverse evidence remains"
1404
+ : coverageLimited
1405
+ ? "No problem identified in available evidence"
1406
+ : "No problem found in checked evidence"}
1407
+ </div>
1408
+ <CopyButton text={report} label="Copy assessment" />
1409
+ </div>
1410
+ <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">
1411
+ {summary}
1412
+ </AIMarkdown>
1413
+ {unexplainedConflict ? (
1414
+ <p className="mt-2 text-xs text-theme-text-secondary">
1415
+ Radar also captured evidence of an active problem. Review that
1416
+ evidence before treating the agent&apos;s conclusion as an
1417
+ all-clear.
1418
+ </p>
1419
+ ) : explained ? (
1420
+ <p className="mt-2 text-xs text-theme-text-secondary">
1421
+ Radar captured evidence of an active problem. The agent explains its
1422
+ interpretation in the note on{" "}
1423
+ {joinTitles(evidenceConflictExplainedBy!)}.
1424
+ {coverageLimited
1425
+ ? " Evidence coverage is also limited — review the limitations in Evidence."
1426
+ : ""}
1427
+ </p>
1428
+ ) : coverageLimited ? (
1429
+ <p className="mt-2 text-xs text-theme-text-secondary">
1430
+ Evidence coverage is limited. Review the limitations in Evidence
1431
+ before treating this as an all-clear.
1432
+ </p>
1433
+ ) : null}
1434
+ </div>
1435
+ {trailing}
1436
+ </div>
1437
+ );
1438
+ }
1439
+
1440
+ // The agent investigated but couldn't determine an answer. A distinct, honest
1441
+ // state — neutral (not the alarming amber root cause, not the reassuring emerald
1442
+ // all-clear) — so "I couldn't tell" never reads as "you're fine."
1443
+ export function InconclusiveCard({
1444
+ diagnosis,
1445
+ animate,
1446
+ storyInline = false,
1447
+ revisedAfter,
1448
+ assessmentLimits,
1449
+ assessmentCopy,
1450
+ }: {
1451
+ diagnosis: Diagnosis;
1452
+ animate: boolean;
1453
+ storyInline?: boolean;
1454
+ revisedAfter?: string;
1455
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
1456
+ assessmentLimits?: string[];
1457
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
1458
+ }) {
1459
+ const storyShape = diagnosisHasStoryShape(diagnosis);
1460
+ const text =
1461
+ (storyShape && !storyInline ? "" : storyPlainText(diagnosis.report)) ||
1462
+ (storyShape
1463
+ ? ""
1464
+ : "The investigation couldn't reach a clear conclusion — some information was unavailable or the evidence was ambiguous.");
1465
+ return (
1466
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
1467
+ <div className="rounded-lg border border-theme-border bg-theme-elevated p-3">
1468
+ <div className="mb-1 flex items-center justify-between gap-2">
1469
+ <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">
1470
+ <HelpCircle className="h-3.5 w-3.5" />
1471
+ Couldn&apos;t determine
1472
+ </div>
1473
+ {storyShape ? null : (
1474
+ <CopyButton text={text} label="Copy assessment" />
1475
+ )}
1476
+ </div>
1477
+ {storyShape ? (
1478
+ <AssessmentHeadline
1479
+ diagnosis={diagnosis}
1480
+ tone="inconclusive"
1481
+ revisedAfter={revisedAfter}
1482
+ limits={assessmentLimits}
1483
+ copy={assessmentCopy}
1484
+ />
1485
+ ) : null}
1486
+ {text ? (
1487
+ <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">
1488
+ {text}
1489
+ </AIMarkdown>
1490
+ ) : null}
1491
+ </div>
1492
+ <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1493
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1494
+ <span>
1495
+ Try a follow-up with more context, or investigate again after
1496
+ addressing any errors shown in Activity.
1497
+ </span>
1498
+ </div>
1499
+ </div>
1500
+ );
1501
+ }
1502
+
1503
+ const COMMAND_BINARIES = new Set([
1504
+ "kubectl",
1505
+ "helm",
1506
+ "argocd",
1507
+ "flux",
1508
+ "kustomize",
1509
+ "docker",
1510
+ "gcloud",
1511
+ "aws",
1512
+ "az",
1513
+ "mongosh",
1514
+ "psql",
1515
+ "redis-cli",
1516
+ "curl",
1517
+ "git",
1518
+ "istioctl",
1519
+ "velero",
1520
+ "cilium",
1521
+ "calicoctl",
1522
+ "terraform",
1523
+ "kn",
1524
+ "oc",
1525
+ "k9s",
1526
+ "skyhook",
1527
+ ]);
1528
+
1529
+ /** The first sentence of a step, without its markdown, for a one-line row. */
1530
+ export function remediationHeadline(step: string): string {
1531
+ const firstLine =
1532
+ step
1533
+ .split(/\r?\n/)
1534
+ .map((line) => line.trim())
1535
+ .find((line) => line && !line.startsWith("```")) ?? "";
1536
+ const sentence = firstLine.split(/(?<=[.!?:])\s/)[0] ?? firstLine;
1537
+ // A step written as "Confirm recovery:" expects its command to follow; as a
1538
+ // folded title the colon dangles.
1539
+ return sentence.replace(/`/g, "").replace(/\*\*/g, "").replace(/:$/, "");
1540
+ }
1541
+
1542
+ /**
1543
+ * The commands inside a remediation step, in order: every fenced block and
1544
+ * every inline code span that reads as a shell invocation. A step's prose is
1545
+ * never worth copying; a command is. The prompt asks the agent to wrap
1546
+ * commands in backticks, so this is the seam to read them from.
1547
+ */
1548
+ export function remediationCommands(step: string): string[] {
1549
+ const commands: string[] = [];
1550
+ // One pass in reading order, so button N is the Nth command in the text.
1551
+ const code = /```[a-zA-Z]*\n([\s\S]*?)```|`([^`\n]+)`/g;
1552
+ let match: RegExpExecArray | null;
1553
+ while ((match = code.exec(step))) {
1554
+ if (match[1] !== undefined) {
1555
+ const body = match[1].trim();
1556
+ if (body) commands.push(body);
1557
+ continue;
1558
+ }
1559
+ const span = match[2].trim();
1560
+ if (looksLikeCommand(span)) commands.push(span);
1561
+ }
1562
+ return commands;
1563
+ }
1564
+
1565
+ function looksLikeCommand(span: string): boolean {
1566
+ if (!/\s/.test(span)) return false;
1567
+ const first = span.split(/\s+/)[0];
1568
+ if (COMMAND_BINARIES.has(first)) return true;
1569
+ // An unknown binary still reads as a command when it takes flags.
1570
+ return /^[a-z][a-z0-9._-]*$/.test(first) && /(^|\s)--?[a-zA-Z]/.test(span);
1571
+ }
1572
+
1573
+ // A coarse band, not a precise %: a two-sig-fig confidence on an LLM judgement
1574
+ // reads as calibrated when it isn't.
1575
+ function confidenceLabel(c: number): string {
1576
+ if (c >= 0.8) return "High";
1577
+ if (c >= 0.5) return "Medium";
1578
+ return "Low";
1579
+ }