From 80a8828d430f4e5a03ba6f427aa2bd29e1134315 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 13 Dec 2025 13:02:57 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20'Open=20Branch=20a?= =?UTF-8?q?s=20Workspace'=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_ --- src/browser/App.tsx | 15 +- .../components/ChatInput/CreationControls.tsx | 189 ++++++++++++------ src/browser/components/ChatInput/index.tsx | 5 + .../ChatInput/useCreationWorkspace.test.tsx | 4 + .../ChatInput/useCreationWorkspace.ts | 86 ++++++-- src/browser/contexts/ProjectContext.test.tsx | 54 +++-- src/browser/contexts/ProjectContext.tsx | 9 +- .../contexts/WorkspaceContext.test.tsx | 4 +- .../hooks/useStartWorkspaceCreation.ts | 9 + src/browser/utils/commandIds.ts | 1 + src/browser/utils/commands/sources.test.ts | 2 + src/browser/utils/commands/sources.ts | 35 ++++ src/common/constants/events.ts | 2 + src/common/constants/storage.ts | 9 + src/common/orpc/schemas/message.ts | 2 + src/node/git.ts | 20 ++ src/node/services/projectService.ts | 20 +- 17 files changed, 371 insertions(+), 95 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 0a8df93f1c..40ef2eba24 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -316,16 +316,27 @@ function AppInner() { [startWorkspaceCreation] ); + const openBranchAsWorkspaceFromPalette = useCallback( + (projectPath: string, branchName: string) => { + startWorkspaceCreation(projectPath, { projectPath, existingBranch: branchName }); + }, + [startWorkspaceCreation] + ); + const getBranchesForProject = useCallback( async (projectPath: string): Promise => { if (!api) { - return { branches: [], recommendedTrunk: null }; + return { branches: [], remoteBranches: [], recommendedTrunk: null }; } const branchResult = await api.projects.listBranches({ projectPath }); const sanitizedBranches = branchResult.branches.filter( (branch): branch is string => typeof branch === "string" ); + const sanitizedRemoteBranches = branchResult.remoteBranches.filter( + (branch): branch is string => typeof branch === "string" + ); + const recommended = branchResult.recommendedTrunk && sanitizedBranches.includes(branchResult.recommendedTrunk) ? branchResult.recommendedTrunk @@ -333,6 +344,7 @@ function AppInner() { return { branches: sanitizedBranches, + remoteBranches: sanitizedRemoteBranches, recommendedTrunk: recommended, }; }, @@ -386,6 +398,7 @@ function AppInner() { getThinkingLevel: getThinkingLevelForWorkspace, onSetThinkingLevel: setThinkingLevelFromPalette, onStartWorkspaceCreation: openNewWorkspaceFromPalette, + onStartWorkspaceCreationWithBranch: openBranchAsWorkspaceFromPalette, getBranchesForProject, onSelectWorkspace: selectWorkspaceFromPalette, onRemoveWorkspace: removeWorkspaceFromPalette, diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index fc2f482d1e..e882920a20 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,15 +1,19 @@ import React, { useCallback, useEffect } from "react"; import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; import { Select } from "../Select"; -import { Loader2, Wand2 } from "lucide-react"; +import { Loader2, Wand2, GitBranch } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import { SSHIcon, WorktreeIcon, LocalIcon } from "../icons/RuntimeIcons"; import { DocsLink } from "../DocsLink"; import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; +export type BranchMode = "new" | "existing"; + interface CreationControlsProps { branches: string[]; + /** Remote-only branches (not in local branches) */ + remoteBranches: string[]; /** Whether branches have finished loading (to distinguish loading vs non-git repo) */ branchesLoaded: boolean; trunkBranch: string; @@ -25,6 +29,12 @@ interface CreationControlsProps { projectName: string; /** Workspace name/title generation state and actions */ nameState: WorkspaceNameState; + /** Branch mode: "new" creates a new branch, "existing" uses an existing branch */ + branchMode: BranchMode; + onBranchModeChange: (mode: BranchMode) => void; + /** Selected existing branch (when branchMode is "existing") */ + selectedExistingBranch: string; + onSelectedExistingBranchChange: (branch: string) => void; } /** Runtime type button group with icons and colors */ @@ -166,6 +176,10 @@ export function CreationControls(props: CreationControlsProps) { } }, [isNonGitRepo, runtimeMode, onRuntimeModeChange]); + // All existing branches (local + remote) + const allExistingBranches = [...props.branches, ...props.remoteBranches]; + const hasExistingBranches = allExistingBranches.length > 0; + const handleNameChange = useCallback( (e: React.ChangeEvent) => { nameState.setName(e.target.value); @@ -187,75 +201,126 @@ export function CreationControls(props: CreationControlsProps) { return (
+ {/* Branch mode toggle - only show for git repos with existing branches */} + {hasExistingBranches && ( +
+ + +
+ )} + {/* Project name / workspace name header row */}

{props.projectName}

/ - {/* Name input with magic wand - uses grid overlay technique for auto-sizing */} -
- {/* Hidden sizer span - determines width based on content, minimum is placeholder width */} - - {nameState.name || "workspace-name"} - - - - - - - A stable identifier used for git branches, worktree folders, and session directories. - - - {/* Magic wand / loading indicator */} -
- {nameState.isGenerating ? ( - - ) : ( - - - - - - {nameState.autoGenerate ? "Auto-naming enabled" : "Click to enable auto-naming"} - - - )} + {/* New branch mode: Name input with magic wand */} + {props.branchMode === "new" && ( +
+ {/* Hidden sizer span - determines width based on content, minimum is placeholder width */} + + {nameState.name || "workspace-name"} + + + + + + + A stable identifier used for git branches, worktree folders, and session + directories. + + + {/* Magic wand / loading indicator */} +
+ {nameState.isGenerating ? ( + + ) : ( + + + + + + {nameState.autoGenerate ? "Auto-naming enabled" : "Click to enable auto-naming"} + + + )} +
-
+ )} + + {/* Existing branch mode: Branch selector */} + {props.branchMode === "existing" && ( +