Skip to content

Commit 461e5a0

Browse files
committed
🤖 feat: add 'Open Branch as Workspace' feature
Two entry points for opening existing branches as workspaces: 1. **Creation Controls UI** - Toggle between 'New branch' and 'Existing branch' modes in the workspace creation flow. When in existing branch mode, shows a searchable dropdown of local + remote branches. 2. **Command Palette** - 'Open Branch as Workspace...' command (Cmd+Shift+P) with a searchable branch selector. Backend changes: - Add listRemoteBranches() to git.ts - Update BranchListResult schema to include remoteBranches - Fetch remotes before listing branches (best-effort, ensures newly pushed PR branches are visible) This enables the workflow: someone opens a PR on GitHub → you fetch → select their branch → experiment with it in an isolated workspace. Future: GitHub PR integration can layer on top - just needs to resolve PR → branch name, then feed into this existing flow. Signed-off-by: Thomas Kosiewski <tk@coder.com> --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent 3cf3d07 commit 461e5a0

File tree

17 files changed

+371
-95
lines changed

17 files changed

+371
-95
lines changed

src/browser/App.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,23 +367,35 @@ function AppInner() {
367367
[startWorkspaceCreation]
368368
);
369369

370+
const openBranchAsWorkspaceFromPalette = useCallback(
371+
(projectPath: string, branchName: string) => {
372+
startWorkspaceCreation(projectPath, { projectPath, existingBranch: branchName });
373+
},
374+
[startWorkspaceCreation]
375+
);
376+
370377
const getBranchesForProject = useCallback(
371378
async (projectPath: string): Promise<BranchListResult> => {
372379
if (!api) {
373-
return { branches: [], recommendedTrunk: null };
380+
return { branches: [], remoteBranches: [], recommendedTrunk: null };
374381
}
375382
const branchResult = await api.projects.listBranches({ projectPath });
376383
const sanitizedBranches = branchResult.branches.filter(
377384
(branch): branch is string => typeof branch === "string"
378385
);
379386

387+
const sanitizedRemoteBranches = branchResult.remoteBranches.filter(
388+
(branch): branch is string => typeof branch === "string"
389+
);
390+
380391
const recommended =
381392
branchResult.recommendedTrunk && sanitizedBranches.includes(branchResult.recommendedTrunk)
382393
? branchResult.recommendedTrunk
383394
: (sanitizedBranches[0] ?? null);
384395

385396
return {
386397
branches: sanitizedBranches,
398+
remoteBranches: sanitizedRemoteBranches,
387399
recommendedTrunk: recommended,
388400
};
389401
},
@@ -437,6 +449,7 @@ function AppInner() {
437449
getThinkingLevel: getThinkingLevelForWorkspace,
438450
onSetThinkingLevel: setThinkingLevelFromPalette,
439451
onStartWorkspaceCreation: openNewWorkspaceFromPalette,
452+
onStartWorkspaceCreationWithBranch: openBranchAsWorkspaceFromPalette,
440453
getBranchesForProject,
441454
onSelectWorkspace: selectWorkspaceFromPalette,
442455
onRemoveWorkspace: removeWorkspaceFromPalette,

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 127 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import React, { useCallback, useEffect } from "react";
22
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
33
import { Select } from "../Select";
4-
import { Loader2, Wand2 } from "lucide-react";
4+
import { Loader2, Wand2, GitBranch } from "lucide-react";
55
import { cn } from "@/common/lib/utils";
66
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
77
import { SSHIcon, WorktreeIcon, LocalIcon } from "../icons/RuntimeIcons";
88
import { DocsLink } from "../DocsLink";
99
import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName";
1010

11+
export type BranchMode = "new" | "existing";
12+
1113
interface CreationControlsProps {
1214
branches: string[];
15+
/** Remote-only branches (not in local branches) */
16+
remoteBranches: string[];
1317
/** Whether branches have finished loading (to distinguish loading vs non-git repo) */
1418
branchesLoaded: boolean;
1519
trunkBranch: string;
@@ -25,6 +29,12 @@ interface CreationControlsProps {
2529
projectName: string;
2630
/** Workspace name/title generation state and actions */
2731
nameState: WorkspaceNameState;
32+
/** Branch mode: "new" creates a new branch, "existing" uses an existing branch */
33+
branchMode: BranchMode;
34+
onBranchModeChange: (mode: BranchMode) => void;
35+
/** Selected existing branch (when branchMode is "existing") */
36+
selectedExistingBranch: string;
37+
onSelectedExistingBranchChange: (branch: string) => void;
2838
}
2939

3040
/** Runtime type button group with icons and colors */
@@ -166,6 +176,10 @@ export function CreationControls(props: CreationControlsProps) {
166176
}
167177
}, [isNonGitRepo, runtimeMode, onRuntimeModeChange]);
168178

179+
// All existing branches (local + remote)
180+
const allExistingBranches = [...props.branches, ...props.remoteBranches];
181+
const hasExistingBranches = allExistingBranches.length > 0;
182+
169183
const handleNameChange = useCallback(
170184
(e: React.ChangeEvent<HTMLInputElement>) => {
171185
nameState.setName(e.target.value);
@@ -187,75 +201,126 @@ export function CreationControls(props: CreationControlsProps) {
187201

188202
return (
189203
<div className="mb-3 flex flex-col gap-4">
204+
{/* Branch mode toggle - only show for git repos with existing branches */}
205+
{hasExistingBranches && (
206+
<div className="flex items-center gap-3" data-component="BranchModeGroup">
207+
<button
208+
type="button"
209+
onClick={() => props.onBranchModeChange("new")}
210+
disabled={props.disabled}
211+
className={cn(
212+
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-all duration-150",
213+
props.branchMode === "new"
214+
? "bg-accent/20 text-accent-foreground border-accent/60"
215+
: "bg-transparent text-muted border-transparent hover:border-accent/40"
216+
)}
217+
>
218+
<Wand2 size={12} />
219+
New branch
220+
</button>
221+
<button
222+
type="button"
223+
onClick={() => props.onBranchModeChange("existing")}
224+
disabled={props.disabled}
225+
className={cn(
226+
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-all duration-150",
227+
props.branchMode === "existing"
228+
? "bg-accent/20 text-accent-foreground border-accent/60"
229+
: "bg-transparent text-muted border-transparent hover:border-accent/40"
230+
)}
231+
>
232+
<GitBranch size={12} />
233+
Existing branch
234+
</button>
235+
</div>
236+
)}
237+
190238
{/* Project name / workspace name header row */}
191239
<div className="flex items-center" data-component="WorkspaceNameGroup">
192240
<h2 className="text-foreground shrink-0 text-lg font-semibold">{props.projectName}</h2>
193241
<span className="text-muted-foreground mx-2 text-lg">/</span>
194242

195-
{/* Name input with magic wand - uses grid overlay technique for auto-sizing */}
196-
<div className="relative inline-grid items-center">
197-
{/* Hidden sizer span - determines width based on content, minimum is placeholder width */}
198-
<span className="invisible col-start-1 row-start-1 pr-7 text-lg font-semibold whitespace-pre">
199-
{nameState.name || "workspace-name"}
200-
</span>
201-
<Tooltip>
202-
<TooltipTrigger asChild>
203-
<input
204-
id="workspace-name"
205-
type="text"
206-
size={1}
207-
value={nameState.name}
208-
onChange={handleNameChange}
209-
onFocus={handleInputFocus}
210-
placeholder={nameState.isGenerating ? "Generating..." : "workspace-name"}
211-
disabled={props.disabled}
212-
className={cn(
213-
"col-start-1 row-start-1 min-w-0 bg-transparent border-border-medium focus:border-accent h-7 w-full rounded-md border border-transparent text-lg font-semibold focus:border focus:bg-bg-dark focus:outline-none disabled:opacity-50",
214-
nameState.autoGenerate ? "text-muted" : "text-foreground",
215-
nameState.error && "border-red-500"
216-
)}
217-
/>
218-
</TooltipTrigger>
219-
<TooltipContent align="start" className="max-w-64">
220-
A stable identifier used for git branches, worktree folders, and session directories.
221-
</TooltipContent>
222-
</Tooltip>
223-
{/* Magic wand / loading indicator */}
224-
<div className="absolute inset-y-0 right-0 flex items-center pr-2">
225-
{nameState.isGenerating ? (
226-
<Loader2 className="text-accent h-3.5 w-3.5 animate-spin" />
227-
) : (
228-
<Tooltip>
229-
<TooltipTrigger asChild>
230-
<button
231-
type="button"
232-
onClick={handleWandClick}
233-
disabled={props.disabled}
234-
className="flex h-full items-center disabled:opacity-50"
235-
aria-label={
236-
nameState.autoGenerate ? "Disable auto-naming" : "Enable auto-naming"
237-
}
238-
>
239-
<Wand2
240-
className={cn(
241-
"h-3.5 w-3.5 transition-colors",
242-
nameState.autoGenerate
243-
? "text-accent"
244-
: "text-muted-foreground opacity-50 hover:opacity-75"
245-
)}
246-
/>
247-
</button>
248-
</TooltipTrigger>
249-
<TooltipContent align="center">
250-
{nameState.autoGenerate ? "Auto-naming enabled" : "Click to enable auto-naming"}
251-
</TooltipContent>
252-
</Tooltip>
253-
)}
243+
{/* New branch mode: Name input with magic wand */}
244+
{props.branchMode === "new" && (
245+
<div className="relative inline-grid items-center">
246+
{/* Hidden sizer span - determines width based on content, minimum is placeholder width */}
247+
<span className="invisible col-start-1 row-start-1 pr-7 text-lg font-semibold whitespace-pre">
248+
{nameState.name || "workspace-name"}
249+
</span>
250+
<Tooltip>
251+
<TooltipTrigger asChild>
252+
<input
253+
id="workspace-name"
254+
type="text"
255+
size={1}
256+
value={nameState.name}
257+
onChange={handleNameChange}
258+
onFocus={handleInputFocus}
259+
placeholder={nameState.isGenerating ? "Generating..." : "workspace-name"}
260+
disabled={props.disabled}
261+
className={cn(
262+
"col-start-1 row-start-1 min-w-0 bg-transparent border-border-medium focus:border-accent h-7 w-full rounded-md border border-transparent text-lg font-semibold focus:border focus:bg-bg-dark focus:outline-none disabled:opacity-50",
263+
nameState.autoGenerate ? "text-muted" : "text-foreground",
264+
nameState.error && "border-red-500"
265+
)}
266+
/>
267+
</TooltipTrigger>
268+
<TooltipContent align="start" className="max-w-64">
269+
A stable identifier used for git branches, worktree folders, and session
270+
directories.
271+
</TooltipContent>
272+
</Tooltip>
273+
{/* Magic wand / loading indicator */}
274+
<div className="absolute inset-y-0 right-0 flex items-center pr-2">
275+
{nameState.isGenerating ? (
276+
<Loader2 className="text-accent h-3.5 w-3.5 animate-spin" />
277+
) : (
278+
<Tooltip>
279+
<TooltipTrigger asChild>
280+
<button
281+
type="button"
282+
onClick={handleWandClick}
283+
disabled={props.disabled}
284+
className="flex h-full items-center disabled:opacity-50"
285+
aria-label={
286+
nameState.autoGenerate ? "Disable auto-naming" : "Enable auto-naming"
287+
}
288+
>
289+
<Wand2
290+
className={cn(
291+
"h-3.5 w-3.5 transition-colors",
292+
nameState.autoGenerate
293+
? "text-accent"
294+
: "text-muted-foreground opacity-50 hover:opacity-75"
295+
)}
296+
/>
297+
</button>
298+
</TooltipTrigger>
299+
<TooltipContent align="center">
300+
{nameState.autoGenerate ? "Auto-naming enabled" : "Click to enable auto-naming"}
301+
</TooltipContent>
302+
</Tooltip>
303+
)}
304+
</div>
254305
</div>
255-
</div>
306+
)}
307+
308+
{/* Existing branch mode: Branch selector */}
309+
{props.branchMode === "existing" && (
310+
<Select
311+
id="existing-branch"
312+
value={props.selectedExistingBranch}
313+
options={allExistingBranches}
314+
onChange={props.onSelectedExistingBranchChange}
315+
disabled={props.disabled}
316+
className="h-8 min-w-[200px] text-lg font-semibold"
317+
/>
318+
)}
256319

257320
{/* Error display */}
258-
{nameState.error && <span className="text-xs text-red-500">{nameState.error}</span>}
321+
{nameState.error && props.branchMode === "new" && (
322+
<span className="text-xs text-red-500">{nameState.error}</span>
323+
)}
259324
</div>
260325

261326
{/* Runtime type - button group */}

src/browser/components/ChatInput/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,6 +1601,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
16011601
{variant === "creation" && (
16021602
<CreationControls
16031603
branches={creationState.branches}
1604+
remoteBranches={creationState.remoteBranches}
16041605
branchesLoaded={creationState.branchesLoaded}
16051606
trunkBranch={creationState.trunkBranch}
16061607
onTrunkBranchChange={creationState.setTrunkBranch}
@@ -1613,6 +1614,10 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
16131614
disabled={isSendInFlight}
16141615
projectName={props.projectName}
16151616
nameState={creationState.nameState}
1617+
branchMode={creationState.branchMode}
1618+
onBranchModeChange={creationState.setBranchMode}
1619+
selectedExistingBranch={creationState.selectedExistingBranch}
1620+
onSelectedExistingBranchChange={creationState.setSelectedExistingBranch}
16161621
/>
16171622
)}
16181623

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ const setupWindow = ({
146146
}
147147
return Promise.resolve({
148148
branches: [FALLBACK_BRANCH],
149+
remoteBranches: [],
149150
recommendedTrunk: FALLBACK_BRANCH,
150151
});
151152
});
@@ -344,6 +345,7 @@ describe("useCreationWorkspace", () => {
344345
(): Promise<BranchListResult> =>
345346
Promise.resolve({
346347
branches: ["main", "dev"],
348+
remoteBranches: [],
347349
recommendedTrunk: "dev",
348350
})
349351
);
@@ -378,6 +380,7 @@ describe("useCreationWorkspace", () => {
378380
(): Promise<BranchListResult> =>
379381
Promise.resolve({
380382
branches: ["main"],
383+
remoteBranches: [],
381384
recommendedTrunk: "main",
382385
})
383386
);
@@ -399,6 +402,7 @@ describe("useCreationWorkspace", () => {
399402
(): Promise<BranchListResult> =>
400403
Promise.resolve({
401404
branches: ["main"],
405+
remoteBranches: [],
402406
recommendedTrunk: "main",
403407
})
404408
);

0 commit comments

Comments
 (0)