@skyhook-io/radar-app 1.8.6 → 1.8.7

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