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