@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.
- package/package.json +2 -2
- package/src/api/client.ts +30 -0
- package/src/components/CloudConnectFlow.tsx +21 -7
- package/src/components/CloudFunnelButton.tsx +85 -8
- package/src/components/cloudConnectHandoff.test.ts +10 -1
- package/src/components/cloudConnectHandoff.ts +15 -1
- package/src/components/diagnose/AIMarkdown.tsx +87 -0
- package/src/components/diagnose/ActivityTurn.tsx +1244 -0
- package/src/components/diagnose/AgentControls.tsx +603 -0
- package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
- package/src/components/diagnose/AnalysisStory.tsx +13 -18
- package/src/components/diagnose/ApplyDialog.tsx +289 -0
- package/src/components/diagnose/AssessmentCard.tsx +1560 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
- package/src/components/diagnose/EvidenceCard.tsx +611 -0
- package/src/components/diagnose/Home.test.tsx +1 -3
- package/src/components/diagnose/Home.tsx +2 -1
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
- package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
- package/src/components/diagnose/InvestigationView.tsx +11 -28
- package/src/components/diagnose/StoryExcerpt.tsx +118 -0
- package/src/components/diagnose/assessmentCopy.ts +169 -0
- package/src/components/diagnose/investigationCase.test.tsx +631 -2
- package/src/components/diagnose/investigationCase.ts +132 -24
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
- package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
- package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
- package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
- package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
- package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
- package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
- package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
- package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
- package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
- package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
- package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
- package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
- package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
- package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
- package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
- package/src/components/diagnose/investigationEvidence/index.ts +1 -1
- package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
- package/src/components/diagnose/investigationEvidence/types.ts +97 -2
- package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
- package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
- package/src/components/diagnose/parts.test.tsx +3 -1
- package/src/components/diagnose/parts.tsx +26 -3904
- package/src/components/diagnose/target.ts +9 -3
- package/src/components/diagnose/toolCallLabel.test.ts +47 -1
- package/src/components/diagnose/toolCallLabel.ts +41 -2
- package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
- package/src/components/resources/PodFilePreview.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +12 -8
- package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
- package/src/components/ui/Omnibar.tsx +33 -10
- package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
- 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: “
|
|
339
|
+
{revisedAfter.length > 110
|
|
340
|
+
? `${revisedAfter.slice(0, 110).trimEnd()}…`
|
|
341
|
+
: revisedAfter}
|
|
342
|
+
”
|
|
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'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'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'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
|
+
}
|