@skyhook-io/radar-app 1.16.0 → 1.16.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 (39) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +5 -4
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +27 -6
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/diagnose.ts +26 -0
  7. package/src/api/httpErrors.ts +12 -0
  8. package/src/api/radarFeatures.render.test.tsx +34 -1
  9. package/src/api/radarFeatures.test.ts +9 -0
  10. package/src/api/radarFeatures.ts +1 -0
  11. package/src/components/ConnectionErrorView.test.tsx +2 -0
  12. package/src/components/ConnectionErrorView.tsx +2 -1
  13. package/src/components/diagnose/AgentControls.tsx +1 -1
  14. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  15. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  16. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  17. package/src/components/diagnose/Home.tsx +1 -1
  18. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  19. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  20. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  21. package/src/components/diagnose/InvestigationView.tsx +199 -65
  22. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  23. package/src/components/diagnose/investigationState.test.ts +83 -1
  24. package/src/components/diagnose/investigationState.ts +43 -0
  25. package/src/components/diagnose/launch.ts +2 -2
  26. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  27. package/src/components/dock/BottomDock.tsx +1 -1
  28. package/src/components/dock/DockContext.tsx +27 -2
  29. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  30. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  31. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  32. package/src/components/logs/LogsViewer.tsx +3 -0
  33. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  34. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  35. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  36. package/src/components/whats-new/releaseNotes.ts +1 -1
  37. package/src/components/workload/WorkloadView.tsx +26 -11
  38. package/src/context/DiagnoseCustomization.tsx +13 -3
  39. package/src/index.ts +4 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.16.0",
3
+ "version": "1.16.2",
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",
@@ -42,7 +42,7 @@
42
42
  "yaml": "^2.9.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@skyhook-io/k8s-ui": ">=1.16.0",
45
+ "@skyhook-io/k8s-ui": ">=1.16.1",
46
46
  "@tanstack/react-query": ">=5",
47
47
  "@xyflow/react": ">=12.0.0",
48
48
  "clsx": ">=2",
package/src/App.tsx CHANGED
@@ -529,8 +529,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
529
529
  navigate(withCrossViewParams(path, location.search))
530
530
  }, [location.search, navigate])
531
531
 
532
- // The standalone rail expresses intent to leave the full-width investigation
533
- // workspace. Close it before routing so the destination is immediately visible;
532
+ // The standalone rail expresses intent to leave the full-width investigations
533
+ // page. Close it before routing so the destination is immediately visible;
534
534
  // docked investigations stay open across views as a persistent side panel.
535
535
  const handlePrimaryNavigate = useCallback((view: ExtendedMainView) => {
536
536
  navigateFromPrimaryRail(
@@ -1890,11 +1890,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1890
1890
 
1891
1891
  {/* Local terminal */}
1892
1892
  {capabilities.localTerminal && (
1893
- <Tooltip content="Open local terminal">
1893
+ <Tooltip content={connection.context || connection.state === 'disconnected' ? 'Open local terminal' : 'Waiting for a context to open a terminal'}>
1894
1894
  <button
1895
1895
  onClick={() => openLocalTerminal()}
1896
+ disabled={!connection.context && connection.state !== 'disconnected'}
1896
1897
  aria-label="Open local terminal"
1897
- className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1898
+ className="p-1.5 rounded-md bg-theme-elevated enabled:hover:bg-theme-hover text-theme-text-secondary enabled:hover:text-theme-text-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
1898
1899
  >
1899
1900
  <SquareTerminal className="w-4 h-4" />
1900
1901
  </button>
package/src/RadarApp.tsx CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  } from "./components/ui/Toast";
35
35
  import { setApiBase, setBasename } from "./api/config";
36
36
  import { shouldRetryRadarQuery } from "./api/radarFeatures";
37
+ import { isShownInline } from "./api/httpErrors";
37
38
  import { NavCustomizationProvider } from "./context/NavCustomization";
38
39
  import { FilterLocationBridge } from "./filter/FilterLocationBridge";
39
40
  import type { NavCustomization } from "./context/NavCustomization";
@@ -49,6 +50,7 @@ import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
49
50
  import type {
50
51
  RenderDiagnoseAction,
51
52
  RenderInvestigationRunActions,
53
+ RenderInvestigationRefusalAction,
52
54
  DiagnoseConsentCopy,
53
55
  } from "./context/DiagnoseCustomization";
54
56
  import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
@@ -132,6 +134,10 @@ export interface RadarAppProps {
132
134
  renderDiagnoseAction?: RenderDiagnoseAction;
133
135
  /** Host-owned controls for the focused investigation; absent in standalone Radar. */
134
136
  renderInvestigationRunActions?: RenderInvestigationRunActions;
137
+ /** Host action beside a refused investigation start or follow-up, e.g. Radar
138
+ * Cloud's "Upgrade" when the organization has used its investigations.
139
+ * Absent in standalone Radar, whose backend sends no refusal details. */
140
+ renderInvestigationRefusalAction?: RenderInvestigationRefusalAction;
135
141
  /**
136
142
  * Replaces the first-run consent card's trust copy. REQUIRED of any host whose
137
143
  * backend runs the agent somewhere other than the user's own machine — the
@@ -179,7 +185,7 @@ export interface RadarAppProps {
179
185
  // Default QueryClient with the same shape Radar's standalone binary uses.
180
186
  // Extracted so both standalone + library consumers get identical
181
187
  // toast-on-error / toast-on-success behavior.
182
- function makeDefaultQueryClient(): QueryClient {
188
+ export function makeDefaultQueryClient(): QueryClient {
183
189
  return new QueryClient({
184
190
  defaultOptions: {
185
191
  queries: {
@@ -190,7 +196,7 @@ function makeDefaultQueryClient(): QueryClient {
190
196
  mutationCache: new MutationCache({
191
197
  onError: (error, _variables, _context, mutation) => {
192
198
  const message = mutation.options.meta?.errorMessage;
193
- if (message) showApiError(message, (error as Error).message);
199
+ if (message && !isShownInline(error)) showApiError(message, (error as Error).message);
194
200
  },
195
201
  onSuccess: (_data, _variables, _context, mutation) => {
196
202
  const message = mutation.options.meta?.successMessage;
@@ -224,6 +230,7 @@ export function RadarApp({
224
230
  documentTitleSuffix,
225
231
  renderDiagnoseAction,
226
232
  renderInvestigationRunActions,
233
+ renderInvestigationRefusalAction,
227
234
  diagnoseConsent,
228
235
  initialPath,
229
236
  onClusterLoadStateChange,
@@ -263,6 +270,7 @@ export function RadarApp({
263
270
  value={renderDiagnoseAction ?? defaultDiagnoseAction}
264
271
  consentCopy={diagnoseConsent}
265
272
  renderRunActions={renderInvestigationRunActions}
273
+ renderRefusalAction={renderInvestigationRefusalAction}
266
274
  >
267
275
  <DiagnoseProvider
268
276
  browserURLState={router !== "memory"}
package/src/api/client.ts CHANGED
@@ -59,7 +59,7 @@ import type {
59
59
  } from '../types'
60
60
  import type { GitOpsOperationResponse } from '../types/gitops'
61
61
  import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath, stripBasename } from './config'
62
- import { httpStatusMessage, readErrorBody, readErrorResponse } from './httpErrors'
62
+ import { httpStatusMessage, markShownInline, readErrorBody, readErrorResponse } from './httpErrors'
63
63
  import {
64
64
  RadarFeatureUnsupportedError,
65
65
  guardRadarFeature,
@@ -1782,6 +1782,7 @@ export function useRadarFeature(feature: RadarFeature) {
1782
1782
  const support = radarFeatureSupport(feature, capabilities, currentVersion)
1783
1783
 
1784
1784
  return {
1785
+ support,
1785
1786
  gatedKey: support === 'unsupported' ? ['radar-feature-unsupported'] : [],
1786
1787
  guard: <T,>(request: () => Promise<T>): Promise<T> =>
1787
1788
  guardRadarFeature(feature, support, { currentVersion, latestVersion }, request),
@@ -2946,14 +2947,28 @@ function workloadHistoryPath(kind: string, namespace: string, name: string, grou
2946
2947
  // The newest page of one workload's history: the workload, what it owns, K8s
2947
2948
  // Events about those, and the resources attached to it. Keyed under
2948
2949
  // "changes" so the live-update invalidation refreshes it.
2950
+ // A Radar that predates the scoped history endpoint settles as unsupported;
2951
+ // WorkloadView then falls back to the namespace's changes.
2949
2952
  export function useWorkloadHistory(kind: string, namespace: string, name: string, group?: string, enabled = true) {
2950
- return useQuery<WorkloadHistoryPage>({
2951
- queryKey: ["changes", "workload-history", kind, namespace, name, group ?? ""],
2952
- queryFn: ({ signal }) => fetchJSON(workloadHistoryPath(kind, namespace, name, group), signal),
2953
+ const { guard, gatedKey, support } = useRadarFeature("workloadHistory");
2954
+ const query = useQuery<WorkloadHistoryPage>({
2955
+ queryKey: ["changes", "workload-history", kind, namespace, name, group ?? "", ...gatedKey],
2956
+ queryFn: ({ signal }) =>
2957
+ guard(() => fetchJSON<WorkloadHistoryPage>(workloadHistoryPath(kind, namespace, name, group), signal)),
2953
2958
  staleTime: 5000,
2954
- refetchInterval: CHANGES_REFRESH_INTERVAL_MS,
2959
+ retry: shouldRetryRadarQuery,
2960
+ refetchInterval: (query) =>
2961
+ query.state.error instanceof RadarFeatureUnsupportedError ? false : CHANGES_REFRESH_INTERVAL_MS,
2955
2962
  enabled,
2956
2963
  });
2964
+ // A probe answered by an older Radar settles as unsupported and stops
2965
+ // polling; once /capabilities confirms the feature (the agent was upgraded),
2966
+ // ask again rather than staying on the fallback.
2967
+ const { error, refetch } = query;
2968
+ useEffect(() => {
2969
+ if (support === "supported" && error instanceof RadarFeatureUnsupportedError) void refetch();
2970
+ }, [support, error, refetch]);
2971
+ return query;
2957
2972
  }
2958
2973
 
2959
2974
  export function fetchWorkloadHistoryPage(kind: string, namespace: string, name: string, group: string | undefined, beforeSeq: number): Promise<WorkloadHistoryPage> {
@@ -4077,7 +4092,13 @@ export function useUpdateResource() {
4077
4092
  });
4078
4093
  if (!response.ok) {
4079
4094
  const error = await readErrorBody(response);
4080
- throw new Error(error.error || `HTTP ${response.status}`);
4095
+ const failure = new Error(error.error || `HTTP ${response.status}`);
4096
+ // A reviewed apply's failure is shown in the review itself (the
4097
+ // changed-after-review notice, or the error), so it gets no toast.
4098
+ if (reviewedResourceVersion !== undefined || reviewedContext !== undefined) {
4099
+ markShownInline(failure);
4100
+ }
4101
+ throw failure;
4081
4102
  }
4082
4103
  return response.json();
4083
4104
  },
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // A reviewed YAML apply shows its failure in the review itself, so the global
4
+ // "Failed to update resource" toast would say the same thing twice.
5
+ import { act } from 'react'
6
+ import { createRoot } from 'react-dom/client'
7
+ import { QueryClientProvider } from '@tanstack/react-query'
8
+ import { afterEach, expect, it, vi } from 'vitest'
9
+
10
+ const toasts = vi.hoisted(() => ({ errors: [] as string[] }))
11
+ vi.mock('@skyhook-io/k8s-ui', async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import('@skyhook-io/k8s-ui')>()),
13
+ showApiError: (title: string) => toasts.errors.push(title),
14
+ }))
15
+
16
+ const { makeDefaultQueryClient } = await import('../RadarApp')
17
+ const { useUpdateResource } = await import('./client')
18
+
19
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals()
22
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
23
+ toasts.errors = []
24
+ })
25
+
26
+ async function save(reviewed: boolean) {
27
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(
28
+ JSON.stringify({ error: 'resource changed after review; review the latest version before applying' }),
29
+ { status: 409, headers: { 'Content-Type': 'application/json' } },
30
+ )))
31
+ let mutate: ReturnType<typeof useUpdateResource>['mutateAsync'] | undefined
32
+ function Probe() {
33
+ mutate = useUpdateResource().mutateAsync
34
+ return null
35
+ }
36
+ const host = document.createElement('div')
37
+ const root = createRoot(host)
38
+ await act(async () => {
39
+ root.render(<QueryClientProvider client={makeDefaultQueryClient()}><Probe /></QueryClientProvider>)
40
+ })
41
+ await act(async () => {
42
+ await mutate!({
43
+ kind: 'configmaps', namespace: 'shop', name: 'app', yaml: 'kind: ConfigMap',
44
+ ...(reviewed ? { reviewedResourceVersion: '42', reviewedContext: 'kind-a' } : {}),
45
+ }).catch(() => {})
46
+ })
47
+ act(() => root.unmount())
48
+ }
49
+
50
+ it('does not toast a reviewed apply, whose review shows the failure', async () => {
51
+ await save(true)
52
+ expect(toasts.errors).toEqual([])
53
+ })
54
+
55
+ it('still toasts a save that did not come from a review', async () => {
56
+ await save(false)
57
+ expect(toasts.errors).toEqual(['Failed to update resource'])
58
+ })
@@ -310,6 +310,32 @@ export class DiagnoseError extends Error {
310
310
  }
311
311
  }
312
312
 
313
+ /** A host's refusal of an investigation request, with what would unblock it. */
314
+ export interface InvestigationRefusal {
315
+ status: number;
316
+ /** The sentence shown to the user. */
317
+ message: string;
318
+ code?: string;
319
+ reason?: string;
320
+ action?: string;
321
+ }
322
+
323
+ /** The refusal a DiagnoseError carries, or null when the host sent none
324
+ * (Radar's own backend never does). */
325
+ export function investigationRefusal(
326
+ error: unknown,
327
+ ): InvestigationRefusal | null {
328
+ if (!(error instanceof DiagnoseError)) return null;
329
+ if (!error.code && !error.reason && !error.action) return null;
330
+ return {
331
+ status: error.status,
332
+ message: error.message,
333
+ code: error.code,
334
+ reason: error.reason,
335
+ action: error.action,
336
+ };
337
+ }
338
+
313
339
  const RUNS = () => `${getApiBase()}/diagnose/runs`;
314
340
 
315
341
  // createRun starts a server-side investigation (or focuses a live one for the same
@@ -99,3 +99,15 @@ export function nonJsonErrorMessage(response: Response, text: string): string {
99
99
  export async function readErrorBody(response: Response): Promise<ErrorBody> {
100
100
  return (await readErrorResponse(response)).body
101
101
  }
102
+
103
+ /**
104
+ * Marks a mutation error whose surface already shows it, so the global
105
+ * mutation handler doesn't toast it a second time.
106
+ */
107
+ export function markShownInline(error: Error): void {
108
+ ;(error as Error & { shownInline?: boolean }).shownInline = true
109
+ }
110
+
111
+ export function isShownInline(error: unknown): boolean {
112
+ return error instanceof Error && (error as Error & { shownInline?: boolean }).shownInline === true
113
+ }
@@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client'
4
4
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
5
  import { afterEach, beforeEach, expect, it, vi } from 'vitest'
6
6
  import { getRadarUpgradeRequirement } from '@skyhook-io/k8s-ui'
7
- import { ApiError, isNotFoundError, useApplications, useCapacityPoolDetail, useDrainPlan, usePodEnvironment } from './client'
7
+ import { ApiError, isNotFoundError, useApplications, useCapacityPoolDetail, useDrainPlan, usePodEnvironment, useWorkloadHistory } from './client'
8
8
  import { getApiBase } from './config'
9
9
  import { usePolicyResource } from './policy'
10
10
  import { isRadarFeatureUnsupported } from './radarFeatures'
@@ -188,3 +188,36 @@ it('never asks a Radar older than v1.8 for applications', async () => {
188
188
  expect(element.textContent).toBe('upgrade')
189
189
  expect(asked('/applications')).toBe(false)
190
190
  })
191
+
192
+ function History() {
193
+ const { data, error } = useWorkloadHistory('deployments', 'shop', 'web', 'apps')
194
+ return <span>{data ? 'data' : isRadarFeatureUnsupported(error, 'workloadHistory') ? 'fallback' : 'pending'}</span>
195
+ }
196
+
197
+ it('never asks a Radar whose capabilities omit workload history', async () => {
198
+ client.setQueryData(['capabilities'], { features: { policyResource: true } })
199
+ await render(<History />)
200
+ expect(element.textContent).toBe('fallback')
201
+ expect(asked('/history')).toBe(false)
202
+ })
203
+
204
+ it("reads the router's 404 on workload history as unsupported before capabilities answer", async () => {
205
+ host.radarVersion = undefined
206
+ chiRoutes = ['/workloads/deployments/shop/web/history']
207
+ await render(<History />)
208
+ expect(element.textContent).toBe('fallback')
209
+ expect(requested.filter((url) => url.includes('/history'))).toHaveLength(1)
210
+ })
211
+
212
+ it('asks again once capabilities confirm workload history after a probe hit an older Radar', async () => {
213
+ host.radarVersion = undefined
214
+ chiRoutes = ['/workloads/deployments/shop/web/history']
215
+ await render(<History />)
216
+ expect(element.textContent).toBe('fallback')
217
+
218
+ chiRoutes = []
219
+ await act(async () => { client.setQueryData(['capabilities'], { features: { workloadHistory: true } }) })
220
+ await settle()
221
+ await settle()
222
+ expect(requested.filter((url) => url.includes('/history'))).toHaveLength(2)
223
+ })
@@ -56,6 +56,15 @@ describe('radarFeatureSupport', () => {
56
56
  })
57
57
  })
58
58
 
59
+ describe('workload history, whose flag shipped with its endpoint', () => {
60
+ it('trusts a loaded capabilities answer, and probes before it arrives', () => {
61
+ expect(radarFeatureSupport('workloadHistory', { features: { workloadHistory: true } }, undefined)).toBe('supported')
62
+ expect(radarFeatureSupport('workloadHistory', { features: { policyResource: true } }, 'v1.15.0')).toBe('unsupported')
63
+ expect(radarFeatureSupport('workloadHistory', { features: {} }, 'dev')).toBe('unsupported')
64
+ expect(radarFeatureSupport('workloadHistory', undefined, 'v1.15.0')).toBe('unknown')
65
+ })
66
+ })
67
+
59
68
  describe('radarSpecSupport for a feature whose flag shipped with its endpoint', () => {
60
69
  const driftAlerts: RadarFeatureSpec = { label: 'Drift alerts', flag: 'resourceIssues', flagShippedWithEndpoint: true }
61
70
 
@@ -34,6 +34,7 @@ export const RADAR_FEATURES = {
34
34
  upgradeReadiness: { label: 'Upgrade impact reports', minimumVersion: 'v1.9.0' },
35
35
  drainPlan: { label: 'Drain plans', minimumVersion: 'v1.14.0' },
36
36
  applications: { label: 'Applications', minimumVersion: 'v1.7.7' },
37
+ workloadHistory: { label: 'Workload history', flag: 'workloadHistory', flagShippedWithEndpoint: true },
37
38
  } as const satisfies Record<string, RadarFeatureSpec>
38
39
 
39
40
  export type RadarFeature = keyof typeof RADAR_FEATURES
@@ -12,6 +12,8 @@ const capabilitiesMock = vi.hoisted(() => ({ localTerminal: true }))
12
12
 
13
13
  vi.mock('@skyhook-io/k8s-ui', () => ({
14
14
  ClusterName: ({ name }: { name: string }) => <span>{name}</span>,
15
+ }))
16
+ vi.mock('./dock/DockContext', () => ({
15
17
  useOpenLocalTerminal: () => vi.fn(),
16
18
  }))
17
19
  vi.mock('../api/client', () => ({
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react'
3
3
  import type { ConnectionState } from '../context/ConnectionContext'
4
4
  import { ContextSwitcher } from './ContextSwitcher'
5
5
  import { parseContextName } from '../utils/context-name'
6
- import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
6
+ import { ClusterName } from '@skyhook-io/k8s-ui'
7
+ import { useOpenLocalTerminal } from './dock/DockContext'
7
8
  import { useAuthMe, useContexts } from '../api/client'
8
9
  import { Tooltip } from './ui/Tooltip'
9
10
  import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
@@ -13,7 +13,7 @@ const OPENCODE_FULL_LOCAL_WARNING =
13
13
  "Radar runs OpenCode with --auto, which automatically approves actions that your configuration would normally ask about, including built-in tools and configured MCP servers. Explicit denials still apply. Radar does not enforce a CLI sandbox.";
14
14
 
15
15
  const CURSOR_FULL_LOCAL_WARNING =
16
- "Radar passes Cursor --force, which auto-approves its built-in tools and every MCP server it loads, including your global servers. Cursor’s sandbox does not reliably confine those tools to Radar’s temporary workspace.";
16
+ "Radar passes Cursor --force, which auto-approves its built-in tools and every MCP server it loads, including your global servers. Cursor’s sandbox does not reliably confine those tools to Radar’s temporary working directory.";
17
17
 
18
18
  // Segmented two-or-more-way selector — shared shape for the agent and execution
19
19
  // profile pickers.
@@ -11,7 +11,12 @@ import {
11
11
  Search,
12
12
  ListChecks,
13
13
  } from "lucide-react";
14
- import type { Diagnosis, ApplyMutationOutcome } from "../../api/diagnose";
14
+ import type {
15
+ Diagnosis,
16
+ ApplyMutationOutcome,
17
+ InvestigationRefusal,
18
+ } from "../../api/diagnose";
19
+ import { InvestigationRefusalAction } from "./InvestigationRefusalAction";
15
20
  import { Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
16
21
  import { Tooltip } from "../ui/Tooltip";
17
22
  import type {
@@ -669,6 +674,8 @@ export type AssessmentExplanation = {
669
674
  status: "idle" | "running" | "done" | "error";
670
675
  text?: string;
671
676
  error?: string;
677
+ /** The host's refusal behind error, for its action beside the message. */
678
+ refusal?: InvestigationRefusal | null;
672
679
  onGenerate?: () => void;
673
680
  openRequest?: number;
674
681
  };
@@ -847,6 +854,9 @@ function AssessmentDetails({
847
854
  {explanation.error ||
848
855
  "The agent did not return an explanation."}
849
856
  </span>
857
+ <InvestigationRefusalAction
858
+ refusal={explanation.refusal ?? null}
859
+ />
850
860
  {explanation.onGenerate && (
851
861
  <button
852
862
  type="button"
@@ -28,9 +28,11 @@ import {
28
28
  createRun,
29
29
  recordConsent,
30
30
  DiagnoseError,
31
+ investigationRefusal,
31
32
  } from "../../api/diagnose";
32
33
  import { getApiBase } from "../../api/config";
33
34
  import {
35
+ type InvestigationRefusal,
34
36
  type RunSummary,
35
37
  type AgentInfo,
36
38
  type ExecutionProfile,
@@ -86,6 +88,9 @@ interface DiagnoseCtx {
86
88
  historyDegraded: boolean; // persistence broke — history won't survive a restart
87
89
  needsConsent: boolean; // a start is pending the one-time consent
88
90
  startError: string | null;
91
+ // The host's refusal behind startError, when it sent one, so the surface can
92
+ // offer the host's action beside the message.
93
+ startRefusal: InvestigationRefusal | null;
89
94
  // Kept apart from startError: the consent card renders this as "why your
90
95
  // approval was refused", and a run-start failure landing in the same slot
91
96
  // would be read as exactly that — the two paths don't share a lifecycle.
@@ -426,7 +431,10 @@ function RoutedDiagnoseProvider({
426
431
  const [runsLoadFailed, setRunsLoadFailed] = useState(false);
427
432
  const [historyDegraded, setHistoryDegraded] = useState(false);
428
433
  const [pendingTarget, setPendingTarget] = useState<Target | null>(null);
429
- const [startError, setStartError] = useState<string | null>(null);
434
+ const [startFailure, setStartFailure] = useState<{
435
+ message: string;
436
+ refusal: InvestigationRefusal | null;
437
+ } | null>(null);
430
438
  const [consentError, setConsentError] = useState<string | null>(null);
431
439
  const [width, setWidth] = useState<number>(() => {
432
440
  try {
@@ -694,24 +702,28 @@ function RoutedDiagnoseProvider({
694
702
  })
695
703
  .catch((e) => {
696
704
  if (seq !== startSeqRef.current) return;
697
- setStartError(
698
- e instanceof DiagnoseError
699
- ? e.message
700
- : "Couldn't start the investigation.",
701
- );
705
+ setStartFailure({
706
+ message:
707
+ e instanceof DiagnoseError
708
+ ? e.message
709
+ : "Couldn't start the investigation.",
710
+ refusal: investigationRefusal(e),
711
+ });
702
712
  });
703
713
  };
704
714
 
705
715
  const openInvestigation = useCallback(
706
716
  (t: Target) => {
707
- setStartError(null);
717
+ setStartFailure(null);
708
718
  setConsentError(null);
709
719
  setOpen(true);
710
720
  if (!hosted && !consentSurface) {
711
721
  setPendingTarget(null);
712
- setStartError(
713
- "Radar can’t run this agent with a verified execution profile.",
714
- );
722
+ setStartFailure({
723
+ message:
724
+ "Radar can’t run this agent with a verified execution profile.",
725
+ refusal: null,
726
+ });
715
727
  setView("investigation");
716
728
  return;
717
729
  }
@@ -728,7 +740,7 @@ function RoutedDiagnoseProvider({
728
740
  const openRun = useCallback(
729
741
  (id: string) => {
730
742
  unavailableRunIDsRef.current.delete(id);
731
- setStartError(null);
743
+ setStartFailure(null);
732
744
  setActiveRunId(id);
733
745
  setView("investigation");
734
746
  setOpen(true);
@@ -748,7 +760,7 @@ function RoutedDiagnoseProvider({
748
760
  );
749
761
 
750
762
  // Browser navigation is the source of truth for presentation: a dedicated
751
- // /investigations route is the full workspace, while ?ai-run keeps the
763
+ // /investigations route is the full investigations page, while ?ai-run keeps the
752
764
  // contextual drawer on its underlying page. Fetch exact ids rather than
753
765
  // assuming the bounded recent list contains them.
754
766
  useEffect(() => {
@@ -756,7 +768,7 @@ function RoutedDiagnoseProvider({
756
768
  return;
757
769
  const workspace = isInvestigationWorkspacePath(location.pathname);
758
770
  // Eligibility is unresolved until the agent probe returns. Once it has
759
- // definitively resolved to off, workspace routes cannot render anything
771
+ // definitively resolved to off, investigations-page routes cannot render anything
760
772
  // useful; return to the app instead of leaving an eternal loading panel.
761
773
  if (agentEligibilityResolved && !eligible) {
762
774
  setActiveRunId(null);
@@ -823,7 +835,7 @@ function RoutedDiagnoseProvider({
823
835
 
824
836
  urlRunIdRef.current = id;
825
837
  unavailableRunIDsRef.current.delete(id);
826
- setStartError(null);
838
+ setStartFailure(null);
827
839
  setActiveRunId(id);
828
840
  setView("investigation");
829
841
  setOpen(true);
@@ -856,14 +868,14 @@ function RoutedDiagnoseProvider({
856
868
  unavailableRunIDsRef.current.clear();
857
869
  setActiveRunId(null);
858
870
  setView("home");
859
- setStartError(null);
871
+ setStartFailure(null);
860
872
  setOpen(true);
861
873
  writeFocusedRunID(null, false);
862
874
  }, [writeFocusedRunID]);
863
875
  const openWorkspace = useCallback(
864
876
  (preferredRunID?: string | null) => {
865
877
  setOpen(true);
866
- setStartError(null);
878
+ setStartFailure(null);
867
879
  if (!forceRouterURLState && !diagnoseURLStateEnabled(browserURLState)) {
868
880
  if (preferredRunID) {
869
881
  setActiveRunId(preferredRunID);
@@ -876,8 +888,8 @@ function RoutedDiagnoseProvider({
876
888
  return;
877
889
  }
878
890
  const current = locationRef.current;
879
- // The global entry remains visible in the workspace. Treat clicking it
880
- // there as an idempotent reveal rather than pushing another workspace
891
+ // The global entry remains visible on the investigations page. Treat clicking it
892
+ // there as an idempotent reveal rather than pushing another page
881
893
  // entry and replacing the page that Close should return to.
882
894
  if (isInvestigationWorkspacePath(current.pathname)) {
883
895
  setMaximized(true);
@@ -927,14 +939,14 @@ function RoutedDiagnoseProvider({
927
939
  unavailableRunIDsRef.current.clear();
928
940
  setActiveRunId(null);
929
941
  setView("home");
930
- setStartError(null);
942
+ setStartFailure(null);
931
943
  writeFocusedRunID(null, false);
932
944
  }, [writeFocusedRunID]);
933
945
  const dismissForNavigation = useCallback(() => {
934
946
  unavailableRunIDsRef.current.clear();
935
947
  setOpen(false);
936
948
  setView("home");
937
- setStartError(null);
949
+ setStartFailure(null);
938
950
  }, []);
939
951
  const close = useCallback(() => {
940
952
  unavailableRunIDsRef.current.clear();
@@ -999,7 +1011,7 @@ function RoutedDiagnoseProvider({
999
1011
  setOpen(true);
1000
1012
  if (!activeRunIdRef.current) setView("home");
1001
1013
  }, []);
1002
- const dismissError = useCallback(() => setStartError(null), []);
1014
+ const dismissError = useCallback(() => setStartFailure(null), []);
1003
1015
 
1004
1016
  // Reserve a right gutter on the CONTENT area (not the navbar/rail — those stay
1005
1017
  // global and static) so docked content reflows beside the panel. Wide viewports
@@ -1029,7 +1041,8 @@ function RoutedDiagnoseProvider({
1029
1041
  // pendingTarget is set ONLY when the current agent's consent is missing, and
1030
1042
  // cleared on approve/cancel — so its presence is exactly "consent needed now".
1031
1043
  needsConsent: !!pendingTarget,
1032
- startError,
1044
+ startError: startFailure?.message ?? null,
1045
+ startRefusal: startFailure?.refusal ?? null,
1033
1046
  consentError,
1034
1047
  openInvestigation,
1035
1048
  openRun,
@@ -1,6 +1,6 @@
1
1
  // The right-docked shell of the AI surface. Two layouts:
2
2
  // - docked: a single-pane right column (app reflows left via the provider's push)
3
- // - expanded: a master-detail workspace that fills ONLY the content area (does
3
+ // - expanded: the investigations page, a master-detail layout that fills ONLY the content area (does
4
4
  // not cover the left nav rail or top bar) — recent list on the left, the
5
5
  // selected investigation/report on the right.
6
6
  import {
@@ -38,6 +38,7 @@ import {
38
38
  type DiagnoseView,
39
39
  } from "./DiagnoseContext";
40
40
  import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
41
+ import { InvestigationRefusalAction } from "./InvestigationRefusalAction";
41
42
  import { useNavCustomization } from "../../context/NavCustomization";
42
43
  import { InvestigationView } from "./InvestigationView";
43
44
  import type { InvestigationTimelineScope } from "./InvestigationEvidencePane";
@@ -419,7 +420,7 @@ export function DiagnoseSurface({
419
420
  panelWidthKey: widthKey,
420
421
  } = useDiagnoseLayout();
421
422
  // Home has no detail pane competing for width, so its history is always part
422
- // of the workspace. Only a focused run collapses history into a drawer when
423
+ // of the page. Only a focused run collapses history into a drawer when
423
424
  // the two-pane layout would become cramped.
424
425
  const persistentHistory = investigationHistoryIsPersistent({
425
426
  maximized,
@@ -604,12 +605,15 @@ export function DiagnoseSurface({
604
605
  ) : d.startError ? (
605
606
  <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
606
607
  <p className="text-sm text-theme-text-secondary">{d.startError}</p>
607
- <button
608
- onClick={d.dismissError}
609
- className="rounded-lg border border-theme-border px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
610
- >
611
- Dismiss
612
- </button>
608
+ <div className="flex flex-wrap items-center justify-center gap-2">
609
+ <InvestigationRefusalAction refusal={d.startRefusal} />
610
+ <button
611
+ onClick={d.dismissError}
612
+ className="rounded-lg border border-theme-border px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
613
+ >
614
+ Dismiss
615
+ </button>
616
+ </div>
613
617
  </div>
614
618
  ) : setupPending ? (
615
619
  <div className="flex-1 overflow-y-auto">
@@ -1,5 +1,5 @@
1
1
  // Server-side runs keep background and running investigations visible in both
2
- // the docked Home view and the maximized workspace's master pane.
2
+ // the docked Home view and the investigations page's list pane.
3
3
  import {
4
4
  ArrowRight,
5
5
  CircleAlert,
@@ -204,7 +204,7 @@ export function InvestigationEvidencePane({
204
204
  : undefined;
205
205
 
206
206
  // A source link is a navigation request, not a disclosure preference. Open
207
- // whichever collection owns the source first, then tell the workspace that
207
+ // whichever collection owns the source first, then tell the investigation view that
208
208
  // its double-rAF focus/scroll can safely run outside an inert subtree.
209
209
  useLayoutEffect(() => {
210
210
  if (