diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 68db99bda3..6b9b1acefd 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -441,7 +441,11 @@ const AIViewInner: React.FC = ({ // Uses same logic as useResumeManager for DRY const showRetryBarrier = workspaceState ? !workspaceState.canInterrupt && - hasInterruptedStream(workspaceState.messages, workspaceState.pendingStreamStartTime) + hasInterruptedStream( + workspaceState.messages, + workspaceState.pendingStreamStartTime, + workspaceState.runtimeStatus + ) : false; // Handle keyboard shortcuts (using optional refs that are safe even if not initialized) diff --git a/src/browser/components/ChatInput/CoderControls.tsx b/src/browser/components/ChatInput/CoderControls.tsx new file mode 100644 index 0000000000..a79f6416df --- /dev/null +++ b/src/browser/components/ChatInput/CoderControls.tsx @@ -0,0 +1,390 @@ +/** + * Coder workspace controls for SSH runtime. + * Enables creating or connecting to Coder cloud workspaces. + */ +import React from "react"; +import type { + CoderInfo, + CoderTemplate, + CoderPreset, + CoderWorkspace, +} from "@/common/orpc/schemas/coder"; +import type { CoderWorkspaceConfig } from "@/common/types/runtime"; +import { cn } from "@/common/lib/utils"; +import { Loader2 } from "lucide-react"; +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) */ + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + + /** Coder CLI availability info (null while checking) */ + coderInfo: CoderInfo | null; + + /** Current Coder configuration */ + coderConfig: CoderWorkspaceConfig | null; + onCoderConfigChange: (config: CoderWorkspaceConfig | null) => void; + + /** Data for dropdowns (loaded async) */ + templates: CoderTemplate[]; + presets: CoderPreset[]; + existingWorkspaces: CoderWorkspace[]; + + /** Loading states */ + loadingTemplates: boolean; + loadingPresets: boolean; + loadingWorkspaces: boolean; + + /** Disabled state (e.g., during creation) */ + disabled: 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. + */ +/** Checkbox row with optional status indicator */ +function CoderCheckbox(props: { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + disabled: boolean; + status?: React.ReactNode; +}) { + return ( + + ); +} + +export function CoderControls(props: CoderControlsProps) { + const { + enabled, + onEnabledChange, + coderInfo, + coderConfig, + onCoderConfigChange, + templates, + presets, + existingWorkspaces, + loadingTemplates, + loadingPresets, + loadingWorkspaces, + disabled, + hasError, + } = props; + + // Coder CLI status: loading (null), unavailable (available=false), or available (available=true) + if (coderInfo === null) { + return ( +
+ + + Checking… + + } + /> +
+ ); + } + + if (!coderInfo.available) { + if (!enabled) return null; + return ( +
+ (CLI unavailable)} + /> +
+ ); + } + + const mode: CoderMode = coderConfig?.existingWorkspace ? "existing" : "new"; + + const handleModeChange = (newMode: CoderMode) => { + if (newMode === "existing") { + // Switch to existing workspace mode (workspaceName starts empty, user selects) + onCoderConfigChange({ + workspaceName: undefined, + existingWorkspace: true, + }); + } 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; + onCoderConfigChange({ + existingWorkspace: false, + template: firstTemplate?.name, + templateOrg: firstIsDuplicate ? firstTemplate?.organizationName : undefined, + }); + } + }; + + const handleTemplateChange = (value: string) => { + if (!coderConfig) return; + + // Value is "org/name" when duplicates exist, otherwise just "name" + const [orgOrName, maybeName] = value.split("/"); + const templateName = maybeName ?? orgOrName; + const templateOrg = maybeName ? orgOrName : undefined; + + onCoderConfigChange({ + ...coderConfig, + template: templateName, + templateOrg, + preset: undefined, // Reset preset when template changes + }); + // Presets will be loaded by parent via effect + }; + + const handlePresetChange = (presetName: string) => { + if (!coderConfig) return; + + onCoderConfigChange({ + ...coderConfig, + preset: presetName || undefined, + }); + }; + + const handleExistingWorkspaceChange = (workspaceName: string) => { + onCoderConfigChange({ + workspaceName, + existingWorkspace: true, + }); + }; + + // Preset value: hook handles auto-selection, but keep a UI fallback to avoid a brief + // "Select preset" flash while async preset loading + config update races. + const defaultPresetName = presets.find((p) => p.isDefault)?.name; + const effectivePreset = + presets.length === 0 + ? undefined + : presets.length === 1 + ? presets[0]?.name + : (coderConfig?.preset ?? defaultPresetName ?? presets[0]?.name); + + return ( +
+ + + {/* Coder controls - only shown when enabled */} + {enabled && ( +
+ {/* Left column: New/Existing toggle buttons */} +
+ + + + + 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 ? ( + + ) : ( + + )} +
+
+ )} + + {/* Existing workspace controls - min-h matches New mode (2×h-7 + gap-1 + p-2) */} + {mode === "existing" && ( +
+ + {loadingWorkspaces ? ( + + ) : ( + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 2716f80888..3a6bd706a5 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -2,6 +2,13 @@ import React, { useCallback, useEffect } from "react"; import { RUNTIME_MODE, type RuntimeMode, type ParsedRuntime } from "@/common/types/runtime"; import type { RuntimeAvailabilityMap } from "./useCreationWorkspace"; import { Select } from "../Select"; +import { + Select as RadixSelect, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; import { Loader2, Wand2 } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; @@ -10,6 +17,7 @@ import { DocsLink } from "../DocsLink"; import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; import type { SectionConfig } from "@/common/types/project"; import { resolveSectionColor } from "@/common/constants/ui"; +import { CoderControls, type CoderControlsProps } from "./CoderControls"; interface CreationControlsProps { branches: string[]; @@ -38,6 +46,8 @@ interface CreationControlsProps { onSectionChange?: (sectionId: string | null) => void; /** Which runtime field (if any) is in error state for visual feedback */ runtimeFieldError?: "docker" | "ssh" | null; + /** Coder workspace controls props (optional - only rendered when provided) */ + coderProps?: Omit; } /** Runtime type button group with icons and colors */ @@ -141,26 +151,28 @@ function SectionPicker(props: SectionPickerProps) { opacity: selectedSection ? 1 : 0.4, }} /> - - + + + + + {sections.map((section) => ( + + {section.name} + + ))} + + ); } @@ -416,8 +428,8 @@ export function CreationControls(props: CreationControlsProps) { )} - {/* SSH Host Input */} - {selectedRuntime.mode === "ssh" && ( + {/* SSH Host Input - hidden when Coder is enabled */} + {selectedRuntime.mode === "ssh" && !props.coderProps?.enabled && (
)} - - {/* Docker Credential Sharing */} - {selectedRuntime.mode === "docker" && ( - - )} + + {/* Docker 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 && ( + + )} ); diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 5a3b92d50d..1f4a54fb5b 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -84,6 +84,7 @@ import { } from "@/browser/utils/imageHandling"; import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; +import type { ParsedRuntime } from "@/common/types/runtime"; import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; import type { MuxFrontendMetadata } from "@/common/types/message"; import { prepareUserMessageForSend } from "@/common/types/message"; @@ -94,6 +95,7 @@ import { CreationCenterContent } from "./CreationCenterContent"; import { cn } from "@/common/lib/utils"; import { CreationControls } from "./CreationControls"; import { useCreationWorkspace } from "./useCreationWorkspace"; +import { useCoderWorkspace } from "@/browser/hooks/useCoderWorkspace"; import { useTutorial } from "@/browser/contexts/TutorialContext"; import { useVoiceInput } from "@/browser/hooks/useVoiceInput"; import { VoiceInputButton } from "./VoiceInputButton"; @@ -113,6 +115,47 @@ const MAX_PERSISTED_IMAGE_DRAFT_CHARS = 4_000_000; import type { ChatInputProps, ChatInputAPI } from "./types"; import type { ImagePart } from "@/common/orpc/types"; +type CreationRuntimeValidationError = + | { mode: "docker"; kind: "missingImage" } + | { mode: "ssh"; kind: "missingHost" } + | { mode: "ssh"; kind: "missingCoderWorkspace" } + | { mode: "ssh"; kind: "missingCoderTemplate" } + | { mode: "ssh"; kind: "missingCoderPreset" }; + +function validateCreationRuntime( + runtime: ParsedRuntime, + coderPresetCount: number +): CreationRuntimeValidationError | null { + if (runtime.mode === "docker") { + return runtime.image.trim() ? null : { mode: "docker", kind: "missingImage" }; + } + + if (runtime.mode === "ssh") { + if (runtime.coder) { + if (runtime.coder.existingWorkspace) { + // Existing mode: workspace name is required + if (!(runtime.coder.workspaceName ?? "").trim()) { + return { mode: "ssh", kind: "missingCoderWorkspace" }; + } + } else { + // New mode: template is required + if (!(runtime.coder.template ?? "").trim()) { + return { mode: "ssh", kind: "missingCoderTemplate" }; + } + // Preset required when 2+ presets exist + const requiresPreset = coderPresetCount >= 2; + if (requiresPreset && !(runtime.coder.preset ?? "").trim()) { + return { mode: "ssh", kind: "missingCoderPreset" }; + } + } + return null; + } + + return runtime.host.trim() ? null : { mode: "ssh", kind: "missingHost" }; + } + + return null; +} function imagePartsToAttachments(imageParts: ImagePart[], idPrefix: string): ImageAttachment[] { return imageParts.map((img, index) => ({ id: `${idPrefix}-${index}`, @@ -470,7 +513,7 @@ const ChatInputInner: React.FC = (props) => { const creationSections = creationProject?.sections ?? []; const [selectedSectionId, setSelectedSectionId] = useState(() => pendingSectionId); - const [runtimeFieldError, setRuntimeFieldError] = useState<"docker" | "ssh" | null>(null); + const [hasAttemptedCreateSend, setHasAttemptedCreateSend] = useState(false); // Keep local selection in sync with the URL-driven pending section (sidebar "+" button). useEffect(() => { @@ -522,6 +565,33 @@ const ChatInputInner: React.FC = (props) => { const isSendInFlight = variant === "creation" ? creationState.isSending : isSending; + // Coder workspace state - config is owned by selectedRuntime.coder, this hook manages async data + const currentRuntime = creationState.selectedRuntime; + const coderState = useCoderWorkspace({ + coderConfig: currentRuntime.mode === "ssh" ? (currentRuntime.coder ?? null) : null, + onCoderConfigChange: (config) => { + if (currentRuntime.mode !== "ssh") return; + // Compute host from workspace name for "existing" mode. + // For "new" mode, workspaceName is omitted/undefined and backend derives it later. + const computedHost = config?.workspaceName + ? `${config.workspaceName}.coder` + : currentRuntime.host; + creationState.setSelectedRuntime({ + mode: "ssh", + host: computedHost, + coder: config ?? undefined, + }); + }, + }); + + const creationRuntimeError = + variant === "creation" + ? validateCreationRuntime(creationState.selectedRuntime, coderState.presets.length) + : null; + + const runtimeFieldError = + variant === "creation" && hasAttemptedCreateSend ? (creationRuntimeError?.mode ?? null) : null; + const creationControlsProps = variant === "creation" ? ({ @@ -541,39 +611,39 @@ const ChatInputInner: React.FC = (props) => { selectedSectionId, onSectionChange: setSelectedSectionId, runtimeFieldError, + // Pass coderProps when CLI is available, Coder is enabled, or still checking (so "Checking…" UI renders) + coderProps: + coderState.coderInfo?.available || coderState.enabled || coderState.coderInfo === null + ? { + enabled: coderState.enabled, + onEnabledChange: coderState.setEnabled, + coderInfo: coderState.coderInfo, + coderConfig: coderState.coderConfig, + onCoderConfigChange: coderState.setCoderConfig, + templates: coderState.templates, + presets: coderState.presets, + existingWorkspaces: coderState.existingWorkspaces, + loadingTemplates: coderState.loadingTemplates, + loadingPresets: coderState.loadingPresets, + loadingWorkspaces: coderState.loadingWorkspaces, + } + : undefined, } satisfies React.ComponentProps) : null; const hasTypedText = input.trim().length > 0; const hasImages = imageAttachments.length > 0; const hasReviews = attachedReviews.length > 0; - const isDockerMissingImage = - variant === "creation" && - creationState.selectedRuntime.mode === "docker" && - !creationState.selectedRuntime.image.trim(); - const isSshMissingHost = - variant === "creation" && - creationState.selectedRuntime.mode === "ssh" && - !creationState.selectedRuntime.host.trim(); - const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSendInFlight; + // Disable send while Coder presets are loading (user could bypass preset validation) + const coderPresetsLoading = + coderState.enabled && !coderState.coderConfig?.existingWorkspace && coderState.loadingPresets; + const canSend = + (hasTypedText || hasImages || hasReviews) && + !disabled && + !isSendInFlight && + !coderPresetsLoading; const creationProjectPath = variant === "creation" ? props.projectPath : ""; - // Clear runtime field error when runtime mode changes or field becomes valid - useEffect(() => { - if (variant !== "creation" || !runtimeFieldError) { - return; - } - const rt = creationState.selectedRuntime; - // Clear if mode changed or if the relevant field is now filled - if (rt.mode !== runtimeFieldError) { - setRuntimeFieldError(null); - } else if (rt.mode === "docker" && rt.image.trim()) { - setRuntimeFieldError(null); - } else if (rt.mode === "ssh" && rt.host.trim()) { - setRuntimeFieldError(null); - } - }, [variant, runtimeFieldError, creationState.selectedRuntime]); - // Creation variant: keep the project-scoped model/thinking in sync with global per-mode defaults // so switching Plan/Exec uses the configured defaults (and respects "inherit" semantics). useEffect(() => { @@ -1258,16 +1328,6 @@ const ChatInputInner: React.FC = (props) => { // Route to creation handler for creation variant if (variant === "creation") { - // Validate runtime fields before creating workspace - if (isDockerMissingImage) { - setRuntimeFieldError("docker"); - return; - } - if (isSshMissingHost) { - setRuntimeFieldError("ssh"); - return; - } - // Handle /init command in creation variant - populate input with init message if (messageText.startsWith("/")) { const parsed = parseCommand(messageText); @@ -1278,6 +1338,16 @@ const ChatInputInner: React.FC = (props) => { } } + setHasAttemptedCreateSend(true); + + const runtimeError = validateCreationRuntime( + creationState.selectedRuntime, + coderState.presets.length + ); + if (runtimeError) { + return; + } + // Creation variant: simple message send + workspace creation const creationImageParts = imageAttachmentsToImageParts(imageAttachments); const ok = await creationState.handleSend( diff --git a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx index 269639e4b4..335852690b 100644 --- a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -171,7 +171,8 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa // Check if current state is eligible for auto-retry const messagesEligible = isEligibleForAutoRetry( workspaceState.messages, - workspaceState.pendingStreamStartTime + workspaceState.pendingStreamStartTime, + workspaceState.runtimeStatus ); // Also check RetryState for SendMessageErrors (from resumeStream failures) diff --git a/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx b/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx index 873c5aa608..38705f9e10 100644 --- a/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx +++ b/src/browser/components/Messages/ChatBarrier/StreamingBarrier.tsx @@ -41,6 +41,7 @@ export const StreamingBarrier: React.FC = ({ workspaceId, currentModel, pendingStreamStartTime, pendingCompactionModel, + runtimeStatus, } = workspaceState; // Determine if we're in "starting" phase (message sent, waiting for stream-start) @@ -89,6 +90,10 @@ export const StreamingBarrier: React.FC = ({ workspaceId, const statusText = (() => { switch (phase) { case "starting": + // Show a runtime-specific message if the workspace is still booting (e.g., Coder/devcontainers). + if (runtimeStatus?.phase === "starting" || runtimeStatus?.phase === "waiting") { + return runtimeStatus.detail ?? "Starting workspace..."; + } return modelName ? `${modelName} starting...` : "starting..."; case "interrupting": return "interrupting..."; diff --git a/src/browser/components/RuntimeBadge.tsx b/src/browser/components/RuntimeBadge.tsx index 1a3dd917c7..09d214cfa5 100644 --- a/src/browser/components/RuntimeBadge.tsx +++ b/src/browser/components/RuntimeBadge.tsx @@ -10,7 +10,7 @@ import { } from "@/common/types/runtime"; import { extractSshHostname } from "@/browser/utils/ui/runtimeBadge"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; -import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon } from "./icons/RuntimeIcons"; +import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon, CoderIcon } from "./icons/RuntimeIcons"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; interface RuntimeBadgeProps { @@ -36,6 +36,12 @@ const RUNTIME_STYLES = { working: "bg-[var(--color-runtime-ssh)]/20 text-[var(--color-runtime-ssh-text)] border-[var(--color-runtime-ssh)]/60 animate-pulse", }, + coder: { + // Coder uses SSH styling since it's an SSH-based runtime + idle: "bg-transparent text-muted border-[var(--color-runtime-ssh)]/50", + working: + "bg-[var(--color-runtime-ssh)]/20 text-[var(--color-runtime-ssh-text)] border-[var(--color-runtime-ssh)]/60 animate-pulse", + }, worktree: { idle: "bg-transparent text-muted border-[var(--color-runtime-worktree)]/50", working: @@ -97,6 +103,7 @@ type RuntimeType = keyof typeof RUNTIME_STYLES; const RUNTIME_ICONS: Record = { ssh: SSHIcon, + coder: CoderIcon, worktree: WorktreeIcon, local: LocalIcon, docker: DockerIcon, @@ -106,6 +113,14 @@ function getRuntimeInfo( runtimeConfig?: RuntimeConfig ): { type: RuntimeType; label: string } | null { if (isSSHRuntime(runtimeConfig)) { + // Coder-backed SSH runtime gets special treatment + if (runtimeConfig.coder) { + const coderWorkspaceName = runtimeConfig.coder.workspaceName; + return { + type: "coder", + label: `Coder Workspace: ${coderWorkspaceName ?? runtimeConfig.host}`, + }; + } const hostname = extractSshHostname(runtimeConfig); return { type: "ssh", label: `SSH: ${hostname ?? runtimeConfig.host}` }; } diff --git a/src/browser/components/icons/RuntimeIcons.tsx b/src/browser/components/icons/RuntimeIcons.tsx index 98ac6b25cd..7c236b0a0e 100644 --- a/src/browser/components/icons/RuntimeIcons.tsx +++ b/src/browser/components/icons/RuntimeIcons.tsx @@ -74,6 +74,24 @@ export function LocalIcon({ size = 10, className }: IconProps) { ); } +/** Coder logo icon for Coder-backed SSH runtime */ +export function CoderIcon({ size = 10, className }: IconProps) { + return ( + + {/* Coder shorthand logo: stylized "C" with cursor block */} + + + + ); +} + /** Container icon for Docker runtime */ export function DockerIcon({ size = 10, className }: IconProps) { return ( diff --git a/src/browser/hooks/useCoderWorkspace.ts b/src/browser/hooks/useCoderWorkspace.ts new file mode 100644 index 0000000000..958d9b98b6 --- /dev/null +++ b/src/browser/hooks/useCoderWorkspace.ts @@ -0,0 +1,323 @@ +/** + * Hook for managing Coder workspace async data in the creation flow. + * Fetches Coder CLI info, templates, presets, and existing workspaces. + * + * The `coderConfig` state is owned by the parent (via selectedRuntime.coder) and passed in. + * This hook only manages async-fetched data and derived state. + */ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import type { + CoderInfo, + CoderTemplate, + CoderPreset, + CoderWorkspace, +} from "@/common/orpc/schemas/coder"; +import type { CoderWorkspaceConfig } from "@/common/types/runtime"; + +interface UseCoderWorkspaceOptions { + /** Current Coder config (null = disabled, owned by parent via selectedRuntime.coder) */ + coderConfig: CoderWorkspaceConfig | null; + /** Callback to update Coder config (updates selectedRuntime.coder) */ + onCoderConfigChange: (config: CoderWorkspaceConfig | null) => void; +} + +interface UseCoderWorkspaceReturn { + /** Whether Coder is enabled (derived: coderConfig != null) */ + enabled: boolean; + /** Toggle Coder on/off (calls onCoderConfigChange with config or null) */ + setEnabled: (enabled: boolean) => void; + + /** Coder CLI availability info */ + coderInfo: CoderInfo | null; + + /** Current Coder configuration (passed through from props) */ + coderConfig: CoderWorkspaceConfig | null; + /** Update Coder config (passed through from props) */ + setCoderConfig: (config: CoderWorkspaceConfig | null) => void; + + /** Available templates */ + templates: CoderTemplate[]; + /** Presets for the currently selected template */ + presets: CoderPreset[]; + /** Running Coder workspaces */ + existingWorkspaces: CoderWorkspace[]; + + /** Loading states */ + loadingTemplates: boolean; + loadingPresets: boolean; + loadingWorkspaces: boolean; +} + +/** + * Manages Coder workspace async data for the creation flow. + * + * Fetches data lazily: + * - Coder info is fetched on mount + * - Templates are fetched when Coder is enabled + * - Presets are fetched when a template is selected + * - Workspaces are fetched when Coder is enabled + * + * State ownership: coderConfig is owned by parent (selectedRuntime.coder). + * This hook derives `enabled` from coderConfig and manages only async data. + */ +export function useCoderWorkspace({ + coderConfig, + onCoderConfigChange, +}: UseCoderWorkspaceOptions): UseCoderWorkspaceReturn { + const { api } = useAPI(); + + // Derived state: enabled when coderConfig is present + const enabled = coderConfig != null; + + // Ref to access current coderConfig in async callbacks (avoids stale closures) + const coderConfigRef = useRef(coderConfig); + useEffect(() => { + coderConfigRef.current = coderConfig; + }, [coderConfig]); + + // Async-fetched data (owned by this hook) + const [coderInfo, setCoderInfo] = useState(null); + const [templates, setTemplates] = useState([]); + const [presets, setPresets] = useState([]); + const [existingWorkspaces, setExistingWorkspaces] = useState([]); + + // Loading states + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [loadingPresets, setLoadingPresets] = useState(false); + const [loadingWorkspaces, setLoadingWorkspaces] = useState(false); + + // Fetch Coder info on mount + useEffect(() => { + if (!api) return; + + let mounted = true; + + api.coder + .getInfo() + .then((info) => { + if (mounted) { + setCoderInfo(info); + } + }) + .catch(() => { + if (mounted) { + setCoderInfo({ available: false }); + } + }); + + return () => { + mounted = false; + }; + }, [api]); + + // Fetch templates when Coder is enabled + useEffect(() => { + if (!api || !enabled || !coderInfo?.available) { + setTemplates([]); + setLoadingTemplates(false); + return; + } + + let mounted = true; + setLoadingTemplates(true); + + api.coder + .listTemplates() + .then((result) => { + if (mounted) { + setTemplates(result); + // Auto-select first template if none selected + // Use ref to get current config (avoids stale closure if user toggled modes during fetch) + const currentConfig = coderConfigRef.current; + if (result.length > 0 && !currentConfig?.template && !currentConfig?.existingWorkspace) { + const firstTemplate = result[0]; + const firstIsDuplicate = result.some( + (t) => + t.name === firstTemplate.name && + t.organizationName !== firstTemplate.organizationName + ); + onCoderConfigChange({ + existingWorkspace: false, + template: firstTemplate.name, + templateOrg: firstIsDuplicate ? firstTemplate.organizationName : undefined, + }); + } + } + }) + .catch(() => { + if (mounted) { + setTemplates([]); + } + }) + .finally(() => { + if (mounted) { + setLoadingTemplates(false); + } + }); + + return () => { + mounted = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally only re-fetch on enable/available changes, not on coderConfig changes + }, [api, enabled, coderInfo?.available]); + + // Fetch existing workspaces when Coder is enabled + useEffect(() => { + if (!api || !enabled || !coderInfo?.available) { + setExistingWorkspaces([]); + setLoadingWorkspaces(false); + return; + } + + let mounted = true; + setLoadingWorkspaces(true); + + api.coder + .listWorkspaces() + .then((result) => { + if (mounted) { + // Backend already filters to running workspaces by default + setExistingWorkspaces(result); + } + }) + .catch(() => { + if (mounted) { + setExistingWorkspaces([]); + } + }) + .finally(() => { + if (mounted) { + setLoadingWorkspaces(false); + } + }); + + return () => { + mounted = false; + }; + }, [api, enabled, coderInfo?.available]); + + // Fetch presets when template changes (only for "new" mode) + useEffect(() => { + if (!api || !enabled || !coderConfig?.template || coderConfig.existingWorkspace) { + setPresets([]); + setLoadingPresets(false); + return; + } + + let mounted = true; + setLoadingPresets(true); + + // Capture template/org at request time to detect stale responses + const templateAtRequest = coderConfig.template; + const orgAtRequest = coderConfig.templateOrg; + + api.coder + .listPresets({ template: templateAtRequest, org: orgAtRequest }) + .then((result) => { + if (!mounted) { + return; + } + + // Stale response guard: if user changed template/org while request was in-flight, ignore this response + if ( + coderConfigRef.current?.template !== templateAtRequest || + coderConfigRef.current?.templateOrg !== orgAtRequest + ) { + return; + } + + setPresets(result); + + // Presets rules (per spec): + // - 0 presets: no dropdown + // - 1 preset: auto-select silently + // - 2+ presets: dropdown shown, auto-select default if exists, otherwise user must pick + // Use ref to get current config (avoids stale closure if user changed config during fetch) + const currentConfig = coderConfigRef.current; + if (currentConfig && !currentConfig.existingWorkspace) { + if (result.length === 1) { + const onlyPreset = result[0]; + if (onlyPreset && currentConfig.preset !== onlyPreset.name) { + onCoderConfigChange({ ...currentConfig, preset: onlyPreset.name }); + } + } else if (result.length >= 2 && !currentConfig.preset) { + // Auto-select default preset if available, otherwise first preset + // This keeps UI and config in sync (UI falls back to first preset for display) + const defaultPreset = result.find((p) => p.isDefault); + const presetToSelect = defaultPreset ?? result[0]; + if (presetToSelect) { + onCoderConfigChange({ ...currentConfig, preset: presetToSelect.name }); + } + } else if (result.length === 0 && currentConfig.preset) { + onCoderConfigChange({ ...currentConfig, preset: undefined }); + } + } + }) + .catch(() => { + if (mounted) { + setPresets([]); + } + }) + .finally(() => { + // Only clear loading for the active request (not stale ones) + if ( + mounted && + coderConfigRef.current?.template === templateAtRequest && + coderConfigRef.current?.templateOrg === orgAtRequest + ) { + setLoadingPresets(false); + } + }); + + return () => { + mounted = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-fetch on template/org/existingWorkspace changes, not on preset changes (would cause loop) + }, [ + api, + enabled, + coderConfig?.template, + coderConfig?.templateOrg, + coderConfig?.existingWorkspace, + ]); + + // Handle enabled toggle + const handleSetEnabled = useCallback( + (newEnabled: boolean) => { + if (newEnabled) { + // Initialize config for new workspace mode (workspaceName omitted; backend derives) + const firstTemplate = templates[0]; + const firstIsDuplicate = firstTemplate + ? templates.some( + (t) => + t.name === firstTemplate.name && + t.organizationName !== firstTemplate.organizationName + ) + : false; + onCoderConfigChange({ + existingWorkspace: false, + template: firstTemplate?.name, + templateOrg: firstIsDuplicate ? firstTemplate?.organizationName : undefined, + }); + } else { + onCoderConfigChange(null); + } + }, + [templates, onCoderConfigChange] + ); + + return { + enabled, + setEnabled: handleSetEnabled, + coderInfo, + coderConfig, + setCoderConfig: onCoderConfigChange, + templates, + presets, + existingWorkspaces, + loadingTemplates, + loadingPresets, + loadingWorkspaces, + }; +} diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 8ae3a10eeb..d3a5257dfc 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -6,9 +6,11 @@ import { getDefaultModel } from "./useModelsFromSettings"; import { type RuntimeMode, type ParsedRuntime, + type CoderWorkspaceConfig, parseRuntimeModeAndHost, buildRuntimeString, RUNTIME_MODE, + CODER_RUNTIME_PLACEHOLDER, } from "@/common/types/runtime"; import { getModelKey, @@ -101,32 +103,39 @@ export function useDraftWorkspaceSettings( { listener: true } ); - const readLastRuntimeConfigString = (mode: RuntimeMode, field: string): string => { - const value = lastRuntimeConfigs[mode]; - if (!value || typeof value !== "object" || Array.isArray(value)) { - return ""; + // Generic reader for lastRuntimeConfigs fields + const readRuntimeConfig = (mode: RuntimeMode, field: string, defaultValue: T): T => { + const modeConfig = lastRuntimeConfigs[mode]; + if (!modeConfig || typeof modeConfig !== "object" || Array.isArray(modeConfig)) { + return defaultValue; } - const fieldValue = (value as Record)[field]; - return typeof fieldValue === "string" ? fieldValue : ""; - }; - - const readLastRuntimeConfigBoolean = (mode: RuntimeMode, field: string): boolean => { - const value = lastRuntimeConfigs[mode]; - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; + const fieldValue = (modeConfig as Record)[field]; + // Type-specific validation based on default value type + if (typeof defaultValue === "string") { + return (typeof fieldValue === "string" ? fieldValue : defaultValue) as T; + } + if (typeof defaultValue === "boolean") { + return (fieldValue === true) as unknown as T; } - return (value as Record)[field] === true; + // Object type (null default means optional object) + if (fieldValue && typeof fieldValue === "object" && !Array.isArray(fieldValue)) { + return fieldValue as T; + } + return defaultValue; }; - const lastSshHost = readLastRuntimeConfigString(RUNTIME_MODE.SSH, "host"); - const lastDockerImage = readLastRuntimeConfigString(RUNTIME_MODE.DOCKER, "image"); - const lastShareCredentials = readLastRuntimeConfigBoolean( - RUNTIME_MODE.DOCKER, - "shareCredentials" + const lastSshHost = readRuntimeConfig(RUNTIME_MODE.SSH, "host", ""); + const lastCoderEnabled = readRuntimeConfig(RUNTIME_MODE.SSH, "coderEnabled", false); + const lastCoderConfig = readRuntimeConfig( + RUNTIME_MODE.SSH, + "coderConfig", + null ); + const lastDockerImage = readRuntimeConfig(RUNTIME_MODE.DOCKER, "image", ""); + const lastShareCredentials = readRuntimeConfig(RUNTIME_MODE.DOCKER, "shareCredentials", false); const setLastRuntimeConfig = useCallback( - (mode: RuntimeMode, field: string, value: string | boolean) => { + (mode: RuntimeMode, field: string, value: string | boolean | object | null) => { setLastRuntimeConfigs((prev) => { const existing = prev[mode]; const existingObj = @@ -161,22 +170,35 @@ export function useDraftWorkspaceSettings( const defaultSshHost = parsedDefault?.mode === RUNTIME_MODE.SSH ? parsedDefault.host : lastSshHost; + const defaultDockerImage = parsedDefault?.mode === RUNTIME_MODE.DOCKER ? parsedDefault.image : lastDockerImage; - // Build ParsedRuntime from mode + stored host/image/shareCredentials + // 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, - shareCredentials: boolean + shareCredentials: boolean, + coderEnabled: boolean, + coderConfig: CoderWorkspaceConfig | null ): ParsedRuntime => { switch (mode) { case RUNTIME_MODE.LOCAL: return { mode: "local" }; - case RUNTIME_MODE.SSH: - return { mode: "ssh", host: sshHost }; + 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 }; case RUNTIME_MODE.WORKTREE: @@ -192,7 +214,9 @@ export function useDraftWorkspaceSettings( defaultRuntimeMode, defaultSshHost, defaultDockerImage, - lastShareCredentials + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig ) ); @@ -211,24 +235,41 @@ export function useDraftWorkspaceSettings( defaultRuntimeMode, defaultSshHost, defaultDockerImage, - lastShareCredentials + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig ) ); } prevProjectPathRef.current = projectPath; prevDefaultRuntimeModeRef.current = defaultRuntimeMode; - }, [projectPath, defaultRuntimeMode, defaultSshHost, defaultDockerImage, lastShareCredentials]); + }, [ + projectPath, + defaultRuntimeMode, + defaultSshHost, + defaultDockerImage, + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig, + ]); - // When the user switches into SSH/Docker mode, seed the field with the remembered host/image. - // This avoids clearing the last host/image when the UI switches modes with an empty field. + // When the user switches into SSH/Docker mode, seed the field with the remembered host/image/coder. + // This avoids clearing the last values when the UI switches modes with an empty field. const prevSelectedRuntimeModeRef = useRef(null); useEffect(() => { const prevMode = prevSelectedRuntimeModeRef.current; if (prevMode !== selectedRuntime.mode) { if (selectedRuntime.mode === RUNTIME_MODE.SSH) { - if (!selectedRuntime.host.trim() && lastSshHost.trim()) { - setSelectedRuntimeState({ mode: RUNTIME_MODE.SSH, host: lastSshHost }); + const needsHostRestore = !selectedRuntime.host.trim() && lastSshHost.trim(); + const needsCoderRestore = + selectedRuntime.coder === undefined && lastCoderEnabled && lastCoderConfig; + if (needsHostRestore || needsCoderRestore) { + setSelectedRuntimeState({ + mode: RUNTIME_MODE.SSH, + host: needsHostRestore ? lastSshHost : selectedRuntime.host, + coder: needsCoderRestore ? lastCoderConfig : selectedRuntime.coder, + }); } } @@ -247,7 +288,14 @@ export function useDraftWorkspaceSettings( } prevSelectedRuntimeModeRef.current = selectedRuntime.mode; - }, [selectedRuntime, lastSshHost, lastDockerImage, lastShareCredentials]); + }, [ + selectedRuntime, + lastSshHost, + lastDockerImage, + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig, + ]); // Initialize trunk branch from backend recommendation or first branch useEffect(() => { @@ -257,16 +305,22 @@ export function useDraftWorkspaceSettings( } }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); - // Setter for selected runtime (also persists host/image for future mode switches) + // Setter for selected runtime (also persists host/image/coder for future mode switches) const setSelectedRuntime = (runtime: ParsedRuntime) => { setSelectedRuntimeState(runtime); - // Persist host/image so they're remembered when switching modes. + // 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. 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); + } } else if (runtime.mode === RUNTIME_MODE.DOCKER) { if (runtime.image.trim()) { setLastRuntimeConfig(RUNTIME_MODE.DOCKER, "image", runtime.image); @@ -283,7 +337,9 @@ export function useDraftWorkspaceSettings( newMode, lastSshHost, lastDockerImage, - lastShareCredentials + lastShareCredentials, + lastCoderEnabled, + lastCoderConfig ); const newRuntimeString = buildRuntimeString(newRuntime); setDefaultRuntimeString(newRuntimeString); diff --git a/src/browser/hooks/useResumeManager.ts b/src/browser/hooks/useResumeManager.ts index d13d88a13c..d79bf4ed01 100644 --- a/src/browser/hooks/useResumeManager.ts +++ b/src/browser/hooks/useResumeManager.ts @@ -105,7 +105,9 @@ export function useResumeManager() { // 1. Must have interrupted stream that's eligible for auto-retry (not currently streaming) if (state.canInterrupt) return false; // Currently streaming - if (!isEligibleForAutoRetry(state.messages, state.pendingStreamStartTime)) { + if ( + !isEligibleForAutoRetry(state.messages, state.pendingStreamStartTime, state.runtimeStatus) + ) { return false; } diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index dbf7c665fc..fcb0143b4a 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -21,7 +21,7 @@ import { isQueuedMessageChanged, isRestoreToInput, } from "@/common/orpc/types"; -import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; +import type { StreamEndEvent, StreamAbortEvent, RuntimeStatusEvent } from "@/common/types/stream"; import { MapStore } from "./MapStore"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager"; @@ -56,6 +56,8 @@ export interface WorkspaceState { pendingStreamStartTime: number | null; // Model override from pending compaction request (used during "starting" phase) pendingCompactionModel: string | null; + // Runtime status from ensureReady (for Coder workspace starting UX) + runtimeStatus: RuntimeStatusEvent | null; // Live streaming stats (updated on each stream-delta) streamingTokenCount: number | undefined; streamingTPS: number | undefined; @@ -472,6 +474,10 @@ export class WorkspaceStore { applyWorkspaceChatEventToAggregator(aggregator, data); this.states.bump(workspaceId); }, + "runtime-status": (workspaceId, aggregator, data) => { + applyWorkspaceChatEventToAggregator(aggregator, data); + this.states.bump(workspaceId); + }, "session-usage-delta": (workspaceId, _aggregator, data) => { const usageDelta = data as Extract; @@ -866,6 +872,7 @@ export class WorkspaceStore { agentStatus: aggregator.getAgentStatus(), pendingStreamStartTime: aggregator.getPendingStreamStartTime(), pendingCompactionModel: aggregator.getPendingCompactionModel(), + runtimeStatus: aggregator.getRuntimeStatus(), streamingTokenCount, streamingTPS, }; diff --git a/src/browser/stories/App.coder.stories.tsx b/src/browser/stories/App.coder.stories.tsx new file mode 100644 index 0000000000..f9b293760a --- /dev/null +++ b/src/browser/stories/App.coder.stories.tsx @@ -0,0 +1,401 @@ +/** + * Coder workspace integration stories. + * Tests the UI for creating and connecting to Coder cloud workspaces. + */ + +import { within, userEvent, waitFor } from "@storybook/test"; + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +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"; + +export default { + ...appMeta, + title: "App/Coder", +}; + +/** Helper to create a project config for a path with no workspaces */ +function projectWithNoWorkspaces(path: string): [string, ProjectConfig] { + return [path, { workspaces: [] }]; +} + +/** Mock Coder templates */ +const mockTemplates: CoderTemplate[] = [ + { + name: "coder-on-coder", + displayName: "Coder on Coder", + organizationName: "default", + }, + { + name: "kubernetes-dev", + displayName: "Kubernetes Development", + organizationName: "default", + }, + { + name: "aws-windows", + displayName: "AWS Windows Instance", + organizationName: "default", + }, +]; + +/** Mock presets for coder-on-coder template */ +const mockPresetsCoderOnCoder: CoderPreset[] = [ + { + id: "preset-sydney", + name: "Sydney", + description: "Australia region", + isDefault: false, + }, + { + id: "preset-helsinki", + name: "Helsinki", + description: "Europe region", + isDefault: false, + }, + { + id: "preset-pittsburgh", + name: "Pittsburgh", + description: "US East region", + isDefault: true, + }, +]; + +/** Mock presets for kubernetes template (only one) */ +const mockPresetsK8s: CoderPreset[] = [ + { + id: "preset-k8s-1", + name: "Standard", + description: "Default configuration", + isDefault: true, + }, +]; + +/** Mock existing Coder workspaces */ +const mockWorkspaces: CoderWorkspace[] = [ + { + name: "mux-dev", + templateName: "coder-on-coder", + templateDisplayName: "Coder on Coder", + status: "running", + }, + { + name: "api-testing", + templateName: "kubernetes-dev", + templateDisplayName: "Kubernetes Dev", + status: "running", + }, + { + name: "frontend-v2", + templateName: "coder-on-coder", + templateDisplayName: "Coder on Coder", + status: "running", + }, +]; + +/** + * SSH runtime with Coder available - shows Coder checkbox. + * When user selects SSH runtime, they can enable Coder workspace mode. + */ +export const SSHWithCoderAvailable: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: mockTemplates, + coderPresets: new Map([ + ["coder-on-coder", mockPresetsCoderOnCoder], + ["kubernetes-dev", mockPresetsK8s], + ["aws-windows", []], + ]), + coderWorkspaces: mockWorkspaces, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // 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 new workspace flow - shows template and preset dropdowns. + * User enables Coder, selects template, and optionally a preset. + */ +export const CoderNewWorkspace: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: mockTemplates, + coderPresets: new Map([ + ["coder-on-coder", mockPresetsCoderOnCoder], + ["kubernetes-dev", mockPresetsK8s], + ["aws-windows", []], + ]), + coderWorkspaces: mockWorkspaces, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // 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); + + // Wait for Coder controls to appear + await canvas.findByTestId("coder-controls-inner", {}, { timeout: 5000 }); + + // The template dropdown should be visible with templates loaded + await canvas.findByTestId("coder-template-select", {}, { timeout: 5000 }); + }, +}; + +/** + * Coder existing workspace flow - shows workspace dropdown. + * User switches to "Existing" mode and selects from running workspaces. + */ +export const CoderExistingWorkspace: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: mockTemplates, + coderPresets: new Map([ + ["coder-on-coder", mockPresetsCoderOnCoder], + ["kubernetes-dev", mockPresetsK8s], + ["aws-windows", []], + ]), + coderWorkspaces: mockWorkspaces, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // 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); + + // Wait for Coder controls + await canvas.findByTestId("coder-controls-inner", {}, { timeout: 5000 }); + + // Click "Existing" button + const existingButton = canvas.getByRole("button", { name: "Existing" }); + await userEvent.click(existingButton); + + // Wait for workspace dropdown to appear + await canvas.findByTestId("coder-workspace-select", {}, { timeout: 5000 }); + }, +}; + +/** + * Coder not available - checkbox should not appear. + * When Coder CLI is not installed, the SSH runtime shows normal host input. + */ +export const CoderNotAvailable: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: false }, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // 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); + + // 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 with template that has no presets. + * When selecting a template with 0 presets, the preset dropdown is visible but disabled. + */ +export const CoderNoPresets: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: [ + { name: "simple-vm", displayName: "Simple VM", organizationName: "default" }, + ], + coderPresets: new Map([["simple-vm", []]]), + coderWorkspaces: [], + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // 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); + + // Wait for Coder controls + await canvas.findByTestId("coder-controls-inner", {}, { timeout: 5000 }); + + // Template dropdown should be visible + await canvas.findByTestId("coder-template-select", {}, { timeout: 5000 }); + + // Preset dropdown should be visible but disabled (shows "No presets" placeholder) + const presetSelect = await canvas.findByTestId("coder-preset-select", {}, { timeout: 5000 }); + await waitFor(() => { + // Radix UI Select sets data-disabled="" (empty string) when disabled + if (!presetSelect.hasAttribute("data-disabled")) { + throw new Error("Preset dropdown should be disabled when template has no presets"); + } + }); + }, +}; + +/** + * Coder with no running workspaces. + * When switching to "Existing" mode with no running workspaces, shows empty state. + */ +export const CoderNoRunningWorkspaces: AppStory = { + render: () => ( + { + expandProjects(["/Users/dev/my-project"]); + return createMockORPCClient({ + projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), + workspaces: [], + coderInfo: { available: true, version: "2.28.0" }, + coderTemplates: mockTemplates, + coderPresets: new Map([ + ["coder-on-coder", mockPresetsCoderOnCoder], + ["kubernetes-dev", mockPresetsK8s], + ]), + coderWorkspaces: [], // No running workspaces + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + const canvas = within(storyRoot); + + // 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 "Existing" button + const existingButton = await canvas.findByRole( + "button", + { name: "Existing" }, + { timeout: 5000 } + ); + await userEvent.click(existingButton); + + // Workspace dropdown should show "No workspaces found" placeholder + // Note: Radix UI Select doesn't render native