diff --git a/src/browser/features/ChatInput/CreationControls.stories.tsx b/src/browser/features/ChatInput/CreationControls.stories.tsx
new file mode 100644
index 0000000000..35c44951d0
--- /dev/null
+++ b/src/browser/features/ChatInput/CreationControls.stories.tsx
@@ -0,0 +1,104 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, userEvent, within } from "@storybook/test";
+import { lightweightMeta } from "@/browser/stories/meta.js";
+import {
+ mockCoderInfoAvailable,
+ mockCoderInfoMissing,
+ mockCoderInfoOutdated,
+} from "@/browser/stories/mocks/coder";
+import { RUNTIME_MODE } from "@/common/types/runtime";
+import { RuntimeButtonGroup, type RuntimeButtonGroupProps } from "./CreationControls";
+
+const BASE_ARGS = {
+ value: RUNTIME_MODE.WORKTREE,
+ defaultMode: RUNTIME_MODE.WORKTREE,
+ onChange: fn(),
+ onSetDefault: fn(),
+ runtimeAvailabilityState: {
+ status: "loaded",
+ data: {
+ local: { available: true },
+ worktree: { available: true },
+ ssh: { available: true },
+ docker: { available: true },
+ devcontainer: { available: true },
+ },
+ },
+} satisfies RuntimeButtonGroupProps;
+
+const meta = {
+ ...lightweightMeta,
+ title: "App/Chat/Creation Controls",
+ component: RuntimeButtonGroup,
+ render: (args) => (
+
+ ),
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+async function openWorkspaceTypeMenu(storyRoot: HTMLElement): Promise {
+ const canvas = within(storyRoot);
+ const trigger = await canvas.findByLabelText("Workspace type", {}, { timeout: 10000 });
+ await userEvent.click(trigger);
+}
+
+/** Coder option is visible and selectable when Coder CLI is available. */
+export const CoderAvailable: Story = {
+ args: {
+ ...BASE_ARGS,
+ coderInfo: mockCoderInfoAvailable,
+ },
+ play: async ({ canvasElement }) => {
+ const storyRoot = document.getElementById("storybook-root") ?? canvasElement;
+
+ await openWorkspaceTypeMenu(storyRoot);
+ await within(document.body).findByRole("option", { name: /^Coder/i }, { timeout: 10000 });
+ await userEvent.keyboard("{Escape}");
+ },
+};
+
+/** Coder option is hidden when Coder CLI is missing. */
+export const CoderNotAvailable: Story = {
+ args: {
+ ...BASE_ARGS,
+ coderInfo: mockCoderInfoMissing,
+ },
+ play: async ({ canvasElement }) => {
+ const storyRoot = document.getElementById("storybook-root") ?? canvasElement;
+
+ await openWorkspaceTypeMenu(storyRoot);
+ await within(document.body).findByRole("option", { name: /^SSH/i }, { timeout: 10000 });
+ await expect(within(document.body).queryByRole("option", { name: /^Coder/i })).toBeNull();
+ await userEvent.keyboard("{Escape}");
+ },
+};
+
+/** Coder option remains visible but disabled when CLI version is outdated. */
+export const CoderOutdated: Story = {
+ args: {
+ ...BASE_ARGS,
+ coderInfo: mockCoderInfoOutdated,
+ },
+ play: async ({ canvasElement }) => {
+ const storyRoot = document.getElementById("storybook-root") ?? canvasElement;
+
+ await openWorkspaceTypeMenu(storyRoot);
+ const coderOption = await within(document.body).findByRole(
+ "option",
+ { name: /^Coder/i },
+ { timeout: 10000 }
+ );
+
+ await expect(coderOption).toHaveAttribute("aria-disabled", "true");
+ await expect(coderOption).toHaveTextContent("2.20.0");
+ await expect(coderOption).toHaveTextContent("2.25.0");
+ await userEvent.keyboard("{Escape}");
+ },
+};
diff --git a/src/browser/features/ChatInput/CreationControls.tsx b/src/browser/features/ChatInput/CreationControls.tsx
index bc8062fb27..db4751f3d8 100644
--- a/src/browser/features/ChatInput/CreationControls.tsx
+++ b/src/browser/features/ChatInput/CreationControls.tsx
@@ -152,7 +152,7 @@ interface CreationControlsProps {
}
/** Runtime type button group with icons and colors */
-interface RuntimeButtonGroupProps {
+export interface RuntimeButtonGroupProps {
value: RuntimeChoice;
onChange: (mode: RuntimeChoice) => void;
defaultMode: RuntimeChoice;
@@ -428,7 +428,7 @@ function SectionPicker(props: SectionPickerProps) {
);
}
-function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
+export function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
const state = props.runtimeAvailabilityState;
const availabilityMap = state?.status === "loaded" ? state.data : null;
const coderInfo = props.coderInfo ?? null;
diff --git a/src/browser/features/Runtime/CoderControls.stories.tsx b/src/browser/features/Runtime/CoderControls.stories.tsx
new file mode 100644
index 0000000000..cc632314b6
--- /dev/null
+++ b/src/browser/features/Runtime/CoderControls.stories.tsx
@@ -0,0 +1,289 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { waitFor, within } from "@storybook/test";
+import { useState } from "react";
+
+import { lightweightMeta } from "@/browser/stories/meta.js";
+import {
+ mockCoderInfoOutdated,
+ mockCoderParseError,
+ mockCoderPresetsCoderOnCoder,
+ mockCoderTemplates,
+ mockCoderWorkspaces,
+} from "@/browser/stories/mocks/coder";
+import type { CoderInfo } from "@/common/orpc/schemas/coder";
+import type { CoderWorkspaceConfig } from "@/common/types/runtime";
+
+import {
+ CoderAvailabilityMessage,
+ CoderWorkspaceForm,
+ type CoderWorkspaceFormProps,
+} from "./CoderControls";
+
+type CoderWorkspaceFormStoryProps = Omit;
+
+interface CoderAvailabilityMessageStoryProps {
+ coderInfo: CoderInfo | null;
+}
+
+const notLoggedInMessage = "Run `coder login ` first.";
+
+const NEW_WORKSPACE_CONFIG: CoderWorkspaceConfig = {
+ existingWorkspace: false,
+ template: "coder-on-coder",
+ templateOrg: "default",
+};
+
+const EXISTING_WORKSPACE_CONFIG: CoderWorkspaceConfig = {
+ existingWorkspace: true,
+ workspaceName: undefined,
+};
+
+const baseCoderWorkspaceFormProps: CoderWorkspaceFormStoryProps = {
+ coderConfig: NEW_WORKSPACE_CONFIG,
+ templates: mockCoderTemplates,
+ templatesError: null,
+ presets: mockCoderPresetsCoderOnCoder,
+ presetsError: null,
+ existingWorkspaces: mockCoderWorkspaces,
+ workspacesError: null,
+ loadingTemplates: false,
+ loadingPresets: false,
+ loadingWorkspaces: false,
+ disabled: false,
+ hasError: false,
+};
+
+function getCoderWorkspaceFormProps(
+ overrides: Partial = {}
+): CoderWorkspaceFormStoryProps {
+ return {
+ ...baseCoderWorkspaceFormProps,
+ ...overrides,
+ };
+}
+
+function CoderWorkspaceFormStory(props: CoderWorkspaceFormStoryProps) {
+ const [coderConfig, setCoderConfig] = useState(
+ () => props.coderConfig
+ );
+
+ return (
+
+
+
+ );
+}
+
+function CoderAvailabilityMessageStory(props: CoderAvailabilityMessageStoryProps) {
+ return (
+
+
+
+ );
+}
+
+const meta = {
+ ...lightweightMeta,
+ title: "Features/Runtime/CoderControls",
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const NewWorkspace: Story = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByTestId("coder-controls-inner");
+ await canvas.findByTestId("coder-template-select");
+ await canvas.findByTestId("coder-preset-select");
+ },
+};
+
+export const ExistingWorkspace: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByTestId("coder-workspace-select");
+ },
+};
+
+export const TemplatesParseError: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText(mockCoderParseError);
+ await waitFor(() => {
+ const templateSelect = canvas.queryByTestId("coder-template-select");
+ if (!templateSelect?.hasAttribute("data-disabled")) {
+ throw new Error("Expected template select to be disabled when templates fail to parse");
+ }
+ });
+ },
+};
+
+export const PresetsParseError: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText(mockCoderParseError);
+ },
+};
+
+export const ExistingWorkspaceParseError: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText(mockCoderParseError);
+ },
+};
+
+export const NoPresets: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await waitFor(() => {
+ const presetSelect = canvas.queryByTestId("coder-preset-select");
+ if (!presetSelect?.hasAttribute("data-disabled")) {
+ throw new Error("Expected preset select to be disabled when no presets are available");
+ }
+ });
+ },
+};
+
+export const NoRunningWorkspaces: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await waitFor(() => {
+ const workspaceSelect = canvas.queryByTestId("coder-workspace-select");
+ if (!workspaceSelect?.textContent?.includes("No workspaces found")) {
+ throw new Error('Expected workspace select to show "No workspaces found"');
+ }
+ });
+ },
+};
+
+export const WithLoginInfo: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText(/Logged in as/i);
+ await canvas.findByText("coder-user");
+ await canvas.findByText("https://coder.example.com");
+ },
+};
+
+export const AvailabilityLoading: Story = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText("Checking…");
+ await waitFor(() => {
+ const spinner = canvasElement.querySelector(".animate-spin");
+ if (!spinner) {
+ throw new Error("Expected loading spinner while Coder availability is being checked");
+ }
+ });
+ },
+};
+
+export const AvailabilityOutdated: Story = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ const warning = await canvas.findByText("Coder CLI 2.20.0 is below minimum v2.25.0.");
+ await waitFor(() => {
+ if (!warning.className.includes("text-yellow-500")) {
+ throw new Error("Expected outdated availability message to use warning styling");
+ }
+ });
+ },
+};
+
+export const AvailabilityUnavailableNotLoggedIn: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ const warning = await canvas.findByText(notLoggedInMessage);
+ await waitFor(() => {
+ if (!warning.className.includes("text-yellow-500")) {
+ throw new Error("Expected unavailable-not-logged-in message to use warning styling");
+ }
+ });
+ },
+};
diff --git a/src/browser/stories/App.coder.stories.tsx b/src/browser/stories/App.coder.stories.tsx
deleted file mode 100644
index d485bc523c..0000000000
--- a/src/browser/stories/App.coder.stories.tsx
+++ /dev/null
@@ -1,574 +0,0 @@
-/**
- * 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";
-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(
- '[data-project-path="/Users/dev/my-project"][aria-controls]'
- );
- if (!el) throw new Error("Project row not found");
- return el;
- },
- { timeout: 10_000 }
- );
-
- await userEvent.click(projectRow);
-}
-
-async function openWorkspaceTypeMenu(storyRoot: HTMLElement): Promise {
- const canvas = within(storyRoot);
- await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 });
- const trigger = await canvas.findByLabelText("Workspace type", {}, { timeout: 10000 });
- await userEvent.click(trigger);
-}
-
-async function selectWorkspaceType(storyRoot: HTMLElement, label: string): Promise {
- await openWorkspaceTypeMenu(storyRoot);
- const option = await within(document.body).findByRole(
- "option",
- { name: new RegExp(`^${label}`, "i") },
- { timeout: 10000 }
- );
- await userEvent.click(option);
-}
-
-async function findWorkspaceTypeOption(label: string): Promise {
- return within(document.body).findByRole(
- "option",
- { name: new RegExp(`^${label}`, "i") },
- { timeout: 10000 }
- );
-}
-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",
- },
-];
-
-const mockParseError = "Unexpected token u in JSON at position 0";
-
-const mockCoderInfo = {
- state: "available" as const,
- version: "2.28.0",
- // Include username + URL so Storybook renders the logged-in label in Coder stories.
- username: "coder-user",
- url: "https://coder.example.com",
-};
-
-/**
- * Coder available - shows Coder runtime button.
- * When Coder CLI is available, the Coder button appears in the runtime selector.
- */
-export const SSHWithCoderAvailable: AppStory = {
- render: () => (
- {
- expandProjects(["/Users/dev/my-project"]);
- return createMockORPCClient({
- projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]),
- workspaces: [],
- coderInfo: mockCoderInfo,
- 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;
- await openProjectCreationView(storyRoot);
-
- // Coder option should appear when Coder CLI is available.
- await openWorkspaceTypeMenu(storyRoot);
- await findWorkspaceTypeOption("Coder");
- await userEvent.keyboard("{Escape}");
- },
-};
-
-/**
- * Coder new workspace flow - shows template and preset dropdowns.
- * User clicks Coder runtime button, then 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: mockCoderInfo,
- 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;
- await openProjectCreationView(storyRoot);
- const canvas = within(storyRoot);
-
- // Select Coder runtime from Workspace Type dropdown.
- await selectWorkspaceType(storyRoot, "Coder");
-
- // Wait for Coder controls to appear
- await canvas.findByTestId("coder-controls-inner");
-
- // The template dropdown should be visible with templates loaded
- await canvas.findByTestId("coder-template-select");
- },
-};
-
-/**
- * Coder existing workspace flow - shows workspace dropdown.
- * User clicks Coder runtime, 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: mockCoderInfo,
- 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;
- await openProjectCreationView(storyRoot);
- const canvas = within(storyRoot);
-
- // Select Coder runtime from Workspace Type dropdown.
- await selectWorkspaceType(storyRoot, "Coder");
-
- // Wait for Coder controls
- await canvas.findByTestId("coder-controls-inner");
-
- // Click "Existing" button — use findByRole (retry-capable) to handle
- // transient DOM gaps between awaits.
- const existingButton = await canvas.findByRole("button", { name: "Existing" });
- await userEvent.click(existingButton);
-
- // Wait for workspace dropdown to appear
- await canvas.findByTestId("coder-workspace-select");
- },
-};
-
-/**
- * Coder existing workspace flow with parse error.
- * Shows the error state when listing workspaces fails to parse.
- */
-export const CoderExistingWorkspaceParseError: AppStory = {
- render: () => (
- {
- expandProjects(["/Users/dev/my-project"]);
- return createMockORPCClient({
- projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]),
- workspaces: [],
- coderInfo: mockCoderInfo,
- coderTemplates: mockTemplates,
- coderPresets: new Map([
- ["coder-on-coder", mockPresetsCoderOnCoder],
- ["kubernetes-dev", mockPresetsK8s],
- ["aws-windows", []],
- ]),
- coderWorkspaces: mockWorkspaces,
- coderWorkspacesResult: { ok: false, error: mockParseError },
- });
- }}
- />
- ),
- play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
- const storyRoot = document.getElementById("storybook-root") ?? canvasElement;
- await openProjectCreationView(storyRoot);
- const canvas = within(storyRoot);
-
- // Select Coder runtime from Workspace Type dropdown.
- await selectWorkspaceType(storyRoot, "Coder");
-
- // Wait for Coder controls
- await canvas.findByTestId("coder-controls-inner");
-
- // Click "Existing" button — use findByRole (retry-capable) to handle
- // transient DOM gaps between awaits.
- const existingButton = await canvas.findByRole("button", { name: "Existing" });
- await userEvent.click(existingButton);
-
- // Error message should appear for workspace listing
- await canvas.findByText(mockParseError);
- },
-};
-
-/**
- * Coder new workspace flow with template parse error.
- * Shows the error state when listing templates fails to parse.
- */
-export const CoderTemplatesParseError: AppStory = {
- render: () => (
- {
- expandProjects(["/Users/dev/my-project"]);
- return createMockORPCClient({
- projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]),
- workspaces: [],
- coderInfo: mockCoderInfo,
- coderTemplatesResult: { ok: false, error: mockParseError },
- coderWorkspaces: mockWorkspaces,
- });
- }}
- />
- ),
- play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
- const storyRoot = document.getElementById("storybook-root") ?? canvasElement;
- await openProjectCreationView(storyRoot);
- const canvas = within(storyRoot);
-
- // Select Coder runtime from Workspace Type dropdown.
- await selectWorkspaceType(storyRoot, "Coder");
-
- // Wait for Coder controls
- await canvas.findByTestId("coder-controls-inner");
-
- await canvas.findByText(mockParseError);
-
- // Re-query inside waitFor to avoid stale DOM refs after React re-renders.
- await waitFor(() => {
- const templateSelect = canvas.queryByTestId("coder-template-select");
- if (!templateSelect?.hasAttribute("data-disabled")) {
- throw new Error("Template dropdown should be disabled when templates fail to load");
- }
- });
- },
-};
-
-/**
- * Coder new workspace flow with preset parse error.
- * Shows the error state when listing presets fails to parse.
- */
-export const CoderPresetsParseError: AppStory = {
- render: () => (
- {
- expandProjects(["/Users/dev/my-project"]);
- return createMockORPCClient({
- projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]),
- workspaces: [],
- coderInfo: mockCoderInfo,
- coderTemplates: mockTemplates,
- coderPresets: new Map([
- ["coder-on-coder", mockPresetsCoderOnCoder],
- ["kubernetes-dev", mockPresetsK8s],
- ["aws-windows", []],
- ]),
- coderPresetsResult: new Map([["coder-on-coder", { ok: false, error: mockParseError }]]),
- coderWorkspaces: mockWorkspaces,
- });
- }}
- />
- ),
- play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
- const storyRoot = document.getElementById("storybook-root") ?? canvasElement;
- await openProjectCreationView(storyRoot);
- const canvas = within(storyRoot);
-
- // Select Coder runtime from Workspace Type dropdown.
- await selectWorkspaceType(storyRoot, "Coder");
-
- // Wait for Coder controls and template select
- await canvas.findByTestId("coder-controls-inner");
- await canvas.findByTestId("coder-template-select");
-
- await canvas.findByText(mockParseError);
- },
-};
-
-/**
- * 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: () => (
- {
- expandProjects(["/Users/dev/my-project"]);
- return createMockORPCClient({
- projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]),
- workspaces: [],
- coderInfo: { state: "unavailable", reason: "missing" },
- });
- }}
- />
- ),
- play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
- const storyRoot = document.getElementById("storybook-root") ?? canvasElement;
- await openProjectCreationView(storyRoot);
-
- await openWorkspaceTypeMenu(storyRoot);
-
- // SSH option should be present.
- await findWorkspaceTypeOption("SSH");
-
- // Coder option should NOT appear when Coder CLI is unavailable.
- const coderOption = within(document.body).queryByRole("option", { name: /^Coder/i });
- if (coderOption) {
- throw new Error("Coder option should not appear when Coder CLI is unavailable");
- }
-
- await userEvent.keyboard("{Escape}");
- },
-};
-
-/**
- * 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: () => (
- {
- expandProjects(["/Users/dev/my-project"]);
- return createMockORPCClient({
- projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]),
- workspaces: [],
- coderInfo: { state: "outdated", version: "2.20.0", minVersion: "2.25.0" },
- });
- }}
- />
- ),
- play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
- const storyRoot = document.getElementById("storybook-root") ?? canvasElement;
- await openProjectCreationView(storyRoot);
-
- await openWorkspaceTypeMenu(storyRoot);
-
- // Coder option should appear but be disabled.
- const coderOption = await findWorkspaceTypeOption("Coder");
- if (coderOption.getAttribute("aria-disabled") !== "true") {
- throw new Error("Coder option should be disabled when CLI is outdated");
- }
-
- if (!coderOption.textContent?.includes("2.20.0")) {
- throw new Error("Coder option should mention the current CLI version");
- }
- if (!coderOption.textContent?.includes("2.25.0")) {
- throw new Error("Coder option should mention the minimum required version");
- }
-
- await userEvent.keyboard("{Escape}");
- },
-};
-
-/**
- * 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: mockCoderInfo,
- 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;
- await openProjectCreationView(storyRoot);
- const canvas = within(storyRoot);
-
- // Select Coder runtime from Workspace Type dropdown.
- await selectWorkspaceType(storyRoot, "Coder");
-
- // Wait for Coder controls
- await canvas.findByTestId("coder-controls-inner");
-
- // Template dropdown should be visible
- await canvas.findByTestId("coder-template-select");
-
- // Preset dropdown should be visible but disabled (shows "No presets" placeholder).
- // Re-query inside waitFor to avoid stale DOM refs after React re-renders.
- await waitFor(() => {
- // Radix UI Select sets data-disabled="" (empty string) when disabled
- const presetSelect = canvas.queryByTestId("coder-preset-select");
- 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: mockCoderInfo,
- 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;
- await openProjectCreationView(storyRoot);
- const canvas = within(storyRoot);
-
- // Select Coder runtime from Workspace Type dropdown.
- await selectWorkspaceType(storyRoot, "Coder");
-
- // Click "Existing" button
- const existingButton = await canvas.findByRole("button", { name: "Existing" });
- await userEvent.click(existingButton);
-
- // Workspace dropdown should show "No workspaces found" placeholder.
- // Note: Radix UI Select doesn't render native