diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 6f7f44387d..6f68c4f339 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -5,6 +5,7 @@ import { type CoderWorkspaceConfig, type RuntimeMode, type ParsedRuntime, + type RuntimeEnablement, CODER_RUNTIME_PLACEHOLDER, } from "@/common/types/runtime"; import type { RuntimeAvailabilityMap, RuntimeAvailabilityState } from "./useCreationWorkspace"; @@ -12,7 +13,6 @@ import { resolveDevcontainerSelection, DEFAULT_DEVCONTAINER_CONFIG_PATH, } from "@/browser/utils/devcontainerSelection"; -import { Select } from "../Select"; import { Select as RadixSelect, SelectContent, @@ -20,9 +20,10 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { Loader2, Wand2, X } from "lucide-react"; +import { GitBranch, Loader2, Wand2, X } from "lucide-react"; import { PlatformPaths } from "@/common/utils/paths"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; +import { useSettings } from "@/browser/contexts/SettingsContext"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; @@ -130,6 +131,8 @@ interface CreationControlsProps { nameState: WorkspaceNameState; /** Runtime availability state for each mode */ runtimeAvailabilityState: RuntimeAvailabilityState; + /** Runtime enablement toggles from Settings (hide disabled runtimes). */ + runtimeEnablement?: RuntimeEnablement; /** Available sections for this project */ sections?: SectionConfig[]; /** Currently selected section ID */ @@ -161,6 +164,7 @@ interface RuntimeButtonGroupProps { onSetDefault: (mode: RuntimeChoice) => void; disabled?: boolean; runtimeAvailabilityState?: RuntimeAvailabilityState; + runtimeEnablement?: RuntimeEnablement; coderInfo?: CoderInfo | null; allowedRuntimeModes?: RuntimeMode[] | null; allowSshHost?: boolean; @@ -176,6 +180,15 @@ const RUNTIME_CHOICE_ORDER: RuntimeChoice[] = [ RUNTIME_MODE.DEVCONTAINER, ]; +const RUNTIME_FALLBACK_ORDER: RuntimeChoice[] = [ + RUNTIME_MODE.WORKTREE, + RUNTIME_MODE.LOCAL, + RUNTIME_MODE.SSH, + "coder", + RUNTIME_MODE.DOCKER, + RUNTIME_MODE.DEVCONTAINER, +]; + const RUNTIME_CHOICE_OPTIONS: Array<{ value: RuntimeChoice; label: string; @@ -376,6 +389,7 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { const availabilityMap = state?.status === "loaded" ? state.data : null; const coderInfo = props.coderInfo ?? null; const coderAvailability = resolveCoderAvailability(coderInfo); + const runtimeEnablement = props.runtimeEnablement; const allowSshHost = props.allowSshHost ?? true; const allowSshCoder = props.allowSshCoder ?? true; @@ -414,6 +428,14 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { return false; } + // User request: hide Settings-disabled runtimes (selection auto-switches elsewhere). + // Keep the currently active runtime visible even if disabled to avoid trapping the user + // when the fallback can't find a replacement (e.g., non-git repo with Local disabled). + const isEnablementDisabled = runtimeEnablement?.[option.value] === false; + if (isEnablementDisabled && option.value !== props.value) { + return false; + } + const { isPolicyDisabled } = resolveRuntimeButtonState( option.value, availabilityMap, @@ -435,25 +457,25 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
{runtimeOptions.map((option) => { const isActive = props.value === option.value; - const { isModeDisabled, isPolicyDisabled, disabledReason, isDefault } = - resolveRuntimeButtonState( - option.value, - availabilityMap, - props.defaultMode, - coderAvailability, - allowedModeSet, - allowSshHost, - allowSshCoder - ); + const { + isModeDisabled, + isPolicyDisabled, + disabledReason: resolvedDisabledReason, + } = resolveRuntimeButtonState( + option.value, + availabilityMap, + props.defaultMode, + coderAvailability, + allowedModeSet, + allowSshHost, + allowSshCoder + ); + const disabledReason = resolvedDisabledReason; const isDisabled = Boolean(props.disabled) || isModeDisabled || isPolicyDisabled; const showDisabledReason = isModeDisabled || isPolicyDisabled; const Icon = option.Icon; - const handleSetDefault = () => { - props.onSetDefault(option.value); - }; - return ( @@ -482,19 +504,10 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { {option.description}
+ {/* User request: remove default-runtime toggle from creation tooltip. */} {showDisabledReason ? (

{disabledReason ?? "Unavailable"}

- ) : ( - - )} + ) : null} ); @@ -509,6 +522,7 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { */ export function CreationControls(props: CreationControlsProps) { const { projects } = useProjectContext(); + const settings = useSettings(); const { beginWorkspaceCreation } = useWorkspaceContext(); const { nameState, runtimeAvailabilityState } = props; @@ -519,19 +533,14 @@ export function CreationControls(props: CreationControlsProps) { const isCoderSelected = selectedRuntime.mode === RUNTIME_MODE.SSH && selectedRuntime.coder != null; const runtimeChoice: RuntimeChoice = isCoderSelected ? "coder" : runtimeMode; - const coderUsername = - props.coderProps?.coderInfo?.state === "available" - ? props.coderProps.coderInfo.username - : undefined; - const coderDeploymentUrl = - props.coderProps?.coderInfo?.state === "available" ? props.coderProps.coderInfo.url : undefined; + const coderInfo = props.coderInfo ?? props.coderProps?.coderInfo ?? null; + const coderAvailability = resolveCoderAvailability(coderInfo); + const isCoderAvailable = coderAvailability.state === "available"; + const coderUsername = coderInfo?.state === "available" ? coderInfo.username : undefined; + const coderDeploymentUrl = coderInfo?.state === "available" ? coderInfo.url : undefined; - // Local runtime doesn't need a trunk branch selector (uses project dir as-is) const availabilityMap = runtimeAvailabilityState.status === "loaded" ? runtimeAvailabilityState.data : null; - const showTrunkBranchSelector = props.branches.length > 0 && runtimeMode !== RUNTIME_MODE.LOCAL; - // Show loading skeleton while branches are loading to avoid layout flash - const showBranchLoadingPlaceholder = !props.branchesLoaded && runtimeMode !== RUNTIME_MODE.LOCAL; // Centralized devcontainer selection logic const devcontainerSelection = resolveDevcontainerSelection({ @@ -549,19 +558,154 @@ export function CreationControls(props: CreationControlsProps) { availabilityMap.worktree.reason === "Requires git repository") || (props.branchesLoaded && props.branches.length === 0); - // Keep selected runtime aligned with availability constraints + const branchOptions = + props.trunkBranch && !props.branches.includes(props.trunkBranch) + ? [props.trunkBranch, ...props.branches] + : props.branches; + const isBranchSelectorDisabled = + Boolean(props.disabled) || isNonGitRepo || branchOptions.length === 0; + + // Keep selected runtime aligned with availability + Settings enablement constraints. + // All constraint checks (non-git, devcontainer missing, enablement, policy) are unified + // into a single firstEnabled fallback so every edge combination is handled consistently. useEffect(() => { - if (isNonGitRepo) { - if (selectedRuntime.mode !== RUNTIME_MODE.LOCAL) { - onSelectedRuntimeChange({ mode: "local" }); + const runtimeEnablement = props.runtimeEnablement; + + // Determine if the current selection needs correction. + const isCurrentDisabledBySettings = runtimeEnablement?.[runtimeChoice] === false; + // In non-git repos all modes except Local are unavailable (not just Worktree). + const isCurrentUnavailable = + (isNonGitRepo && selectedRuntime.mode !== RUNTIME_MODE.LOCAL) || + (isDevcontainerMissing && selectedRuntime.mode === RUNTIME_MODE.DEVCONTAINER); + + if (!isCurrentDisabledBySettings && !isCurrentUnavailable) { + return; + } + + // Build a policy set matching RuntimeButtonGroup's eligibility logic so the + // auto-switch fallback never lands on a policy-forbidden runtime. + const allowedModes = props.allowedRuntimeModes + ? new Set(props.allowedRuntimeModes) + : null; + + const firstEnabled = RUNTIME_FALLBACK_ORDER.find((mode) => { + if (runtimeEnablement?.[mode] === false) { + return false; + } + if (mode === "coder") { + if (!props.coderProps) { + return false; + } + if (!isCoderAvailable) { + return false; + } + } + // Filter by availability to avoid selecting unavailable runtimes (e.g., Docker + // when daemon is down, devcontainer when config missing, non-git projects). + if (isDevcontainerMissing && mode === RUNTIME_MODE.DEVCONTAINER) { + return false; } + if (isNonGitRepo && mode !== RUNTIME_MODE.LOCAL) { + return false; + } + // Check the general availability map for any other unavailable runtimes. + if (mode !== "coder") { + const avail = availabilityMap?.[mode]; + if (avail !== undefined && !avail.available) { + return false; + } + } + // Filter by policy constraints to avoid selecting a blocked runtime. + if (allowedModes) { + if (mode === "coder" && !(props.allowSshCoder ?? true)) { + return false; + } + if (mode === RUNTIME_MODE.SSH && !(props.allowSshHost ?? true)) { + return false; + } + if (mode !== "coder" && mode !== RUNTIME_MODE.SSH && !allowedModes.has(mode)) { + return false; + } + } + return true; + }); + if (!firstEnabled || firstEnabled === runtimeChoice) { + return; + } + + // User request: auto-switch away from Settings-disabled runtimes. + if (firstEnabled === "coder") { + if (!props.coderProps || !isCoderAvailable) { + return; + } + onSelectedRuntimeChange({ + mode: "ssh", + host: CODER_RUNTIME_PLACEHOLDER, + coder: props.coderConfigFallback, + }); return; } - if (isDevcontainerMissing && selectedRuntime.mode === RUNTIME_MODE.DEVCONTAINER) { - onSelectedRuntimeChange({ mode: "worktree" }); + switch (firstEnabled) { + case RUNTIME_MODE.SSH: { + const sshHost = + selectedRuntime.mode === RUNTIME_MODE.SSH && + selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER + ? selectedRuntime.host + : props.sshHostFallback; + onSelectedRuntimeChange({ + mode: "ssh", + host: sshHost, + }); + return; + } + case RUNTIME_MODE.DOCKER: + onSelectedRuntimeChange({ + mode: "docker", + image: selectedRuntime.mode === "docker" ? selectedRuntime.image : "", + }); + return; + case RUNTIME_MODE.DEVCONTAINER: { + const initialSelection = resolveDevcontainerSelection({ + selectedRuntime: { mode: "devcontainer", configPath: "" }, + availabilityState: runtimeAvailabilityState, + }); + onSelectedRuntimeChange({ + mode: "devcontainer", + configPath: + selectedRuntime.mode === "devcontainer" + ? selectedRuntime.configPath + : initialSelection.configPath, + shareCredentials: + selectedRuntime.mode === "devcontainer" ? selectedRuntime.shareCredentials : false, + }); + return; + } + case RUNTIME_MODE.LOCAL: + onSelectedRuntimeChange({ mode: "local" }); + return; + case RUNTIME_MODE.WORKTREE: + default: + onSelectedRuntimeChange({ mode: "worktree" }); + return; } - }, [isDevcontainerMissing, isNonGitRepo, selectedRuntime.mode, onSelectedRuntimeChange]); + }, [ + isDevcontainerMissing, + isNonGitRepo, + onSelectedRuntimeChange, + props.coderConfigFallback, + props.coderProps, + props.runtimeEnablement, + props.sshHostFallback, + props.allowedRuntimeModes, + props.allowSshHost, + props.allowSshCoder, + availabilityMap, + runtimeAvailabilityState, + runtimeChoice, + selectedRuntime, + isCoderAvailable, + ]); const handleNameChange = useCallback( (e: React.ChangeEvent) => { @@ -698,7 +842,18 @@ export function CreationControls(props: CreationControlsProps) { {/* Runtime type - button group */}
- + {/* User request: keep the configure shortcut but render it in muted gray. */} +
+ + - + +
- {/* Branch selector - shown for worktree/SSH */} - {showTrunkBranchSelector && ( -
- - + + + + + All + {projectList.map((path) => ( + + {getProjectLabel(path)} + + ))} + + +
+ + {isProjectScope ? ( +
+
+
Override project settings
+
+ Keep global defaults or customize enabled runtimes for this project. +
+
+ +
+ ) : null} +
+ +
+
+
+
Default runtime
+
+ {isProjectScope + ? "Applied to new workspaces in this project." + : "Applied to new workspaces by default."} +
+
+ +
+ +
+ {RUNTIME_ROWS.map((runtime) => { + const Icon = runtime.Icon; + const isCoder = runtime.id === "coder"; + const availability = isCoder + ? null + : (availabilityMap?.[runtime.id as RuntimeMode] ?? null); + const availabilityReason = isProjectScope + ? isCoder + ? coderAvailability.state !== "available" && coderAvailability.state !== "loading" + ? coderAvailability.reason + : null + : availability && !availability.available + ? availability.reason + : null + : null; + const showLoading = isProjectScope + ? isCoder + ? coderAvailability.state === "loading" + : runtimeAvailabilityState.status === "loading" + : false; + const rowDisabled = isProjectScope && !projectOverrideEnabled; + const isLastEnabled = effectiveEnablement[runtime.id] && enabledRuntimeCount <= 1; + const switchDisabled = rowDisabled || isLastEnabled; + const switchControl = ( + handleRuntimeToggle(runtime.id, checked)} + aria-label={`Toggle ${runtime.label} runtime`} + /> + ); + const switchNode = + isLastEnabled && !rowDisabled ? ( + + + {switchControl} + + At least one runtime must be enabled. + + ) : ( + switchControl + ); + + // Inline status indicators keep availability feedback from shifting row layout. + return ( +
+
+ +
+
+
{runtime.label}
+ {availabilityReason ? ( + + + + + Unavailable + + + + {availabilityReason} + + + ) : null} +
+
{runtime.description}
+
+
+
+
+ {showLoading ? : null} +
+ {switchNode} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index c751ac9c94..0d1693cf10 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -33,6 +33,8 @@ import { GATEWAY_MODELS_KEY, HIDDEN_MODELS_KEY, PREFERRED_COMPACTION_MODEL_KEY, + RUNTIME_ENABLEMENT_KEY, + DEFAULT_RUNTIME_KEY, SELECTED_WORKSPACE_KEY, WORKSPACE_DRAFTS_BY_PROJECT_KEY, } from "@/common/constants/storage"; @@ -485,6 +487,16 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { updatePersistedState(PREFERRED_COMPACTION_MODEL_KEY, cfg.preferredCompactionModel); } + // Seed runtime enablement from backend so switching ports doesn't reset the UI. + if (cfg.runtimeEnablement !== undefined) { + updatePersistedState(RUNTIME_ENABLEMENT_KEY, cfg.runtimeEnablement); + } + + // Seed global default runtime so workspace defaults survive port changes. + if (cfg.defaultRuntime !== undefined) { + updatePersistedState(DEFAULT_RUNTIME_KEY, cfg.defaultRuntime); + } + // One-time best-effort migration: if the backend doesn't have gateway prefs yet, // persist non-default localStorage values so future port changes keep them. if (api.config.updateMuxGatewayPrefs) { diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx index fb80d461e9..877ab53d35 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -3,9 +3,14 @@ import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { GlobalWindow } from "happy-dom"; import React from "react"; import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { ProjectProvider } from "@/browser/contexts/ProjectContext"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { getLastRuntimeConfigKey, getRuntimeKey } from "@/common/constants/storage"; +import { + DEFAULT_RUNTIME_KEY, + getLastRuntimeConfigKey, + getRuntimeKey, +} from "@/common/constants/storage"; import { CODER_RUNTIME_PLACEHOLDER } from "@/common/types/runtime"; import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings"; @@ -21,6 +26,10 @@ function createStubApiClient(): APIClient { getConfig: () => Promise.resolve({}), onConfigChanged: () => Promise.resolve(empty()), }, + // ProjectProvider calls api.projects.list() on mount. + projects: { + list: () => Promise.resolve([]), + }, } as unknown as APIClient; } @@ -43,7 +52,9 @@ describe("useDraftWorkspaceSettings", () => { const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( - {props.children} + + {props.children} + ); @@ -69,7 +80,9 @@ describe("useDraftWorkspaceSettings", () => { const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( - {props.children} + + {props.children} + ); @@ -99,7 +112,9 @@ describe("useDraftWorkspaceSettings", () => { const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( - {props.children} + + {props.children} + ); @@ -124,7 +139,8 @@ describe("useDraftWorkspaceSettings", () => { test("keeps Coder default even after plain SSH usage", async () => { const projectPath = "/tmp/project"; - updatePersistedState(getRuntimeKey(projectPath), `ssh ${CODER_RUNTIME_PLACEHOLDER}`); + updatePersistedState(DEFAULT_RUNTIME_KEY, "coder"); + updatePersistedState(getRuntimeKey(projectPath), "ssh dev@host"); updatePersistedState(getLastRuntimeConfigKey(projectPath), { ssh: { host: "dev@host", @@ -135,7 +151,9 @@ describe("useDraftWorkspaceSettings", () => { const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( - {props.children} + + {props.children} + ); @@ -166,7 +184,9 @@ describe("useDraftWorkspaceSettings", () => { const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( - {props.children} + + {props.children} + ); @@ -207,7 +227,9 @@ describe("useDraftWorkspaceSettings", () => { const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( - {props.children} + + {props.children} + ); @@ -231,7 +253,9 @@ describe("useDraftWorkspaceSettings", () => { const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( - {props.children} + + {props.children} + ); diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index b908741998..5a72888e62 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -2,11 +2,11 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { readPersistedState, usePersistedState } from "./usePersistedState"; import { useThinkingLevel } from "./useThinkingLevel"; import { migrateGatewayModel } from "./useGatewayModels"; +import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { type RuntimeMode, type ParsedRuntime, type CoderWorkspaceConfig, - parseRuntimeModeAndHost, buildRuntimeString, RUNTIME_MODE, CODER_RUNTIME_PLACEHOLDER, @@ -14,6 +14,7 @@ import { import type { RuntimeChoice } from "@/browser/utils/runtimeUi"; import { DEFAULT_MODEL_KEY, + DEFAULT_RUNTIME_KEY, getAgentIdKey, getModelKey, getRuntimeKey, @@ -96,6 +97,38 @@ const buildRuntimeForMode = ( } }; +const normalizeRuntimeChoice = (value: unknown): RuntimeChoice | null => { + if ( + value === "coder" || + value === RUNTIME_MODE.LOCAL || + value === RUNTIME_MODE.WORKTREE || + value === RUNTIME_MODE.SSH || + value === RUNTIME_MODE.DOCKER || + value === RUNTIME_MODE.DEVCONTAINER + ) { + return value; + } + + return null; +}; + +const buildRuntimeFromChoice = (choice: RuntimeChoice): ParsedRuntime => { + switch (choice) { + case "coder": + return { mode: RUNTIME_MODE.SSH, host: CODER_RUNTIME_PLACEHOLDER }; + case RUNTIME_MODE.LOCAL: + return { mode: RUNTIME_MODE.LOCAL }; + case RUNTIME_MODE.WORKTREE: + return { mode: RUNTIME_MODE.WORKTREE }; + case RUNTIME_MODE.SSH: + return { mode: RUNTIME_MODE.SSH, host: "" }; + case RUNTIME_MODE.DOCKER: + return { mode: RUNTIME_MODE.DOCKER, image: "" }; + case RUNTIME_MODE.DEVCONTAINER: + return { mode: RUNTIME_MODE.DEVCONTAINER, configPath: "" }; + } +}; + /** * Hook to manage all draft workspace settings with centralized persistence * Loads saved preferences when projectPath changes, persists all changes automatically @@ -126,6 +159,8 @@ export function useDraftWorkspaceSettings( const [thinkingLevel] = useThinkingLevel(); const projectScopeId = getProjectScopeId(projectPath); + const { projects } = useProjectContext(); + const projectConfig = projects.get(projectPath); const [agentId] = usePersistedState( getAgentIdKey(projectScopeId), @@ -153,15 +188,32 @@ export function useDraftWorkspaceSettings( : defaultModel ); - // Project-scoped default runtime (worktree by default, only changed via checkbox) - const [defaultRuntimeString, setDefaultRuntimeString] = usePersistedState( + const [rawGlobalDefaultRuntime] = usePersistedState(DEFAULT_RUNTIME_KEY, null, { + listener: true, + }); + const globalDefaultRuntime = normalizeRuntimeChoice(rawGlobalDefaultRuntime); + + // Project-scoped default runtime (persisted when the creation tooltip checkbox is used). + // Legacy per-project default (only write-side used by setDefaultRuntimeChoice; reads + // now come from settingsDefaultRuntime above). + const [, setDefaultRuntimeString] = usePersistedState( getRuntimeKey(projectPath), - undefined, // undefined means worktree (the app default) + undefined, { listener: true } ); - // Parse default runtime string into structured form (worktree when undefined or invalid) - const parsedDefault = parseRuntimeModeAndHost(defaultRuntimeString); + const hasProjectRuntimeOverrides = + projectConfig?.runtimeOverridesEnabled === true || + Boolean(projectConfig?.runtimeEnablement) || + projectConfig?.defaultRuntime !== undefined; + const settingsDefaultRuntime: RuntimeChoice = hasProjectRuntimeOverrides + ? (projectConfig?.defaultRuntime ?? globalDefaultRuntime ?? RUNTIME_MODE.WORKTREE) + : (globalDefaultRuntime ?? RUNTIME_MODE.WORKTREE); + + // Always use the Settings-configured default as the canonical source of truth. + // The old per-project localStorage key (getRuntimeKey) is now stale since the creation + // tooltip default toggle was removed; new defaults come from the Runtimes settings panel. + const parsedDefault = buildRuntimeFromChoice(settingsDefaultRuntime); const defaultRuntimeMode: RuntimeMode = parsedDefault?.mode ?? RUNTIME_MODE.WORKTREE; // Project-scoped trunk branch preference (persisted per project) @@ -320,15 +372,22 @@ export function useDraftWorkspaceSettings( ]); const defaultSshHost = - parsedDefault?.mode === RUNTIME_MODE.SSH ? parsedDefault.host : lastSsh.host; + parsedDefault?.mode === RUNTIME_MODE.SSH && parsedDefault.host.trim() + ? parsedDefault.host + : lastSsh.host; - // When the persisted default says "Coder", reuse the saved config even if last-used SSH disabled it. + // When the settings default says "Coder", reuse the saved config even if last-used SSH disabled it. + // When settings say plain "ssh", don't reattach the last-used coder config. const defaultSshCoder = coderDefaultFromString ? (lastSshState.coderConfig ?? DEFAULT_CODER_CONFIG) - : lastSsh.coder; + : settingsDefaultRuntime === RUNTIME_MODE.SSH + ? undefined + : lastSsh.coder; const defaultDockerImage = - parsedDefault?.mode === RUNTIME_MODE.DOCKER ? parsedDefault.image : lastDockerImage; + parsedDefault?.mode === RUNTIME_MODE.DOCKER && parsedDefault.image.trim() + ? parsedDefault.image + : lastDockerImage; const defaultDevcontainerConfigPath = parsedDefault?.mode === RUNTIME_MODE.DEVCONTAINER && parsedDefault.configPath.trim() @@ -349,13 +408,15 @@ export function useDraftWorkspaceSettings( const [selectedRuntime, setSelectedRuntimeState] = useState(() => defaultRuntime); const prevProjectPathRef = useRef(null); - const prevDefaultRuntimeModeRef = useRef(null); + // Track settingsDefaultRuntime (RuntimeChoice) instead of defaultRuntimeMode (RuntimeMode) + // so that switching between "coder" and "ssh" in Settings is detected as a change. + const prevSettingsDefaultRef = useRef(null); // When switching projects or changing the persisted default mode, reset the selection. // Importantly: do NOT reset selection when lastSsh.host/lastDockerImage changes while typing. useEffect(() => { const projectChanged = prevProjectPathRef.current !== projectPath; - const defaultModeChanged = prevDefaultRuntimeModeRef.current !== defaultRuntimeMode; + const defaultModeChanged = prevSettingsDefaultRef.current !== settingsDefaultRuntime; if (projectChanged || defaultModeChanged) { setSelectedRuntimeState( @@ -371,9 +432,10 @@ export function useDraftWorkspaceSettings( } prevProjectPathRef.current = projectPath; - prevDefaultRuntimeModeRef.current = defaultRuntimeMode; + prevSettingsDefaultRef.current = settingsDefaultRuntime; }, [ projectPath, + settingsDefaultRuntime, defaultRuntimeMode, defaultSshHost, defaultDockerImage, diff --git a/src/browser/hooks/useRuntimeEnablement.ts b/src/browser/hooks/useRuntimeEnablement.ts new file mode 100644 index 0000000000..c40ebdfb57 --- /dev/null +++ b/src/browser/hooks/useRuntimeEnablement.ts @@ -0,0 +1,83 @@ +import { useRef } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { DEFAULT_RUNTIME_KEY, RUNTIME_ENABLEMENT_KEY } from "@/common/constants/storage"; +import { + DEFAULT_RUNTIME_ENABLEMENT, + RUNTIME_ENABLEMENT_IDS, + normalizeRuntimeEnablement, + type RuntimeEnablement, + type RuntimeEnablementId, +} from "@/common/types/runtime"; + +interface RuntimeEnablementState { + enablement: RuntimeEnablement; + setRuntimeEnabled: (id: RuntimeEnablementId, enabled: boolean) => void; + defaultRuntime: RuntimeEnablementId | null; + setDefaultRuntime: (id: RuntimeEnablementId | null) => void; +} + +function normalizeDefaultRuntime(value: unknown): RuntimeEnablementId | null { + if (typeof value !== "string") { + return null; + } + + return RUNTIME_ENABLEMENT_IDS.includes(value as RuntimeEnablementId) + ? (value as RuntimeEnablementId) + : null; +} + +export function useRuntimeEnablement(): RuntimeEnablementState { + const { api } = useAPI(); + const [rawEnablement, setRawEnablement] = usePersistedState( + RUNTIME_ENABLEMENT_KEY, + DEFAULT_RUNTIME_ENABLEMENT, + { listener: true } + ); + const [rawDefaultRuntime, setRawDefaultRuntime] = usePersistedState( + DEFAULT_RUNTIME_KEY, + null, + { listener: true } + ); + + // Normalize persisted values so corrupted/legacy payloads don't break toggles. + // Stabilize the reference: normalizeRuntimeEnablement returns a fresh object every call, + // so we use a ref to return the same object when the values haven't changed. This prevents + // downstream effects from re-running on every render. + const normalized = normalizeRuntimeEnablement(rawEnablement); + const enablementRef = useRef(normalized); + const prevSerializedRef = useRef(JSON.stringify(normalized)); + const currentSerialized = JSON.stringify(normalized); + if (currentSerialized !== prevSerializedRef.current) { + enablementRef.current = normalized; + prevSerializedRef.current = currentSerialized; + } + const enablement = enablementRef.current; + const defaultRuntime = normalizeDefaultRuntime(rawDefaultRuntime); + + const setRuntimeEnabled = (id: RuntimeEnablementId, enabled: boolean) => { + const nextMap: RuntimeEnablement = { + ...enablement, + [id]: enabled, + }; + + // Persist locally first so Settings reflects changes immediately and stays in sync. + setRawEnablement(nextMap); + + // Best-effort backend write keeps ~/.mux/config.json aligned across devices. + api?.config?.updateRuntimeEnablement({ runtimeEnablement: nextMap }).catch(() => { + // Best-effort only. + }); + }; + + const setDefaultRuntime = (id: RuntimeEnablementId | null) => { + // Keep the local cache and config.json aligned for the global default runtime. + setRawDefaultRuntime(id); + + api?.config?.updateRuntimeEnablement({ defaultRuntime: id }).catch(() => { + // Best-effort only. + }); + }; + + return { enablement, setRuntimeEnabled, defaultRuntime, setDefaultRuntime }; +} diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 689ef7ecfe..b101cb6209 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -34,6 +34,11 @@ import { MUX_HELP_CHAT_WORKSPACE_NAME, MUX_HELP_CHAT_WORKSPACE_TITLE, } from "@/common/constants/muxChat"; +import { + normalizeRuntimeEnablement, + RUNTIME_ENABLEMENT_IDS, + type RuntimeEnablementId, +} from "@/common/types/runtime"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { DEFAULT_TASK_SETTINGS, @@ -98,6 +103,10 @@ export interface MockORPCClientOptions { subagentAiDefaults?: SubagentAiDefaults; /** Coder lifecycle preferences for config.getConfig (e.g., Settings → Coder section) */ stopCoderWorkspaceOnArchive?: boolean; + /** Initial runtime enablement for config.getConfig */ + runtimeEnablement?: Record; + /** Initial default runtime for config.getConfig (global) */ + defaultRuntime?: RuntimeEnablementId | null; /** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */ onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => (() => void) | void; /** Mock for executeBash per workspace */ @@ -290,6 +299,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl subagentAiDefaults: initialSubagentAiDefaults, agentAiDefaults: initialAgentAiDefaults, stopCoderWorkspaceOnArchive: initialStopCoderWorkspaceOnArchive = true, + runtimeEnablement: initialRuntimeEnablement, + defaultRuntime: initialDefaultRuntime, agentDefinitions: initialAgentDefinitions, listBranches: customListBranches, gitInit: customGitInit, @@ -405,7 +416,16 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let muxGatewayEnabled: boolean | undefined = undefined; let muxGatewayModels: string[] | undefined = undefined; let stopCoderWorkspaceOnArchive = initialStopCoderWorkspaceOnArchive; + let runtimeEnablement: Record = initialRuntimeEnablement ?? { + local: true, + worktree: true, + ssh: true, + coder: true, + docker: true, + devcontainer: true, + }; + let defaultRuntime: RuntimeEnablementId | null = initialDefaultRuntime ?? null; let globalSecretsState: Secret[] = [...globalSecrets]; const globalMcpServersState: MockMcpServers = { ...globalMcpServers }; @@ -527,6 +547,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl muxGatewayEnabled, muxGatewayModels, stopCoderWorkspaceOnArchive, + runtimeEnablement, + defaultRuntime, agentAiDefaults, subagentAiDefaults, muxGovernorUrl, @@ -574,6 +596,82 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl stopCoderWorkspaceOnArchive = input.stopCoderWorkspaceOnArchive; return Promise.resolve(undefined); }, + updateRuntimeEnablement: (input: { + projectPath?: string | null; + runtimeEnablement?: Record | null; + defaultRuntime?: RuntimeEnablementId | null; + runtimeOverridesEnabled?: boolean | null; + }) => { + const shouldUpdateRuntimeEnablement = input.runtimeEnablement !== undefined; + const shouldUpdateDefaultRuntime = input.defaultRuntime !== undefined; + const shouldUpdateOverridesEnabled = input.runtimeOverridesEnabled !== undefined; + const projectPath = input.projectPath?.trim(); + + const runtimeEnablementOverrides = + input.runtimeEnablement == null + ? undefined + : (() => { + const normalized = normalizeRuntimeEnablement(input.runtimeEnablement); + const disabled: Partial> = {}; + + for (const runtimeId of RUNTIME_ENABLEMENT_IDS) { + if (!normalized[runtimeId]) { + disabled[runtimeId] = false; + } + } + + return Object.keys(disabled).length > 0 ? disabled : undefined; + })(); + + const runtimeOverridesEnabled = input.runtimeOverridesEnabled === true ? true : undefined; + + if (projectPath) { + const project = projects.get(projectPath); + if (project) { + const nextProject = { ...project }; + if (shouldUpdateRuntimeEnablement) { + if (runtimeEnablementOverrides) { + nextProject.runtimeEnablement = runtimeEnablementOverrides; + } else { + delete nextProject.runtimeEnablement; + } + } + + if (shouldUpdateDefaultRuntime) { + if (input.defaultRuntime !== null && input.defaultRuntime !== undefined) { + nextProject.defaultRuntime = input.defaultRuntime; + } else { + delete nextProject.defaultRuntime; + } + } + + if (shouldUpdateOverridesEnabled) { + if (runtimeOverridesEnabled) { + nextProject.runtimeOverridesEnabled = true; + } else { + delete nextProject.runtimeOverridesEnabled; + } + } + projects.set(projectPath, nextProject); + } + + return Promise.resolve(undefined); + } + + if (shouldUpdateRuntimeEnablement) { + if (input.runtimeEnablement == null) { + runtimeEnablement = normalizeRuntimeEnablement({}); + } else { + runtimeEnablement = normalizeRuntimeEnablement(input.runtimeEnablement); + } + } + + if (shouldUpdateDefaultRuntime) { + defaultRuntime = input.defaultRuntime ?? null; + } + + return Promise.resolve(undefined); + }, unenrollMuxGovernor: () => Promise.resolve(undefined), }, agents: { diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index c9dcbd39b9..968e78d2f5 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -89,6 +89,16 @@ export const GATEWAY_MODELS_KEY = "gateway-models"; // enabled model IDs (canoni export const GATEWAY_CONFIGURED_KEY = "gateway-available"; // synced from provider config export const GATEWAY_ENABLED_KEY = "gateway-enabled"; // global on/off toggle +/** + * Storage key for runtime enablement settings (shared via ~/.mux/config.json). + */ +export const RUNTIME_ENABLEMENT_KEY = "runtimeEnablement"; + +/** + * Storage key for global default runtime selection (shared via ~/.mux/config.json). + */ +export const DEFAULT_RUNTIME_KEY = "defaultRuntime"; + /** * Get the localStorage key for cached MCP server test results (per project) * Format: "mcpTestResults:{projectPath}" diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 223f4cd32d..9fb1f968b0 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -8,6 +8,7 @@ export { ResultSchema } from "./schemas/result"; export { RuntimeConfigSchema, RuntimeModeSchema, + RuntimeEnablementIdSchema, RuntimeAvailabilitySchema, RuntimeAvailabilityStatusSchema, DevcontainerConfigInfoSchema, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 5132928871..a3eb24b9ba 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -6,7 +6,11 @@ import { SendMessageErrorSchema } from "./errors"; import { BranchListResultSchema, FilePartSchema, MuxMessageSchema } from "./message"; import { ProjectConfigSchema, SectionConfigSchema } from "./project"; import { ResultSchema } from "./result"; -import { RuntimeConfigSchema, RuntimeAvailabilitySchema } from "./runtime"; +import { + RuntimeConfigSchema, + RuntimeAvailabilitySchema, + RuntimeEnablementIdSchema, +} from "./runtime"; import { SecretSchema } from "./secrets"; import { CompletedMessagePartSchema, @@ -1432,6 +1436,8 @@ export const config = { hiddenModels: z.array(z.string()).optional(), preferredCompactionModel: z.string().optional(), stopCoderWorkspaceOnArchive: z.boolean(), + runtimeEnablement: z.record(z.string(), z.boolean()), + defaultRuntime: z.string().nullable(), agentAiDefaults: AgentAiDefaultsSchema, // Legacy fields (downgrade compatibility) subagentAiDefaults: SubagentAiDefaultsSchema, @@ -1487,6 +1493,17 @@ export const config = { .strict(), output: z.void(), }, + updateRuntimeEnablement: { + input: z + .object({ + projectPath: z.string().nullish(), + runtimeEnablement: z.record(z.string(), z.boolean()).nullish(), + defaultRuntime: RuntimeEnablementIdSchema.nullish(), + runtimeOverridesEnabled: z.boolean().nullish(), + }) + .strict(), + output: z.void(), + }, unenrollMuxGovernor: { input: z.void(), output: z.void(), diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index f6fe8fee12..cf55e255b6 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -1,10 +1,18 @@ import { z } from "zod"; -import { RuntimeConfigSchema } from "./runtime"; +import { RuntimeConfigSchema, RuntimeEnablementIdSchema } from "./runtime"; import { WorkspaceMCPOverridesSchema } from "./mcp"; import { WorkspaceAISettingsByAgentSchema, WorkspaceAISettingsSchema } from "./workspaceAiSettings"; const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh", "max"]); +const RuntimeEnablementOverridesSchema = z + .object( + Object.fromEntries( + RuntimeEnablementIdSchema.options.map((runtimeId) => [runtimeId, z.literal(false)]) + ) as Record> + ) + .partial(); + /** * Section schema for organizing workspaces within a project. * Sections are project-scoped and persist to config.json. @@ -123,4 +131,13 @@ export const ProjectConfigSchema = z.object({ description: "Hours of inactivity before auto-compacting workspaces. null/undefined = disabled.", }), + runtimeEnablement: RuntimeEnablementOverridesSchema.optional().meta({ + description: "Runtime enablement overrides (store `false` only to keep config.json minimal)", + }), + runtimeOverridesEnabled: z.boolean().optional().meta({ + description: "Whether this project uses runtime overrides, even if no overrides are set", + }), + defaultRuntime: RuntimeEnablementIdSchema.optional().meta({ + description: "Default runtime override for new workspaces in this project", + }), }); diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts index cdab0503e9..d97cc89c2f 100644 --- a/src/common/orpc/schemas/runtime.ts +++ b/src/common/orpc/schemas/runtime.ts @@ -3,6 +3,15 @@ import { CoderWorkspaceConfigSchema } from "./coder"; export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh", "docker", "devcontainer"]); +export const RuntimeEnablementIdSchema = z.enum([ + "local", + "worktree", + "ssh", + "coder", + "docker", + "devcontainer", +]); + /** * Runtime configuration union type. * diff --git a/src/common/types/project.ts b/src/common/types/project.ts index bcae208fbd..29f353cf01 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -12,6 +12,7 @@ import type { import type { TaskSettings, SubagentAiDefaults } from "./tasks"; import type { LayoutPresetsConfig } from "./uiLayouts"; import type { AgentAiDefaults } from "./agentAiDefaults"; +import type { RuntimeEnablementId } from "./runtime"; export type Workspace = z.infer; @@ -108,4 +109,13 @@ export interface ProjectsConfig { * Stored as `false` only (undefined behaves as true) to keep config.json minimal. */ stopCoderWorkspaceOnArchive?: boolean; + + /** Global default runtime for new workspaces. */ + defaultRuntime?: RuntimeEnablementId; + + /** + * Runtime enablement overrides (shared via ~/.mux/config.json). + * Defaults to enabled; store `false` only to keep config.json minimal. + */ + runtimeEnablement?: Partial>; } diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index 324b59ba0b..21f05598d7 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -4,7 +4,7 @@ import type { z } from "zod"; import type { RuntimeConfigSchema } from "../orpc/schemas"; -import { RuntimeModeSchema } from "../orpc/schemas"; +import { RuntimeEnablementIdSchema, RuntimeModeSchema } from "../orpc/schemas"; import type { CoderWorkspaceConfig } from "../orpc/schemas/coder"; // Re-export CoderWorkspaceConfig type from schema (single source of truth) @@ -22,6 +22,46 @@ export const RUNTIME_MODE = { DEVCONTAINER: "devcontainer" as const, } as const; +/** + * Runtime IDs that can be enabled/disabled in Settings → Runtimes. + * Note: includes "coder" which is a UI-level choice (not a RuntimeMode). + */ +export const RUNTIME_ENABLEMENT_IDS = RuntimeEnablementIdSchema.options; + +export type RuntimeEnablementId = z.infer; + +export type RuntimeEnablement = Record; + +export const DEFAULT_RUNTIME_ENABLEMENT: RuntimeEnablement = { + local: true, + worktree: true, + ssh: true, + coder: true, + docker: true, + devcontainer: true, +}; + +/** + * Normalize runtime enablement, defaulting missing/invalid keys to true. + */ +export function normalizeRuntimeEnablement(value: unknown): RuntimeEnablement { + const normalized: RuntimeEnablement = { ...DEFAULT_RUNTIME_ENABLEMENT }; + + if (!value || typeof value !== "object") { + return normalized; + } + + const record = value as Record; + for (const runtimeId of RUNTIME_ENABLEMENT_IDS) { + const entry = record[runtimeId]; + if (typeof entry === "boolean") { + normalized[runtimeId] = entry; + } + } + + return normalized; +} + /** Runtime string prefix for SSH mode (e.g., "ssh hostname") */ export const SSH_RUNTIME_PREFIX = "ssh "; diff --git a/src/node/config.ts b/src/node/config.ts index c6f0a09ec2..2f4426a8ad 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -19,6 +19,7 @@ import { } from "@/common/types/tasks"; import { isLayoutPresetsConfigEmpty, normalizeLayoutPresetsConfig } from "@/common/types/uiLayouts"; import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; +import { RUNTIME_ENABLEMENT_IDS, type RuntimeEnablementId } from "@/common/types/runtime"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; @@ -131,6 +132,78 @@ function parseOptionalPort(value: unknown): number | undefined { return value; } + +function normalizeRuntimeEnablementId(value: unknown): RuntimeEnablementId | undefined { + const trimmed = parseOptionalNonEmptyString(value); + if (!trimmed) { + return undefined; + } + + const normalized = trimmed.toLowerCase(); + if (RUNTIME_ENABLEMENT_IDS.includes(normalized as RuntimeEnablementId)) { + return normalized as RuntimeEnablementId; + } + + return undefined; +} + +function normalizeRuntimeEnablementOverrides( + value: unknown +): Partial> | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const record = value as Record; + const overrides: Partial> = {}; + + for (const runtimeId of RUNTIME_ENABLEMENT_IDS) { + // Default ON: store `false` only so config.json stays minimal. + if (record[runtimeId] === false) { + overrides[runtimeId] = false; + } + } + + return Object.keys(overrides).length > 0 ? overrides : undefined; +} + +function normalizeProjectRuntimeSettings(projectConfig: ProjectConfig): ProjectConfig { + // Per-project runtime overrides are optional; keep config.json sparse by persisting only explicit + // overrides (false enablement + explicit default runtime selections). + if (!projectConfig || typeof projectConfig !== "object") { + return { workspaces: [] }; + } + + const record = projectConfig as ProjectConfig & { + runtimeEnablement?: unknown; + defaultRuntime?: unknown; + runtimeOverridesEnabled?: unknown; + }; + const runtimeEnablement = normalizeRuntimeEnablementOverrides(record.runtimeEnablement); + const defaultRuntime = normalizeRuntimeEnablementId(record.defaultRuntime); + const runtimeOverridesEnabled = record.runtimeOverridesEnabled === true ? true : undefined; + + const next = { ...record }; + if (runtimeEnablement) { + next.runtimeEnablement = runtimeEnablement; + } else { + delete next.runtimeEnablement; + } + + if (runtimeOverridesEnabled) { + next.runtimeOverridesEnabled = runtimeOverridesEnabled; + } else { + delete next.runtimeOverridesEnabled; + } + + if (defaultRuntime) { + next.defaultRuntime = defaultRuntime; + } else { + delete next.defaultRuntime; + } + + return next; +} export type ProvidersConfig = Record; /** @@ -184,6 +257,8 @@ export class Config { muxGovernorUrl?: unknown; muxGovernorToken?: unknown; stopCoderWorkspaceOnArchive?: unknown; + runtimeEnablement?: unknown; + defaultRuntime?: unknown; }; // Config is stored as array of [path, config] pairs @@ -201,7 +276,11 @@ export class Config { return true; }) .map(([projectPath, projectConfig]) => { - return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig]; + const normalizedProjectConfig = normalizeProjectRuntimeSettings(projectConfig); + return [stripTrailingSlashes(projectPath), normalizedProjectConfig] as [ + string, + ProjectConfig, + ]; }); const projectsMap = new Map(normalizedPairs); @@ -221,6 +300,9 @@ export class Config { const stopCoderWorkspaceOnArchive = parseOptionalBoolean(parsed.stopCoderWorkspaceOnArchive) === false ? false : undefined; + const runtimeEnablement = normalizeRuntimeEnablementOverrides(parsed.runtimeEnablement); + const defaultRuntime = normalizeRuntimeEnablementId(parsed.defaultRuntime); + const agentAiDefaults = parsed.agentAiDefaults !== undefined ? normalizeAgentAiDefaults(parsed.agentAiDefaults) @@ -258,6 +340,8 @@ export class Config { muxGovernorUrl: parseOptionalNonEmptyString(parsed.muxGovernorUrl), muxGovernorToken: parseOptionalNonEmptyString(parsed.muxGovernorToken), stopCoderWorkspaceOnArchive, + defaultRuntime, + runtimeEnablement, }; } } @@ -304,8 +388,13 @@ export class Config { muxGovernorUrl?: string; muxGovernorToken?: string; stopCoderWorkspaceOnArchive?: boolean; + runtimeEnablement?: ProjectsConfig["runtimeEnablement"]; + defaultRuntime?: ProjectsConfig["defaultRuntime"]; } = { - projects: Array.from(config.projects.entries()), + projects: Array.from(config.projects.entries()).map( + ([projectPath, projectConfig]) => + [projectPath, normalizeProjectRuntimeSettings(projectConfig)] as [string, ProjectConfig] + ), taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, }; @@ -416,6 +505,16 @@ export class Config { data.stopCoderWorkspaceOnArchive = false; } + const runtimeEnablement = normalizeRuntimeEnablementOverrides(config.runtimeEnablement); + if (runtimeEnablement) { + data.runtimeEnablement = runtimeEnablement; + } + + const defaultRuntime = normalizeRuntimeEnablementId(config.defaultRuntime); + if (defaultRuntime !== undefined) { + data.defaultRuntime = defaultRuntime; + } + await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8"); } catch (error) { log.error("Error saving config:", error); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 928bae44ba..c496551f73 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -41,6 +41,11 @@ import { normalizeSubagentAiDefaults, normalizeTaskSettings, } from "@/common/types/tasks"; +import { + normalizeRuntimeEnablement, + RUNTIME_ENABLEMENT_IDS, + type RuntimeEnablementId, +} from "@/common/types/runtime"; import { discoverAgentSkills, discoverAgentSkillsDiagnostics, @@ -508,6 +513,8 @@ export const router = (authToken?: string) => { hiddenModels: config.hiddenModels, preferredCompactionModel: config.preferredCompactionModel, stopCoderWorkspaceOnArchive: config.stopCoderWorkspaceOnArchive !== false, + runtimeEnablement: normalizeRuntimeEnablement(config.runtimeEnablement), + defaultRuntime: config.defaultRuntime ?? null, agentAiDefaults: config.agentAiDefaults ?? {}, // Legacy fields (downgrade compatibility) subagentAiDefaults: config.subagentAiDefaults ?? {}, @@ -621,6 +628,93 @@ export const router = (authToken?: string) => { }; }); }), + updateRuntimeEnablement: t + .input(schemas.config.updateRuntimeEnablement.input) + .output(schemas.config.updateRuntimeEnablement.output) + .handler(async ({ context, input }) => { + await context.config.editConfig((config) => { + const shouldUpdateRuntimeEnablement = input.runtimeEnablement !== undefined; + const shouldUpdateDefaultRuntime = input.defaultRuntime !== undefined; + const shouldUpdateOverridesEnabled = input.runtimeOverridesEnabled !== undefined; + const projectPath = input.projectPath?.trim(); + + if ( + !shouldUpdateRuntimeEnablement && + !shouldUpdateDefaultRuntime && + !shouldUpdateOverridesEnabled + ) { + return config; + } + + const runtimeEnablementOverrides = + input.runtimeEnablement == null + ? undefined + : (() => { + const normalized = normalizeRuntimeEnablement(input.runtimeEnablement); + const disabled: Partial> = {}; + + for (const runtimeId of RUNTIME_ENABLEMENT_IDS) { + if (!normalized[runtimeId]) { + disabled[runtimeId] = false; + } + } + + return Object.keys(disabled).length > 0 ? disabled : undefined; + })(); + + const defaultRuntime = input.defaultRuntime ?? undefined; + const runtimeOverridesEnabled = + input.runtimeOverridesEnabled === true ? true : undefined; + + if (projectPath) { + const project = config.projects.get(projectPath); + if (!project) { + log.warn("Runtime settings update requested for missing project", { projectPath }); + return config; + } + + const nextProject = { ...project }; + + if (shouldUpdateRuntimeEnablement) { + if (runtimeEnablementOverrides) { + nextProject.runtimeEnablement = runtimeEnablementOverrides; + } else { + delete nextProject.runtimeEnablement; + } + } + + if (shouldUpdateDefaultRuntime) { + if (defaultRuntime !== undefined) { + nextProject.defaultRuntime = defaultRuntime; + } else { + delete nextProject.defaultRuntime; + } + } + + if (shouldUpdateOverridesEnabled) { + if (runtimeOverridesEnabled) { + nextProject.runtimeOverridesEnabled = true; + } else { + delete nextProject.runtimeOverridesEnabled; + } + } + const nextProjects = new Map(config.projects); + nextProjects.set(projectPath, nextProject); + return { ...config, projects: nextProjects }; + } + + const next = { ...config }; + if (shouldUpdateRuntimeEnablement) { + next.runtimeEnablement = runtimeEnablementOverrides; + } + + if (shouldUpdateDefaultRuntime) { + next.defaultRuntime = defaultRuntime; + } + + return next; + }); + }), saveConfig: t .input(schemas.config.saveConfig.input) .output(schemas.config.saveConfig.output)