Skip to content

Commit 7b6e3d8

Browse files
committed
🤖 feat: add -m flag to /new, forward start message to modal on error
- Support /new -m <model> to set initial model (like /compact) - Forward start message and model to modal when errors occur - Add textarea in NewWorkspaceModal for start message display - Model selector defaults to provided model or workspace default - Send start message with specified model after workspace creation Fixes three bugs: 1. Missing -m flag support for model selection 2. Lost start message on errors (now preserved in modal) 3. No way to see/edit start message in error modal All tests pass (16 /new command tests + 54 other slash command tests).
1 parent e7e0f37 commit 7b6e3d8

File tree

7 files changed

+264
-61
lines changed

7 files changed

+264
-61
lines changed

src/App.tsx

Lines changed: 99 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ function AppInner() {
5454
undefined
5555
);
5656
const [workspaceModalLoadError, setWorkspaceModalLoadError] = useState<string | null>(null);
57+
const [workspaceModalStartMessage, setWorkspaceModalStartMessage] = useState<string | undefined>(
58+
undefined
59+
);
60+
const [workspaceModalModel, setWorkspaceModalModel] = useState<string | undefined>(undefined);
5761
const workspaceModalProjectRef = useRef<string | null>(null);
5862

5963
// Auto-collapse sidebar on mobile by default
@@ -175,46 +179,55 @@ function AppInner() {
175179
[removeProject, selectedWorkspace, setSelectedWorkspace]
176180
);
177181

178-
const handleAddWorkspace = useCallback(async (projectPath: string) => {
179-
const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project";
182+
const handleAddWorkspace = useCallback(
183+
async (
184+
projectPath: string,
185+
initialData?: { startMessage?: string; model?: string; error?: string }
186+
) => {
187+
const projectName =
188+
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project";
189+
190+
workspaceModalProjectRef.current = projectPath;
191+
setWorkspaceModalProject(projectPath);
192+
setWorkspaceModalProjectName(projectName);
193+
setWorkspaceModalBranches([]);
194+
setWorkspaceModalDefaultTrunk(undefined);
195+
setWorkspaceModalLoadError(initialData?.error ?? null);
196+
setWorkspaceModalStartMessage(initialData?.startMessage);
197+
setWorkspaceModalModel(initialData?.model);
198+
setWorkspaceModalOpen(true);
180199

181-
workspaceModalProjectRef.current = projectPath;
182-
setWorkspaceModalProject(projectPath);
183-
setWorkspaceModalProjectName(projectName);
184-
setWorkspaceModalBranches([]);
185-
setWorkspaceModalDefaultTrunk(undefined);
186-
setWorkspaceModalLoadError(null);
187-
setWorkspaceModalOpen(true);
200+
try {
201+
const branchResult = await window.api.projects.listBranches(projectPath);
188202

189-
try {
190-
const branchResult = await window.api.projects.listBranches(projectPath);
203+
// Guard against race condition: only update state if this is still the active project
204+
if (workspaceModalProjectRef.current !== projectPath) {
205+
return;
206+
}
191207

192-
// Guard against race condition: only update state if this is still the active project
193-
if (workspaceModalProjectRef.current !== projectPath) {
194-
return;
195-
}
208+
const sanitizedBranches = Array.isArray(branchResult?.branches)
209+
? branchResult.branches.filter((branch): branch is string => typeof branch === "string")
210+
: [];
196211

197-
const sanitizedBranches = Array.isArray(branchResult?.branches)
198-
? branchResult.branches.filter((branch): branch is string => typeof branch === "string")
199-
: [];
212+
const recommended =
213+
typeof branchResult?.recommendedTrunk === "string" &&
214+
sanitizedBranches.includes(branchResult.recommendedTrunk)
215+
? branchResult.recommendedTrunk
216+
: sanitizedBranches[0];
200217

201-
const recommended =
202-
typeof branchResult?.recommendedTrunk === "string" &&
203-
sanitizedBranches.includes(branchResult.recommendedTrunk)
204-
? branchResult.recommendedTrunk
205-
: sanitizedBranches[0];
206-
207-
setWorkspaceModalBranches(sanitizedBranches);
208-
setWorkspaceModalDefaultTrunk(recommended);
209-
setWorkspaceModalLoadError(null);
210-
} catch (err) {
211-
console.error("Failed to load branches for modal:", err);
212-
const message = err instanceof Error ? err.message : "Unknown error";
213-
setWorkspaceModalLoadError(
214-
`Unable to load branches automatically: ${message}. You can still enter the trunk branch manually.`
215-
);
216-
}
217-
}, []);
218+
setWorkspaceModalBranches(sanitizedBranches);
219+
setWorkspaceModalDefaultTrunk(recommended);
220+
setWorkspaceModalLoadError(null);
221+
} catch (err) {
222+
console.error("Failed to load branches for modal:", err);
223+
const message = err instanceof Error ? err.message : "Unknown error";
224+
setWorkspaceModalLoadError(
225+
`Unable to load branches automatically: ${message}. You can still enter the trunk branch manually.`
226+
);
227+
}
228+
},
229+
[]
230+
);
218231

219232
// Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders
220233
const handleAddProjectCallback = useCallback(() => {
@@ -238,7 +251,9 @@ function AppInner() {
238251
const handleCreateWorkspace = async (
239252
branchName: string,
240253
trunkBranch: string,
241-
runtime?: string
254+
runtime?: string,
255+
startMessage?: string,
256+
model?: string
242257
) => {
243258
if (!workspaceModalProject) return;
244259

@@ -274,6 +289,26 @@ function AppInner() {
274289
const runtimeKey = getRuntimeKey(workspaceModalProject);
275290
localStorage.setItem(runtimeKey, runtime);
276291
}
292+
293+
// Send start message if provided
294+
if (startMessage) {
295+
// Build send message options - use provided model or default
296+
const { buildSendMessageOptions } = await import("@/hooks/useSendMessageOptions");
297+
const sendOptions = buildSendMessageOptions(newWorkspace.workspaceId);
298+
299+
if (model) {
300+
sendOptions.model = model;
301+
}
302+
303+
// Defer until React finishes rendering and WorkspaceStore subscribes
304+
requestAnimationFrame(() => {
305+
void window.api.workspace.sendMessage(
306+
newWorkspace.workspaceId,
307+
startMessage,
308+
sendOptions
309+
);
310+
});
311+
}
277312
}
278313
};
279314

@@ -615,6 +650,30 @@ function AppInner() {
615650
);
616651
}, [projects, setSelectedWorkspace, setWorkspaceMetadata]);
617652

653+
// Handle open new workspace modal event
654+
useEffect(() => {
655+
const handleOpenNewWorkspaceModal = (e: Event) => {
656+
const customEvent = e as CustomEvent<{
657+
projectPath: string;
658+
startMessage?: string;
659+
model?: string;
660+
error?: string;
661+
}>;
662+
const { projectPath, startMessage, model, error } = customEvent.detail;
663+
void handleAddWorkspace(projectPath, { startMessage, model, error });
664+
};
665+
666+
window.addEventListener(
667+
CUSTOM_EVENTS.OPEN_NEW_WORKSPACE_MODAL,
668+
handleOpenNewWorkspaceModal as EventListener
669+
);
670+
return () =>
671+
window.removeEventListener(
672+
CUSTOM_EVENTS.OPEN_NEW_WORKSPACE_MODAL,
673+
handleOpenNewWorkspaceModal as EventListener
674+
);
675+
}, [handleAddWorkspace]);
676+
618677
return (
619678
<>
620679
<div className="bg-bg-dark flex h-screen overflow-hidden [@media(max-width:768px)]:flex-col">
@@ -682,6 +741,8 @@ function AppInner() {
682741
branches={workspaceModalBranches}
683742
defaultTrunkBranch={workspaceModalDefaultTrunk}
684743
loadErrorMessage={workspaceModalLoadError}
744+
initialStartMessage={workspaceModalStartMessage}
745+
initialModel={workspaceModalModel}
685746
onClose={() => {
686747
workspaceModalProjectRef.current = null;
687748
setWorkspaceModalOpen(false);
@@ -690,6 +751,8 @@ function AppInner() {
690751
setWorkspaceModalBranches([]);
691752
setWorkspaceModalDefaultTrunk(undefined);
692753
setWorkspaceModalLoadError(null);
754+
setWorkspaceModalStartMessage(undefined);
755+
setWorkspaceModalModel(undefined);
693756
}}
694757
onAdd={handleCreateWorkspace}
695758
/>

src/components/NewWorkspaceModal.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@ interface NewWorkspaceModalProps {
1212
branches: string[];
1313
defaultTrunkBranch?: string;
1414
loadErrorMessage?: string | null;
15+
initialStartMessage?: string;
16+
initialModel?: string;
1517
onClose: () => void;
16-
onAdd: (branchName: string, trunkBranch: string, runtime?: string) => Promise<void>;
18+
onAdd: (
19+
branchName: string,
20+
trunkBranch: string,
21+
runtime?: string,
22+
startMessage?: string,
23+
model?: string
24+
) => Promise<void>;
1725
}
1826

1927
// Shared form field styles
@@ -27,11 +35,14 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
2735
branches,
2836
defaultTrunkBranch,
2937
loadErrorMessage,
38+
initialStartMessage,
39+
initialModel,
3040
onClose,
3141
onAdd,
3242
}) => {
3343
const [branchName, setBranchName] = useState("");
3444
const [trunkBranch, setTrunkBranch] = useState(defaultTrunkBranch ?? branches[0] ?? "");
45+
const [startMessage, setStartMessage] = useState(initialStartMessage ?? "");
3546
const [isLoading, setIsLoading] = useState(false);
3647
const [error, setError] = useState<string | null>(null);
3748
const infoId = useId();
@@ -45,6 +56,10 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
4556
setError(loadErrorMessage ?? null);
4657
}, [loadErrorMessage]);
4758

59+
useEffect(() => {
60+
setStartMessage(initialStartMessage ?? "");
61+
}, [initialStartMessage]);
62+
4863
useEffect(() => {
4964
const fallbackTrunk = defaultTrunkBranch ?? branches[0] ?? "";
5065
setTrunkBranch((current) => {
@@ -66,6 +81,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
6681
setBranchName("");
6782
setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? "");
6883
setRuntimeOptions(RUNTIME_MODE.LOCAL, "");
84+
setStartMessage("");
6985
setError(loadErrorMessage ?? null);
7086
onClose();
7187
};
@@ -104,11 +120,19 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
104120
try {
105121
// Get runtime string from hook helper
106122
const runtime = getRuntimeString();
123+
const trimmedStartMessage = startMessage.trim();
107124

108-
await onAdd(trimmedBranchName, normalizedTrunkBranch, runtime);
125+
await onAdd(
126+
trimmedBranchName,
127+
normalizedTrunkBranch,
128+
runtime,
129+
trimmedStartMessage || undefined,
130+
initialModel
131+
);
109132
setBranchName("");
110133
setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? "");
111134
setRuntimeOptions(RUNTIME_MODE.LOCAL, "");
135+
setStartMessage("");
112136
onClose();
113137
} catch (err) {
114138
const message = err instanceof Error ? err.message : "Failed to create workspace";
@@ -202,6 +226,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
202226
)}
203227
</div>
204228

229+
<<<<<<< HEAD
205230
<div className={formFieldClasses}>
206231
<label htmlFor="runtimeMode">Runtime:</label>
207232
<select
@@ -243,6 +268,17 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
243268
</div>
244269
)}
245270

271+
<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]">
272+
<label htmlFor="startMessage">Start Message (optional):</label>
273+
<textarea
274+
id="startMessage"
275+
value={startMessage}
276+
onChange={(event) => setStartMessage(event.target.value)}
277+
disabled={isLoading}
278+
placeholder="Enter a message to send after creating the workspace..."
279+
/>
280+
</div>
281+
246282
<ModalInfo id={infoId}>
247283
<p>This will create a workspace at:</p>
248284
<code className="block break-all">
@@ -259,6 +295,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
259295
{formatNewCommand(
260296
branchName.trim(),
261297
trunkBranch.trim() || undefined,
298+
startMessage.trim() || undefined,
262299
getRuntimeString()
263300
)}
264301
</div>

src/constants/events.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ export const CUSTOM_EVENTS = {
4646
* Detail: { commandId: string }
4747
*/
4848
EXECUTE_COMMAND: "cmux:executeCommand",
49+
50+
/**
51+
* Event to open the new workspace modal with initial data
52+
* Detail: { projectPath: string, startMessage?: string, model?: string, error?: string }
53+
*/
54+
OPEN_NEW_WORKSPACE_MODAL: "cmux:openNewWorkspaceModal",
4955
} as const;
5056

5157
/**

src/utils/chatCommands.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,26 @@ export async function handleNewCommand(
302302
): Promise<CommandHandlerResult> {
303303
const { workspaceId, sendMessageOptions, setInput, setIsSending, setToast } = context;
304304

305+
// Get workspace info to extract projectPath (needed for both cases)
306+
const workspaceInfo = await window.api.workspace.getInfo(workspaceId);
307+
if (!workspaceInfo) {
308+
setToast({
309+
id: Date.now().toString(),
310+
type: "error",
311+
message: "Failed to get workspace info",
312+
});
313+
return { clearInput: false, toastShown: true };
314+
}
315+
305316
// Open modal if no workspace name provided
306317
if (!parsed.workspaceName) {
307318
setInput("");
308-
const event = new CustomEvent(CUSTOM_EVENTS.EXECUTE_COMMAND, {
309-
detail: { commandId: "ws:new" },
319+
const event = new CustomEvent(CUSTOM_EVENTS.OPEN_NEW_WORKSPACE_MODAL, {
320+
detail: {
321+
projectPath: workspaceInfo.projectPath,
322+
startMessage: parsed.startMessage,
323+
model: parsed.model,
324+
},
310325
});
311326
window.dispatchEvent(event);
312327
return { clearInput: true, toastShown: false };
@@ -316,12 +331,6 @@ export async function handleNewCommand(
316331
setIsSending(true);
317332

318333
try {
319-
// Get workspace info to extract projectPath
320-
const workspaceInfo = await window.api.workspace.getInfo(workspaceId);
321-
if (!workspaceInfo) {
322-
throw new Error("Failed to get workspace info");
323-
}
324-
325334
const createResult = await createNewWorkspace({
326335
projectPath: workspaceInfo.projectPath,
327336
workspaceName: parsed.workspaceName,
@@ -334,13 +343,17 @@ export async function handleNewCommand(
334343
if (!createResult.success) {
335344
const errorMsg = createResult.error ?? "Failed to create workspace";
336345
console.error("Failed to create workspace:", errorMsg);
337-
setToast({
338-
id: Date.now().toString(),
339-
type: "error",
340-
title: "Create Failed",
341-
message: errorMsg,
346+
// Open modal with error and preserve data
347+
const event = new CustomEvent(CUSTOM_EVENTS.OPEN_NEW_WORKSPACE_MODAL, {
348+
detail: {
349+
projectPath: workspaceInfo.projectPath,
350+
startMessage: parsed.startMessage,
351+
model: parsed.model,
352+
error: errorMsg,
353+
},
342354
});
343-
return { clearInput: false, toastShown: true };
355+
window.dispatchEvent(event);
356+
return { clearInput: true, toastShown: false };
344357
}
345358

346359
setToast({
@@ -352,13 +365,17 @@ export async function handleNewCommand(
352365
} catch (error) {
353366
const errorMsg = error instanceof Error ? error.message : "Failed to create workspace";
354367
console.error("Create error:", error);
355-
setToast({
356-
id: Date.now().toString(),
357-
type: "error",
358-
title: "Create Failed",
359-
message: errorMsg,
368+
// Open modal with error and preserve data
369+
const event = new CustomEvent(CUSTOM_EVENTS.OPEN_NEW_WORKSPACE_MODAL, {
370+
detail: {
371+
projectPath: workspaceInfo.projectPath,
372+
startMessage: parsed.startMessage,
373+
model: parsed.model,
374+
error: errorMsg,
375+
},
360376
});
361-
return { clearInput: false, toastShown: true };
377+
window.dispatchEvent(event);
378+
return { clearInput: true, toastShown: false };
362379
} finally {
363380
setIsSending(false);
364381
}

0 commit comments

Comments
 (0)