@skyhook-io/radar-app 1.13.3 → 1.13.5

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 (45) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +68 -23
  3. package/src/RadarApp.tsx +17 -1
  4. package/src/api/client.ts +28 -10
  5. package/src/api/diagnose.ts +61 -15
  6. package/src/components/ConnectionErrorView.test.tsx +30 -0
  7. package/src/components/ConnectionErrorView.tsx +31 -19
  8. package/src/components/diagnose/AgentCase.tsx +131 -0
  9. package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
  10. package/src/components/diagnose/DiagnoseContext.tsx +402 -77
  11. package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
  12. package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
  13. package/src/components/diagnose/Home.test.tsx +23 -1
  14. package/src/components/diagnose/Home.tsx +49 -3
  15. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
  16. package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
  17. package/src/components/diagnose/InvestigationView.tsx +227 -5
  18. package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
  19. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  20. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  21. package/src/components/diagnose/investigationCase.ts +439 -0
  22. package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
  23. package/src/components/diagnose/investigationEvidence.ts +2246 -166
  24. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  27. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  28. package/src/components/diagnose/investigationMetrics.ts +393 -0
  29. package/src/components/diagnose/investigationSourceFocus.ts +6 -0
  30. package/src/components/diagnose/investigationState.test.ts +322 -0
  31. package/src/components/diagnose/investigationState.ts +147 -25
  32. package/src/components/diagnose/parts.test.tsx +537 -1
  33. package/src/components/diagnose/parts.tsx +486 -42
  34. package/src/components/resource/HPACharts.render.test.tsx +77 -0
  35. package/src/components/resource/HPACharts.tsx +32 -25
  36. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  37. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  38. package/src/components/resources/PodFilePreview.tsx +394 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  40. package/src/components/resources/ResourcesView.tsx +27 -2
  41. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  42. package/src/context/DiagnoseCustomization.tsx +14 -2
  43. package/src/index.ts +8 -5
  44. package/src/utils/shell-safe.test.ts +25 -1
  45. package/src/utils/shell-safe.ts +16 -0
@@ -15,6 +15,12 @@ import {
15
15
  type ReactNode,
16
16
  type SetStateAction,
17
17
  } from "react";
18
+ import {
19
+ MemoryRouter,
20
+ useInRouterContext,
21
+ useLocation,
22
+ useNavigate,
23
+ } from "react-router-dom";
18
24
  import {
19
25
  fetchAgents,
20
26
  getRun,
@@ -86,8 +92,12 @@ interface DiagnoseCtx {
86
92
  openInvestigation: (t: Target) => void;
87
93
  openRun: (id: string) => void;
88
94
  openHome: () => void;
95
+ openWorkspace: (runID?: string | null) => void;
96
+ restoreWorkspace: () => void;
97
+ canRestoreWorkspace: boolean;
89
98
  goHome: () => void;
90
99
  close: () => void;
100
+ dismissForNavigation: () => void;
91
101
  approveConsent: () => void;
92
102
  cancelConsent: () => void;
93
103
  refreshRuns: () => void;
@@ -102,6 +112,7 @@ interface DiagnoseCtx {
102
112
  interface DiagnoseLayoutCtx {
103
113
  open: boolean;
104
114
  close: () => void;
115
+ dismissForNavigation: () => void;
105
116
  contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
106
117
  maximized: boolean;
107
118
  setMaximized: Dispatch<SetStateAction<boolean>>;
@@ -111,6 +122,7 @@ interface DiagnoseLayoutCtx {
111
122
  panelBounds: { min: number; max: number };
112
123
  panelWidthKey: string;
113
124
  runningKeys: ReadonlySet<string>; // resources with a live investigation (see runTargetKey)
125
+ runningCount: number;
114
126
  }
115
127
 
116
128
  const Ctx = createContext<DiagnoseCtx | null>(null);
@@ -183,39 +195,114 @@ function writeStored(key: string, value: string) {
183
195
  }
184
196
  }
185
197
 
186
- function runIDFromLocation(browserURLState: boolean): string | null {
187
- if (!diagnoseURLStateEnabled(browserURLState)) return null;
198
+ const WORKSPACE_RETURN_STATE = "investigationWorkspaceReturn";
199
+ const WORKSPACE_RETURN_HISTORY_STEPS_STATE = "investigationReturnHistorySteps";
200
+ const INVALID_WORKSPACE_RUN_ID = "__invalid_workspace_run__";
201
+
202
+ function runIDFromSearch(search: string): string | null {
188
203
  try {
189
- return new URLSearchParams(window.location.search).get("ai-run");
204
+ return new URLSearchParams(search).get("ai-run");
190
205
  } catch {
191
206
  return null;
192
207
  }
193
208
  }
194
209
 
195
- function writeRunIDToLocation(
196
- id: string | null,
197
- push: boolean,
198
- browserURLState: boolean,
199
- ) {
200
- if (!diagnoseURLStateEnabled(browserURLState)) return;
210
+ export function workspaceRunIDFromPath(pathname: string): string | null {
211
+ const match = pathname.match(/^\/investigations\/([^/]+)\/?$/);
212
+ if (!match) return null;
201
213
  try {
202
- const url = new URL(window.location.href);
203
- if (id) url.searchParams.set("ai-run", id);
204
- else url.searchParams.delete("ai-run");
205
- window.history[push ? "pushState" : "replaceState"](
206
- window.history.state,
207
- "",
208
- `${url.pathname}${url.search}${url.hash}`,
209
- );
210
- // History API writes do not notify BrowserRouter. Replaying the new state as
211
- // popstate keeps every later navigate/setSearchParams call on the same live
212
- // query string instead of letting a stale router snapshot erase ai-run.
213
- window.dispatchEvent(
214
- new PopStateEvent("popstate", { state: window.history.state }),
215
- );
214
+ const id = decodeURIComponent(match[1]);
215
+ return id && !id.includes("/") ? id : null;
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ export function isInvestigationWorkspacePath(pathname: string): boolean {
222
+ return /^\/investigations(?:\/.*)?$/.test(pathname);
223
+ }
224
+
225
+ export function investigationWorkspacePath(runID: string | null): string {
226
+ return runID
227
+ ? `/investigations/${encodeURIComponent(runID)}`
228
+ : "/investigations";
229
+ }
230
+
231
+ export function investigationWorkspaceSearch(search: string): string {
232
+ const current = new URLSearchParams(search);
233
+ const next = new URLSearchParams();
234
+ const org = current.get("org");
235
+ if (org) next.set("org", org);
236
+ const namespaces = current.get("namespaces");
237
+ if (namespaces) next.set("namespaces", namespaces);
238
+ const value = next.toString();
239
+ return value ? `?${value}` : "";
240
+ }
241
+
242
+ function safeWorkspaceReturn(
243
+ state: unknown,
244
+ origin = window.location.origin,
245
+ ): string | null {
246
+ if (!state || typeof state !== "object") return null;
247
+ const value = (state as Record<string, unknown>)[WORKSPACE_RETURN_STATE];
248
+ if (typeof value !== "string" || !value.startsWith("/")) return null;
249
+ try {
250
+ const url = new URL(value, origin);
251
+ if (url.origin !== origin || url.pathname.startsWith("//"))
252
+ return null;
253
+ return `${url.pathname}${url.search}${url.hash}`;
216
254
  } catch {
217
- /* URL APIs unavailable — panel state still works for this session */
255
+ return null;
256
+ }
257
+ }
258
+
259
+ export function investigationWorkspaceRestorePath(
260
+ state: unknown,
261
+ focusedRunID: string | null,
262
+ origin = window.location.origin,
263
+ ): string | null {
264
+ const target = safeWorkspaceReturn(state, origin);
265
+ if (!target) return null;
266
+ const url = new URL(target, origin);
267
+ if (focusedRunID && focusedRunID !== INVALID_WORKSPACE_RUN_ID) {
268
+ url.searchParams.set("ai-run", focusedRunID);
269
+ } else {
270
+ url.searchParams.delete("ai-run");
271
+ }
272
+ return `${url.pathname}${url.search}${url.hash}`;
273
+ }
274
+
275
+ export function shouldExitUnavailableWorkspace(
276
+ pathname: string,
277
+ agentsResolved: boolean,
278
+ eligible: boolean,
279
+ ): boolean {
280
+ return agentsResolved && !eligible && isInvestigationWorkspacePath(pathname);
281
+ }
282
+
283
+ export function investigationWorkspaceNavigationState(options?: {
284
+ returnPath?: string | null;
285
+ drawerOrigin?: boolean;
286
+ closeHistorySteps?: number;
287
+ }): Record<string, string> | undefined {
288
+ const state: Record<string, string> = {};
289
+ if (options?.returnPath) state[WORKSPACE_RETURN_STATE] = options.returnPath;
290
+ if (options?.closeHistorySteps && options.closeHistorySteps > 0) {
291
+ state[WORKSPACE_RETURN_HISTORY_STEPS_STATE] = String(
292
+ options.closeHistorySteps,
293
+ );
218
294
  }
295
+ return Object.keys(state).length > 0 ? state : undefined;
296
+ }
297
+
298
+ function workspaceReturnHistorySteps(state: unknown): number | null {
299
+ if (!state || typeof state !== "object") return null;
300
+ const raw = (state as Record<string, unknown>)[
301
+ WORKSPACE_RETURN_HISTORY_STEPS_STATE
302
+ ];
303
+ if (typeof raw !== "string" || !/^\d+$/.test(raw)) return null;
304
+ const steps = Number(raw);
305
+ return steps > 0 ? steps : null;
219
306
  }
220
307
 
221
308
  // Fleet pages host the panel against a cluster-scoped API while remaining on a
@@ -228,15 +315,29 @@ function diagnoseURLStateEnabled(browserURLState: boolean): boolean {
228
315
  return !clusterScopedAPI || /^\/c\/[^/]+/.test(window.location.pathname);
229
316
  }
230
317
 
231
- export function DiagnoseProvider({
232
- children,
233
- browserURLState = true,
234
- }: {
318
+ interface DiagnoseProviderProps {
235
319
  children: ReactNode;
236
320
  browserURLState?: boolean;
237
- }) {
321
+ forceRouterURLState?: boolean;
322
+ onFocusedRun?: (runID: string) => void;
323
+ }
324
+
325
+ function RoutedDiagnoseProvider({
326
+ children,
327
+ browserURLState = true,
328
+ forceRouterURLState = false,
329
+ onFocusedRun,
330
+ }: DiagnoseProviderProps) {
331
+ const location = useLocation();
332
+ const navigate = useNavigate();
333
+ const locationRef = useRef(location);
334
+ useEffect(() => {
335
+ locationRef.current = location;
336
+ }, [location]);
238
337
  const [available, setAvailable] = useState(false);
239
338
  const [eligible, setEligible] = useState(false);
339
+ const [agentEligibilityResolved, setAgentEligibilityResolved] =
340
+ useState(false);
240
341
  const [agents, setAgents] = useState<AgentInfo[]>([]);
241
342
  const [consented, setConsented] = useState<Record<string, boolean>>({});
242
343
  const [selectedAgent, setSelectedAgentState] = useState<string>(
@@ -264,15 +365,48 @@ export function DiagnoseProvider({
264
365
  // Tracks whether the panel focus belongs to browser history. Fleet opens the
265
366
  // same panel on /issues, where URL state is deliberately disabled; a generic
266
367
  // panel launch must never be closed by the deep-link synchronization effect.
267
- const urlRunIdRef = useRef(runIDFromLocation(browserURLState));
368
+ const urlRunIdRef = useRef<string | null>(null);
369
+ const workspaceRouteRef = useRef(false);
268
370
  const writeFocusedRunID = useCallback(
269
371
  (id: string | null, push: boolean) => {
270
- // Set this before writeRunIDToLocation's synthetic popstate so our own
271
- // listener can distinguish a programmatic close/home write from Back.
272
- if (diagnoseURLStateEnabled(browserURLState)) urlRunIdRef.current = id;
273
- writeRunIDToLocation(id, push, browserURLState);
372
+ if (!forceRouterURLState && !diagnoseURLStateEnabled(browserURLState))
373
+ return;
374
+ const current = locationRef.current;
375
+ urlRunIdRef.current = id;
376
+ if (isInvestigationWorkspacePath(current.pathname)) {
377
+ const returnSteps = workspaceReturnHistorySteps(current.state);
378
+ const state =
379
+ push && returnSteps
380
+ ? {
381
+ ...(current.state && typeof current.state === "object"
382
+ ? current.state
383
+ : {}),
384
+ [WORKSPACE_RETURN_HISTORY_STEPS_STATE]: String(returnSteps + 1),
385
+ }
386
+ : current.state;
387
+ navigate(
388
+ {
389
+ pathname: investigationWorkspacePath(id),
390
+ search: investigationWorkspaceSearch(current.search),
391
+ hash: current.hash,
392
+ },
393
+ { replace: !push, state },
394
+ );
395
+ return;
396
+ }
397
+ const params = new URLSearchParams(current.search);
398
+ if (id) params.set("ai-run", id);
399
+ else params.delete("ai-run");
400
+ navigate(
401
+ {
402
+ pathname: current.pathname,
403
+ search: params.toString(),
404
+ hash: current.hash,
405
+ },
406
+ { replace: !push, state: current.state },
407
+ );
274
408
  },
275
- [browserURLState],
409
+ [browserURLState, forceRouterURLState, navigate],
276
410
  );
277
411
  const [runs, setRuns] = useState<RunSummary[]>([]);
278
412
  const [runsLoaded, setRunsLoaded] = useState(false);
@@ -335,6 +469,7 @@ export function DiagnoseProvider({
335
469
  setEffortState("");
336
470
  writeStored(EFFORT_KEY, "");
337
471
  }
472
+ setAgentEligibilityResolved(true);
338
473
  })
339
474
  .catch(() => {});
340
475
  return () => {
@@ -468,11 +603,30 @@ export function DiagnoseProvider({
468
603
  );
469
604
  }, []);
470
605
 
606
+ // Hosts may need to bridge the focused run into chrome outside RadarApp's
607
+ // router. Notify only after the server has returned a real run summary — a
608
+ // URL-shaped id alone is not evidence that the run exists or is readable.
609
+ const lastNotifiedRunIDRef = useRef<string | null>(null);
610
+ useEffect(() => {
611
+ if (
612
+ !onFocusedRun ||
613
+ !activeRunId ||
614
+ lastNotifiedRunIDRef.current === activeRunId ||
615
+ !runs.some((run) => run.id === activeRunId)
616
+ ) {
617
+ return;
618
+ }
619
+ lastNotifiedRunIDRef.current = activeRunId;
620
+ onFocusedRun(activeRunId);
621
+ }, [activeRunId, onFocusedRun, runs]);
622
+
623
+ const runningRuns = runs.filter(
624
+ (r) => r.status === "running" || r.status === "stopping",
625
+ );
471
626
  // A content-stable signature of the resources with a live investigation,
472
- // so the per-resource Investigate buttons can show a "running" indicator even with the
473
- // panel closed — and only re-render when the set actually changes, not every poll.
474
- const runningSig = runs
475
- .filter((r) => r.status === "running" || r.status === "stopping")
627
+ // so per-resource Investigate buttons only re-render when their live targets
628
+ // change.
629
+ const runningSig = runningRuns
476
630
  .map((r) => runTargetKey(r.kind, r.namespace, r.name, r.group))
477
631
  .sort()
478
632
  // resourceKey itself is pipe-delimited; newlines cannot occur in a
@@ -482,7 +636,8 @@ export function DiagnoseProvider({
482
636
  () => new Set(runningSig ? runningSig.split("\n") : []),
483
637
  [runningSig],
484
638
  );
485
- const hasRunning = runningSig.length > 0;
639
+ const runningCount = runningRuns.length;
640
+ const hasRunning = runningCount > 0;
486
641
 
487
642
  // Keep the run list (statuses, new background runs) fresh while the surface is open
488
643
  // OR while any investigation is still running — so the button indicator stays live
@@ -575,68 +730,217 @@ export function DiagnoseProvider({
575
730
  [updateRunSummary, writeFocusedRunID],
576
731
  );
577
732
 
578
- // The run id is durable navigation state: direct loads and popstate focus the
579
- // exact run, and the query remains in place so the address bar is copyable.
580
- // Fetch by id rather than assuming the bounded recent list contains it.
733
+ // Browser navigation is the source of truth for presentation: a dedicated
734
+ // /investigations route is the full workspace, while ?ai-run keeps the
735
+ // contextual drawer on its underlying page. Fetch exact ids rather than
736
+ // assuming the bounded recent list contains them.
581
737
  useEffect(() => {
582
- if (!available || !diagnoseURLStateEnabled(browserURLState)) return;
583
- const focusFromLocation = (fromPopState: boolean) => {
584
- const id = runIDFromLocation(browserURLState);
585
- if (!id) {
586
- // A missing id on first mount is ordinary. On popstate it means "back
587
- // out of this investigation" only when the focused run was itself
588
- // installed by URL history; unrelated synthetic popstate events must
589
- // not tear down a panel opened by an in-app action.
590
- if (fromPopState && urlRunIdRef.current) {
591
- setActiveRunId(null);
592
- setView("home");
593
- setOpen(false);
594
- }
595
- urlRunIdRef.current = null;
596
- return;
738
+ if (!forceRouterURLState && !diagnoseURLStateEnabled(browserURLState))
739
+ return;
740
+ const workspace = isInvestigationWorkspacePath(location.pathname);
741
+ // Eligibility is unresolved until the agent probe returns. Once it has
742
+ // definitively resolved to off, workspace routes cannot render anything
743
+ // useful; return to the app instead of leaving an eternal loading panel.
744
+ if (agentEligibilityResolved && !eligible) {
745
+ setActiveRunId(null);
746
+ setView("home");
747
+ setOpen(false);
748
+ setMaximized(false);
749
+ if (
750
+ shouldExitUnavailableWorkspace(
751
+ location.pathname,
752
+ agentEligibilityResolved,
753
+ eligible,
754
+ )
755
+ )
756
+ navigate(
757
+ {
758
+ pathname: "/",
759
+ search: investigationWorkspaceSearch(location.search),
760
+ },
761
+ { replace: true },
762
+ );
763
+ return;
764
+ }
765
+ const id = workspace
766
+ ? workspaceRunIDFromPath(location.pathname)
767
+ : runIDFromSearch(location.search);
768
+ const invalidWorkspaceRun =
769
+ workspace &&
770
+ !id &&
771
+ location.pathname.replace(/\/+$/, "") !== "/investigations";
772
+ const previouslyURLFocused = urlRunIdRef.current;
773
+ const wasWorkspace = workspaceRouteRef.current;
774
+ workspaceRouteRef.current = workspace;
775
+
776
+ setMaximized(workspace);
777
+ const workspaceChanged = wasWorkspace !== workspace;
778
+ if (id && id === previouslyURLFocused && !workspaceChanged) return;
779
+ if (!id) {
780
+ urlRunIdRef.current = null;
781
+ if (invalidWorkspaceRun) {
782
+ setActiveRunId(INVALID_WORKSPACE_RUN_ID);
783
+ setView("investigation");
784
+ setOpen(true);
785
+ } else if (workspace) {
786
+ setActiveRunId(null);
787
+ setView("home");
788
+ setOpen(true);
789
+ } else if (previouslyURLFocused || wasWorkspace) {
790
+ setActiveRunId(null);
791
+ setView("home");
792
+ setOpen(false);
597
793
  }
598
- urlRunIdRef.current = id;
599
- unavailableRunIDsRef.current.delete(id);
600
- setStartError(null);
794
+ return;
795
+ }
796
+
797
+ if (!available) {
798
+ // Preserve the URL-selected detail while agent availability is still
799
+ // resolving. Leaving urlRunIdRef untouched ensures the availability
800
+ // transition reruns this effect and fetches the exact run.
601
801
  setActiveRunId(id);
602
802
  setView("investigation");
603
803
  setOpen(true);
604
- getRun(id)
605
- .then(updateRunSummary)
606
- .catch((error) => {
607
- if (error instanceof DiagnoseError && error.status === 404) {
608
- unavailableRunIDsRef.current.add(id);
609
- }
610
- // The list load owns the final missing/degraded state and keeps retrying.
611
- });
612
- };
613
- focusFromLocation(false);
614
- const onPopState = () => focusFromLocation(true);
615
- window.addEventListener("popstate", onPopState);
616
- return () => window.removeEventListener("popstate", onPopState);
617
- }, [available, browserURLState, updateRunSummary]);
804
+ return;
805
+ }
806
+
807
+ urlRunIdRef.current = id;
808
+ unavailableRunIDsRef.current.delete(id);
809
+ setStartError(null);
810
+ setActiveRunId(id);
811
+ setView("investigation");
812
+ setOpen(true);
813
+ getRun(id)
814
+ .then(updateRunSummary)
815
+ .catch((error) => {
816
+ if (error instanceof DiagnoseError && error.status === 404) {
817
+ unavailableRunIDsRef.current.add(id);
818
+ }
819
+ // The list load owns the final missing/degraded state and keeps retrying.
820
+ });
821
+ }, [
822
+ available,
823
+ agentEligibilityResolved,
824
+ browserURLState,
825
+ eligible,
826
+ forceRouterURLState,
827
+ location.pathname,
828
+ location.search,
829
+ navigate,
830
+ updateRunSummary,
831
+ writeFocusedRunID,
832
+ ]);
833
+
618
834
  // Leaving the detail pane drops the failure that belonged to it. startError
619
835
  // renders as the entire pane (maximized home still shows `detail`), where a
620
836
  // message about a resource you just navigated away from has nothing to attach
621
837
  // to and no way to be dismissed.
622
838
  const openHome = useCallback(() => {
623
839
  unavailableRunIDsRef.current.clear();
840
+ setActiveRunId(null);
624
841
  setView("home");
625
842
  setStartError(null);
626
843
  setOpen(true);
627
844
  writeFocusedRunID(null, false);
628
845
  }, [writeFocusedRunID]);
846
+ const openWorkspace = useCallback(
847
+ (preferredRunID?: string | null) => {
848
+ setOpen(true);
849
+ setStartError(null);
850
+ if (!forceRouterURLState && !diagnoseURLStateEnabled(browserURLState)) {
851
+ if (preferredRunID) {
852
+ setActiveRunId(preferredRunID);
853
+ setView("investigation");
854
+ } else {
855
+ setActiveRunId(null);
856
+ setView("home");
857
+ }
858
+ setMaximized(true);
859
+ return;
860
+ }
861
+ const current = locationRef.current;
862
+ // The global entry remains visible in the workspace. Treat clicking it
863
+ // there as an idempotent reveal rather than pushing another workspace
864
+ // entry and replacing the page that Close should return to.
865
+ if (isInvestigationWorkspacePath(current.pathname)) {
866
+ setMaximized(true);
867
+ return;
868
+ }
869
+ // Global entry calls this without a run and always lands on the fresh Home.
870
+ // Expanding a docked detail passes its focused run explicitly so the
871
+ // presentation change does not discard the user's current context.
872
+ const runID = preferredRunID ?? null;
873
+ const state = {
874
+ ...(current.state && typeof current.state === "object"
875
+ ? current.state
876
+ : {}),
877
+ [WORKSPACE_RETURN_STATE]: `${current.pathname}${current.search}${current.hash}`,
878
+ };
879
+ navigate(
880
+ {
881
+ pathname: investigationWorkspacePath(runID),
882
+ search: investigationWorkspaceSearch(current.search),
883
+ },
884
+ { state },
885
+ );
886
+ },
887
+ [browserURLState, forceRouterURLState, navigate],
888
+ );
889
+ const routerURLStateEnabled =
890
+ forceRouterURLState || diagnoseURLStateEnabled(browserURLState);
891
+ const canRestoreWorkspace = routerURLStateEnabled
892
+ ? isInvestigationWorkspacePath(location.pathname) &&
893
+ !!activeRunId &&
894
+ activeRunId !== INVALID_WORKSPACE_RUN_ID &&
895
+ !!safeWorkspaceReturn(location.state)
896
+ : maximized;
897
+ const restoreWorkspace = useCallback(() => {
898
+ if (!routerURLStateEnabled) {
899
+ setMaximized(false);
900
+ return;
901
+ }
902
+ const current = locationRef.current;
903
+ const target = investigationWorkspaceRestorePath(
904
+ current.state,
905
+ activeRunIdRef.current,
906
+ );
907
+ if (target) navigate(target, { replace: true });
908
+ }, [navigate, routerURLStateEnabled]);
629
909
  const goHome = useCallback(() => {
630
910
  unavailableRunIDsRef.current.clear();
911
+ setActiveRunId(null);
631
912
  setView("home");
632
913
  setStartError(null);
633
914
  writeFocusedRunID(null, false);
634
915
  }, [writeFocusedRunID]);
916
+ const dismissForNavigation = useCallback(() => {
917
+ unavailableRunIDsRef.current.clear();
918
+ setOpen(false);
919
+ setView("home");
920
+ setStartError(null);
921
+ }, []);
635
922
  const close = useCallback(() => {
636
923
  unavailableRunIDsRef.current.clear();
637
924
  setOpen(false);
925
+ const current = locationRef.current;
926
+ if (isInvestigationWorkspacePath(current.pathname)) {
927
+ const historySteps = workspaceReturnHistorySteps(current.state);
928
+ if (historySteps) {
929
+ navigate(-historySteps);
930
+ return;
931
+ }
932
+ const returnPath = safeWorkspaceReturn(current.state);
933
+ if (returnPath) {
934
+ const url = new URL(returnPath, window.location.origin);
935
+ url.searchParams.delete("ai-run");
936
+ navigate(`${url.pathname}${url.search}${url.hash}`, { replace: true });
937
+ } else {
938
+ navigate("/", { replace: true });
939
+ }
940
+ return;
941
+ }
638
942
  writeFocusedRunID(null, false);
639
- }, [writeFocusedRunID]);
943
+ }, [navigate, writeFocusedRunID]);
640
944
  const consentBusyRef = useRef(false);
641
945
  const approveConsent = useCallback(() => {
642
946
  if (consentBusyRef.current) return;
@@ -675,7 +979,8 @@ export function DiagnoseProvider({
675
979
  const cancelConsent = useCallback(() => {
676
980
  setPendingTarget(null);
677
981
  setConsentError(null);
678
- setOpen(false);
982
+ setOpen(true);
983
+ if (!activeRunIdRef.current) setView("home");
679
984
  }, []);
680
985
  const dismissError = useCallback(() => setStartError(null), []);
681
986
 
@@ -712,8 +1017,12 @@ export function DiagnoseProvider({
712
1017
  openInvestigation,
713
1018
  openRun,
714
1019
  openHome,
1020
+ openWorkspace,
1021
+ restoreWorkspace,
1022
+ canRestoreWorkspace,
715
1023
  goHome,
716
1024
  close,
1025
+ dismissForNavigation,
717
1026
  approveConsent,
718
1027
  cancelConsent,
719
1028
  refreshRuns,
@@ -729,6 +1038,7 @@ export function DiagnoseProvider({
729
1038
  () => ({
730
1039
  open,
731
1040
  close,
1041
+ dismissForNavigation,
732
1042
  contentGutter,
733
1043
  maximized,
734
1044
  setMaximized,
@@ -738,15 +1048,18 @@ export function DiagnoseProvider({
738
1048
  panelBounds: PANEL_BOUNDS,
739
1049
  panelWidthKey: WIDTH_KEY,
740
1050
  runningKeys,
1051
+ runningCount,
741
1052
  }),
742
1053
  [
743
1054
  open,
744
1055
  close,
1056
+ dismissForNavigation,
745
1057
  contentGutter,
746
1058
  maximized,
747
1059
  width,
748
1060
  narrow,
749
1061
  runningKeys,
1062
+ runningCount,
750
1063
  setMaximized,
751
1064
  setWidth,
752
1065
  ],
@@ -758,3 +1071,15 @@ export function DiagnoseProvider({
758
1071
  </Ctx.Provider>
759
1072
  );
760
1073
  }
1074
+
1075
+ // Standalone consumers may not have a router, while full-app hosts already do.
1076
+ // Supply one only when there is no surrounding routing context.
1077
+ export function DiagnoseProvider(props: DiagnoseProviderProps) {
1078
+ const inRouter = useInRouterContext();
1079
+ if (inRouter) return <RoutedDiagnoseProvider {...props} />;
1080
+ return (
1081
+ <MemoryRouter>
1082
+ <RoutedDiagnoseProvider {...props} />
1083
+ </MemoryRouter>
1084
+ );
1085
+ }