@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,495 @@
1
+ // The single controller for the AI assistant surface. One instance app-wide:
2
+ // the per-resource "Diagnose" button and the global top-bar entry both dispatch
3
+ // here. Investigations are durable, server-side jobs (see internal/ai RunManager);
4
+ // this provider lists them, tracks which one is focused, and owns the push-content
5
+ // layout. The run lifetime is the server's, so closing/navigating never kills one.
6
+ import {
7
+ createContext,
8
+ useCallback,
9
+ useContext,
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ type Dispatch,
15
+ type ReactNode,
16
+ type SetStateAction,
17
+ } from "react";
18
+ import {
19
+ fetchAgents,
20
+ listRuns,
21
+ createRun,
22
+ recordConsent,
23
+ DiagnoseError,
24
+ } from "../../api/diagnose";
25
+ import { type RunSummary, type AgentInfo } from "../../api/diagnose";
26
+
27
+ export interface Target {
28
+ kind: string;
29
+ namespace: string;
30
+ name: string;
31
+ }
32
+ export type DiagnoseView = "home" | "investigation";
33
+
34
+ interface DiagnoseCtx {
35
+ available: boolean; // an agent CLI is present (button/entry gate)
36
+ agentLabel: string; // label of the selected agent, e.g. "Claude Code"
37
+ hosted: boolean; // selected agent runs on the host's backend, not this machine
38
+ agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
39
+ selectedAgent: string; // name of the chosen backend ("claude"/"codex")
40
+ setSelectedAgent: (name: string) => void;
41
+ isolated: boolean; // run the agent without the user's own CLI config
42
+ setIsolated: (v: boolean) => void;
43
+ model: string; // optional model override ("" = the agent's own default)
44
+ setModel: (v: string) => void;
45
+ effort: string; // optional Codex reasoning effort ("" = default)
46
+ setEffort: (v: string) => void;
47
+ view: DiagnoseView;
48
+ activeRunId: string | null;
49
+ runs: RunSummary[];
50
+ runsLoaded: boolean; // first runs fetch landed (gates missing-run states)
51
+ runsLoadFailed: boolean; // latest runs fetch failed (poll keeps retrying)
52
+ historyDegraded: boolean; // persistence broke — history won't survive a restart
53
+ needsConsent: boolean; // a start is pending the one-time consent
54
+ startError: string | null;
55
+ openInvestigation: (t: Target) => void;
56
+ openRun: (id: string) => void;
57
+ openHome: () => void;
58
+ goHome: () => void;
59
+ close: () => void;
60
+ approveConsent: () => void;
61
+ cancelConsent: () => void;
62
+ refreshRuns: () => void;
63
+ dismissError: () => void;
64
+ }
65
+
66
+ // Layout state is a SEPARATE context from the business state above. The app shell
67
+ // (App) consumes only this to position the panel + reserve the content gutter, and
68
+ // its value is memoized on layout-only deps — so the panel's 4s run-poll (which
69
+ // churns the business context) doesn't re-render the whole shell.
70
+ interface DiagnoseLayoutCtx {
71
+ open: boolean;
72
+ contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
73
+ maximized: boolean;
74
+ setMaximized: Dispatch<SetStateAction<boolean>>;
75
+ panelWidth: number;
76
+ setPanelWidth: Dispatch<SetStateAction<number>>;
77
+ panelNarrow: boolean; // viewport too tight to push → overlay
78
+ panelBounds: { min: number; max: number };
79
+ panelWidthKey: string;
80
+ runningKeys: ReadonlySet<string>; // resources with a live investigation (see runTargetKey)
81
+ }
82
+
83
+ // Stable key for "is THIS resource being investigated right now" — built the same way
84
+ // from a run summary and from a button's target so the two always match.
85
+ export function runTargetKey(kind: string, namespace: string, name: string): string {
86
+ return `${kind} ${namespace} ${name}`;
87
+ }
88
+
89
+ const Ctx = createContext<DiagnoseCtx | null>(null);
90
+ const LayoutCtx = createContext<DiagnoseLayoutCtx | null>(null);
91
+
92
+ export function useDiagnoseLayout(): DiagnoseLayoutCtx {
93
+ const c = useContext(LayoutCtx);
94
+ if (!c) throw new Error("useDiagnoseLayout must be used within DiagnoseProvider");
95
+ return c;
96
+ }
97
+
98
+ export function useDiagnose(): DiagnoseCtx {
99
+ const c = useContext(Ctx);
100
+ if (!c) throw new Error("useDiagnose must be used within DiagnoseProvider");
101
+ return c;
102
+ }
103
+
104
+ const MIN_W = 400;
105
+ const MAX_W = 1100;
106
+ const PANEL_BOUNDS = { min: MIN_W, max: MAX_W }; // stable ref for the layout context
107
+ const WIDTH_KEY = "radar-ai-panel-width";
108
+ // Consent is machine-scoped and lives server-side (~/.radar): it gates a
109
+ // machine-scoped action (spawn this machine's agent CLI, persist transcripts to
110
+ // this machine's disk), so one acknowledgment covers this panel AND the
111
+ // `radar diagnose` CLI. Cursor gets its own surface — its trust model is
112
+ // materially different (the user's global MCP servers can't be excluded), so
113
+ // approving Claude/Codex never bypasses Cursor's distinct disclosure.
114
+ type ConsentSurface = "standard" | "cursor";
115
+ function consentSurfaceFor(agent: string): ConsentSurface {
116
+ return agent === "cursor-agent" ? "cursor" : "standard";
117
+ }
118
+ const AGENT_KEY = "radar-ai-agent";
119
+ const ISOLATED_KEY = "radar-ai-isolated";
120
+ const MODEL_KEY = "radar-ai-model";
121
+ const EFFORT_KEY = "radar-ai-effort";
122
+ // Push (reflow the app left) only while the app keeps at least this much width to
123
+ // the LEFT of the panel (nav rail ~176 + a usable content floor); otherwise overlay.
124
+ // Panel-width-aware on purpose: a static viewport cutoff ignored the (resizable)
125
+ // panel width and could push the app to near-zero. We don't fight to keep every-
126
+ // thing on screen on small displays — below this, the panel floats over instead.
127
+ const MIN_APP_LEFT_OF_PANEL = 900;
128
+
129
+ const AGENT_LABELS: Record<string, string> = {
130
+ claude: "Claude Code",
131
+ codex: "Codex",
132
+ gemini: "Gemini CLI",
133
+ "cursor-agent": "Cursor Agent",
134
+ };
135
+
136
+ export function agentLabelFor(name: string, fallbackLabel?: string): string {
137
+ return AGENT_LABELS[name] || fallbackLabel || name || "your AI agent";
138
+ }
139
+
140
+ // openDiagnoseSettings opens the Settings dialog (App.tsx listens for this DOM
141
+ // event) — the canonical home for AI-diagnosis config.
142
+ export function openDiagnoseSettings() {
143
+ window.dispatchEvent(new CustomEvent("radar:open-settings"));
144
+ }
145
+
146
+ function readStored(key: string): string | null {
147
+ try {
148
+ return localStorage.getItem(key);
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+ function writeStored(key: string, value: string) {
154
+ try {
155
+ localStorage.setItem(key, value);
156
+ } catch {
157
+ /* storage disabled — holds for this session */
158
+ }
159
+ }
160
+
161
+ export function DiagnoseProvider({ children }: { children: ReactNode }) {
162
+ const [available, setAvailable] = useState(false);
163
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
164
+ const [consented, setConsented] = useState<
165
+ Record<ConsentSurface, boolean>
166
+ >({ standard: false, cursor: false });
167
+ const [selectedAgent, setSelectedAgentState] = useState<string>(
168
+ () => readStored(AGENT_KEY) || "",
169
+ );
170
+ const [isolated, setIsolatedState] = useState<boolean>(
171
+ () => readStored(ISOLATED_KEY) !== "0", // default isolated
172
+ );
173
+ const [model, setModelState] = useState<string>(
174
+ () => readStored(MODEL_KEY) || "",
175
+ );
176
+ const [effort, setEffortState] = useState<string>(
177
+ () => readStored(EFFORT_KEY) || "",
178
+ );
179
+ const [open, setOpen] = useState(false);
180
+ const [view, setView] = useState<DiagnoseView>("home");
181
+ const [activeRunId, setActiveRunId] = useState<string | null>(null);
182
+ const [runs, setRuns] = useState<RunSummary[]>([]);
183
+ const [runsLoaded, setRunsLoaded] = useState(false);
184
+ const [runsLoadFailed, setRunsLoadFailed] = useState(false);
185
+ const [historyDegraded, setHistoryDegraded] = useState(false);
186
+ const [pendingTarget, setPendingTarget] = useState<Target | null>(null);
187
+ const [startError, setStartError] = useState<string | null>(null);
188
+ const [width, setWidth] = useState<number>(() => {
189
+ try {
190
+ const v = Number(localStorage.getItem(WIDTH_KEY));
191
+ return v >= MIN_W && v <= MAX_W ? v : 560;
192
+ } catch {
193
+ return 560;
194
+ }
195
+ });
196
+ const [maximized, setMaximized] = useState(false);
197
+ const [viewportW, setViewportW] = useState(() =>
198
+ typeof window !== "undefined" ? window.innerWidth : 1920,
199
+ );
200
+ // Too tight to push (given the current, resizable panel width) → overlay instead.
201
+ const narrow = viewportW - width < MIN_APP_LEFT_OF_PANEL;
202
+
203
+ useEffect(() => {
204
+ let live = true;
205
+ fetchAgents()
206
+ .then((r) => {
207
+ if (!live) return;
208
+ setAvailable(r.enabled);
209
+ setConsented({
210
+ standard: !!r.consented?.standard,
211
+ cursor: !!r.consented?.cursor,
212
+ });
213
+ const supported = r.agents.filter((a) => a.supported);
214
+ setAgents(supported);
215
+ // Keep the stored pick only if it's still installed; else default to the
216
+ // first supported agent (matches the server's default selection).
217
+ const stored = readStored(AGENT_KEY) || "";
218
+ const next =
219
+ stored && supported.some((a) => a.name === stored)
220
+ ? stored
221
+ : (supported[0]?.name ?? "");
222
+ setSelectedAgentState(next);
223
+ // Model/effort are agent-specific; if the stored agent is gone, its values
224
+ // don't apply to the fallback agent (e.g. a Codex slug under Claude) — drop them.
225
+ if (next !== stored) {
226
+ setModelState("");
227
+ writeStored(MODEL_KEY, "");
228
+ setEffortState("");
229
+ writeStored(EFFORT_KEY, "");
230
+ }
231
+ })
232
+ .catch(() => {});
233
+ return () => {
234
+ live = false;
235
+ };
236
+ }, []);
237
+
238
+ const setModel = useCallback((v: string) => {
239
+ setModelState(v);
240
+ writeStored(MODEL_KEY, v);
241
+ }, []);
242
+ const setEffort = useCallback((v: string) => {
243
+ setEffortState(v);
244
+ writeStored(EFFORT_KEY, v);
245
+ }, []);
246
+ const setSelectedAgent = useCallback(
247
+ (name: string) => {
248
+ setSelectedAgentState(name);
249
+ writeStored(AGENT_KEY, name);
250
+ // Model + effort are agent-specific (Claude aliases vs Codex slugs); reset
251
+ // to the new agent's default rather than carry an invalid value across.
252
+ setModel("");
253
+ setEffort("");
254
+ },
255
+ [setModel, setEffort],
256
+ );
257
+ const setIsolated = useCallback((v: boolean) => {
258
+ setIsolatedState(v);
259
+ writeStored(ISOLATED_KEY, v ? "1" : "0");
260
+ }, []);
261
+
262
+ const agentLabel = agentLabelFor(
263
+ selectedAgent,
264
+ agents.find((a) => a.name === selectedAgent)?.label,
265
+ );
266
+ const hosted = !!agents.find((a) => a.name === selectedAgent)?.hosted;
267
+
268
+ useEffect(() => {
269
+ const onResize = () => setViewportW(window.innerWidth);
270
+ window.addEventListener("resize", onResize);
271
+ return () => window.removeEventListener("resize", onResize);
272
+ }, []);
273
+
274
+ const refreshRuns = useCallback(() => {
275
+ if (!available) return;
276
+ listRuns()
277
+ .then((r) => {
278
+ setRuns(r.runs);
279
+ setRunsLoaded(true);
280
+ setRunsLoadFailed(false);
281
+ setHistoryDegraded(!!r.historyDegraded);
282
+ })
283
+ .catch(() => {
284
+ // Leave runsLoaded false (a missing-run verdict needs a real list) but
285
+ // record the failure so the panel can say "retrying" instead of
286
+ // pretending nothing happened. The 4s poll keeps retrying while open.
287
+ setRunsLoadFailed(true);
288
+ });
289
+ }, [available]);
290
+
291
+ // A content-stable signature of the resources with a live (running) investigation,
292
+ // so the per-resource Diagnose buttons can show a "running" indicator even with the
293
+ // panel closed — and only re-render when the set actually changes, not every poll.
294
+ const runningSig = runs
295
+ .filter((r) => r.status === "running")
296
+ .map((r) => `${r.kind}\x00${r.namespace}\x00${r.name}`)
297
+ .sort()
298
+ .join("|");
299
+ const runningKeys = useMemo(
300
+ () => new Set(runningSig ? runningSig.split("|") : []),
301
+ [runningSig],
302
+ );
303
+ const hasRunning = runningSig.length > 0;
304
+
305
+ // Keep the run list (statuses, new background runs) fresh while the surface is open
306
+ // OR while any investigation is still running — so the button indicator stays live
307
+ // after the panel is closed, and stops polling once everything has settled. One
308
+ // fetch on mount catches runs already in flight from a prior page load.
309
+ useEffect(() => {
310
+ if (!available) return;
311
+ refreshRuns();
312
+ if (!open && !hasRunning) return;
313
+ const t = setInterval(refreshRuns, 4000);
314
+ return () => clearInterval(t);
315
+ }, [open, available, hasRunning, refreshRuns]);
316
+
317
+ // Keep the live selected agent reachable from the [] -dep callbacks below
318
+ // (consent is agent-specific, so they must read the CURRENT pick, not a closure).
319
+ const selectedAgentRef = useRef(selectedAgent);
320
+ selectedAgentRef.current = selectedAgent;
321
+ const consentedRef = useRef(consented);
322
+ consentedRef.current = consented;
323
+
324
+ // Monotonic token so an earlier createRun that resolves late can't steal focus
325
+ // from a later click on a different resource (only the latest start wins).
326
+ const startSeqRef = useRef(0);
327
+ const startRunRef = useRef<(t: Target) => void>(() => {});
328
+ startRunRef.current = (t: Target) => {
329
+ const seq = ++startSeqRef.current;
330
+ createRun(t, {
331
+ agent: selectedAgent || undefined,
332
+ isolated,
333
+ model: model || undefined,
334
+ effort: effort || undefined,
335
+ })
336
+ .then((run) => {
337
+ setRuns((prev) =>
338
+ prev.some((r) => r.id === run.id) ? prev : [run, ...prev],
339
+ );
340
+ if (seq !== startSeqRef.current) return;
341
+ setActiveRunId(run.id);
342
+ setView("investigation");
343
+ })
344
+ .catch((e) => {
345
+ if (seq !== startSeqRef.current) return;
346
+ setStartError(
347
+ e instanceof DiagnoseError
348
+ ? e.message
349
+ : "Couldn't start the investigation.",
350
+ );
351
+ });
352
+ };
353
+
354
+ const openInvestigation = useCallback((t: Target) => {
355
+ setStartError(null);
356
+ setOpen(true);
357
+ if (!consentedRef.current[consentSurfaceFor(selectedAgentRef.current)]) {
358
+ setPendingTarget(t);
359
+ setView("investigation");
360
+ return;
361
+ }
362
+ setView("investigation");
363
+ startRunRef.current(t);
364
+ }, []);
365
+ const openRun = useCallback((id: string) => {
366
+ setStartError(null);
367
+ setActiveRunId(id);
368
+ setView("investigation");
369
+ setOpen(true);
370
+ }, []);
371
+
372
+ // Deep link: ?ai-run=<id> opens the panel focused on that investigation —
373
+ // the URL `radar diagnose --open` prints/opens. Consumed once (then stripped)
374
+ // so back/forward and copied URLs don't keep re-opening the panel.
375
+ useEffect(() => {
376
+ if (!available) return;
377
+ let id: string | null = null;
378
+ try {
379
+ const params = new URLSearchParams(window.location.search);
380
+ id = params.get("ai-run");
381
+ if (id) {
382
+ params.delete("ai-run");
383
+ const qs = params.toString();
384
+ window.history.replaceState(
385
+ null,
386
+ "",
387
+ window.location.pathname + (qs ? `?${qs}` : "") + window.location.hash,
388
+ );
389
+ }
390
+ } catch {
391
+ /* URL APIs unavailable — skip the deep link */
392
+ }
393
+ if (id) openRun(id);
394
+ }, [available, openRun]);
395
+ const openHome = useCallback(() => {
396
+ setView("home");
397
+ setOpen(true);
398
+ }, []);
399
+ const goHome = useCallback(() => setView("home"), []);
400
+ const close = useCallback(() => setOpen(false), []);
401
+ const consentBusyRef = useRef(false);
402
+ const approveConsent = useCallback(() => {
403
+ if (consentBusyRef.current) return;
404
+ consentBusyRef.current = true;
405
+ setStartError(null);
406
+ const surface = consentSurfaceFor(selectedAgentRef.current);
407
+ const t = pendingTarget;
408
+ // The server ENFORCES consent at start, so the acknowledgment must land
409
+ // before the run request — awaiting also makes it durable for the CLI.
410
+ recordConsent(surface)
411
+ .then(() => {
412
+ setConsented((prev) => ({ ...prev, [surface]: true }));
413
+ // Cleared only on success: a failed write keeps the consent card up
414
+ // (needsConsent = !!pendingTarget) so "try again" works in place.
415
+ setPendingTarget(null);
416
+ if (t) startRunRef.current(t);
417
+ })
418
+ .catch(() => {
419
+ setStartError("Couldn't record your consent — try again.");
420
+ })
421
+ .finally(() => {
422
+ consentBusyRef.current = false;
423
+ });
424
+ }, [pendingTarget]);
425
+ const cancelConsent = useCallback(() => {
426
+ setPendingTarget(null);
427
+ setOpen(false);
428
+ }, []);
429
+ const dismissError = useCallback(() => setStartError(null), []);
430
+
431
+ // Reserve a right gutter on the CONTENT area (not the navbar/rail — those stay
432
+ // global and static) so docked content reflows beside the panel. Wide viewports
433
+ // only; maximized or too-narrow → the panel overlays, no gutter.
434
+ const contentGutter = open && !narrow && !maximized ? width : 0;
435
+
436
+ const value: DiagnoseCtx = {
437
+ available,
438
+ agentLabel,
439
+ hosted,
440
+ agents,
441
+ selectedAgent,
442
+ setSelectedAgent,
443
+ isolated,
444
+ setIsolated,
445
+ model,
446
+ setModel,
447
+ effort,
448
+ setEffort,
449
+ view,
450
+ activeRunId,
451
+ runs,
452
+ runsLoaded,
453
+ runsLoadFailed,
454
+ historyDegraded,
455
+ // pendingTarget is set ONLY when the current agent's consent is missing, and
456
+ // cleared on approve/cancel — so its presence is exactly "consent needed now".
457
+ needsConsent: !!pendingTarget,
458
+ startError,
459
+ openInvestigation,
460
+ openRun,
461
+ openHome,
462
+ goHome,
463
+ close,
464
+ approveConsent,
465
+ cancelConsent,
466
+ refreshRuns,
467
+ dismissError,
468
+ };
469
+
470
+ // Layout value memoized on layout-only deps (setters/bounds/key are stable), so
471
+ // the 4s run-poll churning `value` above doesn't re-render the app shell, which
472
+ // consumes ONLY this. The panel itself is rendered by the shell (App) as an
473
+ // absolute slot in the body frame — not here.
474
+ const layout = useMemo<DiagnoseLayoutCtx>(
475
+ () => ({
476
+ open,
477
+ contentGutter,
478
+ maximized,
479
+ setMaximized,
480
+ panelWidth: width,
481
+ setPanelWidth: setWidth,
482
+ panelNarrow: narrow,
483
+ panelBounds: PANEL_BOUNDS,
484
+ panelWidthKey: WIDTH_KEY,
485
+ runningKeys,
486
+ }),
487
+ [open, contentGutter, maximized, width, narrow, runningKeys, setMaximized, setWidth],
488
+ );
489
+
490
+ return (
491
+ <Ctx.Provider value={value}>
492
+ <LayoutCtx.Provider value={layout}>{children}</LayoutCtx.Provider>
493
+ </Ctx.Provider>
494
+ );
495
+ }