Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 124 additions & 61 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,19 @@ function AppInner() {
selectedWorkspace,
setSelectedWorkspace,
} = useApp();
const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false);
const [workspaceModalProject, setWorkspaceModalProject] = useState<string | null>(null);
const [workspaceModalProjectName, setWorkspaceModalProjectName] = useState<string>("");
const [workspaceModalBranches, setWorkspaceModalBranches] = useState<string[]>([]);
const [workspaceModalDefaultTrunk, setWorkspaceModalDefaultTrunk] = useState<string | undefined>(
undefined
);
const [workspaceModalLoadError, setWorkspaceModalLoadError] = useState<string | null>(null);

const INITIAL_WORKSPACE_MODAL_STATE = {
isOpen: false,
projectPath: null as string | null,
projectName: "",
branches: [] as string[],
defaultTrunk: undefined as string | undefined,
loadError: null as string | null,
startMessage: undefined as string | undefined,
model: undefined as string | undefined,
};

const [workspaceModalState, setWorkspaceModalState] = useState(INITIAL_WORKSPACE_MODAL_STATE);
const workspaceModalProjectRef = useRef<string | null>(null);

// Auto-collapse sidebar on mobile by default
Expand Down Expand Up @@ -175,46 +180,61 @@ function AppInner() {
[removeProject, selectedWorkspace, setSelectedWorkspace]
);

const handleAddWorkspace = useCallback(async (projectPath: string) => {
const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project";
const handleAddWorkspace = useCallback(
async (
projectPath: string,
initialData?: { startMessage?: string; model?: string; error?: string }
) => {
const projectName =
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project";

workspaceModalProjectRef.current = projectPath;
setWorkspaceModalState({
isOpen: true,
projectPath,
projectName,
branches: [],
defaultTrunk: undefined,
loadError: initialData?.error ?? null,
startMessage: initialData?.startMessage,
model: initialData?.model,
});

workspaceModalProjectRef.current = projectPath;
setWorkspaceModalProject(projectPath);
setWorkspaceModalProjectName(projectName);
setWorkspaceModalBranches([]);
setWorkspaceModalDefaultTrunk(undefined);
setWorkspaceModalLoadError(null);
setWorkspaceModalOpen(true);
try {
const branchResult = await window.api.projects.listBranches(projectPath);

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;
}

// 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];

setWorkspaceModalState((prev) => ({
...prev,
branches: sanitizedBranches,
defaultTrunk: recommended,
loadError: null,
}));
} catch (err) {
console.error("Failed to load branches for modal:", err);
const message = err instanceof Error ? err.message : "Unknown error";
setWorkspaceModalState((prev) => ({
...prev,
loadError: `Unable to load branches automatically: ${message}. You can still enter the trunk branch manually.`,
}));
}

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(() => {
Expand All @@ -238,9 +258,11 @@ function AppInner() {
const handleCreateWorkspace = async (
branchName: string,
trunkBranch: string,
runtime?: string
runtime?: string,
startMessage?: string,
model?: string
) => {
if (!workspaceModalProject) return;
if (!workspaceModalState.projectPath) return;

console.assert(
typeof trunkBranch === "string" && trunkBranch.trim().length > 0,
Expand All @@ -259,7 +281,7 @@ function AppInner() {
}

const newWorkspace = await createWorkspace(
workspaceModalProject,
workspaceModalState.projectPath,
branchName,
trunkBranch,
runtimeConfig
Expand All @@ -271,9 +293,29 @@ function AppInner() {

// Save runtime preference for this project if provided
if (runtime) {
const runtimeKey = getRuntimeKey(workspaceModalProject);
const runtimeKey = getRuntimeKey(workspaceModalState.projectPath);
localStorage.setItem(runtimeKey, runtime);
}

// Send start message if provided
if (startMessage) {
// Build send message options - use provided model or default
const { buildSendMessageOptions } = await import("@/hooks/useSendMessageOptions");
const sendOptions = buildSendMessageOptions(newWorkspace.workspaceId);

if (model) {
sendOptions.model = model;
}

// Defer until React finishes rendering and WorkspaceStore subscribes
requestAnimationFrame(() => {
void window.api.workspace.sendMessage(
newWorkspace.workspaceId,
startMessage,
sendOptions
);
});
}
}
};

Expand Down Expand Up @@ -615,6 +657,30 @@ 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 handleAddWorkspace(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
);
}, [handleAddWorkspace]);

return (
<>
<div className="bg-bg-dark flex h-screen overflow-hidden [@media(max-width:768px)]:flex-col">
Expand Down Expand Up @@ -674,22 +740,19 @@ function AppInner() {
workspaceId: selectedWorkspace?.workspaceId,
})}
/>
{workspaceModalOpen && workspaceModalProject && (
{workspaceModalState.isOpen && workspaceModalState.projectPath && (
<NewWorkspaceModal
isOpen={workspaceModalOpen}
projectName={workspaceModalProjectName}
projectPath={workspaceModalProject}
branches={workspaceModalBranches}
defaultTrunkBranch={workspaceModalDefaultTrunk}
loadErrorMessage={workspaceModalLoadError}
isOpen={workspaceModalState.isOpen}
projectName={workspaceModalState.projectName}
projectPath={workspaceModalState.projectPath}
branches={workspaceModalState.branches}
defaultTrunkBranch={workspaceModalState.defaultTrunk}
loadErrorMessage={workspaceModalState.loadError}
initialStartMessage={workspaceModalState.startMessage}
initialModel={workspaceModalState.model}
onClose={() => {
workspaceModalProjectRef.current = null;
setWorkspaceModalOpen(false);
setWorkspaceModalProject(null);
setWorkspaceModalProjectName("");
setWorkspaceModalBranches([]);
setWorkspaceModalDefaultTrunk(undefined);
setWorkspaceModalLoadError(null);
setWorkspaceModalState(INITIAL_WORKSPACE_MODAL_STATE);
}}
onAdd={handleCreateWorkspace}
/>
Expand Down
56 changes: 48 additions & 8 deletions src/components/NewWorkspaceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
onAdd: (
branchName: string,
trunkBranch: string,
runtime?: string,
startMessage?: string,
model?: string
) => Promise<void>;
}

// Shared form field styles
Expand All @@ -27,11 +35,14 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
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<string | null>(null);
const infoId = useId();
Expand All @@ -41,10 +52,22 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
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) => {
Expand All @@ -63,9 +86,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
}, [branches, defaultTrunkBranch, hasBranches]);

const handleCancel = () => {
setBranchName("");
setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? "");
setRuntimeOptions(RUNTIME_MODE.LOCAL, "");
resetForm();
setError(loadErrorMessage ?? null);
onClose();
};
Expand Down Expand Up @@ -104,11 +125,16 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
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";
Expand Down Expand Up @@ -243,6 +269,19 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
</div>
)}

{initialStartMessage && (
<div className="[&_label]:text-foreground [&_textarea]:bg-modal-bg [&_textarea]:border-border-medium [&_textarea]:focus:border-accent mb-5 [&_label]:mb-2 [&_label]:block [&_label]:text-sm [&_textarea]:w-full [&_textarea]:rounded [&_textarea]:border [&_textarea]:px-3 [&_textarea]:py-2 [&_textarea]:text-sm [&_textarea]:text-white [&_textarea]:focus:outline-none [&_textarea]:disabled:cursor-not-allowed [&_textarea]:disabled:opacity-60 [&_textarea]:resize-y [&_textarea]:min-h-[80px]">
<label htmlFor="startMessage">Start Message (optional):</label>
<textarea
id="startMessage"
value={startMessage}
onChange={(event) => setStartMessage(event.target.value)}
disabled={isLoading}
placeholder="Enter a message to send after creating the workspace..."
/>
</div>
)}

<ModalInfo id={infoId}>
<p>This will create a workspace at:</p>
<code className="block break-all">
Expand All @@ -259,6 +298,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
{formatNewCommand(
branchName.trim(),
trunkBranch.trim() || undefined,
startMessage.trim() || undefined,
getRuntimeString()
)}
</div>
Expand Down
6 changes: 6 additions & 0 deletions src/constants/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export const CUSTOM_EVENTS = {
* Detail: { commandId: string }
*/
EXECUTE_COMMAND: "cmux:executeCommand",

/**
* Event to open the new workspace modal with initial data
* Detail: { projectPath: string, startMessage?: string, model?: string, error?: string }
*/
OPEN_NEW_WORKSPACE_MODAL: "cmux:openNewWorkspaceModal",
} as const;

/**
Expand Down
Loading
Loading