@skyhook-io/radar-app 1.13.0 → 1.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/App.tsx CHANGED
@@ -37,6 +37,7 @@ import { CloudFunnelButton } from './components/CloudFunnelButton'
37
37
  import { useNavCustomization } from './context/NavCustomization'
38
38
  import type { FleetTakeoverTarget } from './context/NavCustomization'
39
39
  import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
40
+ import { navigateFromPrimaryRail } from './components/nav/navigation'
40
41
  import { useNavRailPinned } from './hooks/useNavRailPinned'
41
42
  import { useMediaQuery } from './hooks/useMediaQuery'
42
43
  import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
@@ -322,7 +323,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
322
323
  // The AI panel is an absolute slot in the body frame (the column under the header):
323
324
  // it reserves a right gutter on the CONTENT only, so the navbar + nav rail stay
324
325
  // static. contentGutter is the docked panel width (0 when closed/overlay/maximized).
325
- const { open: diagnoseOpen, contentGutter } = useDiagnoseLayout()
326
+ const {
327
+ open: diagnoseOpen,
328
+ close: closeDiagnose,
329
+ contentGutter,
330
+ maximized: diagnoseMaximized,
331
+ } = useDiagnoseLayout()
326
332
  // Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
327
333
  // cross-tree swap) navigates same-document so the chrome morphs instead of
328
334
  // cold-booting; without it we fall back to a hard `window.location` nav.
@@ -468,12 +474,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
468
474
 
469
475
  const path = view === 'home' ? '/' : `/${view}`
470
476
 
471
- // Start fresh — keep only cross-view params (namespaces), discard all view-specific ones
477
+ // Start fresh — keep only cross-view params, discard view-specific ones.
478
+ // Read the live location because Diagnose owns `ai-run` through the History
479
+ // API; React Router's searchParams snapshot does not update for that write.
472
480
  const newParams = new URLSearchParams()
473
- const globalNamespaces = searchParams.get('namespaces')
481
+ const currentParams = new URLSearchParams(window.location.search)
482
+ const globalNamespaces = currentParams.get('namespaces')
474
483
  if (globalNamespaces) {
475
484
  newParams.set('namespaces', globalNamespaces)
476
485
  }
486
+ const diagnoseRun = currentParams.get('ai-run')
487
+ if (diagnoseRun) {
488
+ newParams.set('ai-run', diagnoseRun)
489
+ }
477
490
 
478
491
  // Add any new params
479
492
  if (params) {
@@ -483,7 +496,18 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
483
496
  }
484
497
 
485
498
  navigate({ pathname: path, search: newParams.toString() })
486
- }, [navigate, searchParams, takeover, goHost])
499
+ }, [navigate, takeover, goHost])
500
+
501
+ // The standalone rail expresses intent to leave the full-width investigation
502
+ // workspace. Close it before routing so the destination is immediately visible;
503
+ // docked investigations stay open across views as a persistent side panel.
504
+ const handlePrimaryNavigate = useCallback((view: ExtendedMainView) => {
505
+ navigateFromPrimaryRail(
506
+ diagnoseOpen && diagnoseMaximized,
507
+ closeDiagnose,
508
+ () => setMainView(view),
509
+ )
510
+ }, [diagnoseOpen, diagnoseMaximized, closeDiagnose, setMainView])
487
511
 
488
512
  // Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
489
513
  // own fleet pages scoped to this cluster — owned by the host's left rail — so
@@ -1135,9 +1159,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1135
1159
  setSelectedResource(null)
1136
1160
  setSelectedHelmRelease(null)
1137
1161
 
1138
- // Reset URL to current view with no resource-specific params.
1139
- // Old cluster's selected pod/resource/kind don't exist on the new cluster.
1140
- navigate({ pathname: location.pathname, search: '' }, { replace: true })
1162
+ // Reset resource-specific params while retaining the durable investigation
1163
+ // focus. Diagnose resolves runs by id and owns whether the focused run is
1164
+ // still readable after the context switch.
1165
+ const nextParams = new URLSearchParams()
1166
+ const diagnoseRun = new URLSearchParams(window.location.search).get('ai-run')
1167
+ if (diagnoseRun) nextParams.set('ai-run', diagnoseRun)
1168
+ navigate({ pathname: location.pathname, search: nextParams.toString() }, { replace: true })
1141
1169
 
1142
1170
  // Auto-unpause so the new cluster's topology loads immediately
1143
1171
  setTopologyPaused(false)
@@ -1658,7 +1686,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1658
1686
  {showNavRail && (
1659
1687
  <PrimaryNavRail
1660
1688
  activeView={navActiveView}
1661
- onNavigate={setMainView}
1689
+ onNavigate={handlePrimaryNavigate}
1662
1690
  pinned={navRailEffectivePinned}
1663
1691
  onTogglePinned={toggleNavRailPinned}
1664
1692
  showPinToggle={!railForcedSlim}
package/src/RadarApp.tsx CHANGED
@@ -228,7 +228,7 @@ export function RadarApp({
228
228
  value={renderDiagnoseAction ?? defaultDiagnoseAction}
229
229
  consentCopy={diagnoseConsent}
230
230
  >
231
- <DiagnoseProvider>
231
+ <DiagnoseProvider browserURLState={router !== "memory"}>
232
232
  <App
233
233
  manageDocumentTitle={manageDocumentTitle}
234
234
  documentTitleSuffix={documentTitleSuffix}
@@ -1,7 +1,7 @@
1
1
  // Client for the local AI-diagnose engine (OSS BYO-agent). The agent CLI runs
2
2
  // on the user's own machine/subscription against Radar's MCP; this just starts
3
3
  // the investigation and consumes its SSE event stream.
4
- import { getApiBase, getCredentialsMode } from "./config";
4
+ import { getApiBase, getAuthHeaders, getCredentialsMode } from "./config";
5
5
 
6
6
  export interface AgentInfo {
7
7
  name: string;
@@ -81,6 +81,7 @@ export interface DiagnoseStreamEvent {
81
81
  error?: string;
82
82
  question?: string; // on "turn"
83
83
  apply?: boolean; // on "turn"
84
+ actor?: string; // human author on shared hosted transcripts
84
85
  }
85
86
 
86
87
  // A run is a durable, server-owned investigation. Its lifetime is independent of
@@ -101,11 +102,17 @@ export interface RunSummary {
101
102
  effort?: string;
102
103
  managedBy?: string; // GitOps/Helm owner of the target ("Argo CD"/"Flux"/"Helm"), for the Apply warning
103
104
  health?: ResourceHealthSignal;
104
- status: "running" | "done" | "error" | "stopped" | "stale";
105
+ status: "running" | "stopping" | "done" | "error" | "stopped" | "stale";
105
106
  sessionId?: string;
106
107
  preview?: string;
107
108
  createdAt: string;
108
109
  updatedAt: string;
110
+ visibility?: "private" | "organization";
111
+ ownedByMe?: boolean;
112
+ canManageVisibility?: boolean;
113
+ canContinue?: boolean;
114
+ trigger?: "interactive" | "background";
115
+ radarUrl?: string;
109
116
  }
110
117
 
111
118
  export async function fetchAgents(
@@ -113,6 +120,7 @@ export async function fetchAgents(
113
120
  ): Promise<AgentsResponse> {
114
121
  const res = await fetch(`${getApiBase()}/agents`, {
115
122
  credentials: getCredentialsMode(),
123
+ headers: getAuthHeaders(),
116
124
  signal,
117
125
  });
118
126
  if (!res.ok) throw new Error(`agents: ${res.status}`);
@@ -167,7 +175,7 @@ export async function createRun(
167
175
  const res = await fetch(RUNS(), {
168
176
  method: "POST",
169
177
  credentials: getCredentialsMode(),
170
- headers: { "Content-Type": "application/json" },
178
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
171
179
  body: JSON.stringify({ ...target, ...opts }),
172
180
  });
173
181
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -186,6 +194,7 @@ export interface RunsResponse {
186
194
  export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
187
195
  const res = await fetch(RUNS(), {
188
196
  credentials: getCredentialsMode(),
197
+ headers: getAuthHeaders(),
189
198
  signal,
190
199
  });
191
200
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -193,12 +202,41 @@ export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
193
202
  return { runs: d.runs ?? [], historyDegraded: !!d.historyDegraded };
194
203
  }
195
204
 
205
+ // getRun resolves a stable run id directly. Deep links use this rather than
206
+ // relying on a bounded history page to happen to contain the target.
207
+ export async function getRun(
208
+ id: string,
209
+ signal?: AbortSignal,
210
+ ): Promise<RunSummary> {
211
+ const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
212
+ credentials: getCredentialsMode(),
213
+ headers: getAuthHeaders(),
214
+ signal,
215
+ });
216
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
217
+ return res.json();
218
+ }
219
+
220
+ export async function updateRunVisibility(
221
+ id: string,
222
+ visibility: "private" | "organization",
223
+ ): Promise<RunSummary> {
224
+ const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
225
+ method: "PATCH",
226
+ credentials: getCredentialsMode(),
227
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
228
+ body: JSON.stringify({ visibility }),
229
+ });
230
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
231
+ return res.json();
232
+ }
233
+
196
234
  // recordConsent acknowledges the current disclosure for an execution profile, server-side.
197
235
  export async function recordConsent(surface: string): Promise<void> {
198
236
  const res = await fetch(`${getApiBase()}/diagnose/consent`, {
199
237
  method: "POST",
200
238
  credentials: getCredentialsMode(),
201
- headers: { "Content-Type": "application/json" },
239
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
202
240
  body: JSON.stringify({ surface }),
203
241
  });
204
242
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -210,6 +248,7 @@ export async function clearHistory(): Promise<void> {
210
248
  const res = await fetch(`${getApiBase()}/diagnose/history/clear`, {
211
249
  method: "POST",
212
250
  credentials: getCredentialsMode(),
251
+ headers: getAuthHeaders(),
213
252
  });
214
253
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
215
254
  }
@@ -222,7 +261,7 @@ export async function addTurn(
222
261
  const res = await fetch(`${RUNS()}/${id}/turns`, {
223
262
  method: "POST",
224
263
  credentials: getCredentialsMode(),
225
- headers: { "Content-Type": "application/json" },
264
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
226
265
  body: JSON.stringify(body),
227
266
  });
228
267
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -233,6 +272,7 @@ export async function stopRun(id: string): Promise<void> {
233
272
  await fetch(`${RUNS()}/${id}/stop`, {
234
273
  method: "POST",
235
274
  credentials: getCredentialsMode(),
275
+ headers: getAuthHeaders(),
236
276
  }).catch(() => {});
237
277
  }
238
278
 
@@ -49,7 +49,7 @@ function ClearHistoryRow({
49
49
  <div className="mt-3 flex items-center justify-between gap-2 border-t border-theme-border/60 pt-3">
50
50
  <p className="text-[11px] leading-snug text-theme-text-tertiary">
51
51
  {hosted ? (
52
- `Investigation transcripts are stored by ${agentLabel} so history survives restarts.`
52
+ `${agentLabel} stores private, organization-shared, and automatic investigation transcripts so history survives restarts.`
53
53
  ) : (
54
54
  <>
55
55
  Investigation transcripts are kept on this machine (
@@ -67,6 +67,12 @@ function ClearHistoryRow({
67
67
  Couldn&apos;t clear history.
68
68
  </span>
69
69
  )}
70
+ {hosted && confirming && state === "idle" && (
71
+ <span className="ml-1 font-medium text-red-400">
72
+ This permanently deletes every member&apos;s investigations for this
73
+ cluster — private, organization-shared, and automatic.
74
+ </span>
75
+ )}
70
76
  </p>
71
77
  {confirming ? (
72
78
  <div className="flex shrink-0 items-center gap-1.5">
@@ -133,9 +139,8 @@ export function AISettingsSection({
133
139
  selectedAgent={draft.agent}
134
140
  // Model + effort are agent-specific; reset them when the agent changes.
135
141
  onSelectAgent={(a) => {
136
- const nextProfile = agents.find(
137
- (agent) => agent.name === a,
138
- )?.profiles?.[0];
142
+ const nextProfile = agents.find((agent) => agent.name === a)
143
+ ?.profiles?.[0];
139
144
  onChange({
140
145
  agent: a,
141
146
  profile: nextProfile ?? draft.profile,
@@ -17,11 +17,13 @@ import {
17
17
  } from "react";
18
18
  import {
19
19
  fetchAgents,
20
+ getRun,
20
21
  listRuns,
21
22
  createRun,
22
23
  recordConsent,
23
24
  DiagnoseError,
24
25
  } from "../../api/diagnose";
26
+ import { getApiBase } from "../../api/config";
25
27
  import {
26
28
  type RunSummary,
27
29
  type AgentInfo,
@@ -86,6 +88,7 @@ interface DiagnoseCtx {
86
88
  approveConsent: () => void;
87
89
  cancelConsent: () => void;
88
90
  refreshRuns: () => void;
91
+ updateRunSummary: (run: RunSummary) => void;
89
92
  dismissError: () => void;
90
93
  }
91
94
 
@@ -95,6 +98,7 @@ interface DiagnoseCtx {
95
98
  // churns the business context) doesn't re-render the whole shell.
96
99
  interface DiagnoseLayoutCtx {
97
100
  open: boolean;
101
+ close: () => void;
98
102
  contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
99
103
  maximized: boolean;
100
104
  setMaximized: Dispatch<SetStateAction<boolean>>;
@@ -186,7 +190,58 @@ function writeStored(key: string, value: string) {
186
190
  }
187
191
  }
188
192
 
189
- export function DiagnoseProvider({ children }: { children: ReactNode }) {
193
+ function runIDFromLocation(browserURLState: boolean): string | null {
194
+ if (!diagnoseURLStateEnabled(browserURLState)) return null;
195
+ try {
196
+ return new URLSearchParams(window.location.search).get("ai-run");
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ function writeRunIDToLocation(
203
+ id: string | null,
204
+ push: boolean,
205
+ browserURLState: boolean,
206
+ ) {
207
+ if (!diagnoseURLStateEnabled(browserURLState)) return;
208
+ try {
209
+ const url = new URL(window.location.href);
210
+ if (id) url.searchParams.set("ai-run", id);
211
+ else url.searchParams.delete("ai-run");
212
+ window.history[push ? "pushState" : "replaceState"](
213
+ window.history.state,
214
+ "",
215
+ `${url.pathname}${url.search}${url.hash}`,
216
+ );
217
+ // History API writes do not notify BrowserRouter. Replaying the new state as
218
+ // popstate keeps every later navigate/setSearchParams call on the same live
219
+ // query string instead of letting a stale router snapshot erase ai-run.
220
+ window.dispatchEvent(
221
+ new PopStateEvent("popstate", { state: window.history.state }),
222
+ );
223
+ } catch {
224
+ /* URL APIs unavailable — panel state still works for this session */
225
+ }
226
+ }
227
+
228
+ // Fleet pages host the panel against a cluster-scoped API while remaining on a
229
+ // hub route such as /issues. Writing ?ai-run there would create a non-reloadable
230
+ // pseudo-link. The embedded /c/:id Radar tree and local Radar own their route and
231
+ // therefore keep the query as navigation state; Fleet copies run.radarUrl instead.
232
+ function diagnoseURLStateEnabled(browserURLState: boolean): boolean {
233
+ if (!browserURLState) return false;
234
+ const clusterScopedAPI = /\/c\/[^/]+\/api\/?$/.test(getApiBase());
235
+ return !clusterScopedAPI || /^\/c\/[^/]+/.test(window.location.pathname);
236
+ }
237
+
238
+ export function DiagnoseProvider({
239
+ children,
240
+ browserURLState = true,
241
+ }: {
242
+ children: ReactNode;
243
+ browserURLState?: boolean;
244
+ }) {
190
245
  const [available, setAvailable] = useState(false);
191
246
  const [eligible, setEligible] = useState(false);
192
247
  const [agents, setAgents] = useState<AgentInfo[]>([]);
@@ -206,6 +261,26 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
206
261
  const [open, setOpen] = useState(false);
207
262
  const [view, setView] = useState<DiagnoseView>("home");
208
263
  const [activeRunId, setActiveRunId] = useState<string | null>(null);
264
+ const activeRunIdRef = useRef(activeRunId);
265
+ activeRunIdRef.current = activeRunId;
266
+ // A missing/revoked durable link is terminal until the user explicitly
267
+ // navigates to it again. Keep polling the bounded history list (so a newly
268
+ // shared run can reappear), but do not hammer the exact 404 endpoint every
269
+ // four seconds while the unavailable state is already on screen.
270
+ const unavailableRunIDsRef = useRef(new Set<string>());
271
+ // Tracks whether the panel focus belongs to browser history. Fleet opens the
272
+ // same panel on /issues, where URL state is deliberately disabled; a generic
273
+ // panel launch must never be closed by the deep-link synchronization effect.
274
+ const urlRunIdRef = useRef(runIDFromLocation(browserURLState));
275
+ const writeFocusedRunID = useCallback(
276
+ (id: string | null, push: boolean) => {
277
+ // Set this before writeRunIDToLocation's synthetic popstate so our own
278
+ // listener can distinguish a programmatic close/home write from Back.
279
+ if (diagnoseURLStateEnabled(browserURLState)) urlRunIdRef.current = id;
280
+ writeRunIDToLocation(id, push, browserURLState);
281
+ },
282
+ [browserURLState],
283
+ );
209
284
  const [runs, setRuns] = useState<RunSummary[]>([]);
210
285
  const [runsLoaded, setRunsLoaded] = useState(false);
211
286
  const [runsLoadFailed, setRunsLoadFailed] = useState(false);
@@ -332,28 +407,79 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
332
407
  return () => window.removeEventListener("resize", onResize);
333
408
  }, []);
334
409
 
335
- const refreshRuns = useCallback(() => {
410
+ const refreshRuns = useCallback(async () => {
336
411
  if (!available) return;
337
- listRuns()
338
- .then((r) => {
339
- setRuns(r.runs);
340
- setRunsLoaded(true);
341
- setRunsLoadFailed(false);
342
- setHistoryDegraded(!!r.historyDegraded);
343
- })
344
- .catch(() => {
345
- // Leave runsLoaded false (a missing-run verdict needs a real list) but
346
- // record the failure so the panel can say "retrying" instead of
347
- // pretending nothing happened. The 4s poll keeps retrying while open.
348
- setRunsLoadFailed(true);
412
+ try {
413
+ const r = await listRuns();
414
+ const focusedID = activeRunIdRef.current;
415
+ let focusedRun: RunSummary | null = null;
416
+ let retainFocusedSnapshot = false;
417
+ const listedFocusedRun = focusedID
418
+ ? r.runs.find((run) => run.id === focusedID)
419
+ : undefined;
420
+ if (focusedID && listedFocusedRun) {
421
+ unavailableRunIDsRef.current.delete(focusedID);
422
+ } else if (focusedID && !unavailableRunIDsRef.current.has(focusedID)) {
423
+ try {
424
+ // A stable deep link can target a retained run older than the bounded
425
+ // history page. Refresh it directly too: its status and capabilities
426
+ // must not freeze at the first snapshot while the panel stays open.
427
+ focusedRun = await getRun(focusedID);
428
+ } catch (error) {
429
+ // The bounded list is still authoritative for everything else. A
430
+ // missing/revoked focused run falls out and renders unavailable;
431
+ // transient direct-fetch failures keep the last useful snapshot.
432
+ const unavailable =
433
+ error instanceof DiagnoseError && error.status === 404;
434
+ if (unavailable) unavailableRunIDsRef.current.add(focusedID);
435
+ retainFocusedSnapshot = !unavailable;
436
+ }
437
+ }
438
+ setRuns((prev) => {
439
+ let nextRuns = r.runs;
440
+ if (focusedRun) nextRuns = [focusedRun, ...nextRuns];
441
+ if (focusedID && retainFocusedSnapshot) {
442
+ const previous = prev.find((run) => run.id === focusedID);
443
+ if (previous) nextRuns = [previous, ...nextRuns];
444
+ }
445
+
446
+ // openRun/start can focus and insert a run while the direct fetch above
447
+ // is in flight. Preserve that newer focus instead of replacing it with
448
+ // the snapshot for the run that was active when this refresh started.
449
+ const currentFocusedID = activeRunIdRef.current;
450
+ if (
451
+ currentFocusedID &&
452
+ currentFocusedID !== focusedID &&
453
+ !nextRuns.some((run) => run.id === currentFocusedID)
454
+ ) {
455
+ const current = prev.find((run) => run.id === currentFocusedID);
456
+ if (current) nextRuns = [current, ...nextRuns];
457
+ }
458
+ return nextRuns;
349
459
  });
460
+ setRunsLoaded(true);
461
+ setRunsLoadFailed(false);
462
+ setHistoryDegraded(!!r.historyDegraded);
463
+ } catch {
464
+ // Leave runsLoaded false (a missing-run verdict needs a real list) but
465
+ // record the failure so the panel can say "retrying" instead of
466
+ // pretending nothing happened. The 4s poll keeps retrying while open.
467
+ setRunsLoadFailed(true);
468
+ }
350
469
  }, [available]);
470
+ const updateRunSummary = useCallback((run: RunSummary) => {
471
+ setRuns((prev) =>
472
+ prev.some((item) => item.id === run.id)
473
+ ? prev.map((item) => (item.id === run.id ? run : item))
474
+ : [run, ...prev],
475
+ );
476
+ }, []);
351
477
 
352
- // A content-stable signature of the resources with a live (running) investigation,
478
+ // A content-stable signature of the resources with a live investigation,
353
479
  // so the per-resource Diagnose buttons can show a "running" indicator even with the
354
480
  // panel closed — and only re-render when the set actually changes, not every poll.
355
481
  const runningSig = runs
356
- .filter((r) => r.status === "running")
482
+ .filter((r) => r.status === "running" || r.status === "stopping")
357
483
  .map((r) => runTargetKey(r.kind, r.namespace, r.name))
358
484
  .sort()
359
485
  .join("|");
@@ -397,6 +523,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
397
523
  if (seq !== startSeqRef.current) return;
398
524
  setActiveRunId(run.id);
399
525
  setView("investigation");
526
+ writeFocusedRunID(run.id, true);
400
527
  })
401
528
  .catch((e) => {
402
529
  if (seq !== startSeqRef.current) return;
@@ -431,52 +558,90 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
431
558
  },
432
559
  [consentSurface, hosted],
433
560
  );
434
- const openRun = useCallback((id: string) => {
435
- setStartError(null);
436
- setActiveRunId(id);
437
- setView("investigation");
438
- setOpen(true);
439
- }, []);
561
+ const openRun = useCallback(
562
+ (id: string) => {
563
+ unavailableRunIDsRef.current.delete(id);
564
+ setStartError(null);
565
+ setActiveRunId(id);
566
+ setView("investigation");
567
+ setOpen(true);
568
+ writeFocusedRunID(id, true);
569
+ // A Fleet issue can point at a retained automatic run older than the
570
+ // bounded history page. Resolve the exact id instead of waiting for list.
571
+ getRun(id)
572
+ .then(updateRunSummary)
573
+ .catch((error) => {
574
+ if (error instanceof DiagnoseError && error.status === 404) {
575
+ unavailableRunIDsRef.current.add(id);
576
+ }
577
+ // The loaded-list state renders the durable unavailable message.
578
+ });
579
+ },
580
+ [updateRunSummary, writeFocusedRunID],
581
+ );
440
582
 
441
- // Deep link: ?ai-run=<id> opens the panel focused on that investigation
442
- // the URL `radar diagnose --open` prints/opens. Consumed once (then stripped)
443
- // so back/forward and copied URLs don't keep re-opening the panel.
583
+ // The run id is durable navigation state: direct loads and popstate focus the
584
+ // exact run, and the query remains in place so the address bar is copyable.
585
+ // Fetch by id rather than assuming the bounded recent list contains it.
444
586
  useEffect(() => {
445
- if (!available) return;
446
- let id: string | null = null;
447
- try {
448
- const params = new URLSearchParams(window.location.search);
449
- id = params.get("ai-run");
450
- if (id) {
451
- params.delete("ai-run");
452
- const qs = params.toString();
453
- window.history.replaceState(
454
- null,
455
- "",
456
- window.location.pathname +
457
- (qs ? `?${qs}` : "") +
458
- window.location.hash,
459
- );
587
+ if (!available || !diagnoseURLStateEnabled(browserURLState)) return;
588
+ const focusFromLocation = (fromPopState: boolean) => {
589
+ const id = runIDFromLocation(browserURLState);
590
+ if (!id) {
591
+ // A missing id on first mount is ordinary. On popstate it means "back
592
+ // out of this investigation" only when the focused run was itself
593
+ // installed by URL history; unrelated synthetic popstate events must
594
+ // not tear down a panel opened by an in-app action.
595
+ if (fromPopState && urlRunIdRef.current) {
596
+ setActiveRunId(null);
597
+ setView("home");
598
+ setOpen(false);
599
+ }
600
+ urlRunIdRef.current = null;
601
+ return;
460
602
  }
461
- } catch {
462
- /* URL APIs unavailable — skip the deep link */
463
- }
464
- if (id) openRun(id);
465
- }, [available, openRun]);
603
+ urlRunIdRef.current = id;
604
+ unavailableRunIDsRef.current.delete(id);
605
+ setStartError(null);
606
+ setActiveRunId(id);
607
+ setView("investigation");
608
+ setOpen(true);
609
+ getRun(id)
610
+ .then(updateRunSummary)
611
+ .catch((error) => {
612
+ if (error instanceof DiagnoseError && error.status === 404) {
613
+ unavailableRunIDsRef.current.add(id);
614
+ }
615
+ // The list load owns the final missing/degraded state and keeps retrying.
616
+ });
617
+ };
618
+ focusFromLocation(false);
619
+ const onPopState = () => focusFromLocation(true);
620
+ window.addEventListener("popstate", onPopState);
621
+ return () => window.removeEventListener("popstate", onPopState);
622
+ }, [available, browserURLState, updateRunSummary]);
466
623
  // Leaving the detail pane drops the failure that belonged to it. startError
467
624
  // renders as the entire pane (maximized home still shows `detail`), where a
468
625
  // message about a resource you just navigated away from has nothing to attach
469
626
  // to and no way to be dismissed.
470
627
  const openHome = useCallback(() => {
628
+ unavailableRunIDsRef.current.clear();
471
629
  setView("home");
472
630
  setStartError(null);
473
631
  setOpen(true);
474
- }, []);
632
+ writeFocusedRunID(null, false);
633
+ }, [writeFocusedRunID]);
475
634
  const goHome = useCallback(() => {
635
+ unavailableRunIDsRef.current.clear();
476
636
  setView("home");
477
637
  setStartError(null);
478
- }, []);
479
- const close = useCallback(() => setOpen(false), []);
638
+ writeFocusedRunID(null, false);
639
+ }, [writeFocusedRunID]);
640
+ const close = useCallback(() => {
641
+ unavailableRunIDsRef.current.clear();
642
+ setOpen(false);
643
+ writeFocusedRunID(null, false);
644
+ }, [writeFocusedRunID]);
480
645
  const consentBusyRef = useRef(false);
481
646
  const approveConsent = useCallback(() => {
482
647
  if (consentBusyRef.current) return;
@@ -557,6 +722,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
557
722
  approveConsent,
558
723
  cancelConsent,
559
724
  refreshRuns,
725
+ updateRunSummary,
560
726
  dismissError,
561
727
  };
562
728
 
@@ -567,6 +733,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
567
733
  const layout = useMemo<DiagnoseLayoutCtx>(
568
734
  () => ({
569
735
  open,
736
+ close,
570
737
  contentGutter,
571
738
  maximized,
572
739
  setMaximized,
@@ -579,6 +746,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
579
746
  }),
580
747
  [
581
748
  open,
749
+ close,
582
750
  contentGutter,
583
751
  maximized,
584
752
  width,