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