Skip to content

Commit d44f319

Browse files
committed
🤖 Add trunk branch selector for workspace creation
Adds ability to select a trunk/base branch when creating a new workspace. When a trunk branch is selected, the new workspace branch is created from that trunk instead of from HEAD or an existing remote branch. UI Changes: - 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 Changes: - Added listLocalBranches() to git.ts 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 - Added getBranchesForProject callback to command registry context - Command palette supports async option loading with fuzzy filtering This enables creating feature branches from any local branch, useful for: - Creating branches from main instead of current HEAD - Starting new work from a specific release branch - Isolating work from unrelated changes on current branch _Generated with `cmux`_ Change-Id: Ic2a710104f3a5925e0e6bdff1d0e5ed772bcf108 Signed-off-by: Thomas Kosiewski <tk@coder.com> 🤖 Restore macOS notarization configuration The notarization flag was accidentally removed during rebase. This flag is required to submit the DMG for notarization so that distributed builds are not blocked by Gatekeeper on macOS 10.15+. Restores the 'notarize: true' flag in the mac build configuration. _Generated with `cmux`_ Change-Id: I3d4e4be981cdbb0cee0c822dd973b1a71e000108 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent a680383 commit d44f319

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)