Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
192 changes: 52 additions & 140 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"];

Expand All @@ -46,15 +45,6 @@ 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 workspaceModalProjectRef = useRef<string | null>(null);

// Auto-collapse sidebar on mobile by default
const isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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);
}, []);
Expand Down Expand Up @@ -425,13 +320,13 @@ function AppInner() {
}
}, []);

const registerParamsRef = useRef<BuildSourcesParams | null>(null);
const registerParamsRef = useRef<BuildSourcesParams>({} as BuildSourcesParams);

const openNewWorkspaceFromPalette = useCallback(
(projectPath: string) => {
void handleAddWorkspace(projectPath);
void workspaceModal.openModal(projectPath);
},
[handleAddWorkspace]
[workspaceModal]
);

const getBranchesForProject = useCallback(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 (
<>
<div className="bg-bg-dark flex h-screen overflow-hidden [@media(max-width:768px)]:flex-col">
<LeftSidebar
onSelectWorkspace={handleWorkspaceSwitch}
onAddProject={handleAddProjectCallback}
onAddWorkspace={handleAddWorkspaceCallback}
onRemoveProject={handleRemoveProjectCallback}
onAddProject={() => void addProject()}
onAddWorkspace={(path) => void workspaceModal.openModal(path)}
onRemoveProject={(path) => void handleRemoveProject(path)}
lastReadTimestamps={lastReadTimestamps}
onToggleUnread={onToggleUnread}
collapsed={sidebarCollapsed}
Expand Down Expand Up @@ -674,24 +592,18 @@ function AppInner() {
workspaceId: selectedWorkspace?.workspaceId,
})}
/>
{workspaceModalOpen && workspaceModalProject && (
{workspaceModal.state.isOpen && workspaceModal.state.projectPath && (
<NewWorkspaceModal
isOpen={workspaceModalOpen}
projectName={workspaceModalProjectName}
projectPath={workspaceModalProject}
branches={workspaceModalBranches}
defaultTrunkBranch={workspaceModalDefaultTrunk}
loadErrorMessage={workspaceModalLoadError}
onClose={() => {
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}
/>
)}
<DirectorySelectModal />
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]:min-h-[80px] [&_textarea]:w-full [&_textarea]:resize-y [&_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">
<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