diff --git a/src/App.tsx b/src/App.tsx index dbed6f47f..a9e402281 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef } from "react"; import "./styles/globals.css"; import { useApp } from "./contexts/AppContext"; import type { WorkspaceSelection } from "./components/ProjectSidebar"; @@ -22,13 +22,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 { useWorkspaceModal } from "./hooks/useWorkspaceModal"; const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; @@ -46,15 +45,6 @@ function AppInner() { selectedWorkspace, setSelectedWorkspace, } = useApp(); - 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); // Auto-collapse sidebar on mobile by default const isMobile = typeof window !== "undefined" && window.innerWidth <= 768; @@ -70,6 +60,13 @@ function AppInner() { // Get workspace store for command palette const workspaceStore = useWorkspaceStoreRaw(); + // Workspace modal management + const workspaceModal = useWorkspaceModal({ + createWorkspace, + setSelectedWorkspace, + telemetry, + }); + // Wrapper for setSelectedWorkspace that tracks telemetry const handleWorkspaceSwitch = useCallback( (newWorkspace: WorkspaceSelection | null) => { @@ -175,108 +172,6 @@ function AppInner() { [removeProject, selectedWorkspace, setSelectedWorkspace] ); - const handleAddWorkspace = useCallback(async (projectPath: string) => { - const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project"; - - workspaceModalProjectRef.current = projectPath; - setWorkspaceModalProject(projectPath); - setWorkspaceModalProjectName(projectName); - setWorkspaceModalBranches([]); - setWorkspaceModalDefaultTrunk(undefined); - setWorkspaceModalLoadError(null); - setWorkspaceModalOpen(true); - - try { - const branchResult = await window.api.projects.listBranches(projectPath); - - // Guard against race condition: only update state if this is still the active project - if (workspaceModalProjectRef.current !== projectPath) { - return; - } - - const sanitizedBranches = Array.isArray(branchResult?.branches) - ? branchResult.branches.filter((branch): branch is string => typeof branch === "string") - : []; - - const recommended = - typeof branchResult?.recommendedTrunk === "string" && - sanitizedBranches.includes(branchResult.recommendedTrunk) - ? branchResult.recommendedTrunk - : sanitizedBranches[0]; - - setWorkspaceModalBranches(sanitizedBranches); - setWorkspaceModalDefaultTrunk(recommended); - setWorkspaceModalLoadError(null); - } catch (err) { - console.error("Failed to load branches for modal:", err); - const message = err instanceof Error ? err.message : "Unknown error"; - setWorkspaceModalLoadError( - `Unable to load branches automatically: ${message}. You can still enter the trunk branch manually.` - ); - } - }, []); - - // Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders - const handleAddProjectCallback = useCallback(() => { - void addProject(); - }, [addProject]); - - const handleAddWorkspaceCallback = useCallback( - (projectPath: string) => { - void handleAddWorkspace(projectPath); - }, - [handleAddWorkspace] - ); - - const handleRemoveProjectCallback = useCallback( - (path: string) => { - void handleRemoveProject(path); - }, - [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); }, []); @@ -425,13 +320,13 @@ function AppInner() { } }, []); - const registerParamsRef = useRef(null); + const registerParamsRef = useRef({} as BuildSourcesParams); const openNewWorkspaceFromPalette = useCallback( (projectPath: string) => { - void handleAddWorkspace(projectPath); + void workspaceModal.openModal(projectPath); }, - [handleAddWorkspace] + [workspaceModal] ); const getBranchesForProject = useCallback( @@ -514,8 +409,7 @@ function AppInner() { useEffect(() => { const unregister = registerSource(() => { - const params = registerParamsRef.current; - if (!params) return []; + const params: BuildSourcesParams = registerParamsRef.current; // Compute streaming models here (only when command palette opens) const allStates = workspaceStore.getAllStates(); @@ -615,14 +509,38 @@ function AppInner() { ); }, [projects, setSelectedWorkspace, setWorkspaceMetadata]); + // Handle open new workspace modal event + useEffect(() => { + const handleOpenNewWorkspaceModal = (e: Event) => { + const customEvent = e as CustomEvent<{ + projectPath: string; + startMessage?: string; + model?: string; + error?: string; + }>; + const { projectPath, startMessage, model, error } = customEvent.detail; + void workspaceModal.openModal(projectPath, { startMessage, model, error }); + }; + + window.addEventListener( + CUSTOM_EVENTS.OPEN_NEW_WORKSPACE_MODAL, + handleOpenNewWorkspaceModal as EventListener + ); + return () => + window.removeEventListener( + CUSTOM_EVENTS.OPEN_NEW_WORKSPACE_MODAL, + handleOpenNewWorkspaceModal as EventListener + ); + }, [workspaceModal]); + return ( <>
void addProject()} + onAddWorkspace={(path) => void workspaceModal.openModal(path)} + onRemoveProject={(path) => void handleRemoveProject(path)} lastReadTimestamps={lastReadTimestamps} onToggleUnread={onToggleUnread} collapsed={sidebarCollapsed} @@ -674,24 +592,18 @@ function AppInner() { workspaceId: selectedWorkspace?.workspaceId, })} /> - {workspaceModalOpen && workspaceModalProject && ( + {workspaceModal.state.isOpen && workspaceModal.state.projectPath && ( { - workspaceModalProjectRef.current = null; - setWorkspaceModalOpen(false); - setWorkspaceModalProject(null); - setWorkspaceModalProjectName(""); - setWorkspaceModalBranches([]); - setWorkspaceModalDefaultTrunk(undefined); - setWorkspaceModalLoadError(null); - }} - onAdd={handleCreateWorkspace} + isOpen={workspaceModal.state.isOpen} + projectName={workspaceModal.state.projectName} + projectPath={workspaceModal.state.projectPath} + branches={workspaceModal.state.branches} + defaultTrunkBranch={workspaceModal.state.defaultTrunk} + loadErrorMessage={workspaceModal.state.loadError} + initialStartMessage={workspaceModal.state.startMessage} + initialModel={workspaceModal.state.model} + onClose={workspaceModal.closeModal} + onAdd={workspaceModal.handleCreate} /> )} diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx index 9b1d7e51f..534e157ba 100644 --- a/src/components/NewWorkspaceModal.tsx +++ b/src/components/NewWorkspaceModal.tsx @@ -12,8 +12,16 @@ interface NewWorkspaceModalProps { branches: string[]; defaultTrunkBranch?: string; loadErrorMessage?: string | null; + initialStartMessage?: string; + initialModel?: string; onClose: () => void; - onAdd: (branchName: string, trunkBranch: string, runtime?: string) => Promise; + onAdd: ( + branchName: string, + trunkBranch: string, + runtime?: string, + startMessage?: string, + model?: string + ) => Promise; } // Shared form field styles @@ -27,11 +35,14 @@ const NewWorkspaceModal: React.FC = ({ branches, defaultTrunkBranch, loadErrorMessage, + initialStartMessage, + initialModel, onClose, onAdd, }) => { const [branchName, setBranchName] = useState(""); const [trunkBranch, setTrunkBranch] = useState(defaultTrunkBranch ?? branches[0] ?? ""); + const [startMessage, setStartMessage] = useState(initialStartMessage ?? ""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const infoId = useId(); @@ -41,10 +52,22 @@ const NewWorkspaceModal: React.FC = ({ const [runtimeOptions, setRuntimeOptions] = useNewWorkspaceOptions(projectPath); const { runtimeMode, sshHost, getRuntimeString } = runtimeOptions; + // Reset form to initial state + const resetForm = () => { + setBranchName(""); + setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? ""); + setRuntimeOptions(RUNTIME_MODE.LOCAL, ""); + setStartMessage(""); + }; + useEffect(() => { setError(loadErrorMessage ?? null); }, [loadErrorMessage]); + useEffect(() => { + setStartMessage(initialStartMessage ?? ""); + }, [initialStartMessage]); + useEffect(() => { const fallbackTrunk = defaultTrunkBranch ?? branches[0] ?? ""; setTrunkBranch((current) => { @@ -63,9 +86,7 @@ const NewWorkspaceModal: React.FC = ({ }, [branches, defaultTrunkBranch, hasBranches]); const handleCancel = () => { - setBranchName(""); - setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? ""); - setRuntimeOptions(RUNTIME_MODE.LOCAL, ""); + resetForm(); setError(loadErrorMessage ?? null); onClose(); }; @@ -104,11 +125,16 @@ const NewWorkspaceModal: React.FC = ({ try { // Get runtime string from hook helper const runtime = getRuntimeString(); + const trimmedStartMessage = startMessage.trim(); - await onAdd(trimmedBranchName, normalizedTrunkBranch, runtime); - setBranchName(""); - setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? ""); - setRuntimeOptions(RUNTIME_MODE.LOCAL, ""); + await onAdd( + trimmedBranchName, + normalizedTrunkBranch, + runtime, + trimmedStartMessage || undefined, + initialModel + ); + resetForm(); onClose(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create workspace"; @@ -243,6 +269,19 @@ const NewWorkspaceModal: React.FC = ({
)} + {initialStartMessage && ( +
+ +