@skyhook-io/radar-app 0.2.2 → 0.3.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/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// The single controller for the AI assistant surface. One instance app-wide:
|
|
2
|
+
// the per-resource "Diagnose" button and the global top-bar entry both dispatch
|
|
3
|
+
// here. Investigations are durable, server-side jobs (see internal/ai RunManager);
|
|
4
|
+
// this provider lists them, tracks which one is focused, and owns the push-content
|
|
5
|
+
// layout. The run lifetime is the server's, so closing/navigating never kills one.
|
|
6
|
+
import {
|
|
7
|
+
createContext,
|
|
8
|
+
useCallback,
|
|
9
|
+
useContext,
|
|
10
|
+
useEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
type Dispatch,
|
|
15
|
+
type ReactNode,
|
|
16
|
+
type SetStateAction,
|
|
17
|
+
} from "react";
|
|
18
|
+
import {
|
|
19
|
+
fetchAgents,
|
|
20
|
+
listRuns,
|
|
21
|
+
createRun,
|
|
22
|
+
recordConsent,
|
|
23
|
+
DiagnoseError,
|
|
24
|
+
} from "../../api/diagnose";
|
|
25
|
+
import { type RunSummary, type AgentInfo } from "../../api/diagnose";
|
|
26
|
+
|
|
27
|
+
export interface Target {
|
|
28
|
+
kind: string;
|
|
29
|
+
namespace: string;
|
|
30
|
+
name: string;
|
|
31
|
+
}
|
|
32
|
+
export type DiagnoseView = "home" | "investigation";
|
|
33
|
+
|
|
34
|
+
interface DiagnoseCtx {
|
|
35
|
+
available: boolean; // an agent CLI is present (button/entry gate)
|
|
36
|
+
agentLabel: string; // label of the selected agent, e.g. "Claude Code"
|
|
37
|
+
hosted: boolean; // selected agent runs on the host's backend, not this machine
|
|
38
|
+
agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
|
|
39
|
+
selectedAgent: string; // name of the chosen backend ("claude"/"codex")
|
|
40
|
+
setSelectedAgent: (name: string) => void;
|
|
41
|
+
isolated: boolean; // run the agent without the user's own CLI config
|
|
42
|
+
setIsolated: (v: boolean) => void;
|
|
43
|
+
model: string; // optional model override ("" = the agent's own default)
|
|
44
|
+
setModel: (v: string) => void;
|
|
45
|
+
effort: string; // optional Codex reasoning effort ("" = default)
|
|
46
|
+
setEffort: (v: string) => void;
|
|
47
|
+
view: DiagnoseView;
|
|
48
|
+
activeRunId: string | null;
|
|
49
|
+
runs: RunSummary[];
|
|
50
|
+
runsLoaded: boolean; // first runs fetch landed (gates missing-run states)
|
|
51
|
+
runsLoadFailed: boolean; // latest runs fetch failed (poll keeps retrying)
|
|
52
|
+
historyDegraded: boolean; // persistence broke — history won't survive a restart
|
|
53
|
+
needsConsent: boolean; // a start is pending the one-time consent
|
|
54
|
+
startError: string | null;
|
|
55
|
+
openInvestigation: (t: Target) => void;
|
|
56
|
+
openRun: (id: string) => void;
|
|
57
|
+
openHome: () => void;
|
|
58
|
+
goHome: () => void;
|
|
59
|
+
close: () => void;
|
|
60
|
+
approveConsent: () => void;
|
|
61
|
+
cancelConsent: () => void;
|
|
62
|
+
refreshRuns: () => void;
|
|
63
|
+
dismissError: () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Layout state is a SEPARATE context from the business state above. The app shell
|
|
67
|
+
// (App) consumes only this to position the panel + reserve the content gutter, and
|
|
68
|
+
// its value is memoized on layout-only deps — so the panel's 4s run-poll (which
|
|
69
|
+
// churns the business context) doesn't re-render the whole shell.
|
|
70
|
+
interface DiagnoseLayoutCtx {
|
|
71
|
+
open: boolean;
|
|
72
|
+
contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
|
|
73
|
+
maximized: boolean;
|
|
74
|
+
setMaximized: Dispatch<SetStateAction<boolean>>;
|
|
75
|
+
panelWidth: number;
|
|
76
|
+
setPanelWidth: Dispatch<SetStateAction<number>>;
|
|
77
|
+
panelNarrow: boolean; // viewport too tight to push → overlay
|
|
78
|
+
panelBounds: { min: number; max: number };
|
|
79
|
+
panelWidthKey: string;
|
|
80
|
+
runningKeys: ReadonlySet<string>; // resources with a live investigation (see runTargetKey)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Stable key for "is THIS resource being investigated right now" — built the same way
|
|
84
|
+
// from a run summary and from a button's target so the two always match.
|
|
85
|
+
export function runTargetKey(kind: string, namespace: string, name: string): string {
|
|
86
|
+
return `${kind} ${namespace} ${name}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const Ctx = createContext<DiagnoseCtx | null>(null);
|
|
90
|
+
const LayoutCtx = createContext<DiagnoseLayoutCtx | null>(null);
|
|
91
|
+
|
|
92
|
+
export function useDiagnoseLayout(): DiagnoseLayoutCtx {
|
|
93
|
+
const c = useContext(LayoutCtx);
|
|
94
|
+
if (!c) throw new Error("useDiagnoseLayout must be used within DiagnoseProvider");
|
|
95
|
+
return c;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function useDiagnose(): DiagnoseCtx {
|
|
99
|
+
const c = useContext(Ctx);
|
|
100
|
+
if (!c) throw new Error("useDiagnose must be used within DiagnoseProvider");
|
|
101
|
+
return c;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const MIN_W = 400;
|
|
105
|
+
const MAX_W = 1100;
|
|
106
|
+
const PANEL_BOUNDS = { min: MIN_W, max: MAX_W }; // stable ref for the layout context
|
|
107
|
+
const WIDTH_KEY = "radar-ai-panel-width";
|
|
108
|
+
// Consent is machine-scoped and lives server-side (~/.radar): it gates a
|
|
109
|
+
// machine-scoped action (spawn this machine's agent CLI, persist transcripts to
|
|
110
|
+
// this machine's disk), so one acknowledgment covers this panel AND the
|
|
111
|
+
// `radar diagnose` CLI. Cursor gets its own surface — its trust model is
|
|
112
|
+
// materially different (the user's global MCP servers can't be excluded), so
|
|
113
|
+
// approving Claude/Codex never bypasses Cursor's distinct disclosure.
|
|
114
|
+
type ConsentSurface = "standard" | "cursor";
|
|
115
|
+
function consentSurfaceFor(agent: string): ConsentSurface {
|
|
116
|
+
return agent === "cursor-agent" ? "cursor" : "standard";
|
|
117
|
+
}
|
|
118
|
+
const AGENT_KEY = "radar-ai-agent";
|
|
119
|
+
const ISOLATED_KEY = "radar-ai-isolated";
|
|
120
|
+
const MODEL_KEY = "radar-ai-model";
|
|
121
|
+
const EFFORT_KEY = "radar-ai-effort";
|
|
122
|
+
// Push (reflow the app left) only while the app keeps at least this much width to
|
|
123
|
+
// the LEFT of the panel (nav rail ~176 + a usable content floor); otherwise overlay.
|
|
124
|
+
// Panel-width-aware on purpose: a static viewport cutoff ignored the (resizable)
|
|
125
|
+
// panel width and could push the app to near-zero. We don't fight to keep every-
|
|
126
|
+
// thing on screen on small displays — below this, the panel floats over instead.
|
|
127
|
+
const MIN_APP_LEFT_OF_PANEL = 900;
|
|
128
|
+
|
|
129
|
+
const AGENT_LABELS: Record<string, string> = {
|
|
130
|
+
claude: "Claude Code",
|
|
131
|
+
codex: "Codex",
|
|
132
|
+
gemini: "Gemini CLI",
|
|
133
|
+
"cursor-agent": "Cursor Agent",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function agentLabelFor(name: string, fallbackLabel?: string): string {
|
|
137
|
+
return AGENT_LABELS[name] || fallbackLabel || name || "your AI agent";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// openDiagnoseSettings opens the Settings dialog (App.tsx listens for this DOM
|
|
141
|
+
// event) — the canonical home for AI-diagnosis config.
|
|
142
|
+
export function openDiagnoseSettings() {
|
|
143
|
+
window.dispatchEvent(new CustomEvent("radar:open-settings"));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readStored(key: string): string | null {
|
|
147
|
+
try {
|
|
148
|
+
return localStorage.getItem(key);
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function writeStored(key: string, value: string) {
|
|
154
|
+
try {
|
|
155
|
+
localStorage.setItem(key, value);
|
|
156
|
+
} catch {
|
|
157
|
+
/* storage disabled — holds for this session */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
162
|
+
const [available, setAvailable] = useState(false);
|
|
163
|
+
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
164
|
+
const [consented, setConsented] = useState<
|
|
165
|
+
Record<ConsentSurface, boolean>
|
|
166
|
+
>({ standard: false, cursor: false });
|
|
167
|
+
const [selectedAgent, setSelectedAgentState] = useState<string>(
|
|
168
|
+
() => readStored(AGENT_KEY) || "",
|
|
169
|
+
);
|
|
170
|
+
const [isolated, setIsolatedState] = useState<boolean>(
|
|
171
|
+
() => readStored(ISOLATED_KEY) !== "0", // default isolated
|
|
172
|
+
);
|
|
173
|
+
const [model, setModelState] = useState<string>(
|
|
174
|
+
() => readStored(MODEL_KEY) || "",
|
|
175
|
+
);
|
|
176
|
+
const [effort, setEffortState] = useState<string>(
|
|
177
|
+
() => readStored(EFFORT_KEY) || "",
|
|
178
|
+
);
|
|
179
|
+
const [open, setOpen] = useState(false);
|
|
180
|
+
const [view, setView] = useState<DiagnoseView>("home");
|
|
181
|
+
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
|
182
|
+
const [runs, setRuns] = useState<RunSummary[]>([]);
|
|
183
|
+
const [runsLoaded, setRunsLoaded] = useState(false);
|
|
184
|
+
const [runsLoadFailed, setRunsLoadFailed] = useState(false);
|
|
185
|
+
const [historyDegraded, setHistoryDegraded] = useState(false);
|
|
186
|
+
const [pendingTarget, setPendingTarget] = useState<Target | null>(null);
|
|
187
|
+
const [startError, setStartError] = useState<string | null>(null);
|
|
188
|
+
const [width, setWidth] = useState<number>(() => {
|
|
189
|
+
try {
|
|
190
|
+
const v = Number(localStorage.getItem(WIDTH_KEY));
|
|
191
|
+
return v >= MIN_W && v <= MAX_W ? v : 560;
|
|
192
|
+
} catch {
|
|
193
|
+
return 560;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
const [maximized, setMaximized] = useState(false);
|
|
197
|
+
const [viewportW, setViewportW] = useState(() =>
|
|
198
|
+
typeof window !== "undefined" ? window.innerWidth : 1920,
|
|
199
|
+
);
|
|
200
|
+
// Too tight to push (given the current, resizable panel width) → overlay instead.
|
|
201
|
+
const narrow = viewportW - width < MIN_APP_LEFT_OF_PANEL;
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
let live = true;
|
|
205
|
+
fetchAgents()
|
|
206
|
+
.then((r) => {
|
|
207
|
+
if (!live) return;
|
|
208
|
+
setAvailable(r.enabled);
|
|
209
|
+
setConsented({
|
|
210
|
+
standard: !!r.consented?.standard,
|
|
211
|
+
cursor: !!r.consented?.cursor,
|
|
212
|
+
});
|
|
213
|
+
const supported = r.agents.filter((a) => a.supported);
|
|
214
|
+
setAgents(supported);
|
|
215
|
+
// Keep the stored pick only if it's still installed; else default to the
|
|
216
|
+
// first supported agent (matches the server's default selection).
|
|
217
|
+
const stored = readStored(AGENT_KEY) || "";
|
|
218
|
+
const next =
|
|
219
|
+
stored && supported.some((a) => a.name === stored)
|
|
220
|
+
? stored
|
|
221
|
+
: (supported[0]?.name ?? "");
|
|
222
|
+
setSelectedAgentState(next);
|
|
223
|
+
// Model/effort are agent-specific; if the stored agent is gone, its values
|
|
224
|
+
// don't apply to the fallback agent (e.g. a Codex slug under Claude) — drop them.
|
|
225
|
+
if (next !== stored) {
|
|
226
|
+
setModelState("");
|
|
227
|
+
writeStored(MODEL_KEY, "");
|
|
228
|
+
setEffortState("");
|
|
229
|
+
writeStored(EFFORT_KEY, "");
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
.catch(() => {});
|
|
233
|
+
return () => {
|
|
234
|
+
live = false;
|
|
235
|
+
};
|
|
236
|
+
}, []);
|
|
237
|
+
|
|
238
|
+
const setModel = useCallback((v: string) => {
|
|
239
|
+
setModelState(v);
|
|
240
|
+
writeStored(MODEL_KEY, v);
|
|
241
|
+
}, []);
|
|
242
|
+
const setEffort = useCallback((v: string) => {
|
|
243
|
+
setEffortState(v);
|
|
244
|
+
writeStored(EFFORT_KEY, v);
|
|
245
|
+
}, []);
|
|
246
|
+
const setSelectedAgent = useCallback(
|
|
247
|
+
(name: string) => {
|
|
248
|
+
setSelectedAgentState(name);
|
|
249
|
+
writeStored(AGENT_KEY, name);
|
|
250
|
+
// Model + effort are agent-specific (Claude aliases vs Codex slugs); reset
|
|
251
|
+
// to the new agent's default rather than carry an invalid value across.
|
|
252
|
+
setModel("");
|
|
253
|
+
setEffort("");
|
|
254
|
+
},
|
|
255
|
+
[setModel, setEffort],
|
|
256
|
+
);
|
|
257
|
+
const setIsolated = useCallback((v: boolean) => {
|
|
258
|
+
setIsolatedState(v);
|
|
259
|
+
writeStored(ISOLATED_KEY, v ? "1" : "0");
|
|
260
|
+
}, []);
|
|
261
|
+
|
|
262
|
+
const agentLabel = agentLabelFor(
|
|
263
|
+
selectedAgent,
|
|
264
|
+
agents.find((a) => a.name === selectedAgent)?.label,
|
|
265
|
+
);
|
|
266
|
+
const hosted = !!agents.find((a) => a.name === selectedAgent)?.hosted;
|
|
267
|
+
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
const onResize = () => setViewportW(window.innerWidth);
|
|
270
|
+
window.addEventListener("resize", onResize);
|
|
271
|
+
return () => window.removeEventListener("resize", onResize);
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
const refreshRuns = useCallback(() => {
|
|
275
|
+
if (!available) return;
|
|
276
|
+
listRuns()
|
|
277
|
+
.then((r) => {
|
|
278
|
+
setRuns(r.runs);
|
|
279
|
+
setRunsLoaded(true);
|
|
280
|
+
setRunsLoadFailed(false);
|
|
281
|
+
setHistoryDegraded(!!r.historyDegraded);
|
|
282
|
+
})
|
|
283
|
+
.catch(() => {
|
|
284
|
+
// Leave runsLoaded false (a missing-run verdict needs a real list) but
|
|
285
|
+
// record the failure so the panel can say "retrying" instead of
|
|
286
|
+
// pretending nothing happened. The 4s poll keeps retrying while open.
|
|
287
|
+
setRunsLoadFailed(true);
|
|
288
|
+
});
|
|
289
|
+
}, [available]);
|
|
290
|
+
|
|
291
|
+
// A content-stable signature of the resources with a live (running) investigation,
|
|
292
|
+
// so the per-resource Diagnose buttons can show a "running" indicator even with the
|
|
293
|
+
// panel closed — and only re-render when the set actually changes, not every poll.
|
|
294
|
+
const runningSig = runs
|
|
295
|
+
.filter((r) => r.status === "running")
|
|
296
|
+
.map((r) => `${r.kind}\x00${r.namespace}\x00${r.name}`)
|
|
297
|
+
.sort()
|
|
298
|
+
.join("|");
|
|
299
|
+
const runningKeys = useMemo(
|
|
300
|
+
() => new Set(runningSig ? runningSig.split("|") : []),
|
|
301
|
+
[runningSig],
|
|
302
|
+
);
|
|
303
|
+
const hasRunning = runningSig.length > 0;
|
|
304
|
+
|
|
305
|
+
// Keep the run list (statuses, new background runs) fresh while the surface is open
|
|
306
|
+
// OR while any investigation is still running — so the button indicator stays live
|
|
307
|
+
// after the panel is closed, and stops polling once everything has settled. One
|
|
308
|
+
// fetch on mount catches runs already in flight from a prior page load.
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (!available) return;
|
|
311
|
+
refreshRuns();
|
|
312
|
+
if (!open && !hasRunning) return;
|
|
313
|
+
const t = setInterval(refreshRuns, 4000);
|
|
314
|
+
return () => clearInterval(t);
|
|
315
|
+
}, [open, available, hasRunning, refreshRuns]);
|
|
316
|
+
|
|
317
|
+
// Keep the live selected agent reachable from the [] -dep callbacks below
|
|
318
|
+
// (consent is agent-specific, so they must read the CURRENT pick, not a closure).
|
|
319
|
+
const selectedAgentRef = useRef(selectedAgent);
|
|
320
|
+
selectedAgentRef.current = selectedAgent;
|
|
321
|
+
const consentedRef = useRef(consented);
|
|
322
|
+
consentedRef.current = consented;
|
|
323
|
+
|
|
324
|
+
// Monotonic token so an earlier createRun that resolves late can't steal focus
|
|
325
|
+
// from a later click on a different resource (only the latest start wins).
|
|
326
|
+
const startSeqRef = useRef(0);
|
|
327
|
+
const startRunRef = useRef<(t: Target) => void>(() => {});
|
|
328
|
+
startRunRef.current = (t: Target) => {
|
|
329
|
+
const seq = ++startSeqRef.current;
|
|
330
|
+
createRun(t, {
|
|
331
|
+
agent: selectedAgent || undefined,
|
|
332
|
+
isolated,
|
|
333
|
+
model: model || undefined,
|
|
334
|
+
effort: effort || undefined,
|
|
335
|
+
})
|
|
336
|
+
.then((run) => {
|
|
337
|
+
setRuns((prev) =>
|
|
338
|
+
prev.some((r) => r.id === run.id) ? prev : [run, ...prev],
|
|
339
|
+
);
|
|
340
|
+
if (seq !== startSeqRef.current) return;
|
|
341
|
+
setActiveRunId(run.id);
|
|
342
|
+
setView("investigation");
|
|
343
|
+
})
|
|
344
|
+
.catch((e) => {
|
|
345
|
+
if (seq !== startSeqRef.current) return;
|
|
346
|
+
setStartError(
|
|
347
|
+
e instanceof DiagnoseError
|
|
348
|
+
? e.message
|
|
349
|
+
: "Couldn't start the investigation.",
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const openInvestigation = useCallback((t: Target) => {
|
|
355
|
+
setStartError(null);
|
|
356
|
+
setOpen(true);
|
|
357
|
+
if (!consentedRef.current[consentSurfaceFor(selectedAgentRef.current)]) {
|
|
358
|
+
setPendingTarget(t);
|
|
359
|
+
setView("investigation");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
setView("investigation");
|
|
363
|
+
startRunRef.current(t);
|
|
364
|
+
}, []);
|
|
365
|
+
const openRun = useCallback((id: string) => {
|
|
366
|
+
setStartError(null);
|
|
367
|
+
setActiveRunId(id);
|
|
368
|
+
setView("investigation");
|
|
369
|
+
setOpen(true);
|
|
370
|
+
}, []);
|
|
371
|
+
|
|
372
|
+
// Deep link: ?ai-run=<id> opens the panel focused on that investigation —
|
|
373
|
+
// the URL `radar diagnose --open` prints/opens. Consumed once (then stripped)
|
|
374
|
+
// so back/forward and copied URLs don't keep re-opening the panel.
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
if (!available) return;
|
|
377
|
+
let id: string | null = null;
|
|
378
|
+
try {
|
|
379
|
+
const params = new URLSearchParams(window.location.search);
|
|
380
|
+
id = params.get("ai-run");
|
|
381
|
+
if (id) {
|
|
382
|
+
params.delete("ai-run");
|
|
383
|
+
const qs = params.toString();
|
|
384
|
+
window.history.replaceState(
|
|
385
|
+
null,
|
|
386
|
+
"",
|
|
387
|
+
window.location.pathname + (qs ? `?${qs}` : "") + window.location.hash,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
/* URL APIs unavailable — skip the deep link */
|
|
392
|
+
}
|
|
393
|
+
if (id) openRun(id);
|
|
394
|
+
}, [available, openRun]);
|
|
395
|
+
const openHome = useCallback(() => {
|
|
396
|
+
setView("home");
|
|
397
|
+
setOpen(true);
|
|
398
|
+
}, []);
|
|
399
|
+
const goHome = useCallback(() => setView("home"), []);
|
|
400
|
+
const close = useCallback(() => setOpen(false), []);
|
|
401
|
+
const consentBusyRef = useRef(false);
|
|
402
|
+
const approveConsent = useCallback(() => {
|
|
403
|
+
if (consentBusyRef.current) return;
|
|
404
|
+
consentBusyRef.current = true;
|
|
405
|
+
setStartError(null);
|
|
406
|
+
const surface = consentSurfaceFor(selectedAgentRef.current);
|
|
407
|
+
const t = pendingTarget;
|
|
408
|
+
// The server ENFORCES consent at start, so the acknowledgment must land
|
|
409
|
+
// before the run request — awaiting also makes it durable for the CLI.
|
|
410
|
+
recordConsent(surface)
|
|
411
|
+
.then(() => {
|
|
412
|
+
setConsented((prev) => ({ ...prev, [surface]: true }));
|
|
413
|
+
// Cleared only on success: a failed write keeps the consent card up
|
|
414
|
+
// (needsConsent = !!pendingTarget) so "try again" works in place.
|
|
415
|
+
setPendingTarget(null);
|
|
416
|
+
if (t) startRunRef.current(t);
|
|
417
|
+
})
|
|
418
|
+
.catch(() => {
|
|
419
|
+
setStartError("Couldn't record your consent — try again.");
|
|
420
|
+
})
|
|
421
|
+
.finally(() => {
|
|
422
|
+
consentBusyRef.current = false;
|
|
423
|
+
});
|
|
424
|
+
}, [pendingTarget]);
|
|
425
|
+
const cancelConsent = useCallback(() => {
|
|
426
|
+
setPendingTarget(null);
|
|
427
|
+
setOpen(false);
|
|
428
|
+
}, []);
|
|
429
|
+
const dismissError = useCallback(() => setStartError(null), []);
|
|
430
|
+
|
|
431
|
+
// Reserve a right gutter on the CONTENT area (not the navbar/rail — those stay
|
|
432
|
+
// global and static) so docked content reflows beside the panel. Wide viewports
|
|
433
|
+
// only; maximized or too-narrow → the panel overlays, no gutter.
|
|
434
|
+
const contentGutter = open && !narrow && !maximized ? width : 0;
|
|
435
|
+
|
|
436
|
+
const value: DiagnoseCtx = {
|
|
437
|
+
available,
|
|
438
|
+
agentLabel,
|
|
439
|
+
hosted,
|
|
440
|
+
agents,
|
|
441
|
+
selectedAgent,
|
|
442
|
+
setSelectedAgent,
|
|
443
|
+
isolated,
|
|
444
|
+
setIsolated,
|
|
445
|
+
model,
|
|
446
|
+
setModel,
|
|
447
|
+
effort,
|
|
448
|
+
setEffort,
|
|
449
|
+
view,
|
|
450
|
+
activeRunId,
|
|
451
|
+
runs,
|
|
452
|
+
runsLoaded,
|
|
453
|
+
runsLoadFailed,
|
|
454
|
+
historyDegraded,
|
|
455
|
+
// pendingTarget is set ONLY when the current agent's consent is missing, and
|
|
456
|
+
// cleared on approve/cancel — so its presence is exactly "consent needed now".
|
|
457
|
+
needsConsent: !!pendingTarget,
|
|
458
|
+
startError,
|
|
459
|
+
openInvestigation,
|
|
460
|
+
openRun,
|
|
461
|
+
openHome,
|
|
462
|
+
goHome,
|
|
463
|
+
close,
|
|
464
|
+
approveConsent,
|
|
465
|
+
cancelConsent,
|
|
466
|
+
refreshRuns,
|
|
467
|
+
dismissError,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Layout value memoized on layout-only deps (setters/bounds/key are stable), so
|
|
471
|
+
// the 4s run-poll churning `value` above doesn't re-render the app shell, which
|
|
472
|
+
// consumes ONLY this. The panel itself is rendered by the shell (App) as an
|
|
473
|
+
// absolute slot in the body frame — not here.
|
|
474
|
+
const layout = useMemo<DiagnoseLayoutCtx>(
|
|
475
|
+
() => ({
|
|
476
|
+
open,
|
|
477
|
+
contentGutter,
|
|
478
|
+
maximized,
|
|
479
|
+
setMaximized,
|
|
480
|
+
panelWidth: width,
|
|
481
|
+
setPanelWidth: setWidth,
|
|
482
|
+
panelNarrow: narrow,
|
|
483
|
+
panelBounds: PANEL_BOUNDS,
|
|
484
|
+
panelWidthKey: WIDTH_KEY,
|
|
485
|
+
runningKeys,
|
|
486
|
+
}),
|
|
487
|
+
[open, contentGutter, maximized, width, narrow, runningKeys, setMaximized, setWidth],
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<Ctx.Provider value={value}>
|
|
492
|
+
<LayoutCtx.Provider value={layout}>{children}</LayoutCtx.Provider>
|
|
493
|
+
</Ctx.Provider>
|
|
494
|
+
);
|
|
495
|
+
}
|