diff --git a/src/App.tsx b/src/App.tsx index 8b55fb3dbd..7610bcb386 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,6 @@ import { useApp } from "./contexts/AppContext"; import type { WorkspaceSelection } from "./components/ProjectSidebar"; import type { FrontendWorkspaceMetadata } from "./types/workspace"; import { LeftSidebar } from "./components/LeftSidebar"; -import NewWorkspaceModal from "./components/NewWorkspaceModal"; import { ProjectCreateModal } from "./components/ProjectCreateModal"; import { AIView } from "./components/AIView"; import { ErrorBoundary } from "./components/ErrorBoundary"; @@ -15,6 +14,7 @@ import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue"; import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; import { ChatInput } from "./components/ChatInput/index"; +import type { ChatInputAPI } from "./components/ChatInput/types"; import { useStableReference, compareMaps } from "./hooks/useStableReference"; import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; @@ -25,13 +25,12 @@ import { CommandPalette } from "./components/CommandPalette"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; import type { ThinkingLevel } from "./types/thinking"; -import type { RuntimeConfig } from "./types/runtime"; import { CUSTOM_EVENTS } from "./constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork"; -import { getThinkingLevelKey, getRuntimeKey } from "./constants/storage"; +import { getThinkingLevelKey } from "./constants/storage"; import type { BranchListResult } from "./types/ipc"; import { useTelemetry } from "./hooks/useTelemetry"; -import { parseRuntimeString } from "./utils/chatCommands"; +import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; @@ -43,22 +42,12 @@ function AppInner() { removeProject, workspaceMetadata, setWorkspaceMetadata, - createWorkspace, removeWorkspace, renameWorkspace, selectedWorkspace, setSelectedWorkspace, } = useApp(); const [projectCreateModalOpen, setProjectCreateModalOpen] = useState(false); - const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false); - const [workspaceModalProject, setWorkspaceModalProject] = useState(null); - const [workspaceModalProjectName, setWorkspaceModalProjectName] = useState(""); - const [workspaceModalBranches, setWorkspaceModalBranches] = useState([]); - const [workspaceModalDefaultTrunk, setWorkspaceModalDefaultTrunk] = useState( - undefined - ); - const [workspaceModalLoadError, setWorkspaceModalLoadError] = useState(null); - const workspaceModalProjectRef = useRef(null); // Track when we're in "new workspace creation" mode (show FirstMessageInput) const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState(null); @@ -66,6 +55,27 @@ function AppInner() { // Auto-collapse sidebar on mobile by default const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile); + const defaultProjectPath = getFirstProjectPath(projects); + const creationChatInputRef = useRef(null); + const creationProjectPath = !selectedWorkspace + ? (pendingNewWorkspaceProject ?? (projects.size === 1 ? defaultProjectPath : null)) + : null; + const handleCreationChatReady = useCallback((api: ChatInputAPI) => { + creationChatInputRef.current = api; + api.focus(); + }, []); + + const startWorkspaceCreation = useStartWorkspaceCreation({ + projects, + setPendingNewWorkspaceProject, + setSelectedWorkspace, + }); + + useEffect(() => { + if (creationProjectPath) { + creationChatInputRef.current?.focus(); + } + }, [creationProjectPath]); const handleToggleSidebar = useCallback(() => { setSidebarCollapsed((prev) => !prev); @@ -133,7 +143,6 @@ function AppInner() { void window.api.window.setTitle("mux"); } }, [selectedWorkspace, workspaceMetadata]); - // Validate selected workspace exists and has all required fields useEffect(() => { if (selectedWorkspace) { @@ -177,12 +186,9 @@ function AppInner() { const handleAddWorkspace = useCallback( (projectPath: string) => { - // Show FirstMessageInput for this project - setPendingNewWorkspaceProject(projectPath); - // Clear any selected workspace so FirstMessageInput is shown - setSelectedWorkspace(null); + startWorkspaceCreation(projectPath); }, - [setSelectedWorkspace] + [startWorkspaceCreation] ); // Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders @@ -204,48 +210,6 @@ function AppInner() { [handleRemoveProject] ); - const handleCreateWorkspace = async ( - branchName: string, - trunkBranch: string, - runtime?: string - ) => { - if (!workspaceModalProject) return; - - console.assert( - typeof trunkBranch === "string" && trunkBranch.trim().length > 0, - "Expected trunk branch to be provided by the workspace modal" - ); - - // Parse runtime config if provided - let runtimeConfig: RuntimeConfig | undefined; - if (runtime) { - try { - runtimeConfig = parseRuntimeString(runtime, branchName); - } catch (err) { - console.error("Failed to parse runtime config:", err); - throw err; // Let modal handle the error - } - } - - const newWorkspace = await createWorkspace( - workspaceModalProject, - branchName, - trunkBranch, - runtimeConfig - ); - if (newWorkspace) { - // Track workspace creation - telemetry.workspaceCreated(newWorkspace.workspaceId); - setSelectedWorkspace(newWorkspace); - - // Save runtime preference for this project if provided - if (runtime) { - const runtimeKey = getRuntimeKey(workspaceModalProject); - localStorage.setItem(runtimeKey, runtime); - } - } - }; - const handleGetSecrets = useCallback(async (projectPath: string) => { return await window.api.projects.secrets.get(projectPath); }, []); @@ -398,9 +362,9 @@ function AppInner() { const openNewWorkspaceFromPalette = useCallback( (projectPath: string) => { - void handleAddWorkspace(projectPath); + startWorkspaceCreation(projectPath); }, - [handleAddWorkspace] + [startWorkspaceCreation] ); const getBranchesForProject = useCallback( @@ -469,7 +433,7 @@ function AppInner() { selectedWorkspace, getThinkingLevel: getThinkingLevelForWorkspace, onSetThinkingLevel: setThinkingLevelFromPalette, - onOpenNewWorkspaceModal: openNewWorkspaceFromPalette, + onStartWorkspaceCreation: openNewWorkspaceFromPalette, getBranchesForProject, onSelectWorkspace: selectWorkspaceFromPalette, onRemoveWorkspace: removeWorkspaceFromPalette, @@ -621,9 +585,9 @@ function AppInner() { } /> - ) : pendingNewWorkspaceProject || projects.size === 1 ? ( + ) : creationProjectPath ? ( (() => { - const projectPath = pendingNewWorkspaceProject ?? Array.from(projects.keys())[0]; + const projectPath = creationProjectPath; const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project"; return ( @@ -633,6 +597,7 @@ function AppInner() { variant="creation" projectPath={projectPath} projectName={projectName} + onReady={handleCreationChatReady} onWorkspaceCreated={(metadata) => { // Add to workspace metadata map setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata)); @@ -686,26 +651,6 @@ function AppInner() { workspaceId: selectedWorkspace?.workspaceId, })} /> - {workspaceModalOpen && workspaceModalProject && ( - { - workspaceModalProjectRef.current = null; - setWorkspaceModalOpen(false); - setWorkspaceModalProject(null); - setWorkspaceModalProjectName(""); - setWorkspaceModalBranches([]); - setWorkspaceModalDefaultTrunk(undefined); - setWorkspaceModalLoadError(null); - }} - onAdd={handleCreateWorkspace} - /> - )} setProjectCreateModalOpen(false)} diff --git a/src/components/NewWorkspaceModal.stories.tsx b/src/components/NewWorkspaceModal.stories.tsx deleted file mode 100644 index 97ccaaa8c4..0000000000 --- a/src/components/NewWorkspaceModal.stories.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; -import NewWorkspaceModal from "./NewWorkspaceModal"; - -const meta = { - title: "Components/NewWorkspaceModal", - component: NewWorkspaceModal, - parameters: { - layout: "fullscreen", - controls: { - exclude: ["onClose", "onAdd"], - }, - }, - tags: ["autodocs"], - argTypes: { - isOpen: { - control: "boolean", - description: "Whether the modal is visible", - }, - projectName: { - control: "text", - description: "Name of the project", - }, - projectPath: { - control: "text", - description: "Path to the project", - }, - branches: { - control: "object", - description: "List of available branches", - }, - defaultTrunkBranch: { - control: "text", - description: "Recommended trunk branch (optional)", - }, - }, - args: { - onClose: action("onClose"), - onAdd: async (branchName: string, trunkBranch: string) => { - action("onAdd")({ branchName, trunkBranch }); - // Simulate async operation - await new Promise((resolve) => setTimeout(resolve, 1000)); - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - isOpen: true, - projectName: "my-project", - projectPath: "/path/to/my-project", - branches: ["main", "develop", "feature/new-feature"], - defaultTrunkBranch: "main", - }, -}; - -export const LongProjectName: Story = { - args: { - isOpen: true, - projectName: "very-long-project-name-that-demonstrates-wrapping", - projectPath: "/path/to/very-long-project-name-that-demonstrates-wrapping", - branches: ["main", "develop"], - defaultTrunkBranch: "main", - }, -}; - -export const NoBranches: Story = { - args: { - isOpen: true, - projectName: "empty-project", - projectPath: "/path/to/empty-project", - branches: [], - }, -}; - -export const ManyBranches: Story = { - args: { - isOpen: true, - projectName: "active-project", - projectPath: "/path/to/active-project", - branches: [ - "main", - "develop", - "staging", - "feature/authentication", - "feature/dashboard", - "bugfix/memory-leak", - "release/v1.2.0", - ], - defaultTrunkBranch: "develop", - }, -}; - -export const Closed: Story = { - args: { - isOpen: false, - projectName: "my-project", - projectPath: "/path/to/my-project", - branches: ["main", "develop"], - defaultTrunkBranch: "main", - }, -}; diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx deleted file mode 100644 index 462c3b32ee..0000000000 --- a/src/components/NewWorkspaceModal.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import React, { useEffect, useId, useState } from "react"; -import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; -import { formatNewCommand } from "@/utils/chatCommands"; -import { useNewWorkspaceOptions } from "@/hooks/useNewWorkspaceOptions"; -import { RUNTIME_MODE } from "@/types/runtime"; - -interface NewWorkspaceModalProps { - isOpen: boolean; - projectName: string; - projectPath: string; - branches: string[]; - defaultTrunkBranch?: string; - loadErrorMessage?: string | null; - onClose: () => void; - onAdd: (branchName: string, trunkBranch: string, runtime?: string) => Promise; -} - -// Shared form field styles -const formFieldClasses = - "[&_label]:text-foreground [&_input]:bg-modal-bg [&_input]:border-border-medium [&_input]:focus:border-accent [&_select]:bg-modal-bg [&_select]:border-border-medium [&_select]:focus:border-accent [&_option]:bg-modal-bg mb-5 [&_input]:w-full [&_input]:rounded [&_input]:border [&_input]:px-3 [&_input]:py-2 [&_input]:text-sm [&_input]:text-white [&_input]:focus:outline-none [&_input]:disabled:cursor-not-allowed [&_input]:disabled:opacity-60 [&_label]:mb-2 [&_label]:block [&_label]:text-sm [&_option]:text-white [&_select]:w-full [&_select]:cursor-pointer [&_select]:rounded [&_select]:border [&_select]:px-3 [&_select]:py-2 [&_select]:text-sm [&_select]:text-white [&_select]:focus:outline-none [&_select]:disabled:cursor-not-allowed [&_select]:disabled:opacity-60"; - -const NewWorkspaceModal: React.FC = ({ - isOpen, - projectName, - projectPath, - branches, - defaultTrunkBranch, - loadErrorMessage, - onClose, - onAdd, -}) => { - const [branchName, setBranchName] = useState(""); - const [trunkBranch, setTrunkBranch] = useState(defaultTrunkBranch ?? branches[0] ?? ""); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const infoId = useId(); - const hasBranches = branches.length > 0; - - // Load runtime preferences from localStorage for this project - const [runtimeOptions, setRuntimeOptions] = useNewWorkspaceOptions(projectPath); - const { runtimeMode, sshHost, getRuntimeString } = runtimeOptions; - - useEffect(() => { - setError(loadErrorMessage ?? null); - }, [loadErrorMessage]); - - useEffect(() => { - const fallbackTrunk = defaultTrunkBranch ?? branches[0] ?? ""; - setTrunkBranch((current) => { - const trimmedCurrent = current.trim(); - - if (!hasBranches) { - return trimmedCurrent.length === 0 ? fallbackTrunk : current; - } - - if (trimmedCurrent.length === 0 || !branches.includes(trimmedCurrent)) { - return fallbackTrunk; - } - - return current; - }); - }, [branches, defaultTrunkBranch, hasBranches]); - - const handleCancel = () => { - setBranchName(""); - setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? ""); - setRuntimeOptions(RUNTIME_MODE.LOCAL, ""); - setError(loadErrorMessage ?? null); - onClose(); - }; - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - const trimmedBranchName = branchName.trim(); - if (trimmedBranchName.length === 0) { - setError("Branch name is required"); - return; - } - - const normalizedTrunkBranch = trunkBranch.trim(); - if (normalizedTrunkBranch.length === 0) { - setError("Trunk branch is required"); - return; - } - - console.assert(normalizedTrunkBranch.length > 0, "Expected trunk branch name to be validated"); - console.assert(trimmedBranchName.length > 0, "Expected branch name to be validated"); - - // Validate SSH host if SSH runtime selected - if (runtimeMode === RUNTIME_MODE.SSH) { - const trimmedHost = sshHost.trim(); - if (trimmedHost.length === 0) { - setError("SSH host is required (e.g., hostname or user@host)"); - return; - } - // Accept both "hostname" and "user@hostname" formats - // SSH will use current user or ~/.ssh/config if user not specified - } - - setIsLoading(true); - setError(null); - - try { - // Get runtime string from hook helper - const runtime = getRuntimeString(); - - await onAdd(trimmedBranchName, normalizedTrunkBranch, runtime); - setBranchName(""); - setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? ""); - setRuntimeOptions(RUNTIME_MODE.LOCAL, ""); - onClose(); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create workspace"; - setError(message); - } finally { - setIsLoading(false); - } - }; - - return ( - -
void handleSubmit(event)}> -
- - { - setBranchName(event.target.value); - setError(null); - }} - placeholder="Enter branch name (e.g., feature-name)" - disabled={isLoading} - autoFocus={isOpen} - required - aria-required="true" - /> - {error &&
{error}
} -
- -
- - {hasBranches ? ( - - ) : ( - setTrunkBranch(event.target.value)} - disabled={isLoading} - placeholder="Enter trunk branch (e.g., main)" - required - aria-required="true" - /> - )} - {!hasBranches && ( -
- No branches were detected automatically. Enter the trunk branch manually. -
- )} -
- -
- - -
- - {runtimeMode === RUNTIME_MODE.SSH && ( -
- - { - setRuntimeOptions(RUNTIME_MODE.SSH, event.target.value); - setError(null); - }} - placeholder="hostname or user@hostname" - disabled={isLoading} - required - aria-required="true" - /> -
- Workspace will be created at ~/cmux/{branchName || ""} on remote host -
-
- )} - - -

This will create a workspace at:

- - {runtimeMode === RUNTIME_MODE.SSH - ? `${sshHost || ""}:~/cmux/${branchName || ""}` - : `~/.cmux/src/${projectName}/${branchName || ""}`} - -
- - {branchName.trim() && ( -
-
Equivalent command:
-
- {formatNewCommand( - branchName.trim(), - trunkBranch.trim() || undefined, - getRuntimeString() - )} -
-
- )} - - - - Cancel - - - {isLoading ? "Creating..." : "Create Workspace"} - - -
-
- ); -}; - -export default NewWorkspaceModal; diff --git a/src/constants/events.ts b/src/constants/events.ts index 844d5e3f5a..00b1e66a92 100644 --- a/src/constants/events.ts +++ b/src/constants/events.ts @@ -50,6 +50,11 @@ export const CUSTOM_EVENTS = { * Detail: { commandId: string } */ EXECUTE_COMMAND: "cmux:executeCommand", + /** + * Event to enter the chat-based workspace creation experience. + * Detail: { projectPath: string, startMessage?: string, model?: string, trunkBranch?: string, runtime?: string } + */ + START_WORKSPACE_CREATION: "cmux:startWorkspaceCreation", } as const; /** @@ -79,6 +84,13 @@ export interface CustomEventPayloads { [CUSTOM_EVENTS.EXECUTE_COMMAND]: { commandId: string; }; + [CUSTOM_EVENTS.START_WORKSPACE_CREATION]: { + projectPath: string; + startMessage?: string; + model?: string; + trunkBranch?: string; + runtime?: string; + }; } /** diff --git a/src/hooks/useNewWorkspaceOptions.ts b/src/hooks/useNewWorkspaceOptions.ts deleted file mode 100644 index acc627b5e1..0000000000 --- a/src/hooks/useNewWorkspaceOptions.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useState, useEffect } from "react"; -import { getRuntimeKey } from "@/constants/storage"; -import { - type RuntimeMode, - RUNTIME_MODE, - parseRuntimeModeAndHost, - buildRuntimeString, -} from "@/types/runtime"; - -export interface WorkspaceRuntimeOptions { - runtimeMode: RuntimeMode; - sshHost: string; - /** - * Returns the runtime string for IPC calls (format: "ssh " or undefined for local) - */ - getRuntimeString: () => string | undefined; -} - -/** - * Hook to manage workspace creation runtime options with localStorage persistence. - * Loads saved runtime preference for a project and provides consistent state management. - * - * @param projectPath - Path to the project (used as key for localStorage) - * @returns Runtime options state and setter - */ -export function useNewWorkspaceOptions( - projectPath: string | null | undefined -): [WorkspaceRuntimeOptions, (mode: RuntimeMode, host?: string) => void] { - const [runtimeMode, setRuntimeMode] = useState(RUNTIME_MODE.LOCAL); - const [sshHost, setSshHost] = useState(""); - - // Load saved runtime preference when projectPath changes - useEffect(() => { - if (!projectPath) { - // Reset to defaults when no project - setRuntimeMode(RUNTIME_MODE.LOCAL); - setSshHost(""); - return; - } - - const runtimeKey = getRuntimeKey(projectPath); - const savedRuntime = localStorage.getItem(runtimeKey); - const parsed = parseRuntimeModeAndHost(savedRuntime); - - setRuntimeMode(parsed.mode); - setSshHost(parsed.host); - }, [projectPath]); - - // Setter for updating both mode and host - const setRuntimeOptions = (mode: RuntimeMode, host?: string) => { - setRuntimeMode(mode); - setSshHost(host ?? ""); - }; - - // Helper to get runtime string for IPC calls - const getRuntimeString = (): string | undefined => { - return buildRuntimeString(runtimeMode, sshHost); - }; - - return [ - { - runtimeMode, - sshHost, - getRuntimeString, - }, - setRuntimeOptions, - ]; -} diff --git a/src/hooks/useStartWorkspaceCreation.test.ts b/src/hooks/useStartWorkspaceCreation.test.ts new file mode 100644 index 0000000000..c1a5baceef --- /dev/null +++ b/src/hooks/useStartWorkspaceCreation.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { + getFirstProjectPath, + normalizeRuntimePreference, + persistWorkspaceCreationPrefill, + type StartWorkspaceCreationDetail, +} from "./useStartWorkspaceCreation"; +import { + getInputKey, + getModelKey, + getPendingScopeId, + getProjectScopeId, + getRuntimeKey, + getTrunkBranchKey, +} from "@/constants/storage"; +import type { ProjectConfig } from "@/config"; + +import type { updatePersistedState } from "@/hooks/usePersistedState"; + +type PersistFn = typeof updatePersistedState; +type PersistCall = [string, unknown, unknown?]; + +describe("normalizeRuntimePreference", () => { + test("returns undefined for local or empty runtime", () => { + expect(normalizeRuntimePreference(undefined)).toBeUndefined(); + expect(normalizeRuntimePreference(" ")).toBeUndefined(); + expect(normalizeRuntimePreference("local")).toBeUndefined(); + expect(normalizeRuntimePreference("LOCAL")).toBeUndefined(); + }); + + test("normalizes ssh runtimes", () => { + expect(normalizeRuntimePreference("ssh")).toBe("ssh"); + expect(normalizeRuntimePreference("ssh host")).toBe("ssh host"); + expect(normalizeRuntimePreference("SSH user@host")).toBe("ssh user@host"); + }); + + test("returns trimmed custom runtime", () => { + expect(normalizeRuntimePreference(" custom-runtime ")).toBe("custom-runtime"); + }); +}); + +describe("persistWorkspaceCreationPrefill", () => { + const projectPath = "/tmp/project"; + + function createPersistSpy() { + const calls: PersistCall[] = []; + const persist: PersistFn = ((...args: PersistCall) => { + calls.push(args); + }) as PersistFn; + + return { persist, calls }; + } + + test("writes provided values and normalizes whitespace", () => { + const detail: StartWorkspaceCreationDetail = { + projectPath, + startMessage: "Ship it", + model: "provider/model", + trunkBranch: " main ", + runtime: " ssh dev ", + }; + const { persist, calls } = createPersistSpy(); + + persistWorkspaceCreationPrefill(projectPath, detail, persist); + + const callMap = new Map(); + for (const [key, value] of calls) { + callMap.set(key, value); + } + + expect(callMap.get(getInputKey(getPendingScopeId(projectPath)))).toBe("Ship it"); + expect(callMap.get(getModelKey(getProjectScopeId(projectPath)))).toBe("provider/model"); + expect(callMap.get(getTrunkBranchKey(projectPath))).toBe("main"); + expect(callMap.get(getRuntimeKey(projectPath))).toBe("ssh dev"); + }); + + test("clears persisted values when empty strings are provided", () => { + const detail: StartWorkspaceCreationDetail = { + projectPath, + trunkBranch: " ", + runtime: " ", + }; + const { persist, calls } = createPersistSpy(); + + persistWorkspaceCreationPrefill(projectPath, detail, persist); + + const callMap = new Map(); + for (const [key, value] of calls) { + callMap.set(key, value); + } + + expect(callMap.get(getTrunkBranchKey(projectPath))).toBeUndefined(); + expect(callMap.get(getRuntimeKey(projectPath))).toBeUndefined(); + }); + + test("no-op when detail is undefined", () => { + const { persist, calls } = createPersistSpy(); + persistWorkspaceCreationPrefill(projectPath, undefined, persist); + expect(calls).toHaveLength(0); + }); +}); + +describe("getFirstProjectPath", () => { + test("returns first project path or null", () => { + const emptyProjects = new Map(); + expect(getFirstProjectPath(emptyProjects)).toBeNull(); + + const projects = new Map(); + projects.set("/tmp/a", { path: "/tmp/a", workspaces: [] } as ProjectConfig); + projects.set("/tmp/b", { path: "/tmp/b", workspaces: [] } as ProjectConfig); + + expect(getFirstProjectPath(projects)).toBe("/tmp/a"); + }); +}); diff --git a/src/hooks/useStartWorkspaceCreation.ts b/src/hooks/useStartWorkspaceCreation.ts new file mode 100644 index 0000000000..3f6b9a389d --- /dev/null +++ b/src/hooks/useStartWorkspaceCreation.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect } from "react"; +import type { ProjectConfig } from "@/config"; +import type { WorkspaceSelection } from "@/components/ProjectSidebar"; +import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/constants/events"; +import { updatePersistedState } from "@/hooks/usePersistedState"; +import { + getInputKey, + getModelKey, + getPendingScopeId, + getProjectScopeId, + getRuntimeKey, + getTrunkBranchKey, +} from "@/constants/storage"; +import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/types/runtime"; + +export type StartWorkspaceCreationDetail = + CustomEventPayloads[typeof CUSTOM_EVENTS.START_WORKSPACE_CREATION]; + +export function normalizeRuntimePreference(runtime: string | undefined): string | undefined { + if (!runtime) { + return undefined; + } + + const trimmed = runtime.trim(); + if (!trimmed) { + return undefined; + } + + const lower = trimmed.toLowerCase(); + if (lower === RUNTIME_MODE.LOCAL) { + return undefined; + } + + if (lower === RUNTIME_MODE.SSH) { + return RUNTIME_MODE.SSH; + } + + if (lower.startsWith(SSH_RUNTIME_PREFIX)) { + const host = trimmed.slice(SSH_RUNTIME_PREFIX.length).trim(); + return host ? `${RUNTIME_MODE.SSH} ${host}` : RUNTIME_MODE.SSH; + } + + return trimmed; +} + +export function getFirstProjectPath(projects: Map): string | null { + const iterator = projects.keys().next(); + return iterator.done ? null : iterator.value; +} + +type PersistFn = typeof updatePersistedState; + +export function persistWorkspaceCreationPrefill( + projectPath: string, + detail: StartWorkspaceCreationDetail | undefined, + persist: PersistFn = updatePersistedState +): void { + if (!detail) { + return; + } + + if (detail.startMessage !== undefined) { + persist(getInputKey(getPendingScopeId(projectPath)), detail.startMessage); + } + + if (detail.model !== undefined) { + persist(getModelKey(getProjectScopeId(projectPath)), detail.model); + } + + if (detail.trunkBranch !== undefined) { + const normalizedTrunk = detail.trunkBranch.trim(); + persist( + getTrunkBranchKey(projectPath), + normalizedTrunk.length > 0 ? normalizedTrunk : undefined + ); + } + + if (detail.runtime !== undefined) { + const normalizedRuntime = normalizeRuntimePreference(detail.runtime); + persist(getRuntimeKey(projectPath), normalizedRuntime); + } +} + +interface UseStartWorkspaceCreationOptions { + projects: Map; + setPendingNewWorkspaceProject: (projectPath: string | null) => void; + setSelectedWorkspace: (selection: WorkspaceSelection | null) => void; +} + +function resolveProjectPath( + projects: Map, + requestedPath: string +): string | null { + if (projects.has(requestedPath)) { + return requestedPath; + } + + return getFirstProjectPath(projects); +} + +export function useStartWorkspaceCreation({ + projects, + setPendingNewWorkspaceProject, + setSelectedWorkspace, +}: UseStartWorkspaceCreationOptions) { + const startWorkspaceCreation = useCallback( + (projectPath: string, detail?: StartWorkspaceCreationDetail) => { + const resolvedProjectPath = resolveProjectPath(projects, projectPath); + + if (!resolvedProjectPath) { + console.warn("No projects available for workspace creation"); + return; + } + + persistWorkspaceCreationPrefill(resolvedProjectPath, detail); + setPendingNewWorkspaceProject(resolvedProjectPath); + setSelectedWorkspace(null); + }, + [projects, setPendingNewWorkspaceProject, setSelectedWorkspace] + ); + + useEffect(() => { + const handleStartCreation = (event: Event) => { + const customEvent = event as CustomEvent; + const detail = customEvent.detail; + + if (!detail?.projectPath) { + console.warn("START_WORKSPACE_CREATION event missing projectPath detail"); + return; + } + + startWorkspaceCreation(detail.projectPath, detail); + }; + + window.addEventListener( + CUSTOM_EVENTS.START_WORKSPACE_CREATION, + handleStartCreation as EventListener + ); + + return () => + window.removeEventListener( + CUSTOM_EVENTS.START_WORKSPACE_CREATION, + handleStartCreation as EventListener + ); + }, [startWorkspaceCreation]); + + return startWorkspaceCreation; +} diff --git a/src/utils/chatCommands.ts b/src/utils/chatCommands.ts index 9ed3ccc555..77ab1de009 100644 --- a/src/utils/chatCommands.ts +++ b/src/utils/chatCommands.ts @@ -11,7 +11,7 @@ import type { MuxFrontendMetadata, CompactionRequestData } from "@/types/message import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { RuntimeConfig } from "@/types/runtime"; import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/types/runtime"; -import { CUSTOM_EVENTS } from "@/constants/events"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; import type { Toast } from "@/components/ChatInputToast"; import type { ParsedCommand } from "@/utils/slashCommands/types"; import { applyCompactionOverrides } from "@/utils/messages/compactionOptions"; @@ -305,8 +305,26 @@ export async function handleNewCommand( // Open modal if no workspace name provided if (!parsed.workspaceName) { setInput(""); - const event = new CustomEvent(CUSTOM_EVENTS.EXECUTE_COMMAND, { - detail: { commandId: "ws:new" }, + + // Get workspace info to extract projectPath for the modal + const workspaceInfo = await window.api.workspace.getInfo(workspaceId); + if (!workspaceInfo) { + setToast({ + id: Date.now().toString(), + type: "error", + title: "Error", + message: "Failed to get workspace info", + }); + return { clearInput: false, toastShown: true }; + } + + // Dispatch event with start message, model, and optional preferences + const event = createCustomEvent(CUSTOM_EVENTS.START_WORKSPACE_CREATION, { + projectPath: workspaceInfo.projectPath, + startMessage: parsed.startMessage ?? "", + model: sendMessageOptions.model, + trunkBranch: parsed.trunkBranch, + runtime: parsed.runtime, }); window.dispatchEvent(event); return { clearInput: true, toastShown: false }; diff --git a/src/utils/commands/sources.test.ts b/src/utils/commands/sources.test.ts index dedcd57f11..c46aa6dfbd 100644 --- a/src/utils/commands/sources.test.ts +++ b/src/utils/commands/sources.test.ts @@ -37,7 +37,7 @@ const mk = (over: Partial[0]> = {}) => { streamingModels: new Map(), getThinkingLevel: () => "off", onSetThinkingLevel: () => undefined, - onOpenNewWorkspaceModal: () => undefined, + onStartWorkspaceCreation: () => undefined, onSelectWorkspace: () => undefined, onRemoveWorkspace: () => Promise.resolve({ success: true }), onRenameWorkspace: () => Promise.resolve({ success: true }), diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 8018717ac1..c29c6faec5 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -23,7 +23,7 @@ export interface BuildSourcesParams { getThinkingLevel: (workspaceId: string) => ThinkingLevel; onSetThinkingLevel: (workspaceId: string, level: ThinkingLevel) => void; - onOpenNewWorkspaceModal: (projectPath: string) => void; + onStartWorkspaceCreation: (projectPath: string) => void; getBranchesForProject: (projectPath: string) => Promise; onSelectWorkspace: (sel: { projectPath: string; @@ -70,10 +70,9 @@ const section = { export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandAction[]> { const actions: Array<() => CommandAction[]> = []; - // NOTE: We intentionally just open the NewWorkspaceModal instead of implementing - // an interactive prompt in the CommandPalette. This avoids duplicating UI logic - // and ensures consistency - both `/new` command and the command palette use the - // same modal for workspace creation. + // NOTE: We intentionally route to the chat-based creation flow instead of + // building a separate prompt. This keeps `/new`, keybinds, and the command + // palette perfectly aligned on one experience. const createWorkspaceForSelectedProjectAction = ( selected: NonNullable ): CommandAction => { @@ -83,7 +82,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi subtitle: `for ${selected.projectName}`, section: section.workspaces, shortcutHint: formatKeybind(KEYBINDS.NEW_WORKSPACE), - run: () => p.onOpenNewWorkspaceModal(selected.projectPath), + run: () => p.onStartWorkspaceCreation(selected.projectPath), }; }; @@ -476,8 +475,8 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi ], onSubmit: (vals) => { const projectPath = vals.projectPath; - // Open the New Workspace Modal for the selected project - p.onOpenNewWorkspaceModal(projectPath); + // Reuse the chat-based creation flow for the selected project + p.onStartWorkspaceCreation(projectPath); }, }, }, diff --git a/src/utils/slashCommands/parser.test.ts b/src/utils/slashCommands/parser.test.ts index b22cc6483d..cdf703b8ef 100644 --- a/src/utils/slashCommands/parser.test.ts +++ b/src/utils/slashCommands/parser.test.ts @@ -179,3 +179,32 @@ describe("commandParser", () => { }); }); }); +it("should preserve start message when no workspace name provided", () => { + expectParse("/new\nBuild authentication system", { + type: "new", + workspaceName: undefined, + trunkBranch: undefined, + runtime: undefined, + startMessage: "Build authentication system", + }); +}); + +it("should preserve start message and flags when no workspace name", () => { + expectParse("/new -t develop\nImplement feature X", { + type: "new", + workspaceName: undefined, + trunkBranch: "develop", + runtime: undefined, + startMessage: "Implement feature X", + }); +}); + +it("should preserve start message with runtime flag when no workspace name", () => { + expectParse('/new -r "ssh dev.example.com"\nDeploy to staging', { + type: "new", + workspaceName: undefined, + trunkBranch: undefined, + runtime: "ssh dev.example.com", + startMessage: "Deploy to staging", + }); +}); diff --git a/src/utils/slashCommands/registry.ts b/src/utils/slashCommands/registry.ts index ea7a0f89d9..38261fb2d1 100644 --- a/src/utils/slashCommands/registry.ts +++ b/src/utils/slashCommands/registry.ts @@ -539,12 +539,24 @@ const newCommandDefinition: SlashCommandDefinition = { // No workspace name provided - return undefined to open modal if (parsed._.length === 0) { + // Get trunk branch from -t flag + let trunkBranch: string | undefined; + if (parsed.t !== undefined && typeof parsed.t === "string" && parsed.t.trim().length > 0) { + trunkBranch = parsed.t.trim(); + } + + // Get runtime from -r flag + let runtime: string | undefined; + if (parsed.r !== undefined && typeof parsed.r === "string" && parsed.r.trim().length > 0) { + runtime = parsed.r.trim(); + } + return { type: "new", workspaceName: undefined, - trunkBranch: undefined, - runtime: undefined, - startMessage: undefined, + trunkBranch, + runtime, + startMessage: remainingLines, }; }