@skyhook-io/radar-app 1.9.0 → 1.9.2

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 (52) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +69 -16
  3. package/src/api/apiResources.test.ts +11 -0
  4. package/src/api/apiResources.ts +51 -12
  5. package/src/api/client.capacity.test.ts +92 -0
  6. package/src/api/client.ts +2905 -2081
  7. package/src/api/config.test.ts +47 -0
  8. package/src/api/config.ts +15 -0
  9. package/src/api/diagnose.ts +15 -15
  10. package/src/components/ConnectionErrorView.test.tsx +88 -0
  11. package/src/components/ConnectionErrorView.tsx +128 -22
  12. package/src/components/capacity/CapacityActivity.tsx +787 -0
  13. package/src/components/capacity/CapacityDemand.tsx +961 -0
  14. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  15. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  16. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  17. package/src/components/capacity/CapacityView.tsx +85 -0
  18. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  19. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  20. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  21. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  22. package/src/components/capacity/podDemandGate.test.ts +47 -0
  23. package/src/components/capacity/podDemandGate.ts +22 -0
  24. package/src/components/capacity/schedulingBar.test.ts +244 -0
  25. package/src/components/capacity/shared.tsx +1841 -0
  26. package/src/components/diagnose/AISettings.tsx +21 -7
  27. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  28. package/src/components/diagnose/DiagnoseContext.tsx +127 -57
  29. package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
  30. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  31. package/src/components/diagnose/agentCatalog.ts +30 -0
  32. package/src/components/diagnose/parts.test.tsx +125 -0
  33. package/src/components/diagnose/parts.tsx +166 -75
  34. package/src/components/home/CapacityCard.test.tsx +150 -0
  35. package/src/components/home/CapacityCard.tsx +125 -0
  36. package/src/components/home/HomeView.tsx +15 -1
  37. package/src/components/issues/IssuesPane.test.ts +142 -0
  38. package/src/components/issues/IssuesPane.tsx +142 -38
  39. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  40. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  41. package/src/components/resources/ResourcesView.tsx +9 -8
  42. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  43. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  44. package/src/components/settings/SettingsDialog.tsx +31 -19
  45. package/src/components/timeline/TimelineView.tsx +17 -3
  46. package/src/components/ui/command-items.ts +222 -98
  47. package/src/components/workload/WorkloadView.tsx +16 -83
  48. package/src/context/ConnectionContext.test.ts +39 -0
  49. package/src/context/ConnectionContext.tsx +155 -51
  50. package/src/context/DiagnoseCustomization.tsx +1 -1
  51. package/src/utils/shell-safe.test.ts +55 -0
  52. package/src/utils/shell-safe.ts +21 -0
@@ -5,12 +5,16 @@
5
5
  // Settings tabs' layout — this renders only the controls (no card, no heading).
6
6
  import { useState } from "react";
7
7
  import { Trash2 } from "lucide-react";
8
- import { clearHistory, type AgentInfo } from "../../api/diagnose";
8
+ import {
9
+ clearHistory,
10
+ type AgentInfo,
11
+ type ExecutionProfile,
12
+ } from "../../api/diagnose";
9
13
  import { AgentControls } from "./parts";
10
14
 
11
15
  export interface AIDraft {
12
16
  agent: string;
13
- isolated: boolean;
17
+ profile: ExecutionProfile;
14
18
  model: string;
15
19
  effort: string;
16
20
  }
@@ -120,17 +124,27 @@ export function AISettingsSection({
120
124
  // The agent, its model, and how it runs are all fixed by the host — none
121
125
  // of the local BYO-agent knobs apply, so there's nothing to configure.
122
126
  <p className="text-xs leading-snug text-theme-text-tertiary">
123
- {agentLabel} manages the model and how it runs — there&apos;s nothing to
124
- configure here.
127
+ {agentLabel} manages the model and how it runs — there&apos;s nothing
128
+ to configure here.
125
129
  </p>
126
130
  ) : (
127
131
  <AgentControls
128
132
  agents={agents}
129
133
  selectedAgent={draft.agent}
130
134
  // Model + effort are agent-specific; reset them when the agent changes.
131
- onSelectAgent={(a) => onChange({ agent: a, model: "", effort: "" })}
132
- isolated={draft.isolated}
133
- onSetIsolated={(v) => onChange({ isolated: v })}
135
+ onSelectAgent={(a) => {
136
+ const nextProfile = agents.find(
137
+ (agent) => agent.name === a,
138
+ )?.profiles?.[0];
139
+ onChange({
140
+ agent: a,
141
+ profile: nextProfile ?? draft.profile,
142
+ model: "",
143
+ effort: "",
144
+ });
145
+ }}
146
+ profile={draft.profile}
147
+ onSetProfile={(v) => onChange({ profile: v })}
134
148
  model={draft.model}
135
149
  onSetModel={(v) => onChange({ model: v })}
136
150
  effort={draft.effort}
@@ -0,0 +1,117 @@
1
+ import { useState } from "react";
2
+ import { Sparkles, Copy, Check, ExternalLink, RotateCw } from "lucide-react";
3
+ import { SUPPORTED_AGENTS, type AgentInstall } from "./agentCatalog";
4
+ import { type DiagnoseSetup } from "./DiagnoseContext";
5
+
6
+ function CopyButton({ text }: { text: string }) {
7
+ const [copied, setCopied] = useState(false);
8
+ return (
9
+ <button
10
+ type="button"
11
+ onClick={() => {
12
+ void navigator.clipboard?.writeText(text);
13
+ setCopied(true);
14
+ setTimeout(() => setCopied(false), 1100);
15
+ }}
16
+ className="shrink-0 rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
17
+ aria-label={copied ? "Copied" : "Copy install command"}
18
+ >
19
+ {copied ? (
20
+ <Check className="h-3.5 w-3.5 text-emerald-500" />
21
+ ) : (
22
+ <Copy className="h-3.5 w-3.5" />
23
+ )}
24
+ </button>
25
+ );
26
+ }
27
+
28
+ function AgentRow({ agent }: { agent: AgentInstall }) {
29
+ return (
30
+ <div className="rounded-lg border border-theme-border bg-theme-base p-3">
31
+ <div className="mb-2 flex items-center justify-between gap-2">
32
+ <span className="text-sm font-medium text-theme-text-primary">
33
+ {agent.label}
34
+ </span>
35
+ <a
36
+ href={agent.docs}
37
+ target="_blank"
38
+ rel="noreferrer"
39
+ className="inline-flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
40
+ >
41
+ Docs
42
+ <ExternalLink className="h-3 w-3" />
43
+ </a>
44
+ </div>
45
+ <div className="flex items-center gap-1.5 rounded-md bg-theme-elevated px-2 py-1.5">
46
+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-xs text-theme-text-secondary">
47
+ {agent.install}
48
+ </code>
49
+ <CopyButton text={agent.install} />
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ // Shown in the AI surface's Home when diagnosis is eligible in this deployment
56
+ // but not runnable yet — either no agent CLI is installed ("needs-install") or a
57
+ // supported one appeared after Radar booted ("needs-restart"). Radar decides the
58
+ // engine once at startup, so a fresh install needs a restart to take effect.
59
+ export function AgentSetupNotice({
60
+ setupState,
61
+ }: {
62
+ setupState: DiagnoseSetup;
63
+ }) {
64
+ const needsRestart = setupState === "needs-restart";
65
+ return (
66
+ <div className="mx-auto max-w-md px-1 py-6">
67
+ <div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl border border-accent/30 bg-accent/5">
68
+ <Sparkles className="h-5 w-5 text-accent" />
69
+ </div>
70
+ <h3 className="text-base font-semibold text-theme-text-primary">
71
+ {needsRestart
72
+ ? "Restart Radar to enable AI diagnosis"
73
+ : "Set up AI diagnosis"}
74
+ </h3>
75
+ <p className="mt-1 text-sm text-theme-text-secondary">
76
+ {needsRestart ? (
77
+ <>
78
+ A supported agent CLI is now installed, but Radar started before it
79
+ was — restart Radar to pick it up. Investigations then run locally
80
+ on your machine, keyless, using your own agent.
81
+ </>
82
+ ) : (
83
+ <>
84
+ Radar runs investigations through a coding agent CLI on your own
85
+ machine — no Radar cloud, no API key. Install one of these, then
86
+ restart Radar:
87
+ </>
88
+ )}
89
+ </p>
90
+
91
+ {!needsRestart && (
92
+ <div className="mt-4 flex flex-col gap-2.5">
93
+ {SUPPORTED_AGENTS.map((a) => (
94
+ <AgentRow key={a.name} agent={a} />
95
+ ))}
96
+ </div>
97
+ )}
98
+
99
+ {/* The panel only re-reads /api/agents on load, so after installing or
100
+ restarting the user needs a way to refresh in place rather than
101
+ hunting for a manual browser reload. */}
102
+ <button
103
+ type="button"
104
+ onClick={() => window.location.reload()}
105
+ className="btn-brand mt-4 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm"
106
+ >
107
+ <RotateCw className="h-3.5 w-3.5" />
108
+ {needsRestart ? "Reload after restarting" : "Reload after installing"}
109
+ </button>
110
+
111
+ <p className="mt-4 text-xs text-theme-text-tertiary">
112
+ The agent reads this cluster through Radar and finds the root cause. It
113
+ never leaves your machine.
114
+ </p>
115
+ </div>
116
+ );
117
+ }
@@ -22,7 +22,11 @@ import {
22
22
  recordConsent,
23
23
  DiagnoseError,
24
24
  } from "../../api/diagnose";
25
- import { type RunSummary, type AgentInfo } from "../../api/diagnose";
25
+ import {
26
+ type RunSummary,
27
+ type AgentInfo,
28
+ type ExecutionProfile,
29
+ } from "../../api/diagnose";
26
30
 
27
31
  export interface Target {
28
32
  kind: string;
@@ -31,15 +35,25 @@ export interface Target {
31
35
  }
32
36
  export type DiagnoseView = "home" | "investigation";
33
37
 
38
+ // Setup readiness of the local AI-diagnosis feature, derived from the agents API:
39
+ // - "ready": an agent is installed and the engine is running (available)
40
+ // - "needs-install": the feature is supported here but no agent CLI is installed
41
+ // - "needs-restart": a supported agent is now on PATH but Radar booted before it
42
+ // existed (the engine is decided once, at startup)
43
+ // - "off": not available in this deployment (proxy/OIDC auth, --no-mcp,
44
+ // or an embed host) — no install nudge would help
45
+ export type DiagnoseSetup = "ready" | "needs-install" | "needs-restart" | "off";
46
+
34
47
  interface DiagnoseCtx {
35
48
  available: boolean; // an agent CLI is present (button/entry gate)
49
+ setupState: DiagnoseSetup; // readiness for the setup nudge (see DiagnoseSetup)
36
50
  agentLabel: string; // label of the selected agent, e.g. "Claude Code"
37
51
  hosted: boolean; // selected agent runs on the host's backend, not this machine
38
52
  agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
39
53
  selectedAgent: string; // name of the chosen backend ("claude"/"codex")
40
54
  setSelectedAgent: (name: string) => void;
41
- isolated: boolean; // run the agent without the user's own CLI config
42
- setIsolated: (v: boolean) => void;
55
+ profile: ExecutionProfile;
56
+ setProfile: (v: ExecutionProfile) => void;
43
57
  model: string; // optional model override ("" = the agent's own default)
44
58
  setModel: (v: string) => void;
45
59
  effort: string; // optional Codex reasoning effort ("" = default)
@@ -82,7 +96,11 @@ interface DiagnoseLayoutCtx {
82
96
 
83
97
  // Stable key for "is THIS resource being investigated right now" — built the same way
84
98
  // 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 {
99
+ export function runTargetKey(
100
+ kind: string,
101
+ namespace: string,
102
+ name: string,
103
+ ): string {
86
104
  return `${kind} ${namespace} ${name}`;
87
105
  }
88
106
 
@@ -91,7 +109,8 @@ const LayoutCtx = createContext<DiagnoseLayoutCtx | null>(null);
91
109
 
92
110
  export function useDiagnoseLayout(): DiagnoseLayoutCtx {
93
111
  const c = useContext(LayoutCtx);
94
- if (!c) throw new Error("useDiagnoseLayout must be used within DiagnoseProvider");
112
+ if (!c)
113
+ throw new Error("useDiagnoseLayout must be used within DiagnoseProvider");
95
114
  return c;
96
115
  }
97
116
 
@@ -108,15 +127,10 @@ const WIDTH_KEY = "radar-ai-panel-width";
108
127
  // Consent is machine-scoped and lives server-side (~/.radar): it gates a
109
128
  // machine-scoped action (spawn this machine's agent CLI, persist transcripts to
110
129
  // 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
- }
130
+ // `radar diagnose` CLI. The execution profile selects the disclosure because
131
+ // it determines the actual access available to the agent process.
118
132
  const AGENT_KEY = "radar-ai-agent";
119
- const ISOLATED_KEY = "radar-ai-isolated";
133
+ const PROFILE_KEY = "radar-ai-profile";
120
134
  const MODEL_KEY = "radar-ai-model";
121
135
  const EFFORT_KEY = "radar-ai-effort";
122
136
  // Push (reflow the app left) only while the app keeps at least this much width to
@@ -140,7 +154,9 @@ export function agentLabelFor(name: string, fallbackLabel?: string): string {
140
154
  // openDiagnoseSettings opens the Settings dialog (App.tsx listens for this DOM
141
155
  // event) — the canonical home for AI-diagnosis config.
142
156
  export function openDiagnoseSettings() {
143
- window.dispatchEvent(new CustomEvent("radar:open-settings"));
157
+ window.dispatchEvent(
158
+ new CustomEvent("radar:open-settings", { detail: { section: "ai" } }),
159
+ );
144
160
  }
145
161
 
146
162
  function readStored(key: string): string | null {
@@ -160,15 +176,14 @@ function writeStored(key: string, value: string) {
160
176
 
161
177
  export function DiagnoseProvider({ children }: { children: ReactNode }) {
162
178
  const [available, setAvailable] = useState(false);
179
+ const [eligible, setEligible] = useState(false);
163
180
  const [agents, setAgents] = useState<AgentInfo[]>([]);
164
- const [consented, setConsented] = useState<
165
- Record<ConsentSurface, boolean>
166
- >({ standard: false, cursor: false });
181
+ const [consented, setConsented] = useState<Record<string, boolean>>({});
167
182
  const [selectedAgent, setSelectedAgentState] = useState<string>(
168
183
  () => readStored(AGENT_KEY) || "",
169
184
  );
170
- const [isolated, setIsolatedState] = useState<boolean>(
171
- () => readStored(ISOLATED_KEY) !== "0", // default isolated
185
+ const [profile, setProfileState] = useState<ExecutionProfile>(
186
+ () => (readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded",
172
187
  );
173
188
  const [model, setModelState] = useState<string>(
174
189
  () => readStored(MODEL_KEY) || "",
@@ -205,12 +220,16 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
205
220
  fetchAgents()
206
221
  .then((r) => {
207
222
  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);
223
+ setConsented(r.consented ?? {});
224
+ setEligible(!!r.eligible);
225
+ const supported = r.agents.filter(
226
+ (a) =>
227
+ a.supported &&
228
+ (a.hosted ||
229
+ (!!a.profiles?.length &&
230
+ a.profiles.every((p) => !!a.consentSurfaces?.[p]))),
231
+ );
232
+ setAvailable(r.enabled && supported.length > 0);
214
233
  setAgents(supported);
215
234
  // Keep the stored pick only if it's still installed; else default to the
216
235
  // first supported agent (matches the server's default selection).
@@ -220,6 +239,13 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
220
239
  ? stored
221
240
  : (supported[0]?.name ?? "");
222
241
  setSelectedAgentState(next);
242
+ const profiles = supported.find((a) => a.name === next)?.profiles ?? [];
243
+ const storedProfile =
244
+ (readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded";
245
+ if (!profiles.includes(storedProfile) && profiles[0]) {
246
+ setProfileState(profiles[0]);
247
+ writeStored(PROFILE_KEY, profiles[0]);
248
+ }
223
249
  // Model/effort are agent-specific; if the stored agent is gone, its values
224
250
  // don't apply to the fallback agent (e.g. a Codex slug under Claude) — drop them.
225
251
  if (next !== stored) {
@@ -247,23 +273,45 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
247
273
  (name: string) => {
248
274
  setSelectedAgentState(name);
249
275
  writeStored(AGENT_KEY, name);
276
+ const nextProfile = agents.find((agent) => agent.name === name)
277
+ ?.profiles?.[0];
278
+ if (nextProfile) {
279
+ setProfileState(nextProfile);
280
+ writeStored(PROFILE_KEY, nextProfile);
281
+ }
250
282
  // Model + effort are agent-specific (Claude aliases vs Codex slugs); reset
251
283
  // to the new agent's default rather than carry an invalid value across.
252
284
  setModel("");
253
285
  setEffort("");
254
286
  },
255
- [setModel, setEffort],
287
+ [agents, setModel, setEffort],
256
288
  );
257
- const setIsolated = useCallback((v: boolean) => {
258
- setIsolatedState(v);
259
- writeStored(ISOLATED_KEY, v ? "1" : "0");
289
+ const setProfile = useCallback((v: ExecutionProfile) => {
290
+ setProfileState(v);
291
+ writeStored(PROFILE_KEY, v);
260
292
  }, []);
261
293
 
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;
294
+ const selectedAgentInfo = agents.find((a) => a.name === selectedAgent);
295
+ const effectiveProfile = selectedAgentInfo?.profiles?.includes(profile)
296
+ ? profile
297
+ : (selectedAgentInfo?.profiles?.[0] ?? profile);
298
+ const agentLabel = agentLabelFor(selectedAgent, selectedAgentInfo?.label);
299
+ const hosted = !!selectedAgentInfo?.hosted;
300
+ // Hosted Radar uses its existing per-user disclosure surface and supplies its
301
+ // own copy. Local agents use the execution profile as the consent contract.
302
+ const consentSurface = hosted
303
+ ? "standard"
304
+ : (selectedAgentInfo?.consentSurfaces?.[effectiveProfile] ?? "");
305
+
306
+ // `agents` holds only supported CLIs (filtered on fetch), so a non-empty list
307
+ // while the engine is off means a drivable agent appeared on PATH after boot.
308
+ const setupState: DiagnoseSetup = available
309
+ ? "ready"
310
+ : !eligible
311
+ ? "off"
312
+ : agents.length > 0
313
+ ? "needs-restart"
314
+ : "needs-install";
267
315
 
268
316
  useEffect(() => {
269
317
  const onResize = () => setViewportW(window.innerWidth);
@@ -314,10 +362,6 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
314
362
  return () => clearInterval(t);
315
363
  }, [open, available, hasRunning, refreshRuns]);
316
364
 
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
365
  const consentedRef = useRef(consented);
322
366
  consentedRef.current = consented;
323
367
 
@@ -329,7 +373,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
329
373
  const seq = ++startSeqRef.current;
330
374
  createRun(t, {
331
375
  agent: selectedAgent || undefined,
332
- isolated,
376
+ profile: hosted ? undefined : effectiveProfile,
333
377
  model: model || undefined,
334
378
  effort: effort || undefined,
335
379
  })
@@ -351,17 +395,28 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
351
395
  });
352
396
  };
353
397
 
354
- const openInvestigation = useCallback((t: Target) => {
355
- setStartError(null);
356
- setOpen(true);
357
- if (!consentedRef.current[consentSurfaceFor(selectedAgentRef.current)]) {
358
- setPendingTarget(t);
398
+ const openInvestigation = useCallback(
399
+ (t: Target) => {
400
+ setStartError(null);
401
+ setOpen(true);
402
+ if (!hosted && !consentSurface) {
403
+ setPendingTarget(null);
404
+ setStartError(
405
+ "Radar can’t run this agent with a verified execution profile.",
406
+ );
407
+ setView("investigation");
408
+ return;
409
+ }
410
+ if (!consentedRef.current[consentSurface]) {
411
+ setPendingTarget(t);
412
+ setView("investigation");
413
+ return;
414
+ }
359
415
  setView("investigation");
360
- return;
361
- }
362
- setView("investigation");
363
- startRunRef.current(t);
364
- }, []);
416
+ startRunRef.current(t);
417
+ },
418
+ [consentSurface, hosted],
419
+ );
365
420
  const openRun = useCallback((id: string) => {
366
421
  setStartError(null);
367
422
  setActiveRunId(id);
@@ -384,7 +439,9 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
384
439
  window.history.replaceState(
385
440
  null,
386
441
  "",
387
- window.location.pathname + (qs ? `?${qs}` : "") + window.location.hash,
442
+ window.location.pathname +
443
+ (qs ? `?${qs}` : "") +
444
+ window.location.hash,
388
445
  );
389
446
  }
390
447
  } catch {
@@ -401,15 +458,18 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
401
458
  const consentBusyRef = useRef(false);
402
459
  const approveConsent = useCallback(() => {
403
460
  if (consentBusyRef.current) return;
461
+ if (!consentSurface) {
462
+ setStartError("Radar can’t record consent for this agent.");
463
+ return;
464
+ }
404
465
  consentBusyRef.current = true;
405
466
  setStartError(null);
406
- const surface = consentSurfaceFor(selectedAgentRef.current);
407
467
  const t = pendingTarget;
408
468
  // The server ENFORCES consent at start, so the acknowledgment must land
409
469
  // before the run request — awaiting also makes it durable for the CLI.
410
- recordConsent(surface)
470
+ recordConsent(consentSurface)
411
471
  .then(() => {
412
- setConsented((prev) => ({ ...prev, [surface]: true }));
472
+ setConsented((prev) => ({ ...prev, [consentSurface]: true }));
413
473
  // Cleared only on success: a failed write keeps the consent card up
414
474
  // (needsConsent = !!pendingTarget) so "try again" works in place.
415
475
  setPendingTarget(null);
@@ -421,7 +481,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
421
481
  .finally(() => {
422
482
  consentBusyRef.current = false;
423
483
  });
424
- }, [pendingTarget]);
484
+ }, [consentSurface, pendingTarget]);
425
485
  const cancelConsent = useCallback(() => {
426
486
  setPendingTarget(null);
427
487
  setOpen(false);
@@ -435,13 +495,14 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
435
495
 
436
496
  const value: DiagnoseCtx = {
437
497
  available,
498
+ setupState,
438
499
  agentLabel,
439
500
  hosted,
440
501
  agents,
441
502
  selectedAgent,
442
503
  setSelectedAgent,
443
- isolated,
444
- setIsolated,
504
+ profile: effectiveProfile,
505
+ setProfile,
445
506
  model,
446
507
  setModel,
447
508
  effort,
@@ -484,7 +545,16 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
484
545
  panelWidthKey: WIDTH_KEY,
485
546
  runningKeys,
486
547
  }),
487
- [open, contentGutter, maximized, width, narrow, runningKeys, setMaximized, setWidth],
548
+ [
549
+ open,
550
+ contentGutter,
551
+ maximized,
552
+ width,
553
+ narrow,
554
+ runningKeys,
555
+ setMaximized,
556
+ setWidth,
557
+ ],
488
558
  );
489
559
 
490
560
  return (
@@ -26,27 +26,32 @@ import {
26
26
  import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
27
27
  import { InvestigationView } from "./InvestigationView";
28
28
  import { RecentList } from "./Home";
29
+ import { AgentSetupNotice } from "./AgentSetupNotice";
29
30
  import { ConsentCard } from "./parts";
30
31
  import { buildLaunchCommand, launchAgentLabel, openInTerminal } from "./launch";
31
- import { type RunSummary } from "../../api/diagnose";
32
+ import { type RunSummary, type ExecutionProfile } from "../../api/diagnose";
32
33
 
33
34
  function capWord(s: string): string {
34
35
  return s ? s[0].toUpperCase() + s.slice(1) : s;
35
36
  }
36
37
 
37
38
  // buildConfigLine renders the active AI config as the header subtitle. Codex shows
38
- // its isolation mode + effective reasoning effort (Default → medium); a model
39
+ // its execution profile + effective reasoning effort (Default → medium); a model
39
40
  // override is shown for either agent. Reflects a run's recorded settings, or the
40
41
  // current defaults on Home.
41
42
  function buildConfigLine(cfg: {
42
43
  agent?: string;
43
- isolated?: boolean;
44
+ profile?: ExecutionProfile;
44
45
  model?: string;
45
46
  effort?: string;
46
47
  }): string {
47
48
  const parts = [agentLabelFor(cfg.agent ?? "")];
49
+ if (cfg.profile) {
50
+ parts.push(
51
+ cfg.profile === "full-local" ? "Your agent setup" : "Radar safeguards",
52
+ );
53
+ }
48
54
  if (cfg.agent === "codex") {
49
- parts.push(cfg.isolated === false ? "My setup" : "Isolated");
50
55
  parts.push(`${capWord(cfg.effort || "medium")} effort`);
51
56
  }
52
57
  if (cfg.model) parts.push(capWord(cfg.model));
@@ -139,7 +144,8 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
139
144
  const d = useDiagnose();
140
145
  // Injected settings action: undefined = Radar's own Settings dialog;
141
146
  // null = hide the gear + links.
142
- const { consentCopy, onOpenSettings: hostOpenSettings } = useDiagnoseCustomization();
147
+ const { consentCopy, onOpenSettings: hostOpenSettings } =
148
+ useDiagnoseCustomization();
143
149
  const openSettings =
144
150
  hostOpenSettings === undefined ? openDiagnoseSettings : hostOpenSettings;
145
151
  const {
@@ -174,18 +180,23 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
174
180
  document.addEventListener("mouseup", onUp);
175
181
  };
176
182
 
183
+ // Feature is eligible here but not runnable yet (no agent installed, or one
184
+ // appeared after boot) — Home leads with the setup notice instead of an empty list.
185
+ const setupPending =
186
+ d.setupState === "needs-install" || d.setupState === "needs-restart";
187
+
177
188
  const activeRun = d.runs.find((r) => r.id === d.activeRunId) ?? null;
178
189
  // A focused run shows the agent it actually ran with; Home reflects the current pick.
179
190
  const activeAgentLabel = activeRun?.agent
180
191
  ? agentLabelFor(activeRun.agent)
181
192
  : d.agentLabel;
182
193
  // Header subtitle: the config a focused run actually used (it records agent /
183
- // isolation / model / effort), or the current defaults on Home. Codex shows mode
194
+ // profile / model / effort), or the current defaults on Home. Codex shows mode
184
195
  // + reasoning effort; model is shown only when overridden. Clicking opens Settings.
185
196
  const configLine = buildConfigLine(
186
197
  activeRun ?? {
187
198
  agent: d.selectedAgent,
188
- isolated: d.isolated,
199
+ profile: d.hosted ? undefined : d.profile,
189
200
  model: d.model,
190
201
  effort: d.effort,
191
202
  },
@@ -208,7 +219,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
208
219
  <ConsentCard
209
220
  agentName={d.agentLabel}
210
221
  agent={d.selectedAgent}
211
- isolated={d.isolated}
222
+ profile={d.profile}
212
223
  copy={consentCopy}
213
224
  onOpenSettings={openSettings ?? undefined}
214
225
  onApprove={d.approveConsent}
@@ -258,6 +269,10 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
258
269
  Dismiss
259
270
  </button>
260
271
  </div>
272
+ ) : setupPending ? (
273
+ <div className="flex-1 overflow-y-auto">
274
+ <AgentSetupNotice setupState={d.setupState} />
275
+ </div>
261
276
  ) : (
262
277
  <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-theme-text-tertiary">
263
278
  Select an investigation, or open a resource and click Diagnose.
@@ -357,7 +372,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
357
372
  only appears when expanded; keys keep the detail node identity-stable
358
373
  as it comes and goes. */}
359
374
  <div className="flex min-h-0 flex-1">
360
- {showHistory && (
375
+ {showHistory && (!setupPending || d.runs.length > 0) && (
361
376
  <aside
362
377
  key="recent"
363
378
  className="w-72 shrink-0 overflow-y-auto border-r border-theme-border px-3 py-3"
@@ -376,12 +391,15 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
376
391
  key="main"
377
392
  className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3"
378
393
  >
379
- <RecentList
380
- agentLabel={d.agentLabel}
381
- runs={d.runs}
382
- onSelect={d.openRun}
383
- historyDegraded={d.historyDegraded}
384
- />
394
+ {setupPending && <AgentSetupNotice setupState={d.setupState} />}
395
+ {(!setupPending || d.runs.length > 0) && (
396
+ <RecentList
397
+ agentLabel={d.agentLabel}
398
+ runs={d.runs}
399
+ onSelect={d.openRun}
400
+ historyDegraded={d.historyDegraded}
401
+ />
402
+ )}
385
403
  </div>
386
404
  ) : (
387
405
  <div key="main" className="flex min-h-0 min-w-0 flex-1 flex-col">