Skip to content

Commit 4241982

Browse files
authored
🤖 Add trunk branch selector for workspace creation (#100)
Adds ability to select a trunk/base branch when creating a new workspace. ## Changes ### UI - **Command palette**: Added trunk branch selection field with fuzzy search - **Modal (CMD+N)**: Added dropdown to select trunk branch - Both UIs load local branches and show them as options - Trunk branch selection is optional (defaults to HEAD if not selected) ### Backend - Added `listLocalBranches()` to enumerate local branches - Updated `createWorktree()` to accept optional `trunkBranch` parameter - New logic: if trunk is specified, always create new branch from trunk - Ignores remote branches when trunk is explicitly selected - Errors if local branch already exists (prevents overwrite) - Without trunk: preserves existing behavior (checkout remote or create from HEAD) ### IPC Layer - Added `WORKSPACE_LIST_BRANCHES` channel to fetch branches for a project - Updated `workspace.create` to accept and pass through `trunkBranch` - Wired through preload.ts, ipcMain.ts, and type definitions ### Integration - Updated App.tsx to pass trunk branch from modal to backend - Updated command palette prompts to collect trunk branch selection - Command palette supports async option loading with fuzzy filtering ## Use Cases This enables creating feature branches from any local branch: - Creating branches from `main` instead of current HEAD - Starting new work from a specific release branch - Isolating work from unrelated changes on current branch ## Testing - ✅ All lint checks pass - ✅ All TypeScript checks pass - ✅ Command palette trunk selection works - ✅ Modal trunk selection works - ✅ Trunk branch parameter flows through entire stack _Generated with `cmux`_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent af6ad38 commit 4241982

File tree

18 files changed

+588
-114
lines changed

18 files changed

+588
-114
lines changed

docs/AGENTS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,9 @@ The IPC layer is the boundary between backend and frontend. Follow these rules t
393393

394394
```typescript
395395
// ✅ GOOD - Frontend combines backend data with context it already has
396-
const result = await window.api.workspace.create(projectPath, branchName);
396+
const { recommendedTrunk } = await window.api.projects.listBranches(projectPath);
397+
const trunkBranch = recommendedTrunk ?? "main";
398+
const result = await window.api.workspace.create(projectPath, branchName, trunkBranch);
397399
if (result.success) {
398400
setSelectedWorkspace({
399401
...result.metadata,
@@ -404,7 +406,9 @@ The IPC layer is the boundary between backend and frontend. Follow these rules t
404406
}
405407

406408
// ❌ BAD - Backend returns frontend-specific data
407-
const result = await window.api.workspace.create(projectPath, branchName);
409+
const { recommendedTrunk } = await window.api.projects.listBranches(projectPath);
410+
const trunkBranch = recommendedTrunk ?? "main";
411+
const result = await window.api.workspace.create(projectPath, branchName, trunkBranch);
408412
if (result.success) {
409413
setSelectedWorkspace(result.workspace); // Backend shouldn't know about WorkspaceSelection
410414
}

src/App.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { GitStatusProvider } from "./contexts/GitStatusContext";
2525
import type { ThinkingLevel } from "./types/thinking";
2626
import { CUSTOM_EVENTS } from "./constants/events";
2727
import { getThinkingLevelKey } from "./constants/storage";
28+
import type { BranchListResult } from "./types/ipc";
2829

2930
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
3031

@@ -206,10 +207,15 @@ function AppInner() {
206207
setWorkspaceModalOpen(true);
207208
}, []);
208209

209-
const handleCreateWorkspace = async (branchName: string) => {
210+
const handleCreateWorkspace = async (branchName: string, trunkBranch: string) => {
210211
if (!workspaceModalProject) return;
211212

212-
const newWorkspace = await createWorkspace(workspaceModalProject, branchName);
213+
console.assert(
214+
typeof trunkBranch === "string" && trunkBranch.trim().length > 0,
215+
"Expected trunk branch to be provided by the workspace modal"
216+
);
217+
218+
const newWorkspace = await createWorkspace(workspaceModalProject, branchName, trunkBranch);
213219
if (newWorkspace) {
214220
setSelectedWorkspace(newWorkspace);
215221
}
@@ -329,13 +335,38 @@ function AppInner() {
329335
);
330336

331337
const createWorkspaceFromPalette = useCallback(
332-
async (projectPath: string, branchName: string) => {
333-
const newWs = await createWorkspace(projectPath, branchName);
338+
async (projectPath: string, branchName: string, trunkBranch: string) => {
339+
console.assert(
340+
typeof trunkBranch === "string" && trunkBranch.trim().length > 0,
341+
"Expected trunk branch to be provided by the command palette"
342+
);
343+
const newWs = await createWorkspace(projectPath, branchName, trunkBranch);
334344
if (newWs) setSelectedWorkspace(newWs);
335345
},
336346
[createWorkspace, setSelectedWorkspace]
337347
);
338348

349+
const getBranchesForProject = useCallback(
350+
async (projectPath: string): Promise<BranchListResult> => {
351+
const branchResult = await window.api.projects.listBranches(projectPath);
352+
const sanitizedBranches = Array.isArray(branchResult?.branches)
353+
? branchResult.branches.filter((branch): branch is string => typeof branch === "string")
354+
: [];
355+
356+
const recommended =
357+
typeof branchResult?.recommendedTrunk === "string" &&
358+
sanitizedBranches.includes(branchResult.recommendedTrunk)
359+
? branchResult.recommendedTrunk
360+
: (sanitizedBranches[0] ?? "");
361+
362+
return {
363+
branches: sanitizedBranches,
364+
recommendedTrunk: recommended,
365+
};
366+
},
367+
[]
368+
);
369+
339370
const selectWorkspaceFromPalette = useCallback(
340371
(selection: {
341372
projectPath: string;
@@ -389,6 +420,7 @@ function AppInner() {
389420
onSetThinkingLevel: setThinkingLevelFromPalette,
390421
onOpenNewWorkspaceModal: openNewWorkspaceFromPalette,
391422
onCreateWorkspace: createWorkspaceFromPalette,
423+
getBranchesForProject,
392424
onSelectWorkspace: selectWorkspaceFromPalette,
393425
onRemoveWorkspace: removeWorkspaceFromPalette,
394426
onRenameWorkspace: renameWorkspaceFromPalette,

src/components/CommandPalette.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,65 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
306306
}
307307
}, [activePrompt]);
308308

309+
const [selectOptions, setSelectOptions] = useState<
310+
Array<{ id: string; label: string; keywords?: string[] }>
311+
>([]);
312+
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
313+
309314
const currentField: PromptField | null = activePrompt
310315
? (activePrompt.fields[activePrompt.idx] ?? null)
311316
: null;
317+
318+
useEffect(() => {
319+
// Select prompts can return options synchronously or as a promise. This effect normalizes
320+
// both flows, keeps the loading state in sync, and bails out early if the prompt switches
321+
// while a request is in flight.
322+
let cancelled = false;
323+
324+
const resetState = () => {
325+
if (cancelled) return;
326+
setSelectOptions([]);
327+
setIsLoadingOptions(false);
328+
};
329+
330+
const hydrateSelectOptions = async () => {
331+
if (!currentField || currentField.type !== "select") {
332+
resetState();
333+
return;
334+
}
335+
336+
setIsLoadingOptions(true);
337+
try {
338+
const rawOptions = await Promise.resolve(
339+
currentField.getOptions(activePrompt?.values ?? {})
340+
);
341+
342+
if (!Array.isArray(rawOptions)) {
343+
throw new Error("Prompt select options must resolve to an array");
344+
}
345+
346+
if (!cancelled) {
347+
setSelectOptions(rawOptions);
348+
}
349+
} catch (error) {
350+
if (!cancelled) {
351+
console.error("Failed to resolve prompt select options", error);
352+
setSelectOptions([]);
353+
}
354+
} finally {
355+
if (!cancelled) {
356+
setIsLoadingOptions(false);
357+
}
358+
}
359+
};
360+
361+
void hydrateSelectOptions();
362+
363+
return () => {
364+
cancelled = true;
365+
};
366+
}, [currentField, activePrompt]);
367+
312368
const isSlashQuery = !currentField && query.trim().startsWith("/");
313369
const shouldUseCmdkFilter = currentField ? currentField.type === "select" : !isSlashQuery;
314370

@@ -318,7 +374,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
318374
if (currentField) {
319375
const promptTitle = activePrompt?.title ?? currentField.label ?? "Provide details";
320376
if (currentField.type === "select") {
321-
const options = currentField.getOptions(activePrompt?.values ?? {});
377+
const options = selectOptions;
322378
groups = [
323379
{
324380
name: promptTitle,
@@ -331,7 +387,11 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
331387
})),
332388
},
333389
];
334-
emptyText = options.length ? undefined : "No options";
390+
emptyText = isLoadingOptions
391+
? "Loading options..."
392+
: options.length
393+
? undefined
394+
: "No options";
335395
} else {
336396
const typed = query.trim();
337397
const fallbackHint = currentField.placeholder ?? "Type value and press Enter";

src/components/NewWorkspaceModal.tsx

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useId } from "react";
1+
import React, { useEffect, useId, useState } from "react";
22
import styled from "@emotion/styled";
33
import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal";
44

@@ -12,7 +12,8 @@ const FormGroup = styled.div`
1212
font-size: 14px;
1313
}
1414
15-
input {
15+
input,
16+
select {
1617
width: 100%;
1718
padding: 8px 12px;
1819
background: #2d2d2d;
@@ -31,6 +32,15 @@ const FormGroup = styled.div`
3132
cursor: not-allowed;
3233
}
3334
}
35+
36+
select {
37+
cursor: pointer;
38+
39+
option {
40+
background: #2d2d2d;
41+
color: #fff;
42+
}
43+
}
3444
`;
3545

3646
const ErrorMessage = styled.div`
@@ -48,7 +58,7 @@ interface NewWorkspaceModalProps {
4858
isOpen: boolean;
4959
projectPath: string;
5060
onClose: () => void;
51-
onAdd: (branchName: string) => Promise<void>;
61+
onAdd: (branchName: string, trunkBranch: string) => Promise<void>;
5262
}
5363

5464
const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
@@ -58,13 +68,20 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
5868
onAdd,
5969
}) => {
6070
const [branchName, setBranchName] = useState("");
71+
const [trunkBranch, setTrunkBranch] = useState("");
72+
const [defaultTrunkBranch, setDefaultTrunkBranch] = useState("");
73+
const [branches, setBranches] = useState<string[]>([]);
74+
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
6175
const [isLoading, setIsLoading] = useState(false);
6276
const [error, setError] = useState<string | null>(null);
77+
const [branchesError, setBranchesError] = useState<string | null>(null);
6378
const infoId = useId();
6479

6580
const handleCancel = () => {
6681
setBranchName("");
82+
setTrunkBranch(defaultTrunkBranch);
6783
setError(null);
84+
setBranchesError(null);
6885
onClose();
6986
};
7087

@@ -76,12 +93,22 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
7693
return;
7794
}
7895

96+
const normalizedTrunkBranch = trunkBranch.trim();
97+
if (normalizedTrunkBranch.length === 0) {
98+
setError("Trunk branch is required");
99+
return;
100+
}
101+
102+
console.assert(normalizedTrunkBranch.length > 0, "Expected trunk branch name to be validated");
103+
console.assert(trimmedBranchName.length > 0, "Expected branch name to be validated");
104+
79105
setIsLoading(true);
80106
setError(null);
81107

82108
try {
83-
await onAdd(trimmedBranchName);
109+
await onAdd(trimmedBranchName, normalizedTrunkBranch);
84110
setBranchName("");
111+
setTrunkBranch(defaultTrunkBranch);
85112
onClose();
86113
} catch (err) {
87114
const message = err instanceof Error ? err.message : "Failed to create workspace";
@@ -91,6 +118,60 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
91118
}
92119
};
93120

121+
// Load branches when modal opens
122+
useEffect(() => {
123+
if (!isOpen) {
124+
return;
125+
}
126+
127+
const loadBranches = async () => {
128+
setIsLoadingBranches(true);
129+
setBranchesError(null);
130+
try {
131+
const branchList = await window.api.projects.listBranches(projectPath);
132+
const rawBranches = Array.isArray(branchList?.branches) ? branchList.branches : [];
133+
const sanitizedBranches = rawBranches.filter(
134+
(branch): branch is string => typeof branch === "string"
135+
);
136+
137+
if (!Array.isArray(branchList?.branches)) {
138+
console.warn("Expected listBranches to return BranchListResult", branchList);
139+
}
140+
141+
setBranches(sanitizedBranches);
142+
143+
if (sanitizedBranches.length === 0) {
144+
setTrunkBranch("");
145+
setDefaultTrunkBranch("");
146+
setBranchesError("No branches available in this project");
147+
return;
148+
}
149+
150+
const recommended =
151+
typeof branchList?.recommendedTrunk === "string" &&
152+
sanitizedBranches.includes(branchList.recommendedTrunk)
153+
? branchList.recommendedTrunk
154+
: sanitizedBranches[0];
155+
156+
setBranchesError(null);
157+
setDefaultTrunkBranch(recommended);
158+
setTrunkBranch((current) =>
159+
current && sanitizedBranches.includes(current) ? current : recommended
160+
);
161+
} catch (err) {
162+
const message = err instanceof Error ? err.message : "Failed to load branches";
163+
setBranches([]);
164+
setTrunkBranch("");
165+
setDefaultTrunkBranch("");
166+
setBranchesError(message);
167+
} finally {
168+
setIsLoadingBranches(false);
169+
}
170+
};
171+
172+
void loadBranches();
173+
}, [isOpen, projectPath]);
174+
94175
const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project";
95176

96177
return (
@@ -104,7 +185,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
104185
>
105186
<form onSubmit={(event) => void handleSubmit(event)}>
106187
<FormGroup>
107-
<label htmlFor="branchName">Branch Name:</label>
188+
<label htmlFor="branchName">Workspace Branch Name:</label>
108189
<input
109190
id="branchName"
110191
type="text"
@@ -122,6 +203,31 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
122203
{error && <ErrorMessage>{error}</ErrorMessage>}
123204
</FormGroup>
124205

206+
<FormGroup>
207+
<label htmlFor="trunkBranch">Trunk Branch:</label>
208+
<select
209+
id="trunkBranch"
210+
value={trunkBranch}
211+
onChange={(event) => setTrunkBranch(event.target.value)}
212+
disabled={isLoading || isLoadingBranches || branches.length === 0}
213+
required
214+
aria-required="true"
215+
>
216+
{isLoadingBranches ? (
217+
<option value="">Loading branches...</option>
218+
) : branches.length === 0 ? (
219+
<option value="">No branches available</option>
220+
) : (
221+
branches.map((branch) => (
222+
<option key={branch} value={branch}>
223+
{branch}
224+
</option>
225+
))
226+
)}
227+
</select>
228+
{branchesError && <ErrorMessage>{branchesError}</ErrorMessage>}
229+
</FormGroup>
230+
125231
<ModalInfo id={infoId}>
126232
<p>This will create a git worktree at:</p>
127233
<InfoCode>
@@ -133,7 +239,16 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
133239
<CancelButton type="button" onClick={handleCancel} disabled={isLoading}>
134240
Cancel
135241
</CancelButton>
136-
<PrimaryButton type="submit" disabled={isLoading || branchName.trim().length === 0}>
242+
<PrimaryButton
243+
type="submit"
244+
disabled={
245+
isLoading ||
246+
isLoadingBranches ||
247+
branchName.trim().length === 0 ||
248+
trunkBranch.trim().length === 0 ||
249+
branches.length === 0
250+
}
251+
>
137252
{isLoading ? "Creating..." : "Create Workspace"}
138253
</PrimaryButton>
139254
</ModalActions>

src/constants/ipc-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const IPC_CHANNELS = {
1515
PROJECT_CREATE: "project:create",
1616
PROJECT_REMOVE: "project:remove",
1717
PROJECT_LIST: "project:list",
18+
PROJECT_LIST_BRANCHES: "project:listBranches",
1819
PROJECT_SECRETS_GET: "project:secrets:get",
1920
PROJECT_SECRETS_UPDATE: "project:secrets:update",
2021

0 commit comments

Comments
 (0)