@skyhook-io/radar-app 1.16.1 → 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.
- package/package.json +1 -1
- package/src/App.tsx +5 -4
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +8 -2
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/diagnose/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- package/src/components/whats-new/releaseNotes.ts +1 -1
- package/src/context/DiagnoseCustomization.tsx +13 -3
- package/src/index.ts +4 -2
package/package.json
CHANGED
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
|
|
533
|
-
//
|
|
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=
|
|
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,
|
|
@@ -4092,7 +4092,13 @@ export function useUpdateResource() {
|
|
|
4092
4092
|
});
|
|
4093
4093
|
if (!response.ok) {
|
|
4094
4094
|
const error = await readErrorBody(response);
|
|
4095
|
-
|
|
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;
|
|
4096
4102
|
}
|
|
4097
4103
|
return response.json();
|
|
4098
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
|
+
})
|
package/src/api/diagnose.ts
CHANGED
|
@@ -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
|
package/src/api/httpErrors.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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 [
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
717
|
+
setStartFailure(null);
|
|
708
718
|
setConsentError(null);
|
|
709
719
|
setOpen(true);
|
|
710
720
|
if (!hosted && !consentSurface) {
|
|
711
721
|
setPendingTarget(null);
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
880
|
-
// there as an idempotent reveal rather than pushing another
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
|
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
|
|
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
|
-
<
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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">
|
|
@@ -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
|
|
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 (
|