@skyhook-io/radar-app 0.2.2 → 0.3.1
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/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -0,0 +1,1756 @@
|
|
|
1
|
+
// Presentational pieces of the Diagnose surface — pure + prop-driven (no
|
|
2
|
+
// persistence, no app/routing knowledge) so they lift cleanly into k8s-ui later
|
|
3
|
+
// and Cloud can reuse them. The stateful controller lives in DiagnoseContext;
|
|
4
|
+
// the run logic in InvestigationView.
|
|
5
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
6
|
+
import {
|
|
7
|
+
Loader2,
|
|
8
|
+
CheckCircle2,
|
|
9
|
+
AlertTriangle,
|
|
10
|
+
Copy,
|
|
11
|
+
Check,
|
|
12
|
+
ShieldCheck,
|
|
13
|
+
ChevronRight,
|
|
14
|
+
ChevronDown,
|
|
15
|
+
Wrench,
|
|
16
|
+
Sparkles,
|
|
17
|
+
RefreshCw,
|
|
18
|
+
Maximize2,
|
|
19
|
+
HelpCircle,
|
|
20
|
+
} from "lucide-react";
|
|
21
|
+
import { stringify as toYaml } from "yaml";
|
|
22
|
+
import { codeToHtml } from "shiki";
|
|
23
|
+
import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
|
|
24
|
+
import { useTheme } from "../../context/ThemeContext";
|
|
25
|
+
import { type DiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
|
|
26
|
+
import {
|
|
27
|
+
type Diagnosis,
|
|
28
|
+
type DiagnoseStep,
|
|
29
|
+
type AgentInfo,
|
|
30
|
+
type RunSummary,
|
|
31
|
+
} from "../../api/diagnose";
|
|
32
|
+
import { StatusDot } from "@skyhook-io/k8s-ui";
|
|
33
|
+
import { Markdown } from "../ui/Markdown";
|
|
34
|
+
|
|
35
|
+
// Segmented two-or-more-way selector — shared shape for the agent picker and the
|
|
36
|
+
// isolation toggle.
|
|
37
|
+
function Segmented<T extends string | boolean>({
|
|
38
|
+
label,
|
|
39
|
+
options,
|
|
40
|
+
value,
|
|
41
|
+
onChange,
|
|
42
|
+
}: {
|
|
43
|
+
label?: string;
|
|
44
|
+
options: { value: T; label: string }[];
|
|
45
|
+
value: T;
|
|
46
|
+
onChange: (v: T) => void;
|
|
47
|
+
}) {
|
|
48
|
+
return (
|
|
49
|
+
<div>
|
|
50
|
+
{label && (
|
|
51
|
+
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
52
|
+
{label}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
<div className="flex gap-1 rounded-lg border border-theme-border bg-theme-base p-1">
|
|
56
|
+
{options.map((o) => (
|
|
57
|
+
<button
|
|
58
|
+
key={String(o.value)}
|
|
59
|
+
onClick={() => onChange(o.value)}
|
|
60
|
+
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
|
|
61
|
+
o.value === value
|
|
62
|
+
? "selection-strong selection-text selection-ring"
|
|
63
|
+
: "text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
{o.label}
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Option = { value: string; label: string; description?: string };
|
|
75
|
+
|
|
76
|
+
// Claude Code's --model takes version-stable ALIASES that always resolve to the
|
|
77
|
+
// user's installed latest of that tier (per `claude --help`), so this list never
|
|
78
|
+
// rots across model updates. "" = the agent's own default. Descriptions mirror
|
|
79
|
+
// Claude Code's own /model picker so the tradeoff is legible.
|
|
80
|
+
const CLAUDE_MODEL_OPTIONS: Option[] = [
|
|
81
|
+
{
|
|
82
|
+
value: "",
|
|
83
|
+
label: "Default",
|
|
84
|
+
description: "Use Claude Code's configured model",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
value: "opus",
|
|
88
|
+
label: "Opus",
|
|
89
|
+
description: "Most capable — best for complex problems",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
value: "sonnet",
|
|
93
|
+
label: "Sonnet",
|
|
94
|
+
description: "Balanced — efficient for routine work",
|
|
95
|
+
},
|
|
96
|
+
{ value: "haiku", label: "Haiku", description: "Fastest — quick checks" },
|
|
97
|
+
];
|
|
98
|
+
// Codex has no stable alias set and no way to enumerate models, and slugs change
|
|
99
|
+
// across versions — so we take a free-text override rather than a list that rots.
|
|
100
|
+
const EFFORT_OPTIONS: Option[] = [
|
|
101
|
+
{
|
|
102
|
+
value: "",
|
|
103
|
+
label: "Default",
|
|
104
|
+
description: "Recommended — Radar's default (medium)",
|
|
105
|
+
},
|
|
106
|
+
{ value: "low", label: "Low", description: "Fastest, least reasoning" },
|
|
107
|
+
{ value: "medium", label: "Medium", description: "Balanced depth" },
|
|
108
|
+
{ value: "high", label: "High", description: "Most thorough, slowest" },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
function TextField({
|
|
112
|
+
label,
|
|
113
|
+
value,
|
|
114
|
+
placeholder,
|
|
115
|
+
onChange,
|
|
116
|
+
hint,
|
|
117
|
+
}: {
|
|
118
|
+
label: string;
|
|
119
|
+
value: string;
|
|
120
|
+
placeholder?: string;
|
|
121
|
+
onChange: (v: string) => void;
|
|
122
|
+
hint?: string;
|
|
123
|
+
}) {
|
|
124
|
+
return (
|
|
125
|
+
<div>
|
|
126
|
+
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
127
|
+
{label}
|
|
128
|
+
</div>
|
|
129
|
+
<input
|
|
130
|
+
type="text"
|
|
131
|
+
value={value}
|
|
132
|
+
placeholder={placeholder}
|
|
133
|
+
onChange={(e) => onChange(e.target.value)}
|
|
134
|
+
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"
|
|
135
|
+
/>
|
|
136
|
+
{hint && (
|
|
137
|
+
<p className="mt-1 text-[11px] leading-snug text-theme-text-tertiary">
|
|
138
|
+
{hint}
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// SelectMenu is a themed dropdown (button + popover list) matching the app's other
|
|
146
|
+
// custom dropdowns — unlike a native <select> it renders option descriptions and
|
|
147
|
+
// stays on-theme in both light/dark.
|
|
148
|
+
function SelectMenu({
|
|
149
|
+
label,
|
|
150
|
+
value,
|
|
151
|
+
options,
|
|
152
|
+
onChange,
|
|
153
|
+
hint,
|
|
154
|
+
}: {
|
|
155
|
+
label: string;
|
|
156
|
+
value: string;
|
|
157
|
+
options: Option[];
|
|
158
|
+
onChange: (v: string) => void;
|
|
159
|
+
hint?: string;
|
|
160
|
+
}) {
|
|
161
|
+
const [open, setOpen] = useState(false);
|
|
162
|
+
const current = options.find((o) => o.value === value) ?? options[0];
|
|
163
|
+
return (
|
|
164
|
+
<div>
|
|
165
|
+
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
166
|
+
{label}
|
|
167
|
+
</div>
|
|
168
|
+
<div className="relative">
|
|
169
|
+
<button
|
|
170
|
+
onClick={() => setOpen((v) => !v)}
|
|
171
|
+
aria-haspopup="listbox"
|
|
172
|
+
aria-expanded={open}
|
|
173
|
+
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"
|
|
174
|
+
>
|
|
175
|
+
<span className="truncate">{current?.label}</span>
|
|
176
|
+
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
177
|
+
</button>
|
|
178
|
+
{open && (
|
|
179
|
+
<>
|
|
180
|
+
<div
|
|
181
|
+
className="fixed inset-0 z-10"
|
|
182
|
+
onClick={() => setOpen(false)}
|
|
183
|
+
/>
|
|
184
|
+
<ul
|
|
185
|
+
role="listbox"
|
|
186
|
+
className="absolute left-0 right-0 z-20 mt-1 max-h-72 overflow-y-auto rounded-md border border-theme-border bg-theme-surface py-1 shadow-theme-lg"
|
|
187
|
+
>
|
|
188
|
+
{options.map((o) => {
|
|
189
|
+
const sel = o.value === value;
|
|
190
|
+
return (
|
|
191
|
+
<li key={o.value}>
|
|
192
|
+
<button
|
|
193
|
+
role="option"
|
|
194
|
+
aria-selected={sel}
|
|
195
|
+
onClick={() => {
|
|
196
|
+
onChange(o.value);
|
|
197
|
+
setOpen(false);
|
|
198
|
+
}}
|
|
199
|
+
className="flex w-full items-start gap-2 px-2.5 py-1.5 text-left hover:bg-theme-hover"
|
|
200
|
+
>
|
|
201
|
+
<Check
|
|
202
|
+
className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${sel ? "text-accent" : "opacity-0"}`}
|
|
203
|
+
/>
|
|
204
|
+
<span className="min-w-0">
|
|
205
|
+
<span className="block text-xs font-medium text-theme-text-primary">
|
|
206
|
+
{o.label}
|
|
207
|
+
</span>
|
|
208
|
+
{o.description && (
|
|
209
|
+
<span className="block text-[11px] leading-snug text-theme-text-tertiary">
|
|
210
|
+
{o.description}
|
|
211
|
+
</span>
|
|
212
|
+
)}
|
|
213
|
+
</span>
|
|
214
|
+
</button>
|
|
215
|
+
</li>
|
|
216
|
+
);
|
|
217
|
+
})}
|
|
218
|
+
</ul>
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
{hint && (
|
|
223
|
+
<p className="mt-1 text-[11px] leading-snug text-theme-text-tertiary">
|
|
224
|
+
{hint}
|
|
225
|
+
</p>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// AgentControls is the full AI-diagnosis config block (agent, isolation, model,
|
|
232
|
+
// effort) — pure + prop-driven. It lives in Settings, not the investigation panel,
|
|
233
|
+
// since these are set-once preferences rather than per-run knobs.
|
|
234
|
+
export function AgentControls({
|
|
235
|
+
agents,
|
|
236
|
+
selectedAgent,
|
|
237
|
+
onSelectAgent,
|
|
238
|
+
isolated,
|
|
239
|
+
onSetIsolated,
|
|
240
|
+
model,
|
|
241
|
+
onSetModel,
|
|
242
|
+
effort,
|
|
243
|
+
onSetEffort,
|
|
244
|
+
}: {
|
|
245
|
+
agents: AgentInfo[];
|
|
246
|
+
selectedAgent: string;
|
|
247
|
+
onSelectAgent: (name: string) => void;
|
|
248
|
+
isolated: boolean;
|
|
249
|
+
onSetIsolated: (v: boolean) => void;
|
|
250
|
+
model: string;
|
|
251
|
+
onSetModel: (v: string) => void;
|
|
252
|
+
effort: string;
|
|
253
|
+
onSetEffort: (v: string) => void;
|
|
254
|
+
}) {
|
|
255
|
+
const isCodex = selectedAgent === "codex";
|
|
256
|
+
const isClaude = selectedAgent === "claude";
|
|
257
|
+
const isCursor = selectedAgent === "cursor-agent";
|
|
258
|
+
return (
|
|
259
|
+
<div className="space-y-3">
|
|
260
|
+
{agents.length >= 2 && (
|
|
261
|
+
<Segmented
|
|
262
|
+
label="Agent"
|
|
263
|
+
value={selectedAgent}
|
|
264
|
+
onChange={onSelectAgent}
|
|
265
|
+
options={agents.map((a) => ({
|
|
266
|
+
value: a.name,
|
|
267
|
+
label: a.label || a.name,
|
|
268
|
+
}))}
|
|
269
|
+
/>
|
|
270
|
+
)}
|
|
271
|
+
{isCodex && (
|
|
272
|
+
<div>
|
|
273
|
+
<Segmented<boolean>
|
|
274
|
+
label="Environment"
|
|
275
|
+
value={isolated}
|
|
276
|
+
onChange={onSetIsolated}
|
|
277
|
+
options={[
|
|
278
|
+
{ value: true, label: "Isolated (recommended)" },
|
|
279
|
+
{ value: false, label: "My setup" },
|
|
280
|
+
]}
|
|
281
|
+
/>
|
|
282
|
+
{isolated ? (
|
|
283
|
+
<p className="mt-1.5 text-[11px] leading-snug text-theme-text-tertiary">
|
|
284
|
+
Runs Codex on its own — no access to your other MCP servers,
|
|
285
|
+
guidelines, or project files.
|
|
286
|
+
</p>
|
|
287
|
+
) : (
|
|
288
|
+
<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">
|
|
289
|
+
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
|
|
290
|
+
<span>
|
|
291
|
+
Runs Codex with your full setup — your own MCP servers (which may
|
|
292
|
+
be write- or network-capable) and guidelines, and it can read
|
|
293
|
+
local files. Only choose this if you rely on that config.
|
|
294
|
+
</span>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
{isClaude ? (
|
|
300
|
+
<SelectMenu
|
|
301
|
+
label="Model"
|
|
302
|
+
value={model}
|
|
303
|
+
options={CLAUDE_MODEL_OPTIONS}
|
|
304
|
+
onChange={onSetModel}
|
|
305
|
+
hint="Aliases always resolve to the latest of that tier."
|
|
306
|
+
/>
|
|
307
|
+
) : (
|
|
308
|
+
<TextField
|
|
309
|
+
label="Model"
|
|
310
|
+
value={model}
|
|
311
|
+
placeholder={
|
|
312
|
+
isCursor
|
|
313
|
+
? "Default (e.g. auto, gpt-5.2, composer-2.5)"
|
|
314
|
+
: "Default (e.g. gpt-5-codex, o3)"
|
|
315
|
+
}
|
|
316
|
+
onChange={onSetModel}
|
|
317
|
+
hint={
|
|
318
|
+
isCursor
|
|
319
|
+
? "Leave empty for your Cursor default, or enter a model slug Cursor supports."
|
|
320
|
+
: !isolated
|
|
321
|
+
? "“My setup” uses your own Codex config's model; set a slug here to override it."
|
|
322
|
+
: "Leave empty for Codex's default, or enter a model your Codex version supports."
|
|
323
|
+
}
|
|
324
|
+
/>
|
|
325
|
+
)}
|
|
326
|
+
{isCodex && (
|
|
327
|
+
<SelectMenu
|
|
328
|
+
label="Reasoning effort"
|
|
329
|
+
value={effort}
|
|
330
|
+
options={EFFORT_OPTIONS}
|
|
331
|
+
onChange={onSetEffort}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Turn is one round of the conversation: the initial investigation (no question)
|
|
339
|
+
// or a follow-up, each with its own transcript + result.
|
|
340
|
+
export type Turn = {
|
|
341
|
+
question?: string;
|
|
342
|
+
timeline: TimelineItem[];
|
|
343
|
+
diagnosis: Diagnosis | null;
|
|
344
|
+
error: string | null;
|
|
345
|
+
status: "running" | "done" | "error";
|
|
346
|
+
// apply turns execute the recommended fix (write tools) — they report an
|
|
347
|
+
// outcome, not a root cause, so the UI frames them differently.
|
|
348
|
+
apply?: boolean;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// TimelineItem is one ordered transcript entry: agent reasoning, or a tool call.
|
|
352
|
+
type TimelineItem =
|
|
353
|
+
| { kind: "thinking"; text: string }
|
|
354
|
+
| {
|
|
355
|
+
kind: "tool";
|
|
356
|
+
id: string;
|
|
357
|
+
tool: string;
|
|
358
|
+
status: string;
|
|
359
|
+
ms?: number;
|
|
360
|
+
summary?: string;
|
|
361
|
+
result?: string;
|
|
362
|
+
truncated?: boolean;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
export function appendThinking(
|
|
366
|
+
prev: TimelineItem[],
|
|
367
|
+
text: string,
|
|
368
|
+
): TimelineItem[] {
|
|
369
|
+
const last = prev[prev.length - 1];
|
|
370
|
+
if (last && last.kind === "thinking") {
|
|
371
|
+
const next = [...prev];
|
|
372
|
+
next[next.length - 1] = { ...last, text: (last.text + text).slice(-4000) };
|
|
373
|
+
return next;
|
|
374
|
+
}
|
|
375
|
+
return [...prev, { kind: "thinking", text }];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function upsertTool(
|
|
379
|
+
prev: TimelineItem[],
|
|
380
|
+
step: DiagnoseStep,
|
|
381
|
+
): TimelineItem[] {
|
|
382
|
+
const i = prev.findIndex((it) => it.kind === "tool" && it.id === step.id);
|
|
383
|
+
if (i >= 0) {
|
|
384
|
+
const next = [...prev];
|
|
385
|
+
const cur = next[i] as Extract<TimelineItem, { kind: "tool" }>;
|
|
386
|
+
// The `done` event omits the tool name + input; keep them from `running`.
|
|
387
|
+
next[i] = {
|
|
388
|
+
...cur,
|
|
389
|
+
...step,
|
|
390
|
+
kind: "tool",
|
|
391
|
+
tool: step.tool || cur.tool,
|
|
392
|
+
summary: step.summary || cur.summary,
|
|
393
|
+
};
|
|
394
|
+
return next;
|
|
395
|
+
}
|
|
396
|
+
return [...prev, { kind: "tool", ...step }];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function TurnView({
|
|
400
|
+
turn,
|
|
401
|
+
synthLabel,
|
|
402
|
+
reveal = "full",
|
|
403
|
+
onApply,
|
|
404
|
+
onAsk,
|
|
405
|
+
onCheckStatus,
|
|
406
|
+
onRetryDiagnosis,
|
|
407
|
+
hideVerdict = false,
|
|
408
|
+
}: {
|
|
409
|
+
turn: Turn;
|
|
410
|
+
synthLabel?: string | null;
|
|
411
|
+
reveal?: "rca" | "full";
|
|
412
|
+
onApply?: (fix: string) => void;
|
|
413
|
+
onAsk?: (question: string) => void;
|
|
414
|
+
onCheckStatus?: () => void;
|
|
415
|
+
onRetryDiagnosis?: () => void;
|
|
416
|
+
// In the maximized workspace the pinned turn's verdict renders in the side rail,
|
|
417
|
+
// so the transcript suppresses its own copy (reasoning + tool calls still show).
|
|
418
|
+
hideVerdict?: boolean;
|
|
419
|
+
}) {
|
|
420
|
+
// A follow-up (a turn the user asked a question on) is a conversational reply,
|
|
421
|
+
// not a fresh diagnosis — render it as a plain answer, never the root-cause
|
|
422
|
+
// anchor or a remediation card.
|
|
423
|
+
const followup = !!turn.question && !turn.apply;
|
|
424
|
+
// Whether the done turn has anything for ResultCard to render — mirrors its
|
|
425
|
+
// branch order exactly (apply → followup → structured/healthy), since a followup
|
|
426
|
+
// ONLY ever renders FollowupAnswer (report/rootCause), never the remediation list.
|
|
427
|
+
// When false, TurnView shows the narration or an explicit empty note, not a blank.
|
|
428
|
+
const dx = turn.diagnosis;
|
|
429
|
+
const hasVerdict = dx
|
|
430
|
+
? turn.apply
|
|
431
|
+
? true // ApplyOutcomeCard always renders an outcome
|
|
432
|
+
: dx.healthy && !dx.rootCause
|
|
433
|
+
? true // AllClearCard (checked before followup in ResultCard)
|
|
434
|
+
: dx.inconclusive && !dx.rootCause
|
|
435
|
+
? true // InconclusiveCard
|
|
436
|
+
: followup
|
|
437
|
+
? !!(dx.report?.trim() || dx.rootCause?.trim()) // FollowupAnswer
|
|
438
|
+
: !!dx.rootCause ||
|
|
439
|
+
(dx.remediation?.length ?? 0) > 0 ||
|
|
440
|
+
!!dx.report?.trim()
|
|
441
|
+
: false;
|
|
442
|
+
return (
|
|
443
|
+
<div className="space-y-2">
|
|
444
|
+
{turn.question && (
|
|
445
|
+
<div className="flex justify-end">
|
|
446
|
+
<div className="max-w-[85%] rounded-lg rounded-br-sm bg-accent/10 px-3 py-1.5 text-sm text-theme-text-primary [overflow-wrap:anywhere]">
|
|
447
|
+
{turn.question}
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
)}
|
|
451
|
+
<Timeline
|
|
452
|
+
items={turn.timeline}
|
|
453
|
+
running={turn.status === "running"}
|
|
454
|
+
applyMode={turn.apply}
|
|
455
|
+
followup={followup}
|
|
456
|
+
synthLabel={synthLabel}
|
|
457
|
+
/>
|
|
458
|
+
{turn.status === "done" &&
|
|
459
|
+
(hideVerdict && hasVerdict ? null : hasVerdict ? (
|
|
460
|
+
<ResultCard
|
|
461
|
+
diagnosis={turn.diagnosis!}
|
|
462
|
+
onApply={onApply}
|
|
463
|
+
onAsk={onAsk}
|
|
464
|
+
apply={turn.apply}
|
|
465
|
+
followup={followup}
|
|
466
|
+
reveal={reveal}
|
|
467
|
+
onCheckStatus={onCheckStatus}
|
|
468
|
+
/>
|
|
469
|
+
) : (
|
|
470
|
+
<EmptyResult />
|
|
471
|
+
))}
|
|
472
|
+
{turn.status === "error" && turn.error && (
|
|
473
|
+
<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">
|
|
474
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
|
475
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
476
|
+
<span className="whitespace-pre-wrap break-words">{turn.error}</span>
|
|
477
|
+
{onRetryDiagnosis && (
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
onClick={onRetryDiagnosis}
|
|
481
|
+
className="btn-brand self-start px-3 py-1 text-xs"
|
|
482
|
+
>
|
|
483
|
+
Retry diagnosis
|
|
484
|
+
</button>
|
|
485
|
+
)}
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// RunContextCard opens every investigation with what RADAR already knows — the
|
|
494
|
+
// health frame the server captured at run start. It renders instantly (no agent
|
|
495
|
+
// round-trip), so the agent's boot time reads as "context, then deepening"
|
|
496
|
+
// instead of dead air — and it anchors the verdict against Radar's own signal.
|
|
497
|
+
function healthLineTone(severity?: string): "unhealthy" | "degraded" | "alert" {
|
|
498
|
+
if (severity === "critical") return "unhealthy";
|
|
499
|
+
if (severity === "warning") return "degraded";
|
|
500
|
+
return "alert";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function RunContextCard({ run }: { run: RunSummary }) {
|
|
504
|
+
const h = run.health;
|
|
505
|
+
const issueCount = h?.issueCount ?? 0;
|
|
506
|
+
const issues = h?.issues ?? [];
|
|
507
|
+
const findings = h?.auditFindings ?? [];
|
|
508
|
+
const lines: ReactNode[] = [];
|
|
509
|
+
if (issues.length > 0) {
|
|
510
|
+
// The actual issue rows Radar's engine flagged — the reason bolded, the
|
|
511
|
+
// engine's own detail sentence after it. Concrete beats a count.
|
|
512
|
+
for (const [i, line] of issues.entries()) {
|
|
513
|
+
lines.push(
|
|
514
|
+
<div key={`issue-${i}`} className="flex items-start gap-1.5">
|
|
515
|
+
<StatusDot
|
|
516
|
+
tone={healthLineTone(line.severity)}
|
|
517
|
+
className="mt-1 shrink-0"
|
|
518
|
+
/>
|
|
519
|
+
<span className="min-w-0">
|
|
520
|
+
<span className="font-medium text-theme-text-primary">
|
|
521
|
+
{line.reason}
|
|
522
|
+
</span>
|
|
523
|
+
{line.message ? <> — {line.message}</> : null}
|
|
524
|
+
</span>
|
|
525
|
+
</div>,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (issueCount > issues.length) {
|
|
529
|
+
lines.push(
|
|
530
|
+
<div key="more" className="pl-3.5 text-theme-text-tertiary">
|
|
531
|
+
+{issueCount - issues.length} more active issue
|
|
532
|
+
{issueCount - issues.length === 1 ? "" : "s"}
|
|
533
|
+
</div>,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
} else if (h?.health === "healthy") {
|
|
537
|
+
lines.push(
|
|
538
|
+
<div key="healthy" className="flex items-center gap-1.5">
|
|
539
|
+
<StatusDot tone="healthy" className="shrink-0" />
|
|
540
|
+
Reported healthy — 0 active issues
|
|
541
|
+
</div>,
|
|
542
|
+
);
|
|
543
|
+
} else if (h) {
|
|
544
|
+
lines.push(
|
|
545
|
+
<div key="none" className="flex items-center gap-1.5">
|
|
546
|
+
<StatusDot tone="unknown" className="shrink-0" />0 active issues
|
|
547
|
+
{h.health ? ` — status ${h.health}` : ""}
|
|
548
|
+
</div>,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
for (const [i, f] of findings.entries()) {
|
|
552
|
+
lines.push(
|
|
553
|
+
<div key={`audit-${i}`} className="pl-3.5 text-theme-text-tertiary">
|
|
554
|
+
Audit: <span className="font-medium">{f.reason}</span>
|
|
555
|
+
{f.message ? <> — {f.message}</> : null}
|
|
556
|
+
</div>,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
if ((h?.auditCount ?? 0) > findings.length && findings.length > 0) {
|
|
560
|
+
lines.push(
|
|
561
|
+
<div key="audit-more" className="pl-3.5 text-theme-text-tertiary">
|
|
562
|
+
+{h!.auditCount! - findings.length} more audit finding
|
|
563
|
+
{h!.auditCount! - findings.length === 1 ? "" : "s"}
|
|
564
|
+
</div>,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (run.managedBy) {
|
|
568
|
+
lines.push(
|
|
569
|
+
<div key="managed" className="pl-3.5 text-theme-text-tertiary">
|
|
570
|
+
Managed by {run.managedBy}
|
|
571
|
+
</div>,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (lines.length === 0) return null;
|
|
575
|
+
return (
|
|
576
|
+
<div className="rounded-md border border-theme-border/60 bg-theme-base/40 px-2.5 py-2">
|
|
577
|
+
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
578
|
+
Radar's read at start
|
|
579
|
+
</div>
|
|
580
|
+
<div className="space-y-1 text-xs text-theme-text-secondary">{lines}</div>
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ConsentCardShell owns the card chrome (icon, layout, settings link, Approve/
|
|
586
|
+
// Cancel) so every copy tier — the OSS default, the hosted fallback, and a host
|
|
587
|
+
// override — feeds the same frame instead of duplicating it.
|
|
588
|
+
function ConsentCardShell({
|
|
589
|
+
title,
|
|
590
|
+
body,
|
|
591
|
+
bullets,
|
|
592
|
+
settingsLabel,
|
|
593
|
+
approveLabel = "Approve & investigate",
|
|
594
|
+
onOpenSettings,
|
|
595
|
+
onApprove,
|
|
596
|
+
onCancel,
|
|
597
|
+
}: DiagnoseConsentCopy & {
|
|
598
|
+
onOpenSettings?: () => void;
|
|
599
|
+
onApprove: () => void;
|
|
600
|
+
onCancel: () => void;
|
|
601
|
+
}) {
|
|
602
|
+
// `settingsLabel: null` hides the link outright — a host with one fixed agent
|
|
603
|
+
// has nothing behind it. `undefined` keeps the OSS default label.
|
|
604
|
+
const resolvedSettingsLabel =
|
|
605
|
+
settingsLabel === undefined
|
|
606
|
+
? "Change the agent and how it runs in Settings"
|
|
607
|
+
: settingsLabel;
|
|
608
|
+
return (
|
|
609
|
+
<div className="rounded-lg border border-theme-border bg-theme-elevated p-4">
|
|
610
|
+
<div className="mb-2 flex items-center gap-2">
|
|
611
|
+
<ShieldCheck className="h-4 w-4 text-accent" />
|
|
612
|
+
<div className="text-sm font-medium text-theme-text-primary">
|
|
613
|
+
{title}
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
<div className="text-sm leading-relaxed text-theme-text-secondary">{body}</div>
|
|
617
|
+
{bullets && bullets.length > 0 && (
|
|
618
|
+
<ul className="mt-2 space-y-1 text-xs text-theme-text-tertiary">
|
|
619
|
+
{bullets.map((b, i) => (
|
|
620
|
+
<li key={i}>• {b}</li>
|
|
621
|
+
))}
|
|
622
|
+
</ul>
|
|
623
|
+
)}
|
|
624
|
+
{onOpenSettings && resolvedSettingsLabel && (
|
|
625
|
+
<button
|
|
626
|
+
onClick={onOpenSettings}
|
|
627
|
+
className="mt-3 text-xs text-accent hover:underline"
|
|
628
|
+
>
|
|
629
|
+
{resolvedSettingsLabel}
|
|
630
|
+
</button>
|
|
631
|
+
)}
|
|
632
|
+
<div className="mt-4 flex gap-2">
|
|
633
|
+
<button
|
|
634
|
+
onClick={onCancel}
|
|
635
|
+
className="flex-1 rounded-lg border border-theme-border py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
|
|
636
|
+
>
|
|
637
|
+
Cancel
|
|
638
|
+
</button>
|
|
639
|
+
<button
|
|
640
|
+
onClick={onApprove}
|
|
641
|
+
className="flex-1 rounded-lg btn-brand py-1.5 text-sm"
|
|
642
|
+
>
|
|
643
|
+
{approveLabel}
|
|
644
|
+
</button>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// The first-run consent + trust card. The copy is checkable fact about a data
|
|
651
|
+
// flow — not marketing — and the wrong claim is a lie, not a typo. It resolves
|
|
652
|
+
// in two tiers:
|
|
653
|
+
//
|
|
654
|
+
// 1. `copy` — the embedding host tells its own trust story. Only the host
|
|
655
|
+
// knows where its agent runs, whose key pays, and where transcripts live,
|
|
656
|
+
// so any host that runs the agent somewhere other than the OSS local CLI
|
|
657
|
+
// MUST override rather than let Radar assert the local story over its flow.
|
|
658
|
+
// 2. No `copy` — the OSS bring-your-own-local-CLI default, the only tier where
|
|
659
|
+
// "on your machine / no Radar cloud / your account" actually holds.
|
|
660
|
+
export function ConsentCard({
|
|
661
|
+
agentName,
|
|
662
|
+
agent,
|
|
663
|
+
isolated = true,
|
|
664
|
+
copy,
|
|
665
|
+
onOpenSettings,
|
|
666
|
+
onApprove,
|
|
667
|
+
onCancel,
|
|
668
|
+
}: {
|
|
669
|
+
agentName: string;
|
|
670
|
+
agent?: string;
|
|
671
|
+
isolated?: boolean;
|
|
672
|
+
copy?: DiagnoseConsentCopy;
|
|
673
|
+
onOpenSettings?: () => void;
|
|
674
|
+
onApprove: () => void;
|
|
675
|
+
onCancel: () => void;
|
|
676
|
+
}) {
|
|
677
|
+
const chrome = { onOpenSettings, onApprove, onCancel };
|
|
678
|
+
|
|
679
|
+
// Tier 1: a host (e.g. radar-hub-web) supplied its own copy — use it verbatim.
|
|
680
|
+
if (copy) return <ConsentCardShell {...copy} {...chrome} />;
|
|
681
|
+
|
|
682
|
+
// Tier 2: OSS BYO-local default. Cursor can't be isolated (no flag suppresses
|
|
683
|
+
// its global MCP servers), so it gets its own honest framing rather than the
|
|
684
|
+
// isolated/my-setup pair.
|
|
685
|
+
const isCursor = agent === "cursor-agent";
|
|
686
|
+
return (
|
|
687
|
+
<ConsentCardShell
|
|
688
|
+
{...chrome}
|
|
689
|
+
title={
|
|
690
|
+
isolated && !isCursor
|
|
691
|
+
? "Run a read-only AI investigation?"
|
|
692
|
+
: "Run an AI investigation?"
|
|
693
|
+
}
|
|
694
|
+
body={
|
|
695
|
+
<>
|
|
696
|
+
This runs{" "}
|
|
697
|
+
<span className="font-medium text-theme-text-primary">
|
|
698
|
+
your own {agentName}
|
|
699
|
+
</span>{" "}
|
|
700
|
+
on your machine — no Radar cloud, no API key, no account. Radar sends
|
|
701
|
+
this resource's spec, recent events, and pod logs to it (and on to
|
|
702
|
+
its model provider under your account, not to Radar). Transcripts are
|
|
703
|
+
kept in your local Radar history on this machine until cleared.
|
|
704
|
+
{isolated && !isCursor && (
|
|
705
|
+
<>
|
|
706
|
+
{" "}
|
|
707
|
+
Through Radar the agent can only{" "}
|
|
708
|
+
<span className="font-medium">read</span> — it cannot change your
|
|
709
|
+
cluster.
|
|
710
|
+
</>
|
|
711
|
+
)}
|
|
712
|
+
</>
|
|
713
|
+
}
|
|
714
|
+
bullets={[
|
|
715
|
+
isCursor ? (
|
|
716
|
+
<>
|
|
717
|
+
Through Radar the agent only{" "}
|
|
718
|
+
<span className="font-medium">reads</span> your cluster. But Cursor
|
|
719
|
+
also loads your own global MCP servers and Radar can't exclude
|
|
720
|
+
them (unlike Claude or Codex), so if any of those can make changes,
|
|
721
|
+
Cursor could use them.
|
|
722
|
+
</>
|
|
723
|
+
) : isolated ? (
|
|
724
|
+
<>
|
|
725
|
+
Isolated: only Radar's read-only investigation tools — your
|
|
726
|
+
other CLI config and MCP servers are excluded.
|
|
727
|
+
{agent === "codex" && (
|
|
728
|
+
<>
|
|
729
|
+
{" "}
|
|
730
|
+
Codex's sandboxed shell can still <em>read</em> files on
|
|
731
|
+
your machine (it cannot write or reach the network).
|
|
732
|
+
</>
|
|
733
|
+
)}
|
|
734
|
+
</>
|
|
735
|
+
) : (
|
|
736
|
+
<>
|
|
737
|
+
“My setup”: the agent also runs with your own CLI config
|
|
738
|
+
+ MCP servers and can read local files. Only Radar's own tools
|
|
739
|
+
are read-only.
|
|
740
|
+
</>
|
|
741
|
+
),
|
|
742
|
+
]}
|
|
743
|
+
/>
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// The Apply confirmation — wider than a generic confirm so the recommended fix
|
|
748
|
+
// (rendered markdown) is legible, making it unambiguous what the one click does.
|
|
749
|
+
export function ApplyDialog({
|
|
750
|
+
open,
|
|
751
|
+
onClose,
|
|
752
|
+
onConfirm,
|
|
753
|
+
agentLabel,
|
|
754
|
+
resourceLabel,
|
|
755
|
+
fix,
|
|
756
|
+
managedBy,
|
|
757
|
+
confidence,
|
|
758
|
+
}: {
|
|
759
|
+
open: boolean;
|
|
760
|
+
onClose: () => void;
|
|
761
|
+
onConfirm: () => void;
|
|
762
|
+
agentLabel: string;
|
|
763
|
+
resourceLabel: string;
|
|
764
|
+
fix?: string;
|
|
765
|
+
managedBy?: string; // GitOps/Helm owner of the resource, if any
|
|
766
|
+
confidence?: number;
|
|
767
|
+
}) {
|
|
768
|
+
const fixText = fix?.trim();
|
|
769
|
+
const lowConfidence = confidence != null && confidence < 0.5;
|
|
770
|
+
// A GitOps/Helm-managed resource needs an explicit acknowledgment before applying
|
|
771
|
+
// a direct change — it's the canonical footgun (the controller reverts it). Gating
|
|
772
|
+
// (not just warning) makes the user opt into "yes, I know this may be undone."
|
|
773
|
+
// TODO(SKY-1075): once Radar can connect the user's SCM (GitHub/GitLab/…), replace
|
|
774
|
+
// direct apply on managed resources with "open a PR against the Git source"
|
|
775
|
+
// instead — the durable fix. See Linear SKY-1075.
|
|
776
|
+
const [acked, setAcked] = useState(false);
|
|
777
|
+
useEffect(() => {
|
|
778
|
+
if (open) setAcked(false);
|
|
779
|
+
}, [open]);
|
|
780
|
+
const applyBlocked = !!managedBy && !acked;
|
|
781
|
+
return (
|
|
782
|
+
<DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
|
|
783
|
+
<div className="flex items-start gap-3 border-b border-theme-border p-4">
|
|
784
|
+
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20">
|
|
785
|
+
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
|
786
|
+
</div>
|
|
787
|
+
<div className="min-w-0 flex-1">
|
|
788
|
+
<h3 className="text-lg font-semibold text-theme-text-primary">
|
|
789
|
+
Apply this fix?
|
|
790
|
+
</h3>
|
|
791
|
+
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
792
|
+
Let {agentLabel} apply the recommended change to{" "}
|
|
793
|
+
<span className="font-medium text-theme-text-primary">
|
|
794
|
+
{resourceLabel}
|
|
795
|
+
</span>
|
|
796
|
+
.
|
|
797
|
+
</p>
|
|
798
|
+
</div>
|
|
799
|
+
</div>
|
|
800
|
+
|
|
801
|
+
{fixText && (
|
|
802
|
+
<div className="border-b border-theme-border p-4">
|
|
803
|
+
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent">
|
|
804
|
+
<Sparkles className="h-3.5 w-3.5" />
|
|
805
|
+
What will happen
|
|
806
|
+
</div>
|
|
807
|
+
<AIMarkdown className="max-h-48 overflow-auto text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-primary [&_pre]:my-1.5">
|
|
808
|
+
{fixText}
|
|
809
|
+
</AIMarkdown>
|
|
810
|
+
</div>
|
|
811
|
+
)}
|
|
812
|
+
|
|
813
|
+
<div className="space-y-2 p-4">
|
|
814
|
+
{/* The star warning: when we KNOW a controller owns this resource, a live
|
|
815
|
+
change reverts on the next reconcile — say so authoritatively. */}
|
|
816
|
+
{managedBy && (
|
|
817
|
+
<div className="space-y-2 rounded border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-theme-text-primary">
|
|
818
|
+
<div className="flex items-start gap-2">
|
|
819
|
+
<RefreshCw className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
|
820
|
+
<span>
|
|
821
|
+
<span className="font-medium">Managed by {managedBy}.</span>{" "}
|
|
822
|
+
Unless you turn off auto-sync, a direct change here will be
|
|
823
|
+
undone within minutes when {managedBy} re-syncs from Git — the
|
|
824
|
+
durable fix is to change it in Git (the {managedBy} source).
|
|
825
|
+
</span>
|
|
826
|
+
</div>
|
|
827
|
+
<label className="flex cursor-pointer items-center gap-2 pl-6 text-xs text-theme-text-secondary">
|
|
828
|
+
<input
|
|
829
|
+
type="checkbox"
|
|
830
|
+
checked={acked}
|
|
831
|
+
onChange={(e) => setAcked(e.target.checked)}
|
|
832
|
+
className="h-3.5 w-3.5 accent-amber-500"
|
|
833
|
+
/>
|
|
834
|
+
I understand {managedBy} may revert this — apply anyway.
|
|
835
|
+
</label>
|
|
836
|
+
</div>
|
|
837
|
+
)}
|
|
838
|
+
{lowConfidence && (
|
|
839
|
+
<div className="flex items-start gap-2 rounded border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
|
|
840
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
841
|
+
<span>
|
|
842
|
+
The agent had <span className="font-medium">low confidence</span>{" "}
|
|
843
|
+
in this diagnosis — consider asking a follow-up to verify before
|
|
844
|
+
applying.
|
|
845
|
+
</span>
|
|
846
|
+
</div>
|
|
847
|
+
)}
|
|
848
|
+
<div className="flex items-start gap-2 rounded border border-theme-border bg-theme-base/50 p-3 text-sm text-theme-text-secondary">
|
|
849
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
850
|
+
<span>
|
|
851
|
+
{agentLabel} will change your cluster using your kubeconfig
|
|
852
|
+
credentials. Review the change above; if you're not sure, ask a
|
|
853
|
+
follow-up first.
|
|
854
|
+
</span>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<div className="flex items-center justify-end gap-3 border-t border-theme-border p-4">
|
|
859
|
+
<button
|
|
860
|
+
onClick={onClose}
|
|
861
|
+
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"
|
|
862
|
+
>
|
|
863
|
+
Cancel
|
|
864
|
+
</button>
|
|
865
|
+
<button
|
|
866
|
+
onClick={onConfirm}
|
|
867
|
+
disabled={applyBlocked}
|
|
868
|
+
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"
|
|
869
|
+
>
|
|
870
|
+
<Wrench className="h-4 w-4" />
|
|
871
|
+
Apply fix
|
|
872
|
+
</button>
|
|
873
|
+
</div>
|
|
874
|
+
</DialogPortal>
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
export function Timeline({
|
|
879
|
+
items,
|
|
880
|
+
running,
|
|
881
|
+
applyMode,
|
|
882
|
+
followup,
|
|
883
|
+
synthLabel,
|
|
884
|
+
}: {
|
|
885
|
+
items: TimelineItem[];
|
|
886
|
+
running: boolean;
|
|
887
|
+
applyMode?: boolean;
|
|
888
|
+
followup?: boolean;
|
|
889
|
+
synthLabel?: string | null;
|
|
890
|
+
}) {
|
|
891
|
+
const heading = applyMode
|
|
892
|
+
? "Applying fix"
|
|
893
|
+
: followup
|
|
894
|
+
? "Working"
|
|
895
|
+
: "Investigation";
|
|
896
|
+
// The live status verb tracks the running tool ("Reading logs…") so the wait is
|
|
897
|
+
// informative, not a generic spinner; falls back to a phase-appropriate label.
|
|
898
|
+
const activeTool = [...items]
|
|
899
|
+
.reverse()
|
|
900
|
+
.find((it) => it.kind === "tool" && it.status !== "done") as
|
|
901
|
+
| Extract<TimelineItem, { kind: "tool" }>
|
|
902
|
+
| undefined;
|
|
903
|
+
const runningLabel = applyMode
|
|
904
|
+
? "Applying the fix…"
|
|
905
|
+
: activeTool
|
|
906
|
+
? toolActivity(activeTool.tool)
|
|
907
|
+
: items.length > 0
|
|
908
|
+
? "Working…"
|
|
909
|
+
: followup
|
|
910
|
+
? "Thinking…"
|
|
911
|
+
: "Starting investigation…";
|
|
912
|
+
return (
|
|
913
|
+
<div className="space-y-1.5">
|
|
914
|
+
{items.length > 0 && (
|
|
915
|
+
<div className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
916
|
+
{heading}
|
|
917
|
+
</div>
|
|
918
|
+
)}
|
|
919
|
+
{items.map((it, i) =>
|
|
920
|
+
it.kind === "thinking" ? (
|
|
921
|
+
// The model's reasoning between tool calls — muted + subordinate to the
|
|
922
|
+
// tool rows. Rendered as markdown so Codex's summary headers read cleanly.
|
|
923
|
+
<AIMarkdown
|
|
924
|
+
key={i}
|
|
925
|
+
className="animate-transcript-enter 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"
|
|
926
|
+
>
|
|
927
|
+
{it.text}
|
|
928
|
+
</AIMarkdown>
|
|
929
|
+
) : (
|
|
930
|
+
<ToolRow key={it.id} step={it} />
|
|
931
|
+
),
|
|
932
|
+
)}
|
|
933
|
+
{running &&
|
|
934
|
+
(synthLabel ? (
|
|
935
|
+
<SynthBeat label={synthLabel} />
|
|
936
|
+
) : (
|
|
937
|
+
<RunningStatus label={runningLabel} />
|
|
938
|
+
))}
|
|
939
|
+
</div>
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// The live "working" line: spinner + shimmering activity verb, plus an elapsed
|
|
944
|
+
// counter and — if the same activity sits with no update for a while — a soft
|
|
945
|
+
// "still working" reassurance, so a long investigation reads as progress and a
|
|
946
|
+
// genuine hang is at least legible (a non-expert can't otherwise tell them apart).
|
|
947
|
+
// Self-contained: counts from when this line mounts; the stall timer resets each
|
|
948
|
+
// time the label changes (i.e. whenever the agent moves to a new tool/phase).
|
|
949
|
+
function RunningStatus({ label }: { label: string }) {
|
|
950
|
+
const [elapsed, setElapsed] = useState(0);
|
|
951
|
+
const elapsedRef = useRef(0);
|
|
952
|
+
const lastChangeRef = useRef(0);
|
|
953
|
+
const prevLabelRef = useRef(label);
|
|
954
|
+
// Reset the stall timer synchronously when the label changes (i.e. the agent moved
|
|
955
|
+
// to a new tool/phase) — doing it during render, not in an effect, so an already-
|
|
956
|
+
// stalled line never flashes "no update for Ns" for a tick before resetting.
|
|
957
|
+
if (prevLabelRef.current !== label) {
|
|
958
|
+
prevLabelRef.current = label;
|
|
959
|
+
lastChangeRef.current = elapsedRef.current;
|
|
960
|
+
}
|
|
961
|
+
useEffect(() => {
|
|
962
|
+
const id = setInterval(() => {
|
|
963
|
+
elapsedRef.current += 1;
|
|
964
|
+
setElapsed(elapsedRef.current);
|
|
965
|
+
}, 1000);
|
|
966
|
+
return () => clearInterval(id);
|
|
967
|
+
}, []);
|
|
968
|
+
const sinceChange = elapsed - lastChangeRef.current;
|
|
969
|
+
const stalled = elapsed >= 30 && sinceChange >= 30;
|
|
970
|
+
return (
|
|
971
|
+
<div className="flex items-center gap-2 pt-1 text-xs">
|
|
972
|
+
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
|
|
973
|
+
<span className="ai-shimmer">{label}</span>
|
|
974
|
+
{elapsed >= 3 && (
|
|
975
|
+
<span className="shrink-0 text-theme-text-tertiary">· {elapsed}s</span>
|
|
976
|
+
)}
|
|
977
|
+
{stalled && (
|
|
978
|
+
<span className="shrink-0 text-theme-text-tertiary">
|
|
979
|
+
· still working — no update for {sinceChange}s
|
|
980
|
+
</span>
|
|
981
|
+
)}
|
|
982
|
+
</div>
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// A staged "thinking" beat — a calm breathing dot + shimmering label. Used both in
|
|
987
|
+
// the timeline (pre-verdict) and between the root-cause and remediation cards.
|
|
988
|
+
function SynthBeat({ label }: { label: string }) {
|
|
989
|
+
return (
|
|
990
|
+
<div className="flex items-center gap-2 pt-1 text-xs animate-transcript-enter">
|
|
991
|
+
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent animate-synth-pulse" />
|
|
992
|
+
<span className="ai-shimmer font-medium">{label}…</span>
|
|
993
|
+
</div>
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Maps a running tool to a human verb so the status line reads as activity, not
|
|
998
|
+
// machinery. Falls back to the prettified tool name for anything unmapped.
|
|
999
|
+
function toolActivity(tool: string): string {
|
|
1000
|
+
const t = tool.toLowerCase();
|
|
1001
|
+
if (t.includes("log")) return "Reading logs…";
|
|
1002
|
+
if (t.includes("event")) return "Checking recent events…";
|
|
1003
|
+
if (t.includes("list")) return "Scanning related resources…";
|
|
1004
|
+
if (t.includes("describe") || t.includes("get_resource"))
|
|
1005
|
+
return "Inspecting the resource…";
|
|
1006
|
+
if (t.includes("resource")) return "Inspecting the resource…";
|
|
1007
|
+
if (t.includes("metric") || t.includes("top")) return "Checking metrics…";
|
|
1008
|
+
if (t.includes("topology") || t.includes("graph"))
|
|
1009
|
+
return "Tracing dependencies…";
|
|
1010
|
+
return `${prettyTool(tool)}…`;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function ToolRow({ step }: { step: Extract<TimelineItem, { kind: "tool" }> }) {
|
|
1014
|
+
const [open, setOpen] = useState(false);
|
|
1015
|
+
const [showFull, setShowFull] = useState(false);
|
|
1016
|
+
const hasDetail = !!(step.summary || step.result);
|
|
1017
|
+
// Offer the rich dialog when the result is structured or non-trivial in size.
|
|
1018
|
+
const richResult =
|
|
1019
|
+
!!step.result && (isJsonPayload(step.result) || step.result.length > 200);
|
|
1020
|
+
return (
|
|
1021
|
+
<div className="animate-transcript-enter rounded-md border border-theme-border/60 bg-theme-base/40">
|
|
1022
|
+
<button
|
|
1023
|
+
onClick={() => hasDetail && setOpen((v) => !v)}
|
|
1024
|
+
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm ${
|
|
1025
|
+
hasDetail ? "hover:bg-theme-hover" : "cursor-default"
|
|
1026
|
+
}`}
|
|
1027
|
+
>
|
|
1028
|
+
{step.status === "done" ? (
|
|
1029
|
+
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
|
1030
|
+
) : (
|
|
1031
|
+
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
|
1032
|
+
)}
|
|
1033
|
+
<span className="font-mono text-xs text-theme-text-secondary">
|
|
1034
|
+
{prettyTool(step.tool)}
|
|
1035
|
+
</span>
|
|
1036
|
+
{step.summary && (
|
|
1037
|
+
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-theme-text-tertiary">
|
|
1038
|
+
{compactArgs(step.summary)}
|
|
1039
|
+
</span>
|
|
1040
|
+
)}
|
|
1041
|
+
{step.ms != null && (
|
|
1042
|
+
<span className="ml-auto shrink-0 text-[11px] text-theme-text-tertiary">
|
|
1043
|
+
{step.ms}ms
|
|
1044
|
+
</span>
|
|
1045
|
+
)}
|
|
1046
|
+
{hasDetail && (
|
|
1047
|
+
<ChevronRight
|
|
1048
|
+
className={`h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform ${open ? "rotate-90" : ""}`}
|
|
1049
|
+
/>
|
|
1050
|
+
)}
|
|
1051
|
+
</button>
|
|
1052
|
+
{hasDetail && (
|
|
1053
|
+
<Collapse open={open}>
|
|
1054
|
+
<div className="space-y-2 border-t border-theme-border/60 px-2 py-2">
|
|
1055
|
+
{step.summary && <PayloadBlock label="Input" text={step.summary} />}
|
|
1056
|
+
{step.result && (
|
|
1057
|
+
<PayloadBlock
|
|
1058
|
+
label="Result"
|
|
1059
|
+
text={step.result}
|
|
1060
|
+
truncated={step.truncated}
|
|
1061
|
+
action={
|
|
1062
|
+
richResult ? (
|
|
1063
|
+
<button
|
|
1064
|
+
onClick={() => setShowFull(true)}
|
|
1065
|
+
className="flex items-center gap-1 text-[11px] text-accent hover:underline"
|
|
1066
|
+
>
|
|
1067
|
+
<Maximize2 className="h-3 w-3" />
|
|
1068
|
+
View payload
|
|
1069
|
+
</button>
|
|
1070
|
+
) : undefined
|
|
1071
|
+
}
|
|
1072
|
+
/>
|
|
1073
|
+
)}
|
|
1074
|
+
</div>
|
|
1075
|
+
</Collapse>
|
|
1076
|
+
)}
|
|
1077
|
+
{step.result && (
|
|
1078
|
+
<ToolResultDialog
|
|
1079
|
+
open={showFull}
|
|
1080
|
+
onClose={() => setShowFull(false)}
|
|
1081
|
+
title={prettyTool(step.tool)}
|
|
1082
|
+
text={step.result}
|
|
1083
|
+
truncated={step.truncated}
|
|
1084
|
+
/>
|
|
1085
|
+
)}
|
|
1086
|
+
</div>
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// isJsonPayload / formatJson — a tool result is "structured" if it parses as JSON.
|
|
1091
|
+
function isJsonPayload(text: string): boolean {
|
|
1092
|
+
try {
|
|
1093
|
+
JSON.parse(text);
|
|
1094
|
+
return true;
|
|
1095
|
+
} catch {
|
|
1096
|
+
return false;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
function formatJson(text: string): string | null {
|
|
1100
|
+
try {
|
|
1101
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
1102
|
+
} catch {
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// PayloadBlock — compact inline view of a tool input/result: pretty JSON (scrolled
|
|
1108
|
+
// to keep indentation) or wrapped text (logs/prose), with copy + optional action.
|
|
1109
|
+
function PayloadBlock({
|
|
1110
|
+
label,
|
|
1111
|
+
text,
|
|
1112
|
+
truncated,
|
|
1113
|
+
action,
|
|
1114
|
+
}: {
|
|
1115
|
+
label: string;
|
|
1116
|
+
text: string;
|
|
1117
|
+
truncated?: boolean;
|
|
1118
|
+
action?: ReactNode;
|
|
1119
|
+
}) {
|
|
1120
|
+
const json = formatJson(text);
|
|
1121
|
+
return (
|
|
1122
|
+
<div>
|
|
1123
|
+
<div className="mb-0.5 flex items-center justify-between gap-2">
|
|
1124
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
1125
|
+
{label}
|
|
1126
|
+
</span>
|
|
1127
|
+
<div className="flex items-center gap-2">
|
|
1128
|
+
{action}
|
|
1129
|
+
<CopyButton text={json ?? text} />
|
|
1130
|
+
</div>
|
|
1131
|
+
</div>
|
|
1132
|
+
<pre
|
|
1133
|
+
className={`max-h-64 overflow-auto rounded bg-theme-elevated p-1.5 font-mono text-[11px] text-theme-text-secondary ${json ? "" : "whitespace-pre-wrap [overflow-wrap:anywhere]"}`}
|
|
1134
|
+
>
|
|
1135
|
+
{json ?? text}
|
|
1136
|
+
</pre>
|
|
1137
|
+
{truncated && (
|
|
1138
|
+
<div className="mt-0.5 text-[10px] text-amber-500">
|
|
1139
|
+
Capped at 32 KB — partial output.
|
|
1140
|
+
</div>
|
|
1141
|
+
)}
|
|
1142
|
+
</div>
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ToolResultDialog — the rich payload viewer: syntax-highlighted + searchable via
|
|
1147
|
+
// CodeViewer, with a JSON⇄YAML toggle for structured results (YAML default — k8s
|
|
1148
|
+
// reads better) and plain text for non-JSON (logs/prose).
|
|
1149
|
+
function ToolResultDialog({
|
|
1150
|
+
open,
|
|
1151
|
+
onClose,
|
|
1152
|
+
title,
|
|
1153
|
+
text,
|
|
1154
|
+
truncated,
|
|
1155
|
+
}: {
|
|
1156
|
+
open: boolean;
|
|
1157
|
+
onClose: () => void;
|
|
1158
|
+
title: string;
|
|
1159
|
+
text: string;
|
|
1160
|
+
truncated?: boolean;
|
|
1161
|
+
}) {
|
|
1162
|
+
const { theme } = useTheme();
|
|
1163
|
+
const [fmt, setFmt] = useState<"yaml" | "json">("yaml");
|
|
1164
|
+
const parsed = useMemo<{ ok: boolean; value?: unknown }>(() => {
|
|
1165
|
+
try {
|
|
1166
|
+
return { ok: true, value: JSON.parse(text) };
|
|
1167
|
+
} catch {
|
|
1168
|
+
return { ok: false };
|
|
1169
|
+
}
|
|
1170
|
+
}, [text]);
|
|
1171
|
+
|
|
1172
|
+
const display = !parsed.ok
|
|
1173
|
+
? text
|
|
1174
|
+
: fmt === "yaml"
|
|
1175
|
+
? safeYaml(parsed.value)
|
|
1176
|
+
: JSON.stringify(parsed.value, null, 2);
|
|
1177
|
+
const language = parsed.ok ? fmt : "text";
|
|
1178
|
+
|
|
1179
|
+
// Progressive syntax highlighting: render the plain text instantly, then swap in
|
|
1180
|
+
// shiki's highlighted HTML once it resolves. A slow/failed highlighter load never
|
|
1181
|
+
// blocks the payload (unlike CodeViewer's "Loading…" gate) — worst case it stays
|
|
1182
|
+
// plain. Native browser find still works on the rendered text.
|
|
1183
|
+
const [html, setHtml] = useState<string | null>(null);
|
|
1184
|
+
useEffect(() => {
|
|
1185
|
+
if (!open) return;
|
|
1186
|
+
setHtml(null);
|
|
1187
|
+
let alive = true;
|
|
1188
|
+
codeToHtml(display, {
|
|
1189
|
+
lang: language,
|
|
1190
|
+
theme: theme === "light" ? "github-light" : "github-dark",
|
|
1191
|
+
})
|
|
1192
|
+
.then((h) => alive && setHtml(h))
|
|
1193
|
+
.catch(() => {}); // keep the plain pre on failure
|
|
1194
|
+
return () => {
|
|
1195
|
+
alive = false;
|
|
1196
|
+
};
|
|
1197
|
+
}, [open, display, language, theme]);
|
|
1198
|
+
return (
|
|
1199
|
+
<DialogPortal open={open} onClose={onClose} className="w-[min(90vw,820px)]">
|
|
1200
|
+
<div className="flex items-center justify-between gap-3 border-b border-theme-border p-3">
|
|
1201
|
+
<div className="min-w-0">
|
|
1202
|
+
<div className="truncate font-mono text-sm text-theme-text-primary">
|
|
1203
|
+
{title}
|
|
1204
|
+
</div>
|
|
1205
|
+
{truncated && (
|
|
1206
|
+
<div className="text-[11px] text-amber-500">
|
|
1207
|
+
Capped at 32 KB — partial output.
|
|
1208
|
+
</div>
|
|
1209
|
+
)}
|
|
1210
|
+
</div>
|
|
1211
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
1212
|
+
{parsed.ok && (
|
|
1213
|
+
<div className="w-32">
|
|
1214
|
+
<Segmented<"yaml" | "json">
|
|
1215
|
+
value={fmt}
|
|
1216
|
+
onChange={setFmt}
|
|
1217
|
+
options={[
|
|
1218
|
+
{ value: "yaml", label: "YAML" },
|
|
1219
|
+
{ value: "json", label: "JSON" },
|
|
1220
|
+
]}
|
|
1221
|
+
/>
|
|
1222
|
+
</div>
|
|
1223
|
+
)}
|
|
1224
|
+
<CopyButton text={display} />
|
|
1225
|
+
</div>
|
|
1226
|
+
</div>
|
|
1227
|
+
{html ? (
|
|
1228
|
+
<div
|
|
1229
|
+
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"
|
|
1230
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
1231
|
+
/>
|
|
1232
|
+
) : (
|
|
1233
|
+
<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">
|
|
1234
|
+
{display}
|
|
1235
|
+
</pre>
|
|
1236
|
+
)}
|
|
1237
|
+
</DialogPortal>
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function safeYaml(value: unknown): string {
|
|
1242
|
+
try {
|
|
1243
|
+
return toYaml(value, { lineWidth: 0 });
|
|
1244
|
+
} catch {
|
|
1245
|
+
return JSON.stringify(value, null, 2);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Collapse — the Radar-standard expand/collapse motion (grid-template-rows
|
|
1250
|
+
// 0fr↔1fr) used across issue rows. Children stay mounted so close animates too.
|
|
1251
|
+
function Collapse({ open, children }: { open: boolean; children: ReactNode }) {
|
|
1252
|
+
return (
|
|
1253
|
+
<div
|
|
1254
|
+
className={`issue-details-motion ${open ? "issue-details-motion-open" : ""}`}
|
|
1255
|
+
>
|
|
1256
|
+
<div className="overflow-hidden">{children}</div>
|
|
1257
|
+
</div>
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function compactArgs(raw: string): string {
|
|
1262
|
+
try {
|
|
1263
|
+
const o = JSON.parse(raw);
|
|
1264
|
+
return Object.entries(o)
|
|
1265
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
1266
|
+
.join(" ");
|
|
1267
|
+
} catch {
|
|
1268
|
+
return raw;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
export function ResultCard({
|
|
1273
|
+
diagnosis,
|
|
1274
|
+
onApply,
|
|
1275
|
+
onAsk,
|
|
1276
|
+
apply,
|
|
1277
|
+
followup,
|
|
1278
|
+
reveal = "full",
|
|
1279
|
+
onCheckStatus,
|
|
1280
|
+
}: {
|
|
1281
|
+
diagnosis: Diagnosis;
|
|
1282
|
+
onApply?: (fix: string) => void;
|
|
1283
|
+
onAsk?: (question: string) => void;
|
|
1284
|
+
apply?: boolean;
|
|
1285
|
+
followup?: boolean;
|
|
1286
|
+
reveal?: "rca" | "full";
|
|
1287
|
+
onCheckStatus?: () => void;
|
|
1288
|
+
}) {
|
|
1289
|
+
// Apply turns report what changed — an outcome, not a diagnosis. Frame as a
|
|
1290
|
+
// success confirmation (emerald) rather than the amber root-cause anchor.
|
|
1291
|
+
if (apply)
|
|
1292
|
+
return (
|
|
1293
|
+
<ApplyOutcomeCard diagnosis={diagnosis} onCheckStatus={onCheckStatus} />
|
|
1294
|
+
);
|
|
1295
|
+
if (diagnosis.healthy && !diagnosis.rootCause)
|
|
1296
|
+
return <AllClearCard diagnosis={diagnosis} />;
|
|
1297
|
+
// Couldn't-determine is its own honest state — never a confident all-clear, never
|
|
1298
|
+
// the alarming root-cause anchor.
|
|
1299
|
+
if (diagnosis.inconclusive && !diagnosis.rootCause)
|
|
1300
|
+
return <InconclusiveCard diagnosis={diagnosis} />;
|
|
1301
|
+
// Follow-ups are conversational replies, not fresh diagnoses — plain answer.
|
|
1302
|
+
if (followup) return <FollowupAnswer diagnosis={diagnosis} />;
|
|
1303
|
+
|
|
1304
|
+
// A turn with no structured root cause and no remediation (e.g. "looks healthy",
|
|
1305
|
+
// or a clarifying question) is not a diagnosis — render it neutrally rather than
|
|
1306
|
+
// forcing the alarming root-cause anchor onto a non-problem.
|
|
1307
|
+
const structured =
|
|
1308
|
+
!!diagnosis.rootCause || (diagnosis.remediation?.length ?? 0) > 0;
|
|
1309
|
+
if (!structured) return <FollowupAnswer diagnosis={diagnosis} />;
|
|
1310
|
+
|
|
1311
|
+
return (
|
|
1312
|
+
<DiagnosisResult
|
|
1313
|
+
diagnosis={diagnosis}
|
|
1314
|
+
onApply={onApply}
|
|
1315
|
+
onAsk={onAsk}
|
|
1316
|
+
reveal={reveal}
|
|
1317
|
+
/>
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const EXPLAIN_SIMPLY_PROMPT =
|
|
1322
|
+
"Explain this in plain language for someone who isn't a Kubernetes expert — what's broken, why it matters, and what each remediation step actually does. Gloss any k8s terms.";
|
|
1323
|
+
|
|
1324
|
+
// The diagnosis result: root cause + remediation (any step applyable) + the
|
|
1325
|
+
// agent's full analysis on demand.
|
|
1326
|
+
function DiagnosisResult({
|
|
1327
|
+
diagnosis,
|
|
1328
|
+
onApply,
|
|
1329
|
+
onAsk,
|
|
1330
|
+
reveal = "full",
|
|
1331
|
+
}: {
|
|
1332
|
+
diagnosis: Diagnosis;
|
|
1333
|
+
onApply?: (fix: string) => void;
|
|
1334
|
+
onAsk?: (question: string) => void;
|
|
1335
|
+
reveal?: "rca" | "full";
|
|
1336
|
+
}) {
|
|
1337
|
+
const [showAnalysis, setShowAnalysis] = useState(false);
|
|
1338
|
+
// Only a real structured root cause anchors the amber card; the full prose lives
|
|
1339
|
+
// in "Full analysis" (never relabel the report as a root cause).
|
|
1340
|
+
const rootCause = diagnosis.rootCause;
|
|
1341
|
+
const remediation = diagnosis.remediation || [];
|
|
1342
|
+
const hasRemediation = remediation.length > 0;
|
|
1343
|
+
const recIdx = diagnosis.recommendedIndex;
|
|
1344
|
+
const recValid =
|
|
1345
|
+
recIdx != null && recIdx >= 1 && recIdx <= remediation.length;
|
|
1346
|
+
// Apply is offered ONLY when the agent pointed at a safe step (recommended_index).
|
|
1347
|
+
// When it returns 0 / none ("needs human judgement"), we honor that and don't
|
|
1348
|
+
// offer one-click apply — the steps stay copy-only with a note.
|
|
1349
|
+
const canApply = !!onApply && recValid;
|
|
1350
|
+
return (
|
|
1351
|
+
<div className="mt-3 space-y-2 animate-result-in">
|
|
1352
|
+
{/* Root cause — the anchor: distinct tone + heavier type so it pops. */}
|
|
1353
|
+
{rootCause && (
|
|
1354
|
+
<div
|
|
1355
|
+
className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 animate-verdict-reveal"
|
|
1356
|
+
style={{ "--glow": "rgb(245 158 11)" } as React.CSSProperties}
|
|
1357
|
+
>
|
|
1358
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
1359
|
+
<div className="relative flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-amber-500">
|
|
1360
|
+
<AlertTriangle className="h-3.5 w-3.5" />
|
|
1361
|
+
Root cause
|
|
1362
|
+
<span className="absolute -bottom-0.5 left-0 right-0 h-px bg-amber-500/60 animate-underline-sweep" />
|
|
1363
|
+
</div>
|
|
1364
|
+
<div className="flex items-center gap-2">
|
|
1365
|
+
{diagnosis.confidence != null ? (
|
|
1366
|
+
<ConfidenceMeter value={diagnosis.confidence} />
|
|
1367
|
+
) : (
|
|
1368
|
+
<ConfidenceUnstated />
|
|
1369
|
+
)}
|
|
1370
|
+
<CopyButton text={rootCause} />
|
|
1371
|
+
</div>
|
|
1372
|
+
</div>
|
|
1373
|
+
<AIMarkdown className="text-sm font-medium text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-primary">
|
|
1374
|
+
{rootCause}
|
|
1375
|
+
</AIMarkdown>
|
|
1376
|
+
{onAsk && reveal === "full" && (
|
|
1377
|
+
<button
|
|
1378
|
+
onClick={() => onAsk(EXPLAIN_SIMPLY_PROMPT)}
|
|
1379
|
+
className="mt-2 inline-flex items-center gap-1 rounded-md border border-theme-border px-2 py-1 text-[11px] font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
1380
|
+
>
|
|
1381
|
+
<HelpCircle className="h-3 w-3" />
|
|
1382
|
+
Explain simply
|
|
1383
|
+
</button>
|
|
1384
|
+
)}
|
|
1385
|
+
</div>
|
|
1386
|
+
)}
|
|
1387
|
+
|
|
1388
|
+
{/* Between the root cause and the remediation, a beat lands where the steps
|
|
1389
|
+
will appear — the verdict unfolds rather than dumping all at once. */}
|
|
1390
|
+
{reveal === "rca" && hasRemediation && (
|
|
1391
|
+
<SynthBeat label="Weighing remediation options" />
|
|
1392
|
+
)}
|
|
1393
|
+
|
|
1394
|
+
{/* Remediation — copyable steps; the recommended one is highlighted as the
|
|
1395
|
+
default, and any step can be applied (Apply binds to that step's text). */}
|
|
1396
|
+
{reveal === "full" && hasRemediation && (
|
|
1397
|
+
<div
|
|
1398
|
+
className="rounded-lg border border-theme-border bg-theme-elevated p-3 animate-verdict-reveal"
|
|
1399
|
+
style={{ "--glow": "var(--accent)" } as React.CSSProperties}
|
|
1400
|
+
>
|
|
1401
|
+
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1402
|
+
<Wrench className="h-3.5 w-3.5 text-accent" />
|
|
1403
|
+
Remediation
|
|
1404
|
+
</div>
|
|
1405
|
+
<ol className="space-y-2">
|
|
1406
|
+
{remediation.map((r, i) => {
|
|
1407
|
+
const isRec = recValid && i === recIdx! - 1;
|
|
1408
|
+
return (
|
|
1409
|
+
<li
|
|
1410
|
+
key={i}
|
|
1411
|
+
className={`animate-transcript-enter ${
|
|
1412
|
+
isRec
|
|
1413
|
+
? "rounded-lg border border-accent/40 bg-accent/5 p-2.5"
|
|
1414
|
+
: ""
|
|
1415
|
+
}`}
|
|
1416
|
+
style={{ animationDelay: `${260 + i * 70}ms` }}
|
|
1417
|
+
>
|
|
1418
|
+
<div className="flex items-start gap-2">
|
|
1419
|
+
<span
|
|
1420
|
+
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] ${
|
|
1421
|
+
isRec
|
|
1422
|
+
? "bg-accent/20 text-accent"
|
|
1423
|
+
: "bg-theme-base text-theme-text-tertiary"
|
|
1424
|
+
}`}
|
|
1425
|
+
>
|
|
1426
|
+
{i + 1}
|
|
1427
|
+
</span>
|
|
1428
|
+
<div className="min-w-0 flex-1">
|
|
1429
|
+
{isRec && (
|
|
1430
|
+
<div className="mb-1">
|
|
1431
|
+
<div className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
|
1432
|
+
<Sparkles className="h-3 w-3" />
|
|
1433
|
+
Recommended
|
|
1434
|
+
</div>
|
|
1435
|
+
{diagnosis.recommendedReason && (
|
|
1436
|
+
<div className="mt-0.5 text-[11px] leading-snug text-theme-text-tertiary">
|
|
1437
|
+
{diagnosis.recommendedReason}
|
|
1438
|
+
</div>
|
|
1439
|
+
)}
|
|
1440
|
+
</div>
|
|
1441
|
+
)}
|
|
1442
|
+
<AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_p]:my-0 [&_pre]:my-1.5">
|
|
1443
|
+
{r}
|
|
1444
|
+
</AIMarkdown>
|
|
1445
|
+
</div>
|
|
1446
|
+
{/* Action cluster: compact Apply (recommended = subtly
|
|
1447
|
+
filled, others = ghost) sits next to Copy so each row's
|
|
1448
|
+
actions stay together. The ellipsis signals a confirm
|
|
1449
|
+
dialog follows — it doesn't apply immediately. */}
|
|
1450
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
1451
|
+
{canApply && (
|
|
1452
|
+
<button
|
|
1453
|
+
onClick={() => onApply!(r)}
|
|
1454
|
+
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-accent transition-colors ${
|
|
1455
|
+
isRec
|
|
1456
|
+
? "border border-accent/40 bg-accent/10 hover:bg-accent/20"
|
|
1457
|
+
: "hover:bg-accent/10"
|
|
1458
|
+
}`}
|
|
1459
|
+
>
|
|
1460
|
+
<Wrench className="h-3 w-3" />
|
|
1461
|
+
Apply…
|
|
1462
|
+
</button>
|
|
1463
|
+
)}
|
|
1464
|
+
<CopyButton text={r} />
|
|
1465
|
+
</div>
|
|
1466
|
+
</div>
|
|
1467
|
+
</li>
|
|
1468
|
+
);
|
|
1469
|
+
})}
|
|
1470
|
+
</ol>
|
|
1471
|
+
{!recValid && (
|
|
1472
|
+
<p className="mt-2 flex items-start gap-1.5 text-[11px] leading-snug text-theme-text-tertiary">
|
|
1473
|
+
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
|
|
1474
|
+
No safe one-click fix — the agent flagged this as needing your
|
|
1475
|
+
judgement. Review the steps, or resume in your agent to apply them
|
|
1476
|
+
interactively.
|
|
1477
|
+
</p>
|
|
1478
|
+
)}
|
|
1479
|
+
</div>
|
|
1480
|
+
)}
|
|
1481
|
+
|
|
1482
|
+
{/* Full analysis — the agent's detailed evidence, on demand. */}
|
|
1483
|
+
{reveal === "full" && diagnosis.report && (
|
|
1484
|
+
<div className="rounded-lg border border-theme-border bg-theme-elevated">
|
|
1485
|
+
<button
|
|
1486
|
+
onClick={() => setShowAnalysis((v) => !v)}
|
|
1487
|
+
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs font-medium uppercase tracking-wide text-theme-text-tertiary hover:text-theme-text-primary"
|
|
1488
|
+
>
|
|
1489
|
+
<ChevronRight
|
|
1490
|
+
className={`h-3.5 w-3.5 transition-transform ${showAnalysis ? "rotate-90" : ""}`}
|
|
1491
|
+
/>
|
|
1492
|
+
Full analysis
|
|
1493
|
+
</button>
|
|
1494
|
+
<Collapse open={showAnalysis}>
|
|
1495
|
+
<div className="border-t border-theme-border/60 px-3 py-2">
|
|
1496
|
+
<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">
|
|
1497
|
+
{diagnosis.report}
|
|
1498
|
+
</AIMarkdown>
|
|
1499
|
+
</div>
|
|
1500
|
+
</Collapse>
|
|
1501
|
+
</div>
|
|
1502
|
+
)}
|
|
1503
|
+
|
|
1504
|
+
{reveal === "full" && (
|
|
1505
|
+
<div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
|
|
1506
|
+
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
|
|
1507
|
+
<span>AI-generated — review before applying</span>
|
|
1508
|
+
</div>
|
|
1509
|
+
)}
|
|
1510
|
+
</div>
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function AllClearCard({ diagnosis }: { diagnosis: Diagnosis }) {
|
|
1515
|
+
const text =
|
|
1516
|
+
diagnosis.report || "No active problem found for this resource.";
|
|
1517
|
+
return (
|
|
1518
|
+
<div className="mt-3 space-y-2 animate-result-in">
|
|
1519
|
+
<div
|
|
1520
|
+
className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3 animate-verdict-reveal"
|
|
1521
|
+
style={{ "--glow": "rgb(16 185 129)" } as React.CSSProperties}
|
|
1522
|
+
>
|
|
1523
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
1524
|
+
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-emerald-500">
|
|
1525
|
+
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
1526
|
+
No problems found
|
|
1527
|
+
</div>
|
|
1528
|
+
<CopyButton text={text} />
|
|
1529
|
+
</div>
|
|
1530
|
+
<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">
|
|
1531
|
+
{text}
|
|
1532
|
+
</AIMarkdown>
|
|
1533
|
+
</div>
|
|
1534
|
+
<div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
|
|
1535
|
+
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
|
|
1536
|
+
<span>AI-generated — verify if symptoms persist</span>
|
|
1537
|
+
</div>
|
|
1538
|
+
</div>
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// The agent investigated but couldn't determine an answer. A distinct, honest
|
|
1543
|
+
// state — neutral (not the alarming amber root cause, not the reassuring emerald
|
|
1544
|
+
// all-clear) — so "I couldn't tell" never reads as "you're fine."
|
|
1545
|
+
function InconclusiveCard({ diagnosis }: { diagnosis: Diagnosis }) {
|
|
1546
|
+
const text =
|
|
1547
|
+
diagnosis.report ||
|
|
1548
|
+
"The investigation couldn't reach a clear conclusion — some checks were blocked or the evidence was ambiguous.";
|
|
1549
|
+
return (
|
|
1550
|
+
<div className="mt-3 space-y-2 animate-result-in">
|
|
1551
|
+
<div
|
|
1552
|
+
className="rounded-lg border border-theme-border bg-theme-elevated p-3 animate-verdict-reveal"
|
|
1553
|
+
style={{ "--glow": "rgb(100 116 139)" } as React.CSSProperties}
|
|
1554
|
+
>
|
|
1555
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
1556
|
+
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">
|
|
1557
|
+
<HelpCircle className="h-3.5 w-3.5" />
|
|
1558
|
+
Couldn't determine
|
|
1559
|
+
</div>
|
|
1560
|
+
<CopyButton text={text} />
|
|
1561
|
+
</div>
|
|
1562
|
+
<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">
|
|
1563
|
+
{text}
|
|
1564
|
+
</AIMarkdown>
|
|
1565
|
+
</div>
|
|
1566
|
+
<div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
|
|
1567
|
+
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
|
|
1568
|
+
<span>
|
|
1569
|
+
Try a follow-up with more detail, or re-run after granting access.
|
|
1570
|
+
</span>
|
|
1571
|
+
</div>
|
|
1572
|
+
</div>
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// A follow-up reply: the agent answering a question, not re-diagnosing. Plain
|
|
1577
|
+
// neutral block — no root-cause anchor, no remediation/apply.
|
|
1578
|
+
function FollowupAnswer({ diagnosis }: { diagnosis: Diagnosis }) {
|
|
1579
|
+
const text = diagnosis.report || diagnosis.rootCause;
|
|
1580
|
+
if (!text) return null;
|
|
1581
|
+
return (
|
|
1582
|
+
<div className="mt-1 rounded-lg border border-theme-border bg-theme-elevated p-3">
|
|
1583
|
+
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
1584
|
+
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1585
|
+
<Sparkles className="h-3.5 w-3.5 text-accent" />
|
|
1586
|
+
Answer
|
|
1587
|
+
</div>
|
|
1588
|
+
<CopyButton text={text} />
|
|
1589
|
+
</div>
|
|
1590
|
+
<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">
|
|
1591
|
+
{text}
|
|
1592
|
+
</AIMarkdown>
|
|
1593
|
+
</div>
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// A done turn that produced no renderable verdict at all (empty diagnosis, no
|
|
1598
|
+
// narration). Without this the turn would render blank — which reads as "the tool
|
|
1599
|
+
// broke." Make the dead-end explicit and point at the recovery (a follow-up).
|
|
1600
|
+
function EmptyResult() {
|
|
1601
|
+
return (
|
|
1602
|
+
<div 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">
|
|
1603
|
+
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
1604
|
+
<span>
|
|
1605
|
+
The investigation finished without a clear result. Try a follow-up
|
|
1606
|
+
question, or re-run Diagnose.
|
|
1607
|
+
</span>
|
|
1608
|
+
</div>
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// The result of an apply turn: a success confirmation of what changed, not a
|
|
1613
|
+
// diagnosis. Emerald + checkmark so it reads as an outcome.
|
|
1614
|
+
function ApplyOutcomeCard({
|
|
1615
|
+
diagnosis,
|
|
1616
|
+
onCheckStatus,
|
|
1617
|
+
}: {
|
|
1618
|
+
diagnosis: Diagnosis;
|
|
1619
|
+
onCheckStatus?: () => void;
|
|
1620
|
+
}) {
|
|
1621
|
+
const outcome = diagnosis.report || diagnosis.rootCause;
|
|
1622
|
+
return (
|
|
1623
|
+
<div className="mt-3 space-y-2">
|
|
1624
|
+
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3">
|
|
1625
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
1626
|
+
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-emerald-500">
|
|
1627
|
+
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
1628
|
+
Applied
|
|
1629
|
+
</div>
|
|
1630
|
+
{outcome && <CopyButton text={outcome} />}
|
|
1631
|
+
</div>
|
|
1632
|
+
{outcome && (
|
|
1633
|
+
<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">
|
|
1634
|
+
{outcome}
|
|
1635
|
+
</AIMarkdown>
|
|
1636
|
+
)}
|
|
1637
|
+
{onCheckStatus && (
|
|
1638
|
+
<button
|
|
1639
|
+
onClick={onCheckStatus}
|
|
1640
|
+
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-emerald-500/40 py-2 text-sm font-medium text-emerald-500 hover:bg-emerald-500/10"
|
|
1641
|
+
>
|
|
1642
|
+
<RefreshCw className="h-4 w-4" />
|
|
1643
|
+
Check status
|
|
1644
|
+
</button>
|
|
1645
|
+
)}
|
|
1646
|
+
</div>
|
|
1647
|
+
<div className="flex items-center gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
|
|
1648
|
+
<ShieldCheck className="h-3 w-3 shrink-0" />
|
|
1649
|
+
<span className="truncate">
|
|
1650
|
+
Applied by AI — verify the change took effect
|
|
1651
|
+
</span>
|
|
1652
|
+
</div>
|
|
1653
|
+
</div>
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function CopyButton({ text }: { text: string }) {
|
|
1658
|
+
const [copied, setCopied] = useState(false);
|
|
1659
|
+
return (
|
|
1660
|
+
<button
|
|
1661
|
+
onClick={() => {
|
|
1662
|
+
navigator.clipboard?.writeText(text);
|
|
1663
|
+
setCopied(true);
|
|
1664
|
+
setTimeout(() => setCopied(false), 1200);
|
|
1665
|
+
}}
|
|
1666
|
+
className="shrink-0 rounded p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
1667
|
+
aria-label="Copy"
|
|
1668
|
+
>
|
|
1669
|
+
{copied ? (
|
|
1670
|
+
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
|
1671
|
+
) : (
|
|
1672
|
+
<Copy className="h-3.5 w-3.5" />
|
|
1673
|
+
)}
|
|
1674
|
+
</button>
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function prettyTool(tool: string): string {
|
|
1679
|
+
return tool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// A coarse band, not a precise %: a two-sig-fig confidence on an LLM judgement
|
|
1683
|
+
// reads as calibrated when it isn't.
|
|
1684
|
+
function confidenceLabel(c: number): string {
|
|
1685
|
+
if (c >= 0.8) return "High";
|
|
1686
|
+
if (c >= 0.5) return "Medium";
|
|
1687
|
+
return "Low";
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Confidence shown as a band + three discrete pips (Low=1, Med=2, High=3 filled).
|
|
1691
|
+
// Deliberately discrete, NOT a continuous bar: a filled fraction reads as a precise
|
|
1692
|
+
// percentage, which is exactly the false calibration `confidenceLabel` exists to
|
|
1693
|
+
// avoid (an LLM's two-sig-fig confidence isn't that precise). Accent-toned so it
|
|
1694
|
+
// reads as "trust in the analysis," not problem severity.
|
|
1695
|
+
function ConfidenceMeter({ value }: { value: number }) {
|
|
1696
|
+
const band = confidenceLabel(value);
|
|
1697
|
+
const filled = band === "High" ? 3 : band === "Medium" ? 2 : 1;
|
|
1698
|
+
return (
|
|
1699
|
+
<span className="flex items-center gap-1.5">
|
|
1700
|
+
<span className="text-[11px] text-theme-text-tertiary">
|
|
1701
|
+
{band} confidence
|
|
1702
|
+
</span>
|
|
1703
|
+
<span className="flex items-center gap-0.5" aria-hidden>
|
|
1704
|
+
{[0, 1, 2].map((i) => (
|
|
1705
|
+
<span
|
|
1706
|
+
key={i}
|
|
1707
|
+
className={`h-1 w-2.5 rounded-full ${i < filled ? "bg-accent" : "bg-theme-base"}`}
|
|
1708
|
+
/>
|
|
1709
|
+
))}
|
|
1710
|
+
</span>
|
|
1711
|
+
</span>
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// Shown when the model returned no confidence at all — so "unknown confidence"
|
|
1716
|
+
// is visible rather than silently absent (which looks identical to high confidence
|
|
1717
|
+
// minus the badge) on a trust-bearing surface.
|
|
1718
|
+
function ConfidenceUnstated() {
|
|
1719
|
+
return (
|
|
1720
|
+
<span className="text-[11px] text-theme-text-tertiary">
|
|
1721
|
+
Confidence not stated
|
|
1722
|
+
</span>
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// LLMs occasionally open a ```fence mid-line ("run this: ```bash kubectl …") or
|
|
1727
|
+
// put the command on the same line as the ```lang marker. GFM then won't parse
|
|
1728
|
+
// it as a fence — it leaks the literal ``` and renders an empty code box. Coerce
|
|
1729
|
+
// fence markers onto their own lines and push trailing content off the opener so
|
|
1730
|
+
// the block renders. (Well-formed markdown is unaffected.)
|
|
1731
|
+
function tidyFences(md: string): string {
|
|
1732
|
+
if (!md || !md.includes("```")) return md;
|
|
1733
|
+
return md
|
|
1734
|
+
.replace(/([^\n])```/g, "$1\n\n```") // opener/closer must start a line
|
|
1735
|
+
.replace(/```([A-Za-z0-9_-]*)[ \t]+(\S)/g, "```$1\n$2"); // content off the opener line
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// Diagnosis output is dense with inline `code`; the shared chip's brand tint is
|
|
1739
|
+
// too loud at that density, so neutralize it (border/bg only) for this surface.
|
|
1740
|
+
const SOFT_INLINE_CODE =
|
|
1741
|
+
"[&_.inline-code]:border-theme-border/60 [&_.inline-code]:bg-theme-base [&_.inline-code]:font-normal";
|
|
1742
|
+
|
|
1743
|
+
// Markdown for agent-generated text — normalizes flaky fences + softens code.
|
|
1744
|
+
function AIMarkdown({
|
|
1745
|
+
className,
|
|
1746
|
+
children,
|
|
1747
|
+
}: {
|
|
1748
|
+
className?: string;
|
|
1749
|
+
children: string;
|
|
1750
|
+
}) {
|
|
1751
|
+
return (
|
|
1752
|
+
<Markdown className={`${SOFT_INLINE_CODE} ${className ?? ""}`}>
|
|
1753
|
+
{tidyFences(children)}
|
|
1754
|
+
</Markdown>
|
|
1755
|
+
);
|
|
1756
|
+
}
|