From f07dfcfd915ca61858ab4ededea9caf1fe9fc78b Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 11:52:41 -0700 Subject: [PATCH] Allow hiding agent harness options Signed-off-by: kenny lopez --- desktop/src/features/agents/AGENTS.md | 9 +- .../lib/runtimeVisibilityPreference.test.mjs | 53 +++++ .../agents/lib/runtimeVisibilityPreference.ts | 185 ++++++++++++++++++ .../agents/ui/AgentDefaultsEditor.tsx | 6 +- .../agents/ui/AgentDefinitionDialog.tsx | 18 +- .../agents/ui/AgentInstanceEditDialog.tsx | 6 +- .../settings/ui/DoctorSettingsPanel.tsx | 18 +- desktop/tests/e2e/doctor-states.spec.ts | 48 ++++- 8 files changed, 319 insertions(+), 24 deletions(-) create mode 100644 desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs create mode 100644 desktop/src/features/agents/lib/runtimeVisibilityPreference.ts diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 06e6c02ac..1dece1ef6 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -85,7 +85,14 @@ with a TypeScript lookup table or an id comparison in a component. harnesses always keep the field. Gate: `defaults hides model when optional harness has empty discovery` (and the failed-discovery counterpart) in `onboarding-agent-defaults.spec.ts`. -9. **The defaults modal is progressively disclosed.** An unset global config +9. **Runtime enablement is a device-local menu preference, not a capability or + lifecycle fact.** Settings persists disabled runtime IDs through + `lib/runtimeVisibilityPreference.ts`; harness dropdowns filter those IDs + from new choices while the raw Rust catalog remains authoritative for + installation, capabilities, and existing agent configuration. Turning a + runtime off must not uninstall it, stop it, or invalidate an existing + agent that already uses it. +10. **The defaults modal is progressively disclosed.** An unset global config starts on the Buzz Agent-first deployment fallback and carries that visible harness into the next saved edit. The `progressive-defaults` disclosure preset therefore begins at Provider for Buzz Agent, then reveals Model, diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs new file mode 100644 index 000000000..1f96cb55e --- /dev/null +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ACP_RUNTIME_VISIBILITY_STORAGE_KEY, + filterEnabledAcpRuntimes, + nextDisabledAcpRuntimeIds, + parseDisabledAcpRuntimeIds, + readDisabledAcpRuntimeIds, +} from "./runtimeVisibilityPreference.ts"; + +test("runtime visibility parsing is normalized and corruption tolerant", () => { + assert.deepEqual( + parseDisabledAcpRuntimeIds('[" Goose ","codex","goose",4]'), + ["codex", "goose"], + ); + assert.deepEqual(parseDisabledAcpRuntimeIds("{not-json"), []); + assert.deepEqual(parseDisabledAcpRuntimeIds('{"goose":false}'), []); +}); + +test("enabling and disabling runtimes preserves the other choices", () => { + const disabled = nextDisabledAcpRuntimeIds([], " Goose ", false); + assert.deepEqual(disabled, ["goose"]); + assert.deepEqual(nextDisabledAcpRuntimeIds(disabled, "codex", false), [ + "codex", + "goose", + ]); + assert.deepEqual(nextDisabledAcpRuntimeIds(disabled, "GOOSE", true), []); +}); + +test("disabled runtimes are removed from selectable catalog entries", () => { + const runtimes = [ + { id: "buzz-agent", label: "Buzz Agent" }, + { id: "goose", label: "Goose" }, + { id: "codex", label: "Codex" }, + ]; + + assert.deepEqual(filterEnabledAcpRuntimes(runtimes, ["Goose"]), [ + runtimes[0], + runtimes[2], + ]); +}); + +test("stored runtime visibility is read from the versioned device key", () => { + const storage = { + getItem(key) { + assert.equal(key, ACP_RUNTIME_VISIBILITY_STORAGE_KEY); + return '["claude"]'; + }, + }; + + assert.deepEqual(readDisabledAcpRuntimeIds(storage), ["claude"]); +}); diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts new file mode 100644 index 000000000..f21f86671 --- /dev/null +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -0,0 +1,185 @@ +import * as React from "react"; + +export const ACP_RUNTIME_VISIBILITY_STORAGE_KEY = + "buzz-agent-runtime-visibility.v1"; + +type RuntimeVisibilityStorage = Pick; + +const EMPTY_DISABLED_RUNTIME_IDS: readonly string[] = Object.freeze([]); +const listeners = new Set<() => void>(); + +let cachedSerializedValue: string | null | undefined; +let cachedDisabledRuntimeIds = EMPTY_DISABLED_RUNTIME_IDS; + +function normalizeRuntimeId(runtimeId: string): string { + return runtimeId.trim().toLowerCase(); +} + +function getLocalStorage(): RuntimeVisibilityStorage | null { + if (typeof window === "undefined") return null; + + try { + return window.localStorage; + } catch { + return null; + } +} + +export function parseDisabledAcpRuntimeIds( + serialized: string | null, +): readonly string[] { + if (!serialized) return EMPTY_DISABLED_RUNTIME_IDS; + + try { + const parsed: unknown = JSON.parse(serialized); + if (!Array.isArray(parsed)) return EMPTY_DISABLED_RUNTIME_IDS; + + const runtimeIds = [ + ...new Set( + parsed + .filter((value): value is string => typeof value === "string") + .map(normalizeRuntimeId) + .filter(Boolean), + ), + ].sort(); + return runtimeIds.length > 0 + ? Object.freeze(runtimeIds) + : EMPTY_DISABLED_RUNTIME_IDS; + } catch { + return EMPTY_DISABLED_RUNTIME_IDS; + } +} + +export function readDisabledAcpRuntimeIds( + storage: Pick, +): readonly string[] { + try { + return parseDisabledAcpRuntimeIds( + storage.getItem(ACP_RUNTIME_VISIBILITY_STORAGE_KEY), + ); + } catch { + return EMPTY_DISABLED_RUNTIME_IDS; + } +} + +export function nextDisabledAcpRuntimeIds( + current: readonly string[], + runtimeId: string, + enabled: boolean, +): readonly string[] { + const normalizedRuntimeId = normalizeRuntimeId(runtimeId); + if (!normalizedRuntimeId) return current; + + const next = new Set(current.map(normalizeRuntimeId).filter(Boolean)); + if (enabled) { + next.delete(normalizedRuntimeId); + } else { + next.add(normalizedRuntimeId); + } + + const runtimeIds = [...next].sort(); + return runtimeIds.length > 0 + ? Object.freeze(runtimeIds) + : EMPTY_DISABLED_RUNTIME_IDS; +} + +export function filterEnabledAcpRuntimes( + runtimes: readonly T[], + disabledRuntimeIds: readonly string[], +): T[] { + if (disabledRuntimeIds.length === 0) return [...runtimes]; + + const disabled = new Set(disabledRuntimeIds.map(normalizeRuntimeId)); + return runtimes.filter( + (runtime) => !disabled.has(normalizeRuntimeId(runtime.id)), + ); +} + +function getDisabledRuntimeIdsSnapshot(): readonly string[] { + const storage = getLocalStorage(); + if (!storage) return EMPTY_DISABLED_RUNTIME_IDS; + + let serialized: string | null; + try { + serialized = storage.getItem(ACP_RUNTIME_VISIBILITY_STORAGE_KEY); + } catch { + return EMPTY_DISABLED_RUNTIME_IDS; + } + + if (serialized === cachedSerializedValue) { + return cachedDisabledRuntimeIds; + } + + cachedSerializedValue = serialized; + cachedDisabledRuntimeIds = parseDisabledAcpRuntimeIds(serialized); + return cachedDisabledRuntimeIds; +} + +function subscribeToRuntimeVisibility(onStoreChange: () => void): () => void { + listeners.add(onStoreChange); + + const handleStorage = (event: StorageEvent) => { + if ( + event.key !== null && + event.key !== ACP_RUNTIME_VISIBILITY_STORAGE_KEY + ) { + return; + } + cachedSerializedValue = undefined; + onStoreChange(); + }; + window.addEventListener("storage", handleStorage); + + return () => { + listeners.delete(onStoreChange); + window.removeEventListener("storage", handleStorage); + }; +} + +export function setAcpRuntimeEnabled( + runtimeId: string, + enabled: boolean, +): boolean { + const storage = getLocalStorage(); + if (!storage) return false; + + const current = getDisabledRuntimeIdsSnapshot(); + const next = nextDisabledAcpRuntimeIds(current, runtimeId, enabled); + if (next === current) return true; + + const serialized = JSON.stringify(next); + try { + storage.setItem(ACP_RUNTIME_VISIBILITY_STORAGE_KEY, serialized); + } catch { + return false; + } + + cachedSerializedValue = serialized; + cachedDisabledRuntimeIds = next; + for (const listener of listeners) listener(); + return true; +} + +export function useDisabledAcpRuntimeIds(): readonly string[] { + return React.useSyncExternalStore( + subscribeToRuntimeVisibility, + getDisabledRuntimeIdsSnapshot, + () => EMPTY_DISABLED_RUNTIME_IDS, + ); +} + +export function useSelectableAcpRuntimes( + runtimes: readonly T[], +): T[] { + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); + return React.useMemo( + () => filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds), + [disabledRuntimeIds, runtimes], + ); +} + +export function useAcpRuntimeEnabled(runtimeId: string): boolean { + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); + const normalizedRuntimeId = normalizeRuntimeId(runtimeId); + return !disabledRuntimeIds.includes(normalizedRuntimeId); +} diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 69e8b3a5b..341c1e1b1 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -39,6 +39,7 @@ import { } from "@/features/agents/ui/AgentConfigFields"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference"; type SaveState = "idle" | "saving" | "saved" | "error"; @@ -135,9 +136,10 @@ export function AgentDefaultsEditor({ }, []); const runtimesQuery = useAcpRuntimesQuery(); + const selectableRuntimes = useSelectableAcpRuntimes(runtimesQuery.data ?? []); const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimesQuery.data ?? []), - [runtimesQuery.data], + () => sortPersonaRuntimes(selectableRuntimes), + [selectableRuntimes], ); // An unset preferred runtime uses the same Buzz Agent-first fallback as // deployment. The rendered draft below carries that fallback forward so the diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 29cb48c64..888ea9fc1 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -83,6 +83,7 @@ import { } from "./agentAiConfigurationPolicy"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; +import { useSelectableAcpRuntimes } from "../lib/runtimeVisibilityPreference"; type AgentDefinitionDialogProps = { open: boolean; @@ -150,10 +151,8 @@ export function AgentDefinitionDialog({ // the seeded runtime from the submit payload for builtin definitions whose // canonical runtime is null — the sync would revert it anyway. const isRuntimeAutoSeededRef = React.useRef(false); - // Guards the seeding effect so it fires at most once per dialog-open. - // Without this, clearing runtime back to "" via "No preference" would re- - // trigger the effect (the `runtime` dep would pass the length guard) and - // snap the dropdown back to the default — an edit-mode regression. + // Seed once per open so choosing "No preference" cannot snap the dropdown + // back to the default. const hasSeededForOpenRef = React.useRef(false); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [isAvatarUploadPending, setIsAvatarUploadPending] = @@ -166,9 +165,10 @@ export function AgentDefinitionDialog({ }, inheritedEnvVars: inheritedEnvVarsForAdvanced, } = useAgentDialogDefaults({ open }); - const defaultRuntime = React.useMemo( - () => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime), - [globalConfig.preferred_runtime, runtimes], + const selectableRuntimes = useSelectableAcpRuntimes(runtimes); + const defaultRuntime = getDefaultPersonaRuntime( + selectableRuntimes, + globalConfig.preferred_runtime, ); const isCreateMode = Boolean(initialValues && !("id" in initialValues)); const shouldReduceMotion = useReducedMotion(); @@ -554,8 +554,8 @@ export function AgentDefinitionDialog({ llmProviderFieldVisible && isCustomProviderEditing; const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE; const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimes), - [runtimes], + () => sortPersonaRuntimes(selectableRuntimes), + [selectableRuntimes], ); const blankRuntimeOptionLabel = runtimesLoading ? "Loading harnesses..." diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 601d57f95..52edfcf18 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -80,6 +80,7 @@ import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; +import { useSelectableAcpRuntimes } from "../lib/runtimeVisibilityPreference"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning"; @@ -111,6 +112,7 @@ export function AgentInstanceEditDialog({ const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; + const selectableRuntimes = useSelectableAcpRuntimes(runtimes); const [name, setName] = React.useState(agent.name); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); @@ -215,8 +217,8 @@ export function AgentInstanceEditDialog({ // Build the sorted runtime catalog for the dropdown. const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimes), - [runtimes], + () => sortPersonaRuntimes(selectableRuntimes), + [selectableRuntimes], ); const selectedRuntime = React.useMemo( diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 96127876a..641769ba1 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -9,6 +9,10 @@ import { useGitBashPrerequisiteQuery, useInstallAcpRuntimeMutation, } from "@/features/agents/hooks"; +import { + setAcpRuntimeEnabled, + useAcpRuntimeEnabled, +} from "@/features/agents/lib/runtimeVisibilityPreference"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { RuntimeIcon } from "@/features/onboarding/ui/RuntimeIcon"; import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types"; @@ -185,6 +189,8 @@ function RuntimeActions({ }) { const isAvailable = runtime.availability === "available"; const canInstall = runtime.canAutoInstall && !runtime.nodeRequired; + const isEnabled = useAcpRuntimeEnabled(runtime.id); + const isOn = isAvailable && isEnabled; const isWorking = isInstalling || isConnecting; return ( @@ -206,13 +212,13 @@ function RuntimeActions({ ) : ( { - if (checked) { + setAcpRuntimeEnabled(runtime.id, checked); + if (checked && !isAvailable) { onInstall(); } }} @@ -574,7 +580,7 @@ export function DoctorSettingsPanel() { { }); await page.keyboard.press("Escape"); await expect(page.getByTestId("doctor-runtime-toggle-goose")).toBeChecked(); - await expect( - page.getByTestId("doctor-runtime-toggle-goose"), - ).toBeDisabled(); + await expect(page.getByTestId("doctor-runtime-toggle-goose")).toBeEnabled(); await expect(page.getByTestId("doctor-runtime-codex")).not.toContainText( "Not installed", ); @@ -233,6 +231,48 @@ test.describe("Doctor panel state screenshots", () => { }); }); + test("available runtimes can be hidden from agent harness menus", async ({ + page, + }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + BUZZ_AGENT_AVAILABLE, + ], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + const gooseToggle = page.getByTestId("doctor-runtime-toggle-goose"); + await expect(gooseToggle).toBeChecked(); + await expect(gooseToggle).toBeEnabled(); + await gooseToggle.click(); + await expect(gooseToggle).not.toBeChecked(); + + await page.getByRole("button", { name: "Back to app" }).click(); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("new-agent-card").click(); + await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + + const harnessDropdown = page.locator("#persona-runtime"); + await expect(harnessDropdown).toContainText("Buzz Agent"); + await harnessDropdown.press("Enter"); + await expect( + page.getByRole("menuitemradio", { name: /^Goose/ }), + ).toHaveCount(0); + await expect( + page.getByRole("menuitemradio", { name: /^Buzz Agent/ }), + ).toBeVisible(); + + await page.reload({ waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + await expect( + page.getByTestId("doctor-runtime-toggle-goose"), + ).not.toBeChecked(); + }); + /** 01 — a ready runtime stays compact without redundant status copy. */ test("01-auth-logged-in", async ({ page }) => { await installMockBridge(page, { @@ -560,7 +600,7 @@ test.describe("Doctor panel state screenshots", () => { await expect(toggle).toBeEnabled(); await toggle.click(); await expect(toggle).toBeChecked({ timeout: 5_000 }); - await expect(toggle).toBeDisabled(); + await expect(toggle).toBeEnabled(); await expect(page.getByTestId("doctor-runtime-guidance-codex")).toHaveCount( 0, );