From 902074bd6b065a536bf43c89ef73d675e56ffc02 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 30 Jan 2026 14:18:21 +1100 Subject: [PATCH 1/8] feat: add Coder as separate runtime button - Add Coder as a first-class runtime button (UI-only, SSH under the hood) - RuntimeChoice type extends RuntimeMode with 'coder' for UI selection - Coder button visible when CLI available, disabled with tooltip when outdated - Update resolveRuntimeButtonState to handle Coder outdated case - Add missing policy props (allowedRuntimeModes, allowSshHost, etc.) - Update stories for new Coder runtime button UX --- .../components/ChatInput/CoderControls.tsx | 574 ++++++++++-------- .../components/ChatInput/CreationControls.tsx | 409 ++++++++----- .../ChatInput/useCreationWorkspace.ts | 3 +- .../hooks/useDraftWorkspaceSettings.ts | 226 ++++--- src/browser/stories/App.coder.stories.tsx | 116 ++-- src/browser/utils/runtimeUi.ts | 20 +- 6 files changed, 750 insertions(+), 598 deletions(-) diff --git a/src/browser/components/ChatInput/CoderControls.tsx b/src/browser/components/ChatInput/CoderControls.tsx index 6ae222c674..a830ec19ff 100644 --- a/src/browser/components/ChatInput/CoderControls.tsx +++ b/src/browser/components/ChatInput/CoderControls.tsx @@ -1,5 +1,5 @@ /** - * Coder workspace controls for SSH runtime. + * Coder workspace controls for the SSH-based Coder runtime. * Enables creating or connecting to Coder cloud workspaces. */ import React from "react"; @@ -16,7 +16,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; export interface CoderControlsProps { - /** Whether to use Coder workspace (checkbox state) */ + /** Whether Coder is enabled for this workspace */ enabled: boolean; onEnabledChange: (enabled: boolean) => void; @@ -40,18 +40,126 @@ export interface CoderControlsProps { /** Disabled state (e.g., during creation) */ disabled: boolean; - /** Policy: plain host SSH may be disallowed, requiring Coder workspaces. */ - requiredByPolicy?: boolean; /** Error state for visual feedback */ hasError?: boolean; } type CoderMode = "new" | "existing"; -/** - * Coder workspace controls component. - * Shows checkbox to enable Coder, then New/Existing toggle with appropriate dropdowns. - */ +const CODER_CHECKING_LABEL = "Checking…"; + +/** Check if a template name exists in multiple organizations (for disambiguation in UI) */ +function hasTemplateDuplicateName(template: CoderTemplate, allTemplates: CoderTemplate[]): boolean { + return allTemplates.some( + (t) => t.name === template.name && t.organizationName !== template.organizationName + ); +} + +export type CoderAvailabilityState = + | { state: "loading"; shouldShowRuntimeButton: false } + | { state: "outdated"; reason: string; shouldShowRuntimeButton: true } + | { state: "unavailable"; shouldShowRuntimeButton: false } + | { state: "available"; shouldShowRuntimeButton: true }; + +function getCoderOutdatedReason(coderInfo: Extract) { + return `Coder CLI v${coderInfo.version} is below the minimum required v${coderInfo.minVersion}. Update the CLI to enable.`; +} + +export function resolveCoderAvailability(coderInfo: CoderInfo | null): CoderAvailabilityState { + if (coderInfo === null) { + return { state: "loading", shouldShowRuntimeButton: false }; + } + + if (coderInfo.state === "outdated") { + return { + state: "outdated", + reason: getCoderOutdatedReason(coderInfo), + shouldShowRuntimeButton: true, + }; + } + + if (coderInfo.state === "unavailable") { + return { state: "unavailable", shouldShowRuntimeButton: false }; + } + + // Only show the runtime button once the CLI is confirmed available (matches devcontainer UX). + return { state: "available", shouldShowRuntimeButton: true }; +} + +// Split status messaging from the SSH-only checkbox so the Coder runtime can render +// availability without a hidden toggle prop. +export function CoderAvailabilityMessage(props: { coderInfo: CoderInfo | null }) { + const availability = resolveCoderAvailability(props.coderInfo); + + if (availability.state === "loading") { + return ( + + + {CODER_CHECKING_LABEL} + + ); + } + + if (availability.state === "outdated") { + return

{availability.reason}

; + } + + return null; +} + +function CoderEnableToggle(props: { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + disabled: boolean; + coderInfo: CoderInfo | null; +}) { + const availability = resolveCoderAvailability(props.coderInfo); + + if (availability.state === "loading") { + return ( + + + {CODER_CHECKING_LABEL} + + } + /> + ); + } + + if (availability.state === "outdated") { + return ( + + ); + } + + if (availability.state === "unavailable") { + return null; + } + + return ( + + ); +} + +export type CoderWorkspaceFormProps = Omit< + CoderControlsProps, + "enabled" | "onEnabledChange" | "coderInfo" +>; + /** Checkbox row with optional status indicator and tooltip for disabled state */ function CoderCheckbox(props: { enabled: boolean; @@ -100,11 +208,8 @@ function CoderCheckbox(props: { return checkboxElement; } -export function CoderControls(props: CoderControlsProps) { +export function CoderWorkspaceForm(props: CoderWorkspaceFormProps) { const { - enabled, - onEnabledChange, - coderInfo, coderConfig, onCoderConfigChange, templates, @@ -117,59 +222,6 @@ export function CoderControls(props: CoderControlsProps) { hasError, } = props; - // Coder CLI status: loading (null), unavailable, outdated, or available - if (coderInfo === null) { - return ( -
- - - Checking… - - } - /> -
- ); - } - - // CLI outdated: show checkbox disabled with tooltip explaining version mismatch - if (coderInfo.state === "outdated") { - const reason = `Coder CLI v${coderInfo.version} is below the minimum required v${coderInfo.minVersion}. Update the CLI to enable.`; - return ( -
- -
- ); - } - - // CLI unavailable (missing/broken): hide checkbox entirely unless policy requires Coder. - if (coderInfo.state === "unavailable") { - if (props.requiredByPolicy) { - const reason = "Coder CLI is unavailable, but Coder workspaces are required by policy."; - return ( -
- -
- ); - } - - return null; - } - const mode: CoderMode = coderConfig?.existingWorkspace ? "existing" : "new"; const handleModeChange = (newMode: CoderMode) => { @@ -182,12 +234,7 @@ export function CoderControls(props: CoderControlsProps) { } else { // Switch to new workspace mode (workspaceName omitted; backend derives from branch) const firstTemplate = templates[0]; - const firstIsDuplicate = firstTemplate - ? templates.some( - (t) => - t.name === firstTemplate.name && t.organizationName !== firstTemplate.organizationName - ) - : false; + const firstIsDuplicate = firstTemplate && hasTemplateDuplicateName(firstTemplate, templates); onCoderConfigChange({ existingWorkspace: false, template: firstTemplate?.name, @@ -240,199 +287,210 @@ export function CoderControls(props: CoderControlsProps) { : (coderConfig?.preset ?? defaultPresetName ?? presets[0]?.name); return ( -
- {props.requiredByPolicy &&

Required by policy.

} - - - {/* Coder controls - only shown when enabled */} - {enabled && ( -
- {/* Left column: New/Existing toggle buttons */} -
- - - - - Create a new Coder workspace from a template - - - - + + Create a new Coder workspace from a template + + + + + + Connect to an existing Coder workspace + +
+ + {/* Right column: Mode-specific controls */} + {/* New workspace controls - template/preset stacked vertically */} + {mode === "new" && ( +
+
+ + {loadingTemplates ? ( + + ) : ( + + )}
+
+ + {loadingPresets ? ( + + ) : ( + + )} +
+
+ )} - {/* Right column: Mode-specific controls */} - {/* New workspace controls - template/preset stacked vertically */} - {mode === "new" && ( -
-
- - {loadingTemplates ? ( - - ) : ( - - )} -
-
- - {loadingPresets ? ( - - ) : ( - - )} -
-
- )} - - {/* Existing workspace controls - min-h matches New mode (2×h-7 + gap-1 + p-2) */} - {mode === "existing" && ( -
- - {loadingWorkspaces ? ( - - ) : ( - - )} -
+ {/* Existing workspace controls - min-h matches New mode (2×h-7 + gap-1 + p-2) */} + {mode === "existing" && ( +
+ + {loadingWorkspaces ? ( + + ) : ( + )}
)}
); } + +export function CoderControls(props: CoderControlsProps) { + const availability = resolveCoderAvailability(props.coderInfo); + + if (availability.state === "unavailable") { + return null; + } + + return ( +
+ + {availability.state === "available" && props.enabled && ( + + )} +
+ ); +} diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index c1bc976405..a43720d3ef 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,6 +1,11 @@ import React, { useCallback, useEffect } from "react"; -import { RUNTIME_MODE, type RuntimeMode, type ParsedRuntime } from "@/common/types/runtime"; -import { type RuntimeAvailabilityState } from "./useCreationWorkspace"; +import { + RUNTIME_MODE, + type RuntimeMode, + type ParsedRuntime, + CODER_RUNTIME_PLACEHOLDER, +} from "@/common/types/runtime"; +import type { RuntimeAvailabilityMap, RuntimeAvailabilityState } from "./useCreationWorkspace"; import { resolveDevcontainerSelection, DEFAULT_DEVCONTAINER_CONFIG_PATH, @@ -20,11 +25,77 @@ import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import { DocsLink } from "../DocsLink"; -import { RUNTIME_UI, type RuntimeIconProps } from "@/browser/utils/runtimeUi"; +import { + RUNTIME_CHOICE_UI, + type RuntimeChoice, + type RuntimeIconProps, +} from "@/browser/utils/runtimeUi"; import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; +import type { CoderInfo } from "@/common/orpc/schemas/coder"; import type { SectionConfig } from "@/common/types/project"; import { resolveSectionColor } from "@/common/constants/ui"; -import { CoderControls, type CoderControlsProps } from "./CoderControls"; +import { + CoderAvailabilityMessage, + CoderWorkspaceForm, + resolveCoderAvailability, + type CoderAvailabilityState, + type CoderControlsProps, +} from "./CoderControls"; + +/** Shared runtime config text input - used for SSH host, Docker image, etc. */ +function RuntimeConfigInput(props: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + disabled?: boolean; + hasError?: boolean; + id?: string; + ariaLabel?: string; +}) { + return ( +
+ + props.onChange(e.target.value)} + placeholder={props.placeholder} + disabled={props.disabled} + className={cn( + "bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50", + props.hasError && "border-red-500" + )} + /> +
+ ); +} + +/** Credential sharing checkbox - used by Docker and Devcontainer runtimes */ +function CredentialSharingCheckbox(props: { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + docsPath: string; +}) { + return ( + + ); +} interface CreationControlsProps { branches: string[]; @@ -34,7 +105,7 @@ interface CreationControlsProps { onTrunkBranchChange: (branch: string) => void; /** Currently selected runtime (discriminated union: SSH has host, Docker has image) */ selectedRuntime: ParsedRuntime; - defaultRuntimeMode: RuntimeMode; + defaultRuntimeMode: RuntimeChoice; /** Set the currently selected runtime (discriminated union) */ onSelectedRuntimeChange: (runtime: ParsedRuntime) => void; onSetDefaultRuntime: (mode: RuntimeMode) => void; @@ -70,30 +141,26 @@ interface CreationControlsProps { /** Runtime type button group with icons and colors */ interface RuntimeButtonGroupProps { - value: RuntimeMode; - onChange: (mode: RuntimeMode) => void; - defaultMode: RuntimeMode; + value: RuntimeChoice; + onChange: (mode: RuntimeChoice) => void; + defaultMode: RuntimeChoice; onSetDefault: (mode: RuntimeMode) => void; disabled?: boolean; - /** Policy: allowed runtime modes (null/undefined = allow all) */ - allowedRuntimeModes?: RuntimeMode[] | null; - /** Policy: allow plain host SSH */ - allowSshHost?: boolean; - /** Policy: allow Coder-backed SSH */ - allowSshCoder?: boolean; runtimeAvailabilityState?: RuntimeAvailabilityState; + coderInfo?: CoderInfo | null; } -const RUNTIME_ORDER: RuntimeMode[] = [ +const RUNTIME_CHOICE_ORDER: RuntimeChoice[] = [ RUNTIME_MODE.LOCAL, RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH, + "coder", RUNTIME_MODE.DOCKER, RUNTIME_MODE.DEVCONTAINER, ]; -const RUNTIME_OPTIONS: Array<{ - value: RuntimeMode; +const RUNTIME_CHOICE_OPTIONS: Array<{ + value: RuntimeChoice; label: string; description: string; docsPath: string; @@ -101,8 +168,8 @@ const RUNTIME_OPTIONS: Array<{ // Active state colors using CSS variables for theme support activeClass: string; idleClass: string; -}> = RUNTIME_ORDER.map((mode) => { - const ui = RUNTIME_UI[mode]; +}> = RUNTIME_CHOICE_ORDER.map((mode) => { + const ui = RUNTIME_CHOICE_UI[mode]; return { value: mode, label: ui.label, @@ -114,6 +181,43 @@ const RUNTIME_OPTIONS: Array<{ }; }); +interface RuntimeButtonState { + isModeDisabled: boolean; + disabledReason?: string; + isDefault: boolean; +} + +const resolveRuntimeButtonState = ( + value: RuntimeChoice, + availabilityMap: RuntimeAvailabilityMap | null, + defaultMode: RuntimeChoice, + coderAvailability: CoderAvailabilityState +): RuntimeButtonState => { + // Coder outdated: button visible but disabled with version reason. + if (value === "coder" && coderAvailability.state === "outdated") { + return { + isModeDisabled: true, + disabledReason: coderAvailability.reason, + isDefault: defaultMode === value, + }; + } + + // Coder is SSH under the hood; all other RuntimeChoice values are RuntimeMode identity. + const availabilityKey = value === "coder" ? RUNTIME_MODE.SSH : value; + const availability = availabilityMap?.[availabilityKey]; + // Disable only if availability is explicitly known and unavailable. + // When availability is undefined (loading or fetch failed), allow selection + // as fallback - the config picker will validate before creation. + const isModeDisabled = availability !== undefined && !availability.available; + const disabledReason = availability && !availability.available ? availability.reason : undefined; + + return { + isModeDisabled, + disabledReason, + isDefault: defaultMode === value, + }; +}; + /** Aesthetic section picker with color accent */ interface SectionPickerProps { sections: SectionConfig[]; @@ -178,6 +282,8 @@ function SectionPicker(props: SectionPickerProps) { function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { const state = props.runtimeAvailabilityState; const availabilityMap = state?.status === "loaded" ? state.data : null; + const coderInfo = props.coderInfo ?? null; + const coderAvailability = resolveCoderAvailability(coderInfo); // Hide devcontainer while loading OR when confirmed missing. // Only show when availability is loaded and devcontainer is available. @@ -187,68 +293,50 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { (availabilityMap?.devcontainer?.available === false && availabilityMap.devcontainer.reason === "No devcontainer.json found"); - const allowSshHost = props.allowSshHost ?? true; - const allowSshCoder = props.allowSshCoder ?? true; - const allowedModeSet = props.allowedRuntimeModes ? new Set(props.allowedRuntimeModes) : null; - - const isModeAllowedByPolicy = (mode: RuntimeMode): boolean => { - if (!allowedModeSet) { - return true; - } + // Match devcontainer UX: only surface Coder once availability is confirmed (no flash). + const hideCoder = !coderAvailability.shouldShowRuntimeButton; - if (mode === RUNTIME_MODE.SSH) { - return allowSshHost || allowSshCoder; - } - - return allowedModeSet.has(mode); + const runtimeVisibilityOverrides: Partial> = { + [RUNTIME_MODE.DEVCONTAINER]: !hideDevcontainer, + coder: !hideCoder, }; - const runtimeOptions = ( - hideDevcontainer - ? RUNTIME_OPTIONS.filter((option) => option.value !== RUNTIME_MODE.DEVCONTAINER) - : RUNTIME_OPTIONS - ).filter((option) => { - const allowed = isModeAllowedByPolicy(option.value); - if (!allowed && option.value !== props.value) { - // Hide policy-disabled options unless they're currently selected. - return false; - } - - return true; - }); + const runtimeOptions = RUNTIME_CHOICE_OPTIONS.filter( + (option) => runtimeVisibilityOverrides[option.value] ?? true + ); return (
{runtimeOptions.map((option) => { const isActive = props.value === option.value; - const isDefault = props.defaultMode === option.value; - const availability = availabilityMap?.[option.value]; - // Disable only if availability is explicitly known and unavailable. - // When availability is undefined (loading or fetch failed), allow selection - // as fallback - the config picker will validate before creation. - const isPolicyDisabled = !isModeAllowedByPolicy(option.value); - const isModeDisabled = availability !== undefined && !availability.available; - const disabledReason = isPolicyDisabled - ? "Disabled by policy" - : availability && !availability.available - ? availability.reason - : undefined; - const isDisabled = Boolean(props.disabled) || isModeDisabled || isPolicyDisabled; + const { isModeDisabled, disabledReason, isDefault } = resolveRuntimeButtonState( + option.value, + availabilityMap, + props.defaultMode, + coderAvailability + ); + const Icon = option.Icon; + const handleSetDefault = () => { + // Coder maps to SSH mode (Coder config persisted separately) + const mode = option.value === "coder" ? RUNTIME_MODE.SSH : option.value; + props.onSetDefault(mode); + }; + return (
- {disabledReason ? ( + {isModeDisabled ? (

{disabledReason ?? "Unavailable"}

) : (
- {/* Validation error - own line below name row */} - {nameState.error &&
{nameState.error}
} - {/* Runtime type - button group */}
{ + if (mode === "coder") { + if (!props.coderProps) { + return; + } + if (props.coderProps.coderConfig) { + props.coderProps.onCoderConfigChange(props.coderProps.coderConfig); + } else { + props.coderProps.onEnabledChange(true); + } + return; + } // Convert mode to ParsedRuntime with appropriate defaults switch (mode) { - case RUNTIME_MODE.SSH: + case RUNTIME_MODE.SSH: { + const sshHost = + selectedRuntime.mode === "ssh" && + selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER + ? selectedRuntime.host + : ""; onSelectedRuntimeChange({ mode: "ssh", - host: selectedRuntime.mode === "ssh" ? selectedRuntime.host : "", + host: sshHost, }); break; + } case RUNTIME_MODE.DOCKER: onSelectedRuntimeChange({ mode: "docker", @@ -520,10 +629,8 @@ export function CreationControls(props: CreationControlsProps) { defaultMode={props.defaultRuntimeMode} onSetDefault={props.onSetDefaultRuntime} disabled={props.disabled} - allowedRuntimeModes={props.allowedRuntimeModes} - allowSshHost={props.allowSshHost} - allowSshCoder={props.allowSshCoder} runtimeAvailabilityState={runtimeAvailabilityState} + coderInfo={props.coderProps?.coderInfo ?? null} /> {/* Branch selector - shown for worktree/SSH */} @@ -554,62 +661,40 @@ export function CreationControls(props: CreationControlsProps) {
)} - {/* SSH Host Input - hidden when Coder is enabled or will be enabled after checking */} - {selectedRuntime.mode === "ssh" && - (props.allowSshHost ?? true) && - !props.coderProps?.enabled && - // Also hide when Coder is still checking but has saved config (will enable after check) - !(props.coderProps?.coderInfo === null && props.coderProps?.coderConfig) && ( -
- - onSelectedRuntimeChange({ mode: "ssh", host: e.target.value })} - placeholder="user@host" - disabled={props.disabled} - className={cn( - "bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50", - props.runtimeFieldError === "ssh" && "border-red-500" - )} - /> -
- )} + {/* SSH Host Input - hidden when Coder runtime is selected */} + {selectedRuntime.mode === "ssh" && !isCoderSelected && ( + onSelectedRuntimeChange({ mode: "ssh", host: value })} + placeholder="user@host" + disabled={props.disabled} + hasError={props.runtimeFieldError === "ssh"} + /> + )} {/* Runtime-specific config inputs */} {selectedRuntime.mode === "docker" && ( -
- - - onSelectedRuntimeChange({ - mode: "docker", - image: e.target.value, - shareCredentials: selectedRuntime.shareCredentials, - }) - } - placeholder="node:20" - disabled={props.disabled} - className={cn( - "bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50", - props.runtimeFieldError === "docker" && "border-red-500" - )} - /> -
+ + onSelectedRuntimeChange({ + mode: "docker", + image: value, + shareCredentials: selectedRuntime.shareCredentials, + }) + } + placeholder="node:20" + disabled={props.disabled} + hasError={props.runtimeFieldError === "docker"} + id="docker-image" + ariaLabel="Docker image" + /> )}
- {props.runtimePolicyError && ( -

{props.runtimePolicyError}

- )} - {/* Dev container controls - config dropdown/input + credential sharing */} {selectedRuntime.mode === "devcontainer" && devcontainerSelection.uiMode !== "hidden" && (
@@ -667,54 +752,58 @@ export function CreationControls(props: CreationControlsProps) { {devcontainerSelection.helperText && (

{devcontainerSelection.helperText}

)} - -
- )} - {/* Credential sharing - separate row for consistency with Coder controls */} - {selectedRuntime.mode === "docker" && ( - + )} - {/* Coder Controls - shown when SSH mode is selected and Coder is available */} - {selectedRuntime.mode === "ssh" && props.coderProps && ( - + onSelectedRuntimeChange({ + mode: "docker", + image: selectedRuntime.image, + shareCredentials: checked, + }) + } disabled={props.disabled} - hasError={props.runtimeFieldError === "ssh"} + docsPath="/runtime/docker#credential-sharing" /> )} + + {/* Coder Controls - shown when Coder runtime is selected */} + {isCoderSelected && props.coderProps && ( +
+ {/* Coder runtime needs availability status without the SSH-only toggle. */} + + {props.coderProps.enabled && ( + + )} +
+ )} ); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 3bfbcac35a..26685d9cb1 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -6,6 +6,7 @@ import type { ParsedRuntime, RuntimeAvailabilityStatus, } from "@/common/types/runtime"; +import type { RuntimeChoice } from "@/browser/utils/runtimeUi"; import { buildRuntimeConfig, RUNTIME_MODE } from "@/common/types/runtime"; import type { ThinkingLevel } from "@/common/types/thinking"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; @@ -142,7 +143,7 @@ interface UseCreationWorkspaceReturn { setTrunkBranch: (branch: string) => void; /** Currently selected runtime (discriminated union: SSH has host, Docker has image) */ selectedRuntime: ParsedRuntime; - defaultRuntimeMode: RuntimeMode; + defaultRuntimeMode: RuntimeChoice; /** Set the currently selected runtime (discriminated union) */ setSelectedRuntime: (runtime: ParsedRuntime) => void; /** Set the default runtime mode for this project (persists via checkbox) */ diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index a0af9549e0..19014f9580 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import { usePersistedState } from "./usePersistedState"; +import { readPersistedState, usePersistedState } from "./usePersistedState"; import { useThinkingLevel } from "./useThinkingLevel"; import { getDefaultModel } from "./useModelsFromSettings"; import { @@ -11,6 +11,7 @@ import { RUNTIME_MODE, CODER_RUNTIME_PLACEHOLDER, } from "@/common/types/runtime"; +import type { RuntimeChoice } from "@/browser/utils/runtimeUi"; import { getAgentIdKey, getModelKey, @@ -38,11 +39,53 @@ export interface DraftWorkspaceSettings { * Uses discriminated union so SSH has host, Docker has image, etc. */ selectedRuntime: ParsedRuntime; - /** Persisted default runtime for this project (used to initialize selection) */ - defaultRuntimeMode: RuntimeMode; + /** Persisted default runtime choice for this project (used to initialize selection) */ + defaultRuntimeMode: RuntimeChoice; trunkBranch: string; } +interface SshRuntimeConfig { + host: string; + coder?: CoderWorkspaceConfig; +} + +const buildRuntimeForMode = ( + mode: RuntimeMode, + sshConfig: SshRuntimeConfig, + dockerImage: string, + dockerShareCredentials: boolean, + devcontainerConfigPath: string, + devcontainerShareCredentials: boolean +): ParsedRuntime => { + switch (mode) { + case RUNTIME_MODE.LOCAL: + return { mode: "local" }; + case RUNTIME_MODE.SSH: { + // Use placeholder when Coder is enabled with no explicit SSH host + // This ensures the runtime string round-trips correctly for Coder-only users + const effectiveHost = + sshConfig.coder && !sshConfig.host.trim() ? CODER_RUNTIME_PLACEHOLDER : sshConfig.host; + + return { + mode: "ssh", + host: effectiveHost, + coder: sshConfig.coder, + }; + } + case RUNTIME_MODE.DOCKER: + return { mode: "docker", image: dockerImage, shareCredentials: dockerShareCredentials }; + case RUNTIME_MODE.DEVCONTAINER: + return { + mode: "devcontainer", + configPath: devcontainerConfigPath, + shareCredentials: devcontainerShareCredentials, + }; + case RUNTIME_MODE.WORKTREE: + default: + return { mode: "worktree" }; + } +}; + /** * Hook to manage all draft workspace settings with centralized persistence * Loads saved preferences when projectPath changes, persists all changes automatically @@ -108,9 +151,13 @@ export function useDraftWorkspaceSettings( { listener: true } ); - // Generic reader for lastRuntimeConfigs fields - const readRuntimeConfig = (mode: RuntimeMode, field: string, defaultValue: T): T => { - const modeConfig = lastRuntimeConfigs[mode]; + const readRuntimeConfigFrom = ( + configs: LastRuntimeConfigs, + mode: RuntimeMode, + field: string, + defaultValue: T + ): T => { + const modeConfig = configs[mode]; if (!modeConfig || typeof modeConfig !== "object" || Array.isArray(modeConfig)) { return defaultValue; } @@ -129,13 +176,29 @@ export function useDraftWorkspaceSettings( return defaultValue; }; - const lastSshHost = readRuntimeConfig(RUNTIME_MODE.SSH, "host", ""); - const lastCoderEnabled = readRuntimeConfig(RUNTIME_MODE.SSH, "coderEnabled", false); - const lastCoderConfig = readRuntimeConfig( - RUNTIME_MODE.SSH, - "coderConfig", - null - ); + // Generic reader for lastRuntimeConfigs fields + const readRuntimeConfig = (mode: RuntimeMode, field: string, defaultValue: T): T => { + return readRuntimeConfigFrom(lastRuntimeConfigs, mode, field, defaultValue); + }; + + // Hide Coder-specific persistence fields behind a helper so callsites stay clean. + const readSshRuntimeConfig = (configs: LastRuntimeConfigs): SshRuntimeConfig => { + const host = readRuntimeConfigFrom(configs, RUNTIME_MODE.SSH, "host", ""); + const coderEnabled = readRuntimeConfigFrom(configs, RUNTIME_MODE.SSH, "coderEnabled", false); + const coderConfig = readRuntimeConfigFrom( + configs, + RUNTIME_MODE.SSH, + "coderConfig", + null + ); + + return { + host, + coder: coderEnabled && coderConfig ? coderConfig : undefined, + }; + }; + + const lastSsh = readSshRuntimeConfig(lastRuntimeConfigs); const lastDockerImage = readRuntimeConfig(RUNTIME_MODE.DOCKER, "image", ""); const lastShareCredentials = readRuntimeConfig(RUNTIME_MODE.DOCKER, "shareCredentials", false); const lastDevcontainerConfigPath = readRuntimeConfig(RUNTIME_MODE.DEVCONTAINER, "configPath", ""); @@ -145,6 +208,14 @@ export function useDraftWorkspaceSettings( false ); + const coderDefaultFromString = + parsedDefault?.mode === RUNTIME_MODE.SSH && parsedDefault.host === CODER_RUNTIME_PLACEHOLDER; + // Keep the UI's default checkbox aligned with persisted Coder defaults without extra flags. + const defaultRuntimeChoice: RuntimeChoice = + defaultRuntimeMode === RUNTIME_MODE.SSH && (coderDefaultFromString || lastSsh.coder) + ? "coder" + : defaultRuntimeMode; + const setLastRuntimeConfig = useCallback( (mode: RuntimeMode, field: string, value: string | boolean | object | null) => { setLastRuntimeConfigs((prev) => { @@ -160,12 +231,27 @@ export function useDraftWorkspaceSettings( [setLastRuntimeConfigs] ); + // Persist SSH config while keeping the legacy field shape hidden from callsites. + const writeSshRuntimeConfig = useCallback( + (config: SshRuntimeConfig) => { + if (config.host.trim() && config.host !== CODER_RUNTIME_PLACEHOLDER) { + setLastRuntimeConfig(RUNTIME_MODE.SSH, "host", config.host); + } + const coderEnabled = config.coder !== undefined; + setLastRuntimeConfig(RUNTIME_MODE.SSH, "coderEnabled", coderEnabled); + if (config.coder) { + setLastRuntimeConfig(RUNTIME_MODE.SSH, "coderConfig", config.coder); + } + }, + [setLastRuntimeConfig] + ); + // If the default runtime string contains a host/image (e.g. older persisted values like "ssh devbox"), // prefer it as the initial remembered value. useEffect(() => { if ( parsedDefault?.mode === RUNTIME_MODE.SSH && - !lastSshHost.trim() && + !lastSsh.host.trim() && parsedDefault.host.trim() ) { setLastRuntimeConfig(RUNTIME_MODE.SSH, "host", parsedDefault.host); @@ -187,14 +273,14 @@ export function useDraftWorkspaceSettings( }, [ projectPath, parsedDefault, - lastSshHost, + lastSsh.host, lastDockerImage, lastDevcontainerConfigPath, setLastRuntimeConfig, ]); const defaultSshHost = - parsedDefault?.mode === RUNTIME_MODE.SSH ? parsedDefault.host : lastSshHost; + parsedDefault?.mode === RUNTIME_MODE.SSH ? parsedDefault.host : lastSsh.host; const defaultDockerImage = parsedDefault?.mode === RUNTIME_MODE.DOCKER ? parsedDefault.image : lastDockerImage; @@ -204,67 +290,24 @@ export function useDraftWorkspaceSettings( ? parsedDefault.configPath : lastDevcontainerConfigPath; - // Build ParsedRuntime from mode + stored host/image/shareCredentials/coder - // Defined as a function so it can be used in both useState init and useEffect - const buildRuntimeForMode = ( - mode: RuntimeMode, - sshHost: string, - dockerImage: string, - dockerShareCredentials: boolean, - coderEnabled: boolean, - coderConfig: CoderWorkspaceConfig | null, - devcontainerConfigPath: string, - devcontainerShareCredentials: boolean - ): ParsedRuntime => { - switch (mode) { - case RUNTIME_MODE.LOCAL: - return { mode: "local" }; - case RUNTIME_MODE.SSH: { - // Use placeholder when Coder is enabled with no explicit SSH host - // This ensures the runtime string round-trips correctly for Coder-only users - const effectiveHost = - coderEnabled && coderConfig && !sshHost.trim() ? CODER_RUNTIME_PLACEHOLDER : sshHost; - - return { - mode: "ssh", - host: effectiveHost, - coder: coderEnabled && coderConfig ? coderConfig : undefined, - }; - } - case RUNTIME_MODE.DOCKER: - return { mode: "docker", image: dockerImage, shareCredentials: dockerShareCredentials }; - case RUNTIME_MODE.DEVCONTAINER: - return { - mode: "devcontainer", - configPath: devcontainerConfigPath, - shareCredentials: devcontainerShareCredentials, - }; - case RUNTIME_MODE.WORKTREE: - default: - return { mode: "worktree" }; - } - }; + const defaultRuntime = buildRuntimeForMode( + defaultRuntimeMode, + { host: defaultSshHost, coder: lastSsh.coder }, + defaultDockerImage, + lastShareCredentials, + defaultDevcontainerConfigPath, + lastDevcontainerShareCredentials + ); // Currently selected runtime for this session (initialized from default) // Uses discriminated union: SSH has host, Docker has image - const [selectedRuntime, setSelectedRuntimeState] = useState(() => - buildRuntimeForMode( - defaultRuntimeMode, - defaultSshHost, - defaultDockerImage, - lastShareCredentials, - lastCoderEnabled, - lastCoderConfig, - defaultDevcontainerConfigPath, - lastDevcontainerShareCredentials - ) - ); + const [selectedRuntime, setSelectedRuntimeState] = useState(() => defaultRuntime); const prevProjectPathRef = useRef(null); const prevDefaultRuntimeModeRef = useRef(null); // When switching projects or changing the persisted default mode, reset the selection. - // Importantly: do NOT reset selection when lastSshHost/lastDockerImage changes while typing. + // Importantly: do NOT reset selection when lastSsh.host/lastDockerImage changes while typing. useEffect(() => { const projectChanged = prevProjectPathRef.current !== projectPath; const defaultModeChanged = prevDefaultRuntimeModeRef.current !== defaultRuntimeMode; @@ -273,11 +316,9 @@ export function useDraftWorkspaceSettings( setSelectedRuntimeState( buildRuntimeForMode( defaultRuntimeMode, - defaultSshHost, + { host: defaultSshHost, coder: lastSsh.coder }, defaultDockerImage, lastShareCredentials, - lastCoderEnabled, - lastCoderConfig, defaultDevcontainerConfigPath, lastDevcontainerShareCredentials ) @@ -292,8 +333,7 @@ export function useDraftWorkspaceSettings( defaultSshHost, defaultDockerImage, lastShareCredentials, - lastCoderEnabled, - lastCoderConfig, + lastSsh.coder, defaultDevcontainerConfigPath, lastDevcontainerShareCredentials, ]); @@ -306,14 +346,13 @@ export function useDraftWorkspaceSettings( const prevMode = prevSelectedRuntimeModeRef.current; if (prevMode !== null && prevMode !== selectedRuntime.mode) { if (selectedRuntime.mode === RUNTIME_MODE.SSH) { - const needsHostRestore = !selectedRuntime.host.trim() && lastSshHost.trim(); - const needsCoderRestore = - selectedRuntime.coder === undefined && lastCoderEnabled && lastCoderConfig; + const needsHostRestore = !selectedRuntime.host.trim() && lastSsh.host.trim(); + const needsCoderRestore = selectedRuntime.coder === undefined && lastSsh.coder != null; if (needsHostRestore || needsCoderRestore) { setSelectedRuntimeState({ mode: RUNTIME_MODE.SSH, - host: needsHostRestore ? lastSshHost : selectedRuntime.host, - coder: needsCoderRestore ? lastCoderConfig : selectedRuntime.coder, + host: needsHostRestore ? lastSsh.host : selectedRuntime.host, + coder: needsCoderRestore ? lastSsh.coder : selectedRuntime.coder, }); } } @@ -350,11 +389,10 @@ export function useDraftWorkspaceSettings( prevSelectedRuntimeModeRef.current = selectedRuntime.mode; }, [ selectedRuntime, - lastSshHost, + lastSsh.host, lastDockerImage, lastShareCredentials, - lastCoderEnabled, - lastCoderConfig, + lastSsh.coder, lastDevcontainerConfigPath, lastDevcontainerShareCredentials, ]); @@ -373,16 +411,9 @@ export function useDraftWorkspaceSettings( // Persist host/image/coder so they're remembered when switching modes. // Avoid wiping the remembered value when the UI switches modes with an empty field. + // Avoid persisting the Coder placeholder as the remembered SSH host. if (runtime.mode === RUNTIME_MODE.SSH) { - if (runtime.host.trim()) { - setLastRuntimeConfig(RUNTIME_MODE.SSH, "host", runtime.host); - } - // Persist Coder enabled state and config - const coderEnabled = runtime.coder !== undefined; - setLastRuntimeConfig(RUNTIME_MODE.SSH, "coderEnabled", coderEnabled); - if (runtime.coder) { - setLastRuntimeConfig(RUNTIME_MODE.SSH, "coderConfig", runtime.coder); - } + writeSshRuntimeConfig({ host: runtime.host, coder: runtime.coder }); } else if (runtime.mode === RUNTIME_MODE.DOCKER) { if (runtime.image.trim()) { setLastRuntimeConfig(RUNTIME_MODE.DOCKER, "image", runtime.image); @@ -406,13 +437,18 @@ export function useDraftWorkspaceSettings( // Setter for default runtime mode (persists via checkbox in tooltip) const setDefaultRuntimeMode = (newMode: RuntimeMode) => { + // Read persisted SSH config so the default snapshot reflects the latest toggle state. + const freshRuntimeConfigs = readPersistedState( + getLastRuntimeConfigKey(projectPath), + {} + ); + const freshSsh = readSshRuntimeConfig(freshRuntimeConfigs); + const newRuntime = buildRuntimeForMode( newMode, - lastSshHost, + freshSsh, lastDockerImage, lastShareCredentials, - lastCoderEnabled, - lastCoderConfig, defaultDevcontainerConfigPath, lastDevcontainerShareCredentials ); @@ -433,7 +469,7 @@ export function useDraftWorkspaceSettings( thinkingLevel, agentId, selectedRuntime, - defaultRuntimeMode, + defaultRuntimeMode: defaultRuntimeChoice, trunkBranch, }, setSelectedRuntime, diff --git a/src/browser/stories/App.coder.stories.tsx b/src/browser/stories/App.coder.stories.tsx index 659aaba977..47f5bf0b68 100644 --- a/src/browser/stories/App.coder.stories.tsx +++ b/src/browser/stories/App.coder.stories.tsx @@ -111,8 +111,8 @@ const mockWorkspaces: CoderWorkspace[] = [ ]; /** - * SSH runtime with Coder available - shows Coder checkbox. - * When user selects SSH runtime, they can enable Coder workspace mode. + * Coder available - shows Coder runtime button. + * When Coder CLI is available, the Coder button appears in the runtime selector. */ export const SSHWithCoderAvailable: AppStory = { render: () => ( @@ -142,24 +142,14 @@ export const SSHWithCoderAvailable: AppStory = { // Wait for the runtime button group to appear await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); - // Click SSH runtime button - const sshButton = canvas.getByRole("button", { name: /SSH/i }); - await userEvent.click(sshButton); - - // Wait for SSH mode to be active and Coder checkbox to appear - await waitFor( - () => { - const coderCheckbox = canvas.queryByTestId("coder-checkbox"); - if (!coderCheckbox) throw new Error("Coder checkbox not found"); - }, - { timeout: 5000 } - ); + // Coder button should appear when Coder CLI is available + await canvas.findByRole("button", { name: /Coder/i }, { timeout: 5000 }); }, }; /** * Coder new workspace flow - shows template and preset dropdowns. - * User enables Coder, selects template, and optionally a preset. + * User clicks Coder runtime button, then selects template and optionally a preset. */ export const CoderNewWorkspace: AppStory = { render: () => ( @@ -189,13 +179,9 @@ export const CoderNewWorkspace: AppStory = { // Wait for runtime controls await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); - // Click SSH runtime button - const sshButton = canvas.getByRole("button", { name: /SSH/i }); - await userEvent.click(sshButton); - - // Enable Coder - const coderCheckbox = await canvas.findByTestId("coder-checkbox", {}, { timeout: 5000 }); - await userEvent.click(coderCheckbox); + // Click Coder runtime button directly + const coderButton = await canvas.findByRole("button", { name: /Coder/i }, { timeout: 5000 }); + await userEvent.click(coderButton); // Wait for Coder controls to appear await canvas.findByTestId("coder-controls-inner", {}, { timeout: 5000 }); @@ -207,7 +193,7 @@ export const CoderNewWorkspace: AppStory = { /** * Coder existing workspace flow - shows workspace dropdown. - * User switches to "Existing" mode and selects from running workspaces. + * User clicks Coder runtime, switches to "Existing" mode and selects from running workspaces. */ export const CoderExistingWorkspace: AppStory = { render: () => ( @@ -237,13 +223,9 @@ export const CoderExistingWorkspace: AppStory = { // Wait for runtime controls await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); - // Click SSH runtime button - const sshButton = canvas.getByRole("button", { name: /SSH/i }); - await userEvent.click(sshButton); - - // Enable Coder - const coderCheckbox = await canvas.findByTestId("coder-checkbox", {}, { timeout: 5000 }); - await userEvent.click(coderCheckbox); + // Click Coder runtime button directly + const coderButton = await canvas.findByRole("button", { name: /Coder/i }, { timeout: 5000 }); + await userEvent.click(coderButton); // Wait for Coder controls await canvas.findByTestId("coder-controls-inner", {}, { timeout: 5000 }); @@ -258,8 +240,8 @@ export const CoderExistingWorkspace: AppStory = { }; /** - * Coder not available - checkbox should not appear. - * When Coder CLI is not installed, the SSH runtime shows normal host input. + * Coder not available - Coder button should not appear. + * When Coder CLI is not installed, the runtime selector only shows SSH (no Coder). */ export const CoderNotAvailable: AppStory = { render: () => ( @@ -279,33 +261,23 @@ export const CoderNotAvailable: AppStory = { await openProjectCreationView(storyRoot); const canvas = within(storyRoot); - // Wait for runtime controls + // Wait for runtime controls to load await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); - // Click SSH runtime button - const sshButton = canvas.getByRole("button", { name: /SSH/i }); - await userEvent.click(sshButton); - - // SSH host input should appear (normal SSH mode) - await waitFor( - () => { - const hostInput = canvas.queryByPlaceholderText("user@host"); - if (!hostInput) throw new Error("SSH host input not found"); - }, - { timeout: 5000 } - ); + // SSH button should be present + await canvas.findByRole("button", { name: /SSH/i }, { timeout: 5000 }); - // Coder checkbox should NOT appear - const coderCheckbox = canvas.queryByTestId("coder-checkbox"); - if (coderCheckbox) { - throw new Error("Coder checkbox should not appear when Coder is unavailable"); + // Coder button should NOT appear when Coder CLI is unavailable + const coderButton = canvas.queryByRole("button", { name: /Coder/i }); + if (coderButton) { + throw new Error("Coder button should not appear when Coder CLI is unavailable"); } }, }; /** - * Coder CLI outdated - checkbox appears but is disabled with tooltip. - * When Coder CLI is installed but version is below minimum, shows explanation. + * Coder CLI outdated - Coder button appears but is disabled with tooltip. + * When Coder CLI is installed but version is below minimum, shows explanation on hover. */ export const CoderOutdated: AppStory = { render: () => ( @@ -328,26 +300,16 @@ export const CoderOutdated: AppStory = { // Wait for runtime controls await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); - // Click SSH runtime button - const sshButton = canvas.getByRole("button", { name: /SSH/i }); - await userEvent.click(sshButton); - - // Coder checkbox should appear but be disabled - const coderCheckbox = await canvas.findByTestId("coder-checkbox", {}, { timeout: 5000 }); + // Coder button should appear but be disabled + const coderButton = await canvas.findByRole("button", { name: /Coder/i }); await waitFor(() => { - if (!(coderCheckbox instanceof HTMLInputElement)) { - throw new Error("Coder checkbox should be an input element"); - } - if (!coderCheckbox.disabled) { - throw new Error("Coder checkbox should be disabled when CLI is outdated"); - } - if (coderCheckbox.checked) { - throw new Error("Coder checkbox should be unchecked when CLI is outdated"); + if (!coderButton.hasAttribute("disabled")) { + throw new Error("Coder button should be disabled when CLI is outdated"); } }); - // Hover over checkbox to trigger tooltip - await userEvent.hover(coderCheckbox.parentElement!); + // Hover over Coder button to trigger tooltip with version error + await userEvent.hover(coderButton); // Wait for tooltip to appear with version info await waitFor( @@ -396,13 +358,9 @@ export const CoderNoPresets: AppStory = { // Wait for runtime controls await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); - // Click SSH runtime button - const sshButton = canvas.getByRole("button", { name: /SSH/i }); - await userEvent.click(sshButton); - - // Enable Coder - const coderCheckbox = await canvas.findByTestId("coder-checkbox", {}, { timeout: 5000 }); - await userEvent.click(coderCheckbox); + // Click Coder runtime button directly + const coderButton = await canvas.findByRole("button", { name: /Coder/i }, { timeout: 5000 }); + await userEvent.click(coderButton); // Wait for Coder controls await canvas.findByTestId("coder-controls-inner", {}, { timeout: 5000 }); @@ -452,13 +410,9 @@ export const CoderNoRunningWorkspaces: AppStory = { // Wait for runtime controls await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); - // Click SSH runtime button - const sshButton = canvas.getByRole("button", { name: /SSH/i }); - await userEvent.click(sshButton); - - // Enable Coder - const coderCheckbox = await canvas.findByTestId("coder-checkbox", {}, { timeout: 5000 }); - await userEvent.click(coderCheckbox); + // Click Coder runtime button directly + const coderButton = await canvas.findByRole("button", { name: /Coder/i }, { timeout: 5000 }); + await userEvent.click(coderButton); // Click "Existing" button const existingButton = await canvas.findByRole( diff --git a/src/browser/utils/runtimeUi.ts b/src/browser/utils/runtimeUi.ts index c401edd9de..67b2c17d90 100644 --- a/src/browser/utils/runtimeUi.ts +++ b/src/browser/utils/runtimeUi.ts @@ -33,7 +33,8 @@ export interface RuntimeUiSpec { }; } -export type RuntimeBadgeType = RuntimeMode | "coder"; +export type RuntimeChoice = RuntimeMode | "coder"; +export type RuntimeBadgeType = RuntimeChoice; export const RUNTIME_UI = { local: { @@ -153,14 +154,27 @@ export const RUNTIME_UI = { }, } satisfies Record; +const CODER_RUNTIME_UI: RuntimeUiSpec = { + ...RUNTIME_UI.ssh, + label: "Coder", + description: "Coder-managed workspace via the Coder CLI", + docsPath: "/runtime/coder", + Icon: CoderIcon, +}; + +export const RUNTIME_CHOICE_UI = { + ...RUNTIME_UI, + coder: CODER_RUNTIME_UI, +} satisfies Record; + export const RUNTIME_BADGE_UI = { ssh: { Icon: RUNTIME_UI.ssh.Icon, badge: RUNTIME_UI.ssh.badge, }, coder: { - Icon: CoderIcon, - badge: RUNTIME_UI.ssh.badge, + Icon: CODER_RUNTIME_UI.Icon, + badge: CODER_RUNTIME_UI.badge, }, worktree: { Icon: RUNTIME_UI.worktree.Icon, From e6a382dab422319e3c2f4e76bed207c24b46f818 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 30 Jan 2026 14:39:07 +1100 Subject: [PATCH 2/8] Fix coder runtime selection --- .../components/ChatInput/CreationControls.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index a43720d3ef..bfceaa9ce9 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -571,11 +571,13 @@ export function CreationControls(props: CreationControlsProps) { if (!props.coderProps) { return; } - if (props.coderProps.coderConfig) { - props.coderProps.onCoderConfigChange(props.coderProps.coderConfig); - } else { - props.coderProps.onEnabledChange(true); - } + // Switch to SSH mode with a minimal config; the Coder hook will + // auto-select the first template once templates load. + onSelectedRuntimeChange({ + mode: "ssh", + host: CODER_RUNTIME_PLACEHOLDER, + coder: props.coderProps.coderConfig ?? { existingWorkspace: false }, + }); return; } // Convert mode to ParsedRuntime with appropriate defaults From f6ebce8f29a7b12a33e13dfcadc3ca35328da58e Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 30 Jan 2026 15:11:31 +1100 Subject: [PATCH 3/8] fix: re-apply policy constraints to RuntimeButtonGroup PR #2012 introduced Coder as a separate runtime button but accidentally removed policy enforcement from the runtime selector. This restores: - Policy props (allowedRuntimeModes, allowSshHost, allowSshCoder) passed to RuntimeButtonGroup - Policy checks in resolveRuntimeButtonState with isPolicyDisabled field - Filtering of policy-forbidden runtimes (hidden unless currently selected) - 'Disabled by policy' reason shown for policy-blocked options Also simplifies Coder click handler to set minimal config - the Coder hook auto-selects the first template once templates load. --- .../components/ChatInput/CreationControls.tsx | 94 ++++++++++++++++--- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index bfceaa9ce9..de38f40283 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -148,6 +148,9 @@ interface RuntimeButtonGroupProps { disabled?: boolean; runtimeAvailabilityState?: RuntimeAvailabilityState; coderInfo?: CoderInfo | null; + allowedRuntimeModes?: RuntimeMode[] | null; + allowSshHost?: boolean; + allowSshCoder?: boolean; } const RUNTIME_CHOICE_ORDER: RuntimeChoice[] = [ @@ -183,6 +186,7 @@ const RUNTIME_CHOICE_OPTIONS: Array<{ interface RuntimeButtonState { isModeDisabled: boolean; + isPolicyDisabled: boolean; disabledReason?: string; isDefault: boolean; } @@ -191,13 +195,35 @@ const resolveRuntimeButtonState = ( value: RuntimeChoice, availabilityMap: RuntimeAvailabilityMap | null, defaultMode: RuntimeChoice, - coderAvailability: CoderAvailabilityState + coderAvailability: CoderAvailabilityState, + allowedModeSet: Set | null, + allowSshHost: boolean, + allowSshCoder: boolean ): RuntimeButtonState => { + const isPolicyAllowed = (): boolean => { + if (!allowedModeSet) { + return true; + } + + if (value === "coder") { + return allowSshCoder; + } + + if (value === RUNTIME_MODE.SSH) { + return allowSshHost || allowSshCoder; + } + + return allowedModeSet.has(value); + }; + + const isPolicyDisabled = !isPolicyAllowed(); + // Coder outdated: button visible but disabled with version reason. if (value === "coder" && coderAvailability.state === "outdated") { return { isModeDisabled: true, - disabledReason: coderAvailability.reason, + isPolicyDisabled, + disabledReason: isPolicyDisabled ? "Disabled by policy" : coderAvailability.reason, isDefault: defaultMode === value, }; } @@ -209,10 +235,15 @@ const resolveRuntimeButtonState = ( // When availability is undefined (loading or fetch failed), allow selection // as fallback - the config picker will validate before creation. const isModeDisabled = availability !== undefined && !availability.available; - const disabledReason = availability && !availability.available ? availability.reason : undefined; + const disabledReason = isPolicyDisabled + ? "Disabled by policy" + : availability && !availability.available + ? availability.reason + : undefined; return { isModeDisabled, + isPolicyDisabled, disabledReason, isDefault: defaultMode === value, }; @@ -301,20 +332,50 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { coder: !hideCoder, }; - const runtimeOptions = RUNTIME_CHOICE_OPTIONS.filter( - (option) => runtimeVisibilityOverrides[option.value] ?? true - ); + const allowSshHost = props.allowSshHost ?? true; + const allowSshCoder = props.allowSshCoder ?? true; + const allowedModeSet = props.allowedRuntimeModes ? new Set(props.allowedRuntimeModes) : null; + + // Policy filtering keeps forbidden runtimes out of the selector so users don't + // get stuck with defaults that can never be created. + const runtimeOptions = RUNTIME_CHOICE_OPTIONS.filter((option) => { + if (runtimeVisibilityOverrides[option.value] === false) { + return false; + } + + const { isPolicyDisabled } = resolveRuntimeButtonState( + option.value, + availabilityMap, + props.defaultMode, + coderAvailability, + allowedModeSet, + allowSshHost, + allowSshCoder + ); + + if (isPolicyDisabled && props.value !== option.value) { + return false; + } + + return true; + }); return (
{runtimeOptions.map((option) => { const isActive = props.value === option.value; - const { isModeDisabled, disabledReason, isDefault } = resolveRuntimeButtonState( - option.value, - availabilityMap, - props.defaultMode, - coderAvailability - ); + const { isModeDisabled, isPolicyDisabled, disabledReason, isDefault } = + resolveRuntimeButtonState( + option.value, + availabilityMap, + props.defaultMode, + coderAvailability, + allowedModeSet, + allowSshHost, + allowSshCoder + ); + const isDisabled = Boolean(props.disabled) || isModeDisabled || isPolicyDisabled; + const showDisabledReason = isModeDisabled || isPolicyDisabled; const Icon = option.Icon; @@ -330,13 +391,13 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
- {isModeDisabled ? ( + {showDisabledReason ? (

{disabledReason ?? "Unavailable"}

) : (