@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,622 @@
1
+ // A view over one durable, server-side investigation run. It SUBSCRIBES to the
2
+ // run's event stream (replay + live) and reconstructs the transcript; it does not
3
+ // own the run's lifetime — the server does. So closing the panel or navigating
4
+ // away just unsubscribes; the run keeps going and re-subscribing replays it.
5
+ import {
6
+ useCallback,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { useQueryClient } from "@tanstack/react-query";
13
+ import { Send, AlertTriangle, ArrowDown } from "lucide-react";
14
+ import {
15
+ subscribeRun,
16
+ addTurn,
17
+ stopRun,
18
+ DiagnoseError,
19
+ type Diagnosis,
20
+ type DiagnoseStreamEvent,
21
+ type RunSummary,
22
+ } from "../../api/diagnose";
23
+ import { useDiagnose } from "./DiagnoseContext";
24
+ import {
25
+ TurnView,
26
+ ResultCard,
27
+ ApplyDialog,
28
+ RunContextCard,
29
+ appendThinking,
30
+ upsertTool,
31
+ type Turn,
32
+ } from "./parts";
33
+
34
+ const RECHECK_QUESTION =
35
+ "Did the fix resolve the issue? Re-check the resource's current status and health now, and say whether it's healthy.";
36
+
37
+ export function InvestigationView({
38
+ run,
39
+ agentLabel,
40
+ maximized,
41
+ }: {
42
+ run: RunSummary;
43
+ agentLabel: string;
44
+ maximized: boolean;
45
+ }) {
46
+ const { kind, namespace, name } = run;
47
+ // Apply is off for hosted agents (read-only server-side). Keyed on the selected
48
+ // agent, which matches run.agent unless a deployment mixes hosted + local agents.
49
+ const { refreshRuns, openInvestigation, startError, hosted } = useDiagnose();
50
+ const retryDiagnosis = useCallback(
51
+ () => openInvestigation({ kind, namespace, name }),
52
+ [openInvestigation, kind, namespace, name],
53
+ );
54
+ const queryClient = useQueryClient();
55
+ const [turns, setTurns] = useState<Turn[]>([]);
56
+ // The run is gone server-side (evicted past the retention cap, or lost on a
57
+ // restart) — the stream 404s / closes with nothing to replay. Without this we'd
58
+ // show a silent blank panel; instead we render a "no longer available" state.
59
+ const [gone, setGone] = useState(false);
60
+ const [busy, setBusy] = useState(false);
61
+ const [input, setInput] = useState("");
62
+ const [actionError, setActionError] = useState<string | null>(null);
63
+ const scrollRef = useRef<HTMLDivElement>(null);
64
+ const pendingApplyRef = useRef(false);
65
+ // Set when THIS view initiates an apply, consumed once on that apply's done event
66
+ // to auto-run the health re-check (the verification). Ref, not derived from the
67
+ // stream, so replaying a past apply on reopen never re-fires the re-check.
68
+ const autoRecheckRef = useRef(false);
69
+ // Stick-to-bottom: follow streaming output while the user is at/near the bottom,
70
+ // detach the moment they scroll up to read history, re-attach when they return.
71
+ // Tracked from scroll events (the user's intent) — NOT post-render geometry, which
72
+ // mis-detaches whenever a streamed chunk is taller than the threshold.
73
+ const pinnedRef = useRef(true);
74
+ const [showJump, setShowJump] = useState(false);
75
+ const STICK_THRESHOLD = 64; // px from bottom counted as "at the bottom"
76
+
77
+ // Staged synthesis beats: when a real diagnosis is ready, hold it for a beat and
78
+ // narrate the closing reasoning ("Formulating the root cause…" → "Weighing
79
+ // remediation options…") before revealing the verdict. These ARE the phases the
80
+ // model just ran; pacing their presentation makes the payoff feel earned instead
81
+ // of dumped. Apply outcomes and plain follow-ups skip it (no root cause to build).
82
+ const [synth, setSynth] = useState<string | null>(null);
83
+ // Controls how much of a freshly-revealed diagnosis the card shows, so the verdict
84
+ // unfolds in beats: "rca" = root cause only (+ a "weighing remediation" beat),
85
+ // "full" = everything. null/"full" for replayed turns (no choreography on rebuild).
86
+ const [reveal, setReveal] = useState<"rca" | "full" | null>(null);
87
+ const synthTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
88
+ const clearSynth = () => {
89
+ synthTimers.current.forEach(clearTimeout);
90
+ synthTimers.current = [];
91
+ setSynth(null);
92
+ };
93
+
94
+ // After a successful apply, refresh the cluster-state views so the fix shows in
95
+ // the surrounding UI (Issues, the resource, topology, …), not just the transcript.
96
+ const refreshClusterState = useCallback(() => {
97
+ for (const key of [
98
+ ["issues"],
99
+ ["dashboard"],
100
+ ["topology"],
101
+ ["applications"],
102
+ ["audit"],
103
+ ["gitops-insights"],
104
+ ["gitops-tree"],
105
+ ["resource", kind, namespace, name],
106
+ ]) {
107
+ queryClient.invalidateQueries({ queryKey: key });
108
+ }
109
+ }, [queryClient, kind, namespace, name]);
110
+
111
+ const updateLast = (fn: (t: Turn) => Turn) =>
112
+ setTurns((prev) => prev.map((t, i) => (i === prev.length - 1 ? fn(t) : t)));
113
+
114
+ // Progressive reasoning reveal: the agent hands us each thinking block whole, but
115
+ // dumping a paragraph at once reads as a jarring pop. Instead we buffer it and
116
+ // drip it into the transcript line-by-line so it streams the way Claude Code /
117
+ // Codex feel live. A tool call, the final report, or an error flushes the buffer
118
+ // instantly (reasoning must fully precede its own tool, and the result can't wait
119
+ // on an animation) — which also makes tab-reopen replay fast-forward for free,
120
+ // since every turn ends in one of those events.
121
+ const revealBufRef = useRef("");
122
+ const revealTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
123
+ const stopReveal = () => {
124
+ if (revealTimerRef.current) {
125
+ clearInterval(revealTimerRef.current);
126
+ revealTimerRef.current = null;
127
+ }
128
+ };
129
+ const flushReveal = () => {
130
+ stopReveal();
131
+ const rest = revealBufRef.current;
132
+ revealBufRef.current = "";
133
+ if (rest)
134
+ updateLast((t) => ({ ...t, timeline: appendThinking(t.timeline, rest) }));
135
+ };
136
+ // Next reveal unit: a whole line, but cap a long unwrapped line at a sentence
137
+ // boundary so prose paragraphs (no hard breaks) still reveal in pieces.
138
+ const nextRevealUnit = (buf: string): [string, string] => {
139
+ const nl = buf.indexOf("\n");
140
+ let cut = nl === -1 ? buf.length : nl + 1;
141
+ if (cut > 160) {
142
+ const seg = buf.slice(0, 160);
143
+ const s = Math.max(
144
+ seg.lastIndexOf(". "),
145
+ seg.lastIndexOf("? "),
146
+ seg.lastIndexOf("! "),
147
+ );
148
+ cut = s > 40 ? s + 2 : 160;
149
+ }
150
+ return [buf.slice(0, cut), buf.slice(cut)];
151
+ };
152
+ const pumpReveal = () => {
153
+ if (revealTimerRef.current) return;
154
+ revealTimerRef.current = setInterval(() => {
155
+ if (!revealBufRef.current) {
156
+ stopReveal();
157
+ return;
158
+ }
159
+ // Drain faster when a backlog builds so the reveal can't fall behind a fast
160
+ // model — pace is cosmetic, never a bottleneck on the actual investigation.
161
+ const units = revealBufRef.current.length > 900 ? 3 : 1;
162
+ let take = "";
163
+ for (let k = 0; k < units && revealBufRef.current; k++) {
164
+ const [u, rest] = nextRevealUnit(revealBufRef.current);
165
+ take += u;
166
+ revealBufRef.current = rest;
167
+ }
168
+ if (take)
169
+ updateLast((t) => ({
170
+ ...t,
171
+ timeline: appendThinking(t.timeline, take),
172
+ }));
173
+ }, 150);
174
+ };
175
+
176
+ // Subscribe to the run's event stream; rebuild the transcript from scratch on
177
+ // (re)subscribe — the server replays everything, so a fresh tab reconstructs the
178
+ // whole conversation.
179
+ useEffect(() => {
180
+ setTurns([]);
181
+ setGone(false);
182
+ setBusy(false);
183
+ setActionError(null);
184
+ pendingApplyRef.current = false;
185
+ revealBufRef.current = "";
186
+ stopReveal();
187
+ clearSynth();
188
+ setReveal(null);
189
+ // Was the run ALREADY finished when we opened it? Then this subscribe is a
190
+ // replay of history — show every verdict immediately, no staged-reveal beats.
191
+ // The choreography is only for a verdict we watch land live (status running at
192
+ // open). Captured here, not read live, so a follow-up later doesn't re-trigger
193
+ // it for the replayed turns.
194
+ const replaying = run.status !== "running";
195
+ const cancel = subscribeRun(run.id, {
196
+ onEvent: (ev: DiagnoseStreamEvent) => {
197
+ switch (ev.type) {
198
+ case "turn":
199
+ flushReveal(); // close out the prior turn's reasoning before the new one
200
+ clearSynth();
201
+ setReveal(null);
202
+ if (ev.apply) pendingApplyRef.current = true;
203
+ setBusy(true);
204
+ setTurns((prev) => [
205
+ ...prev,
206
+ {
207
+ question: ev.question,
208
+ timeline: [],
209
+ diagnosis: null,
210
+ error: null,
211
+ status: "running",
212
+ apply: ev.apply,
213
+ },
214
+ ]);
215
+ break;
216
+ case "thinking":
217
+ if (ev.token) {
218
+ revealBufRef.current += ev.token;
219
+ pumpReveal();
220
+ }
221
+ break;
222
+ case "step":
223
+ flushReveal(); // reasoning fully precedes the tool it led to
224
+ if (ev.step)
225
+ updateLast((t) => ({
226
+ ...t,
227
+ timeline: upsertTool(t.timeline, ev.step!),
228
+ }));
229
+ break;
230
+ case "done": {
231
+ flushReveal(); // the result can't wait on a reveal animation
232
+ const dx = (ev.diagnosis ?? null) as Diagnosis | null;
233
+ const isApply = pendingApplyRef.current;
234
+ const finalize = () => {
235
+ setBusy(false);
236
+ if (isApply) {
237
+ pendingApplyRef.current = false;
238
+ refreshClusterState();
239
+ // Verify the write automatically: re-check health as a follow-up
240
+ // turn. Guarded by autoRecheckRef so replaying a past apply on
241
+ // reopen never re-fires it.
242
+ if (autoRecheckRef.current && run.status !== "stale") {
243
+ autoRecheckRef.current = false;
244
+ setTimeout(() => {
245
+ addTurn(run.id, { question: RECHECK_QUESTION }).catch(
246
+ () => {},
247
+ );
248
+ }, 900);
249
+ }
250
+ }
251
+ refreshRuns();
252
+ };
253
+ const showCard = (stage: "rca" | "full") => {
254
+ setReveal(stage);
255
+ updateLast((t) => ({ ...t, diagnosis: dx, status: "done" }));
256
+ };
257
+ // Only a real, structured diagnosis earns the staged reveal — and only
258
+ // when watched live. On replay (the run was already finished when we
259
+ // opened it) the beats would just stall showing a verdict that's
260
+ // already known, so we skip straight to the full card.
261
+ const hasRC = !!dx?.rootCause;
262
+ const hasRem = (dx?.remediation?.length ?? 0) > 0;
263
+ const allClear = !!dx?.healthy && !hasRC;
264
+ const inconclusive = !!dx?.inconclusive && !hasRC;
265
+ const structured =
266
+ !!dx && (allClear || inconclusive || hasRC || hasRem);
267
+ if (!isApply && structured && !replaying) {
268
+ const STEP = 2000;
269
+ // Beat 1 (in the timeline): formulating, before any card is shown.
270
+ setReveal(null);
271
+ setSynth(
272
+ allClear
273
+ ? "Confirming health"
274
+ : inconclusive
275
+ ? "Weighing the evidence"
276
+ : hasRC
277
+ ? "Formulating the root cause"
278
+ : "Analyzing the findings",
279
+ );
280
+ synthTimers.current.push(
281
+ setTimeout(() => {
282
+ setSynth(null);
283
+ // Reveal the root cause. If remediation follows, the card shows a
284
+ // "weighing remediation options" beat where the steps will land.
285
+ showCard(hasRC && hasRem ? "rca" : "full");
286
+ if (hasRC && hasRem) {
287
+ synthTimers.current.push(
288
+ setTimeout(() => {
289
+ setReveal("full");
290
+ finalize();
291
+ }, STEP),
292
+ );
293
+ } else {
294
+ finalize();
295
+ }
296
+ }, STEP),
297
+ );
298
+ } else {
299
+ setReveal("full");
300
+ updateLast((t) => ({ ...t, diagnosis: dx, status: "done" }));
301
+ finalize();
302
+ }
303
+ break;
304
+ }
305
+ case "error":
306
+ flushReveal();
307
+ updateLast((t) => ({
308
+ ...t,
309
+ error: ev.error || "The investigation failed.",
310
+ status: "error",
311
+ }));
312
+ setBusy(false);
313
+ pendingApplyRef.current = false;
314
+ refreshRuns();
315
+ break;
316
+ }
317
+ },
318
+ // The run can no longer produce events (evicted / gone). Stale runs emit their
319
+ // own error event + banner before closing, so this only bites the case where a
320
+ // run vanishes while we still think it's running — clear the spinner and mark
321
+ // the open turn terminal so it can't shimmer forever.
322
+ onClosed: () => {
323
+ stopReveal();
324
+ clearSynth();
325
+ setBusy(false);
326
+ setGone(true); // render decides: empty → gone state; mid-run → terminal error
327
+ updateLast((t) =>
328
+ t.status === "running"
329
+ ? {
330
+ ...t,
331
+ status: "error",
332
+ error:
333
+ "This investigation is no longer available. Re-run Diagnose to analyze the current cluster.",
334
+ }
335
+ : t,
336
+ );
337
+ },
338
+ });
339
+ return () => {
340
+ stopReveal();
341
+ clearSynth();
342
+ cancel();
343
+ };
344
+ // eslint-disable-next-line react-hooks/exhaustive-deps
345
+ }, [run.id]);
346
+
347
+ // Follow the bottom IFF still pinned, on anything that changes rendered height:
348
+ // new transcript content (turns), the staged verdict reveal (reveal: rca→full
349
+ // adds the remediation card), and synthesis beats (synth). useLayoutEffect runs
350
+ // before paint, so the jump is invisible and it overrides browser scroll-anchoring
351
+ // (which would otherwise nudge us off the bottom when the remediation card lands).
352
+ useLayoutEffect(() => {
353
+ const el = scrollRef.current;
354
+ if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
355
+ }, [turns, reveal, synth]);
356
+
357
+ // User scroll updates the pin state: scrolling up past the threshold detaches;
358
+ // scrolling back within it re-attaches. Programmatic scroll-to-bottom lands at
359
+ // distance≈0, so it keeps us pinned — no fight with the auto-follow.
360
+ const onScroll = () => {
361
+ const el = scrollRef.current;
362
+ if (!el) return;
363
+ const atBottom =
364
+ el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
365
+ pinnedRef.current = atBottom;
366
+ setShowJump(!atBottom);
367
+ };
368
+ const jumpToBottom = () => {
369
+ const el = scrollRef.current;
370
+ if (!el) return;
371
+ pinnedRef.current = true;
372
+ setShowJump(false);
373
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
374
+ };
375
+
376
+ const stale = run.status === "stale";
377
+
378
+ const submitFollowup = () => {
379
+ const q = input.trim();
380
+ if (!q || busy || stale) return;
381
+ setInput("");
382
+ setActionError(null);
383
+ pinnedRef.current = true; // a user-initiated turn always follows to the bottom
384
+ addTurn(run.id, { question: q }).catch((e) =>
385
+ setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send."),
386
+ );
387
+ };
388
+ const stop = () => stopRun(run.id);
389
+
390
+ // Ask a canned follow-up (e.g. "explain simply") — a one-tap path that turns the
391
+ // prompt's plain-language instruction into something the user controls.
392
+ const askFollowup = (q: string) => {
393
+ if (busy || stale) return;
394
+ setActionError(null);
395
+ pinnedRef.current = true;
396
+ addTurn(run.id, { question: q }).catch((e) =>
397
+ setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send."),
398
+ );
399
+ };
400
+
401
+ // Apply: a user-confirmed remediation turn. Any step is applyable; the chosen
402
+ // step's text is sent so the server binds the apply to it.
403
+ const [confirmApply, setConfirmApply] = useState(false);
404
+ const [pendingFix, setPendingFix] = useState("");
405
+ const requestApply = (fix: string) => {
406
+ setPendingFix(fix);
407
+ setConfirmApply(true);
408
+ };
409
+ const runApply = () => {
410
+ setConfirmApply(false);
411
+ setActionError(null);
412
+ autoRecheckRef.current = true; // verify the write automatically once it lands
413
+ addTurn(run.id, { apply: true, fix: pendingFix }).catch((e) => {
414
+ autoRecheckRef.current = false; // the apply never started — don't auto-recheck
415
+ setActionError(e instanceof DiagnoseError ? e.message : "Couldn't apply.");
416
+ });
417
+ };
418
+ const checkStatus = () => addTurn(run.id, { question: RECHECK_QUESTION }).catch(() => {});
419
+
420
+ // Apply tracks the latest turn that produced remediation (so follow-ups don't
421
+ // strip it) and is blocked on a stale (context-switched) run.
422
+ let lastRemediationIdx = -1;
423
+ turns.forEach((t, i) => {
424
+ if (
425
+ t.status === "done" &&
426
+ !t.apply &&
427
+ (t.diagnosis?.remediation?.length ?? 0) > 0
428
+ )
429
+ lastRemediationIdx = i;
430
+ });
431
+
432
+ // The "primary verdict" — the latest initial-style structured diagnosis (root
433
+ // cause / remediation / healthy / inconclusive), excluding apply outcomes and
434
+ // conversational follow-ups. In the maximized workspace this pins to a side rail
435
+ // so it (and Apply) stay in view while the transcript scrolls as evidence.
436
+ let pinnedIdx = -1;
437
+ turns.forEach((t, i) => {
438
+ const dx = t.diagnosis;
439
+ const structured =
440
+ !!dx &&
441
+ (!!dx.rootCause ||
442
+ (dx.remediation?.length ?? 0) > 0 ||
443
+ dx.healthy ||
444
+ dx.inconclusive);
445
+ if (t.status === "done" && !t.apply && !t.question && structured)
446
+ pinnedIdx = i;
447
+ });
448
+ const pinned = maximized && pinnedIdx >= 0;
449
+
450
+ return (
451
+ <div className="relative flex min-h-0 flex-1 flex-col">
452
+ <div className="flex min-h-0 flex-1">
453
+ <div
454
+ ref={scrollRef}
455
+ onScroll={onScroll}
456
+ className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3 [scrollbar-gutter:stable]"
457
+ >
458
+ <div className={maximized ? "mx-auto max-w-3xl" : ""}>
459
+ <div className="space-y-4">
460
+ {stale && (
461
+ <div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-theme-text-secondary">
462
+ <div className="flex items-start gap-2">
463
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
464
+ <span>
465
+ This investigation ran against{" "}
466
+ <span className="font-medium text-theme-text-primary">
467
+ {run.context || "a different cluster"}
468
+ </span>
469
+ . The cluster context has changed — it's read-only now.
470
+ </span>
471
+ </div>
472
+ <button
473
+ onClick={() => openInvestigation({ kind, namespace, name })}
474
+ className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-amber-500/50 px-2.5 py-1 font-medium text-amber-600 hover:bg-amber-500/10 dark:text-amber-400"
475
+ >
476
+ <Send className="h-3 w-3" />
477
+ Re-run on current cluster
478
+ </button>
479
+ </div>
480
+ )}
481
+ {gone && turns.length === 0 && (
482
+ <div className="rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
483
+ <div className="flex items-start gap-2">
484
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
485
+ <span>
486
+ This investigation is no longer available — history keeps
487
+ the most recent investigations, and this one has been
488
+ cleared. Re-run Diagnose to analyze the current cluster.
489
+ </span>
490
+ </div>
491
+ <button
492
+ onClick={() => openInvestigation({ kind, namespace, name })}
493
+ className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-theme-border px-2.5 py-1 font-medium text-theme-text-primary hover:bg-theme-hover"
494
+ >
495
+ <Send className="h-3 w-3" />
496
+ Re-run Diagnose
497
+ </button>
498
+ </div>
499
+ )}
500
+ <RunContextCard run={run} />
501
+ {turns.map((t, i) => {
502
+ const isLast = i === turns.length - 1;
503
+ // Hosted runners are read-only — the server refuses apply turns.
504
+ const canApply = i === lastRemediationIdx && !stale && !hosted;
505
+ const canCheck = isLast && t.status === "done" && !!t.apply;
506
+ return (
507
+ <TurnView
508
+ key={i}
509
+ turn={t}
510
+ synthLabel={isLast ? synth : null}
511
+ reveal={isLast ? (reveal ?? "full") : "full"}
512
+ onApply={canApply ? requestApply : undefined}
513
+ onAsk={isLast && !busy && !stale ? askFollowup : undefined}
514
+ onCheckStatus={canCheck ? checkStatus : undefined}
515
+ onRetryDiagnosis={
516
+ isLast &&
517
+ t.status === "error" &&
518
+ !t.question &&
519
+ !t.apply &&
520
+ !stale
521
+ ? retryDiagnosis
522
+ : undefined
523
+ }
524
+ hideVerdict={pinned && i === pinnedIdx}
525
+ />
526
+ );
527
+ })}
528
+ {(actionError || startError) && (
529
+ <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">
530
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
531
+ <span>{actionError || startError}</span>
532
+ </div>
533
+ )}
534
+ </div>
535
+ </div>
536
+ </div>
537
+ {pinned && (
538
+ <aside
539
+ className={`w-[400px] shrink-0 overflow-y-auto border-l border-theme-border px-4 py-3 ${busy ? "opacity-70" : ""}`}
540
+ >
541
+ <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
542
+ {busy ? "Verdict · revising…" : "Verdict"}
543
+ </div>
544
+ <ResultCard
545
+ diagnosis={turns[pinnedIdx].diagnosis!}
546
+ onApply={
547
+ pinnedIdx === lastRemediationIdx && !stale && !hosted
548
+ ? requestApply
549
+ : undefined
550
+ }
551
+ onAsk={!busy && !stale ? askFollowup : undefined}
552
+ reveal="full"
553
+ />
554
+ </aside>
555
+ )}
556
+ </div>
557
+
558
+ {showJump && (
559
+ <button
560
+ onClick={jumpToBottom}
561
+ className="absolute bottom-20 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-theme-border bg-theme-elevated px-3 py-1.5 text-xs font-medium text-theme-text-secondary shadow-theme-md transition hover:bg-theme-hover hover:text-theme-text-primary"
562
+ >
563
+ <ArrowDown className="h-3.5 w-3.5" />
564
+ {busy ? "Jump to latest" : "Scroll to bottom"}
565
+ </button>
566
+ )}
567
+
568
+ <ApplyDialog
569
+ open={confirmApply}
570
+ onClose={() => setConfirmApply(false)}
571
+ onConfirm={runApply}
572
+ agentLabel={agentLabel}
573
+ resourceLabel={`${kind} ${namespace ? `${namespace}/` : ""}${name}`}
574
+ fix={pendingFix}
575
+ managedBy={run.managedBy}
576
+ confidence={turns[lastRemediationIdx]?.diagnosis?.confidence}
577
+ />
578
+
579
+ <div
580
+ className={`border-t border-theme-border px-3 py-2.5 ${maximized ? "[&>*]:mx-auto [&>*]:max-w-3xl" : ""}`}
581
+ >
582
+ {busy ? (
583
+ <button
584
+ onClick={stop}
585
+ className="w-full rounded-lg border border-theme-border py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
586
+ >
587
+ Stop
588
+ </button>
589
+ ) : (
590
+ <div className="flex items-end gap-2">
591
+ <textarea
592
+ value={input}
593
+ onChange={(e) => setInput(e.target.value)}
594
+ onKeyDown={(e) => {
595
+ if (e.key === "Enter" && !e.shiftKey) {
596
+ e.preventDefault();
597
+ submitFollowup();
598
+ }
599
+ }}
600
+ rows={1}
601
+ disabled={stale}
602
+ placeholder={
603
+ stale
604
+ ? "Cluster changed — re-run Diagnose"
605
+ : "Ask a follow-up or refine…"
606
+ }
607
+ className="max-h-32 min-h-[38px] flex-1 resize-none rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-primary placeholder:text-theme-text-tertiary focus:border-accent focus:outline-none disabled:opacity-50"
608
+ />
609
+ <button
610
+ onClick={submitFollowup}
611
+ disabled={!input.trim() || stale}
612
+ className="shrink-0 rounded-lg btn-brand p-2 disabled:opacity-40"
613
+ aria-label="Send follow-up"
614
+ >
615
+ <Send className="h-4 w-4" />
616
+ </button>
617
+ </div>
618
+ )}
619
+ </div>
620
+ </div>
621
+ );
622
+ }