@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.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. 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&apos;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&apos;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&apos;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&apos;s read-only investigation tools — your
726
+ other CLI config and MCP servers are excluded.
727
+ {agent === "codex" && (
728
+ <>
729
+ {" "}
730
+ Codex&apos;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
+ &ldquo;My setup&rdquo;: the agent also runs with your own CLI config
738
+ + MCP servers and can read local files. Only Radar&apos;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&apos;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&apos;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
+ }