diff --git a/src/browser/components/ChatInput/CoderControls.tsx b/src/browser/components/ChatInput/CoderControls.tsx index 6ae222c674..8ffa7922d4 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,136 @@ 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"; reason: string; shouldShowRuntimeButton: false } + | { state: "outdated"; reason: string; shouldShowRuntimeButton: true } + | { state: "unavailable"; reason: string; 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.`; +} + +function getCoderUnavailableReason(coderInfo: Extract) { + return coderInfo.reason === "missing" + ? "Coder CLI not found. Install to enable." + : `Coder CLI error: ${coderInfo.reason.message}`; +} + +export function resolveCoderAvailability(coderInfo: CoderInfo | null): CoderAvailabilityState { + if (coderInfo === null) { + return { state: "loading", reason: CODER_CHECKING_LABEL, shouldShowRuntimeButton: false }; + } + + if (coderInfo.state === "outdated") { + return { + state: "outdated", + reason: getCoderOutdatedReason(coderInfo), + shouldShowRuntimeButton: true, + }; + } + + if (coderInfo.state === "unavailable") { + return { + state: "unavailable", + reason: getCoderUnavailableReason(coderInfo), + 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 +218,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 +232,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 +244,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 +297,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..0ba229ac2a 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,6 +1,12 @@ 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 CoderWorkspaceConfig, + 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 +26,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,10 +106,14 @@ interface CreationControlsProps { onTrunkBranchChange: (branch: string) => void; /** Currently selected runtime (discriminated union: SSH has host, Docker has image) */ selectedRuntime: ParsedRuntime; - defaultRuntimeMode: RuntimeMode; + /** Fallback Coder config to restore prior selections. */ + coderConfigFallback: CoderWorkspaceConfig; + /** Fallback SSH host to restore when leaving Coder. */ + sshHostFallback: string; + defaultRuntimeMode: RuntimeChoice; /** Set the currently selected runtime (discriminated union) */ onSelectedRuntimeChange: (runtime: ParsedRuntime) => void; - onSetDefaultRuntime: (mode: RuntimeMode) => void; + onSetDefaultRuntime: (mode: RuntimeChoice) => void; disabled: boolean; /** Project path to display (and used for project selector) */ projectPath: string; @@ -64,36 +140,37 @@ interface CreationControlsProps { allowSshCoder?: boolean; /** Optional policy error message to display near runtime controls */ runtimePolicyError?: string | null; + /** Coder CLI availability info (null while checking) */ + coderInfo?: CoderInfo | null; /** Coder workspace controls props (optional - only rendered when provided) */ coderProps?: Omit; } /** Runtime type button group with icons and colors */ interface RuntimeButtonGroupProps { - value: RuntimeMode; - onChange: (mode: RuntimeMode) => void; - defaultMode: RuntimeMode; - onSetDefault: (mode: RuntimeMode) => void; + value: RuntimeChoice; + onChange: (mode: RuntimeChoice) => void; + defaultMode: RuntimeChoice; + onSetDefault: (mode: RuntimeChoice) => void; disabled?: boolean; - /** Policy: allowed runtime modes (null/undefined = allow all) */ + runtimeAvailabilityState?: RuntimeAvailabilityState; + coderInfo?: CoderInfo | null; allowedRuntimeModes?: RuntimeMode[] | null; - /** Policy: allow plain host SSH */ allowSshHost?: boolean; - /** Policy: allow Coder-backed SSH */ allowSshCoder?: boolean; - runtimeAvailabilityState?: RuntimeAvailabilityState; } -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 +178,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 +191,72 @@ const RUNTIME_OPTIONS: Array<{ }; }); +interface RuntimeButtonState { + isModeDisabled: boolean; + isPolicyDisabled: boolean; + disabledReason?: string; + isDefault: boolean; +} + +const resolveRuntimeButtonState = ( + value: RuntimeChoice, + availabilityMap: RuntimeAvailabilityMap | null, + defaultMode: RuntimeChoice, + 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) { + // Host SSH is separate from Coder; block it when policy forbids host SSH. + return allowSshHost; + } + + return allowedModeSet.has(value); + }; + + const isPolicyDisabled = !isPolicyAllowed(); + + // Coder availability: keep the button disabled with a reason until the CLI is ready. + if (value === "coder" && coderAvailability.state !== "available") { + return { + isModeDisabled: true, + isPolicyDisabled, + disabledReason: isPolicyDisabled ? "Disabled by policy" : 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 = isPolicyDisabled + ? "Disabled by policy" + : availability && !availability.available + ? availability.reason + : undefined; + + return { + isModeDisabled, + isPolicyDisabled, + disabledReason, + isDefault: defaultMode === value, + }; +}; + /** Aesthetic section picker with color accent */ interface SectionPickerProps { sections: SectionConfig[]; @@ -178,39 +321,57 @@ function SectionPicker(props: SectionPickerProps) { function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { const state = props.runtimeAvailabilityState; const availabilityMap = state?.status === "loaded" ? state.data : null; - - // Hide devcontainer while loading OR when confirmed missing. - // Only show when availability is loaded and devcontainer is available. - // This prevents layout flash for projects without devcontainer.json (the common case). - const hideDevcontainer = - state?.status === "loading" || - (availabilityMap?.devcontainer?.available === false && - availabilityMap.devcontainer.reason === "No devcontainer.json found"); + const coderInfo = props.coderInfo ?? null; + const coderAvailability = resolveCoderAvailability(coderInfo); const allowSshHost = props.allowSshHost ?? true; const allowSshCoder = props.allowSshCoder ?? true; const allowedModeSet = props.allowedRuntimeModes ? new Set(props.allowedRuntimeModes) : null; + const isSshModeAllowed = !allowedModeSet || allowedModeSet.has(RUNTIME_MODE.SSH); - const isModeAllowedByPolicy = (mode: RuntimeMode): boolean => { - if (!allowedModeSet) { - return true; - } + const isDevcontainerMissing = + availabilityMap?.devcontainer?.available === false && + availabilityMap.devcontainer.reason === "No devcontainer.json found"; + // Hide devcontainer while loading OR when confirmed missing. + // Only show when availability is loaded and devcontainer is available. + // This prevents layout flash for projects without devcontainer.json (the common case). + const hideDevcontainer = state?.status === "loading" || isDevcontainerMissing; + // Keep Devcontainer visible when policy requires it so the selector doesn't go empty. + const isDevcontainerOnlyPolicy = + allowedModeSet?.size === 1 && allowedModeSet.has(RUNTIME_MODE.DEVCONTAINER); + const shouldForceShowDevcontainer = + props.value === RUNTIME_MODE.DEVCONTAINER || + (isDevcontainerOnlyPolicy && isDevcontainerMissing); + + // Match devcontainer UX: only surface Coder once availability is confirmed (no flash), + // but keep it visible when policy requires it or when already selected to avoid an empty selector. + const shouldForceShowCoder = + props.value === "coder" || (allowSshCoder && !allowSshHost && isSshModeAllowed); + const shouldShowCoder = coderAvailability.shouldShowRuntimeButton || shouldForceShowCoder; + + const runtimeVisibilityOverrides: Partial> = { + [RUNTIME_MODE.DEVCONTAINER]: !hideDevcontainer || shouldForceShowDevcontainer, + coder: shouldShowCoder, + }; - if (mode === RUNTIME_MODE.SSH) { - return allowSshHost || allowSshCoder; + // 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; } - return allowedModeSet.has(mode); - }; - - 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. + const { isPolicyDisabled } = resolveRuntimeButtonState( + option.value, + availabilityMap, + props.defaultMode, + coderAvailability, + allowedModeSet, + allowSshHost, + allowSshCoder + ); + + if (isPolicyDisabled && props.value !== option.value) { return false; } @@ -221,21 +382,25 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
{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 { 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; + const handleSetDefault = () => { + props.onSetDefault(option.value); + }; + return ( @@ -264,14 +429,14 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { {option.description}
- {disabledReason ? ( + {showDisabledReason ? (

{disabledReason ?? "Unavailable"}

) : (
- {/* Validation error - own line below name row */} - {nameState.error &&
{nameState.error}
} - {/* Runtime type - button group */}
{ + if (mode === "coder") { + if (!props.coderProps) { + return; + } + // Switch to SSH mode with the last known Coder config so prior selections restore. + onSelectedRuntimeChange({ + mode: "ssh", + host: CODER_RUNTIME_PLACEHOLDER, + coder: props.coderConfigFallback, + }); + 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 + : props.sshHostFallback; onSelectedRuntimeChange({ mode: "ssh", - host: selectedRuntime.mode === "ssh" ? selectedRuntime.host : "", + host: sshHost, }); break; + } case RUNTIME_MODE.DOCKER: onSelectedRuntimeChange({ mode: "docker", @@ -520,10 +707,11 @@ export function CreationControls(props: CreationControlsProps) { defaultMode={props.defaultRuntimeMode} onSetDefault={props.onSetDefaultRuntime} disabled={props.disabled} + runtimeAvailabilityState={runtimeAvailabilityState} + coderInfo={props.coderInfo ?? props.coderProps?.coderInfo ?? null} allowedRuntimeModes={props.allowedRuntimeModes} allowSshHost={props.allowSshHost} allowSshCoder={props.allowSshCoder} - runtimeAvailabilityState={runtimeAvailabilityState} /> {/* Branch selector - shown for worktree/SSH */} @@ -554,59 +742,42 @@ 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 && ( + // Explain why send is blocked when policy forbids the selected runtime.

{props.runtimePolicyError}

)} @@ -667,54 +838,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/index.tsx b/src/browser/components/ChatInput/index.tsx index 0ce5cb578e..1597769ebe 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -653,7 +653,7 @@ const ChatInputInner: React.FC = (props) => { !creationState.selectedRuntime.coder && runtimePolicy.allowSshHost === false && runtimePolicy.allowSshCoder - ? "Host SSH runtimes are disabled by policy. Enable “Use Coder Workspace”." + ? "Host SSH runtimes are disabled by policy. Select the Coder runtime instead." : "Selected runtime is disabled by policy." : null; @@ -668,9 +668,11 @@ const ChatInputInner: React.FC = (props) => { trunkBranch: creationState.trunkBranch, onTrunkBranchChange: creationState.setTrunkBranch, selectedRuntime: creationState.selectedRuntime, + coderConfigFallback: creationState.coderConfigFallback, + sshHostFallback: creationState.sshHostFallback, defaultRuntimeMode: creationState.defaultRuntimeMode, onSelectedRuntimeChange: creationState.setSelectedRuntime, - onSetDefaultRuntime: creationState.setDefaultRuntimeMode, + onSetDefaultRuntime: creationState.setDefaultRuntimeChoice, disabled: isSendInFlight, projectPath: props.projectPath, projectName: props.projectName, @@ -683,6 +685,7 @@ const ChatInputInner: React.FC = (props) => { allowSshHost: runtimePolicy.allowSshHost, allowSshCoder: runtimePolicy.allowSshCoder, runtimePolicyError: creationRuntimePolicyError, + coderInfo: coderState.coderInfo, runtimeFieldError, // Pass coderProps when CLI is available/outdated, Coder is enabled, or still checking (so "Checking…" UI renders) coderProps: diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index d590a1f25c..d845e08f87 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -11,7 +11,12 @@ import { getThinkingLevelKey, } from "@/common/constants/storage"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import type { RuntimeMode, ParsedRuntime } from "@/common/types/runtime"; +import { + CODER_RUNTIME_PLACEHOLDER, + type CoderWorkspaceConfig, + type ParsedRuntime, +} from "@/common/types/runtime"; +import type { RuntimeChoice } from "@/browser/utils/runtimeUi"; import type { FrontendWorkspaceMetadata, WorkspaceActivitySnapshot, @@ -710,7 +715,9 @@ function createDraftSettingsHarness( selectedRuntime: ParsedRuntime; trunkBranch: string; runtimeString?: string | undefined; - defaultRuntimeMode?: RuntimeMode; + defaultRuntimeMode?: RuntimeChoice; + coderConfigFallback?: CoderWorkspaceConfig; + sshHostFallback?: string; }> ) { const state = { @@ -718,11 +725,15 @@ function createDraftSettingsHarness( defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree", trunkBranch: initial?.trunkBranch ?? "main", runtimeString: initial?.runtimeString, + coderConfigFallback: initial?.coderConfigFallback ?? { existingWorkspace: false }, + sshHostFallback: initial?.sshHostFallback ?? "", } satisfies { selectedRuntime: ParsedRuntime; - defaultRuntimeMode: RuntimeMode; + defaultRuntimeMode: RuntimeChoice; trunkBranch: string; runtimeString: string | undefined; + coderConfigFallback: CoderWorkspaceConfig; + sshHostFallback: string; }; const setTrunkBranch = mock((branch: string) => { @@ -742,18 +753,27 @@ function createDraftSettingsHarness( } }); - const setDefaultRuntimeMode = mock((mode: RuntimeMode) => { - state.defaultRuntimeMode = mode; + const setDefaultRuntimeChoice = mock((choice: RuntimeChoice) => { + state.defaultRuntimeMode = choice; // Update selected runtime to match new default - if (mode === "ssh") { + if (choice === "coder") { + state.selectedRuntime = { + mode: "ssh", + host: CODER_RUNTIME_PLACEHOLDER, + coder: { existingWorkspace: false }, + }; + state.runtimeString = `ssh ${CODER_RUNTIME_PLACEHOLDER}`; + return; + } + if (choice === "ssh") { const host = state.selectedRuntime.mode === "ssh" ? state.selectedRuntime.host : ""; state.selectedRuntime = { mode: "ssh", host }; state.runtimeString = host ? `ssh ${host}` : "ssh"; - } else if (mode === "docker") { + } else if (choice === "docker") { const image = state.selectedRuntime.mode === "docker" ? state.selectedRuntime.image : ""; state.selectedRuntime = { mode: "docker", image }; state.runtimeString = image ? `docker ${image}` : "docker"; - } else if (mode === "local") { + } else if (choice === "local") { state.selectedRuntime = { mode: "local" }; state.runtimeString = undefined; } else { @@ -765,13 +785,15 @@ function createDraftSettingsHarness( return { state, setSelectedRuntime, - setDefaultRuntimeMode, + setDefaultRuntimeChoice, setTrunkBranch, getRuntimeString, snapshot(): { settings: DraftWorkspaceSettings; + coderConfigFallback: CoderWorkspaceConfig; + sshHostFallback: string; setSelectedRuntime: typeof setSelectedRuntime; - setDefaultRuntimeMode: typeof setDefaultRuntimeMode; + setDefaultRuntimeChoice: typeof setDefaultRuntimeChoice; setTrunkBranch: typeof setTrunkBranch; getRuntimeString: typeof getRuntimeString; } { @@ -785,8 +807,10 @@ function createDraftSettingsHarness( }; return { settings, + coderConfigFallback: state.coderConfigFallback, + sshHostFallback: state.sshHostFallback, setSelectedRuntime, - setDefaultRuntimeMode, + setDefaultRuntimeChoice, setTrunkBranch, getRuntimeString, }; diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 3bfbcac35a..cf651ebe01 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -1,11 +1,13 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { + CoderWorkspaceConfig, RuntimeConfig, RuntimeMode, 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,11 +144,15 @@ interface UseCreationWorkspaceReturn { setTrunkBranch: (branch: string) => void; /** Currently selected runtime (discriminated union: SSH has host, Docker has image) */ selectedRuntime: ParsedRuntime; - defaultRuntimeMode: RuntimeMode; + /** Fallback Coder config used when re-selecting Coder runtime. */ + coderConfigFallback: CoderWorkspaceConfig; + /** Fallback SSH host used when leaving the Coder runtime. */ + sshHostFallback: string; + defaultRuntimeMode: RuntimeChoice; /** Set the currently selected runtime (discriminated union) */ setSelectedRuntime: (runtime: ParsedRuntime) => void; - /** Set the default runtime mode for this project (persists via checkbox) */ - setDefaultRuntimeMode: (mode: RuntimeMode) => void; + /** Set the default runtime choice for this project (persists via checkbox) */ + setDefaultRuntimeChoice: (choice: RuntimeChoice) => void; toast: Toast | null; setToast: (toast: Toast | null) => void; isSending: boolean; @@ -217,8 +223,14 @@ export function useCreationWorkspace({ useState({ status: "loading" }); // Centralized draft workspace settings with automatic persistence - const { settings, setSelectedRuntime, setDefaultRuntimeMode, setTrunkBranch } = - useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); + const { + settings, + coderConfigFallback, + sshHostFallback, + setSelectedRuntime, + setDefaultRuntimeChoice, + setTrunkBranch, + } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); // Persist draft workspace name generation state per draft (so multiple drafts don't share a // single auto-naming/manual-name state). @@ -566,9 +578,11 @@ export function useCreationWorkspace({ trunkBranch: settings.trunkBranch, setTrunkBranch, selectedRuntime: settings.selectedRuntime, + coderConfigFallback, + sshHostFallback, defaultRuntimeMode: settings.defaultRuntimeMode, setSelectedRuntime, - setDefaultRuntimeMode, + setDefaultRuntimeChoice, toast, setToast, isSending, diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx index a4a8b5c8c0..fb80d461e9 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -4,8 +4,9 @@ import { GlobalWindow } from "happy-dom"; import React from "react"; import { APIProvider, type APIClient } from "@/browser/contexts/API"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; -import { updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { getLastRuntimeConfigKey } from "@/common/constants/storage"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { getLastRuntimeConfigKey, getRuntimeKey } from "@/common/constants/storage"; +import { CODER_RUNTIME_PLACEHOLDER } from "@/common/types/runtime"; import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings"; function createStubApiClient(): APIClient { @@ -119,4 +120,127 @@ describe("useDraftWorkspaceSettings", () => { }); }); }); + + test("keeps Coder default even after plain SSH usage", async () => { + const projectPath = "/tmp/project"; + + updatePersistedState(getRuntimeKey(projectPath), `ssh ${CODER_RUNTIME_PLACEHOLDER}`); + updatePersistedState(getLastRuntimeConfigKey(projectPath), { + ssh: { + host: "dev@host", + coderEnabled: false, + coderConfig: { existingWorkspace: false }, + }, + }); + + const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( + + {props.children} + + ); + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.settings.defaultRuntimeMode).toBe("coder"); + expect(result.current.settings.selectedRuntime).toEqual({ + mode: "ssh", + host: CODER_RUNTIME_PLACEHOLDER, + coder: { existingWorkspace: false }, + }); + }); + }); + + test("persists Coder default string when toggling default", async () => { + const projectPath = "/tmp/project"; + + updatePersistedState(getLastRuntimeConfigKey(projectPath), { + ssh: { + host: "dev@host", + coderEnabled: false, + coderConfig: { existingWorkspace: false }, + }, + }); + + const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( + + {props.children} + + ); + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), { + wrapper, + }); + + act(() => { + result.current.setDefaultRuntimeChoice("coder"); + }); + + await waitFor(() => { + expect(result.current.settings.selectedRuntime).toEqual({ + mode: "ssh", + host: CODER_RUNTIME_PLACEHOLDER, + coder: { existingWorkspace: false }, + }); + }); + + const defaultRuntimeString = readPersistedState( + getRuntimeKey(projectPath), + undefined + ); + expect(defaultRuntimeString).toBe(`ssh ${CODER_RUNTIME_PLACEHOLDER}`); + }); + + test("exposes persisted Coder config as fallback when re-selecting Coder", async () => { + const projectPath = "/tmp/project"; + const savedCoderConfig = { existingWorkspace: true, workspaceName: "saved-workspace" }; + + updatePersistedState(getLastRuntimeConfigKey(projectPath), { + ssh: { + host: "dev@host", + coderEnabled: false, + coderConfig: savedCoderConfig, + }, + }); + + const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( + + {props.children} + + ); + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.coderConfigFallback).toEqual(savedCoderConfig); + }); + }); + + test("exposes persisted SSH host as fallback when leaving Coder", async () => { + const projectPath = "/tmp/project"; + + updatePersistedState(getLastRuntimeConfigKey(projectPath), { + ssh: { + host: "dev@host", + }, + }); + + const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( + + {props.children} + + ); + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.sshHostFallback).toBe("dev@host"); + }); + }); }); diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index a0af9549e0..fd571fb972 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,62 @@ 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; +} + +interface SshRuntimeState { + host: string; + coderEnabled: boolean; + coderConfig: CoderWorkspaceConfig | null; +} + +/** Stable fallback for Coder config to avoid new object on every render */ +const DEFAULT_CODER_CONFIG: CoderWorkspaceConfig = { existingWorkspace: false }; + +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 @@ -58,10 +110,14 @@ export function useDraftWorkspaceSettings( recommendedTrunk: string | null ): { settings: DraftWorkspaceSettings; + /** Restores prior Coder selections when re-entering Coder mode. */ + coderConfigFallback: CoderWorkspaceConfig; + /** Preserves the last SSH host when leaving Coder so the input stays populated. */ + sshHostFallback: string; /** Set the currently selected runtime (discriminated union) */ setSelectedRuntime: (runtime: ParsedRuntime) => void; - /** Set the default runtime mode for this project (persists via checkbox) */ - setDefaultRuntimeMode: (mode: RuntimeMode) => void; + /** Set the default runtime choice for this project (persists via checkbox) */ + setDefaultRuntimeChoice: (choice: RuntimeChoice) => void; setTrunkBranch: (branch: string) => void; getRuntimeString: () => string | undefined; } { @@ -108,9 +164,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 +189,40 @@ 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 helpers so callsites stay clean. + const readSshRuntimeState = (configs: LastRuntimeConfigs): SshRuntimeState => ({ + host: readRuntimeConfigFrom(configs, RUNTIME_MODE.SSH, "host", ""), + coderEnabled: readRuntimeConfigFrom(configs, RUNTIME_MODE.SSH, "coderEnabled", false), + coderConfig: readRuntimeConfigFrom( + configs, + RUNTIME_MODE.SSH, + "coderConfig", + null + ), + }); + + const readSshRuntimeConfig = (configs: LastRuntimeConfigs): SshRuntimeConfig => { + const sshState = readSshRuntimeState(configs); + + return { + host: sshState.host, + coder: sshState.coderEnabled && sshState.coderConfig ? sshState.coderConfig : undefined, + }; + }; + + const lastSshState = readSshRuntimeState(lastRuntimeConfigs); + + // Preserve the last SSH host when switching out of Coder so the input stays populated. + const sshHostFallback = lastSshState.host; + + // Restore prior Coder selections when switching back into Coder mode. + const coderConfigFallback = lastSshState.coderConfig ?? DEFAULT_CODER_CONFIG; + 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 +232,14 @@ export function useDraftWorkspaceSettings( false ); + const coderDefaultFromString = + parsedDefault?.mode === RUNTIME_MODE.SSH && parsedDefault.host === CODER_RUNTIME_PLACEHOLDER; + // Defaults must stay explicit and sticky; last-used SSH state should only seed inputs. + const defaultRuntimeChoice: RuntimeChoice = + defaultRuntimeMode === RUNTIME_MODE.SSH && coderDefaultFromString + ? "coder" + : defaultRuntimeMode; + const setLastRuntimeConfig = useCallback( (mode: RuntimeMode, field: string, value: string | boolean | object | null) => { setLastRuntimeConfigs((prev) => { @@ -160,12 +255,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 +297,19 @@ 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; + + // When the persisted default says "Coder", reuse the saved config even if last-used SSH disabled it. + const defaultSshCoder = coderDefaultFromString + ? (lastSshState.coderConfig ?? DEFAULT_CODER_CONFIG) + : lastSsh.coder; const defaultDockerImage = parsedDefault?.mode === RUNTIME_MODE.DOCKER ? parsedDefault.image : lastDockerImage; @@ -204,67 +319,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: defaultSshCoder }, + 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 +345,9 @@ export function useDraftWorkspaceSettings( setSelectedRuntimeState( buildRuntimeForMode( defaultRuntimeMode, - defaultSshHost, + { host: defaultSshHost, coder: defaultSshCoder }, defaultDockerImage, lastShareCredentials, - lastCoderEnabled, - lastCoderConfig, defaultDevcontainerConfigPath, lastDevcontainerShareCredentials ) @@ -292,8 +362,7 @@ export function useDraftWorkspaceSettings( defaultSshHost, defaultDockerImage, lastShareCredentials, - lastCoderEnabled, - lastCoderConfig, + defaultSshCoder, defaultDevcontainerConfigPath, lastDevcontainerShareCredentials, ]); @@ -306,14 +375,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 +418,10 @@ export function useDraftWorkspaceSettings( prevSelectedRuntimeModeRef.current = selectedRuntime.mode; }, [ selectedRuntime, - lastSshHost, + lastSsh.host, lastDockerImage, lastShareCredentials, - lastCoderEnabled, - lastCoderConfig, + lastSsh.coder, lastDevcontainerConfigPath, lastDevcontainerShareCredentials, ]); @@ -373,16 +440,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); @@ -404,15 +464,32 @@ export function useDraftWorkspaceSettings( } }; - // Setter for default runtime mode (persists via checkbox in tooltip) - const setDefaultRuntimeMode = (newMode: RuntimeMode) => { + // Setter for default runtime choice (persists via checkbox in tooltip) + const setDefaultRuntimeChoice = (choice: RuntimeChoice) => { + // Defaults should only change when the checkbox is toggled, not when last-used SSH flips. + const freshRuntimeConfigs = readPersistedState( + getLastRuntimeConfigKey(projectPath), + {} + ); + const freshSshState = readSshRuntimeState(freshRuntimeConfigs); + + const newMode = choice === "coder" ? RUNTIME_MODE.SSH : choice; + const sshConfig: SshRuntimeConfig = + choice === "coder" + ? { + host: CODER_RUNTIME_PLACEHOLDER, + coder: freshSshState.coderConfig ?? DEFAULT_CODER_CONFIG, + } + : { + host: freshSshState.host, + coder: undefined, + }; + const newRuntime = buildRuntimeForMode( newMode, - lastSshHost, + sshConfig, lastDockerImage, lastShareCredentials, - lastCoderEnabled, - lastCoderConfig, defaultDevcontainerConfigPath, lastDevcontainerShareCredentials ); @@ -433,11 +510,13 @@ export function useDraftWorkspaceSettings( thinkingLevel, agentId, selectedRuntime, - defaultRuntimeMode, + defaultRuntimeMode: defaultRuntimeChoice, trunkBranch, }, + coderConfigFallback, + sshHostFallback, setSelectedRuntime, - setDefaultRuntimeMode, + setDefaultRuntimeChoice, setTrunkBranch, getRuntimeString, }; diff --git a/src/browser/stories/App.coder.stories.tsx b/src/browser/stories/App.coder.stories.tsx index 659aaba977..c63a6e6741 100644 --- a/src/browser/stories/App.coder.stories.tsx +++ b/src/browser/stories/App.coder.stories.tsx @@ -10,10 +10,17 @@ import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; import { expandProjects } from "./storyHelpers"; import type { ProjectConfig } from "@/node/config"; import type { CoderTemplate, CoderPreset, CoderWorkspace } from "@/common/orpc/schemas/coder"; +import { getLastRuntimeConfigKey, getRuntimeKey } from "@/common/constants/storage"; async function openProjectCreationView(storyRoot: HTMLElement): Promise { // App now boots into the built-in mux-chat workspace. // Navigate to the project creation page so runtime controls are visible. + if (typeof localStorage !== "undefined") { + // Ensure runtime selection state doesn't leak between stories. + localStorage.removeItem(getLastRuntimeConfigKey("/Users/dev/my-project")); + localStorage.removeItem(getRuntimeKey("/Users/dev/my-project")); + } + const projectRow = await waitFor( () => { const el = storyRoot.querySelector( @@ -111,8 +118,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 +149,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 +186,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 +200,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 +230,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 +247,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 +268,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 button should be present + await canvas.findByRole("button", { name: /SSH/i }, { timeout: 5000 }); - // 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 } - ); - - // 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 +307,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 +365,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 +417,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,