From 9d299ba7bee0fb1b91893ef91659898e136af28a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 13 Dec 2025 13:02:57 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20'Open=20Branch?= =?UTF-8?q?=20as=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. Signed-off-by: Thomas Kosiewski --- _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 d85e0d71f8..0eb22c5aa7 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -324,16 +324,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 @@ -341,6 +352,7 @@ function AppInner() { return { branches: sanitizedBranches, + remoteBranches: sanitizedRemoteBranches, recommendedTrunk: recommended, }; }, @@ -394,6 +406,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" && ( + setSearch(e.target.value)} + placeholder="Search branches..." + className="text-foreground placeholder:text-muted w-full bg-transparent font-mono text-[11px] outline-none" + /> +
+ +
+ {/* Remotes as expandable groups */} + {remoteGroups.length > 0 && hasMatchingRemoteBranches && ( + <> + {remoteGroups.map((group) => { + const isExpanded = expandedRemotes.has(group.remote); + const filteredRemoteBranches = getFilteredRemoteBranches(group.remote); + + // Hide remote if fetched and no matching branches + if (group.fetched && filteredRemoteBranches.length === 0 && search) { + return null; + } + + return ( +
+ + + {isExpanded && ( +
+ {group.isLoading ? ( +
+ +
+ ) : filteredRemoteBranches.length === 0 ? ( +
No branches
+ ) : ( + <> + {filteredRemoteBranches.map((branch) => ( + + ))} + {group.truncated && !search && ( +
+ +more branches (use search) +
+ )} + + )} +
+ )} +
+ ); + })} + + {filteredLocalBranches.length > 0 &&
} + + )} + + {/* Local branches */} + {props.isLoading && props.localBranches.length <= 1 ? ( +
+ +
+ ) : filteredLocalBranches.length === 0 ? ( +
No matching branches
+ ) : ( + <> + {filteredLocalBranches.map((branch) => ( + + ))} + {props.localBranchesTruncated && !search && ( +
+ +more branches (use search) +
+ )} + + )} +
+ + + ); +} diff --git a/src/browser/components/BranchSelector.tsx b/src/browser/components/BranchSelector.tsx index 8e1fded0be..151dacb3e4 100644 --- a/src/browser/components/BranchSelector.tsx +++ b/src/browser/components/BranchSelector.tsx @@ -1,9 +1,9 @@ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { GitBranch, Loader2, Check, Copy, Globe, ChevronRight } from "lucide-react"; +import React, { useState, useCallback, useEffect } from "react"; +import { GitBranch, Loader2, Check, Copy } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { useAPI } from "@/browser/contexts/API"; -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import { BranchPickerPopover } from "./BranchPickerPopover"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import { invalidateGitStatus } from "@/browser/stores/GitStatusStore"; @@ -38,9 +38,6 @@ export function BranchSelector({ workspaceId, workspaceName, className }: Branch const [localBranchesTruncated, setLocalBranchesTruncated] = useState(false); const [remotes, setRemotes] = useState([]); const [remoteStates, setRemoteStates] = useState>({}); - const [expandedRemotes, setExpandedRemotes] = useState>(new Set()); - const [search, setSearch] = useState(""); - const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isSwitching, setIsSwitching] = useState(false); const [error, setError] = useState(null); @@ -138,11 +135,16 @@ export function BranchSelector({ workspaceId, workspaceName, className }: Branch .split("\n") .map((b) => b.trim()) .filter((b) => b.length > 0); - const truncated = branches.length > MAX_REMOTE_BRANCHES; + + const branchNames = branches + .map((b) => b.replace(`${remote}/`, "")) + .filter((b) => b.length > 0 && b !== "HEAD"); + + const truncated = branchNames.length > MAX_REMOTE_BRANCHES; setRemoteStates((prev) => ({ ...prev, [remote]: { - branches: truncated ? branches.slice(0, MAX_REMOTE_BRANCHES) : branches, + branches: truncated ? branchNames.slice(0, MAX_REMOTE_BRANCHES) : branchNames, isLoading: false, fetched: true, truncated, @@ -171,13 +173,11 @@ export function BranchSelector({ workspaceId, workspaceName, className }: Branch const checkoutTarget = isRemote ? targetBranch.replace(/^[^/]+\//, "") : targetBranch; if (checkoutTarget === currentBranch) { - setIsOpen(false); return; } setIsSwitching(true); setError(null); - setIsOpen(false); // Invalidate git status immediately to prevent stale data flash invalidateGitStatus(workspaceId); @@ -219,31 +219,6 @@ export function BranchSelector({ workspaceId, workspaceName, className }: Branch } }, [error]); - useEffect(() => { - if (isOpen) { - void fetchLocalBranches(); - } - }, [isOpen, fetchLocalBranches]); - - useEffect(() => { - if (!isOpen) { - setRemoteStates({}); - setExpandedRemotes(new Set()); - setSearch(""); - } - }, [isOpen]); - - const inputRef = useRef(null); - - // Focus search input when popover opens - useEffect(() => { - if (isOpen) { - // Small delay to ensure popover is rendered - const timer = setTimeout(() => inputRef.current?.focus(), 50); - return () => clearTimeout(timer); - } - }, [isOpen]); - const handleCopy = (e: React.MouseEvent) => { e.stopPropagation(); if (typeof currentBranch === "string") { @@ -254,36 +229,15 @@ export function BranchSelector({ workspaceId, workspaceName, className }: Branch // Display name: actual git branch if available, otherwise workspace name const displayName = typeof currentBranch === "string" ? currentBranch : workspaceName; - const toggleRemote = (remote: string) => { - setExpandedRemotes((prev) => { - const next = new Set(prev); - if (next.has(remote)) { - next.delete(remote); - } else { - next.add(remote); - // Fetch branches when expanding - void fetchRemoteBranches(remote); - } - return next; - }); - }; - - // Filter branches by search - const searchLower = search.toLowerCase(); - const filteredLocalBranches = localBranches.filter((b) => b.toLowerCase().includes(searchLower)); - - // For remotes, filter branches within each remote - const getFilteredRemoteBranches = (remote: string) => { + const remoteGroups = remotes.map((remote) => { const state = remoteStates[remote]; - if (!state?.branches) return []; - return state.branches.filter((b) => b.toLowerCase().includes(searchLower)); - }; - - // Check if any remote has matching branches (for showing remotes section) - const hasMatchingRemoteBranches = remotes.some((remote) => { - const state = remoteStates[remote]; - if (!state?.fetched) return true; // Show unfetched remotes - return getFilteredRemoteBranches(remote).length > 0; + return { + remote, + branches: state?.branches ?? [], + isLoading: state?.isLoading ?? false, + fetched: state?.fetched ?? false, + truncated: state?.truncated ?? false, + }; }); // Non-git repo: just show workspace name, no interactive features @@ -311,9 +265,10 @@ export function BranchSelector({ workspaceId, workspaceName, className }: Branch return (
- - + )} - - - {/* Search input */} -
- setSearch(e.target.value)} - placeholder="Search branches..." - className="text-foreground placeholder:text-muted w-full bg-transparent font-mono text-[11px] outline-none" - /> -
- -
- {/* Remotes as expandable groups */} - {remotes.length > 0 && hasMatchingRemoteBranches && ( - <> - {remotes.map((remote) => { - const state = remoteStates[remote]; - const isExpanded = expandedRemotes.has(remote); - const isRemoteLoading = state?.isLoading ?? false; - const remoteBranches = getFilteredRemoteBranches(remote); - - // Hide remote if fetched and no matching branches - if (state?.fetched && remoteBranches.length === 0 && search) { - return null; - } - - return ( -
- - - {isExpanded && ( -
- {isRemoteLoading ? ( -
- -
- ) : remoteBranches.length === 0 ? ( -
No branches
- ) : ( - <> - {remoteBranches.map((branch) => { - const displayName = branch.replace(`${remote}/`, ""); - return ( - - ); - })} - {state?.truncated && !search && ( -
- +more branches (use search) -
- )} - - )} -
- )} -
- ); - })} - - {filteredLocalBranches.length > 0 &&
} - - )} - - {/* Local branches */} - {isLoading && localBranches.length <= 1 ? ( -
- -
- ) : filteredLocalBranches.length === 0 ? ( -
No matching branches
- ) : ( - <> - {filteredLocalBranches.map((branch) => ( - - ))} - {localBranchesTruncated && !search && ( -
- +more branches (use search) -
- )} - - )} -
- - + } + isLoading={isLoading} + localBranches={localBranches} + localBranchesTruncated={localBranchesTruncated} + remotes={remoteGroups} + selection={{ kind: "local", branch: currentBranch }} + onOpen={fetchLocalBranches} + onClose={() => { + setRemoteStates({}); + }} + onExpandRemote={(remote) => fetchRemoteBranches(remote)} + onSelectLocalBranch={(branch) => switchBranch(branch)} + onSelectRemoteBranch={(_remote, branch) => switchBranch(branch, true)} + /> {/* Copy button - only show on hover */} diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index e882920a20..a915c71f2d 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,11 +1,14 @@ import React, { useCallback, useEffect } from "react"; import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; +import { BranchPickerPopover } from "../BranchPickerPopover"; import { Select } from "../Select"; 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 { ExistingBranchSelection } from "@/common/types/branchSelection"; +import type { BranchListResult } from "@/common/orpc/types"; import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; export type BranchMode = "new" | "existing"; @@ -14,6 +17,8 @@ interface CreationControlsProps { branches: string[]; /** Remote-only branches (not in local branches) */ remoteBranches: string[]; + /** Remote-only branches grouped by remote name (e.g. origin/upstream) */ + remoteBranchGroups: BranchListResult["remoteBranchGroups"]; /** Whether branches have finished loading (to distinguish loading vs non-git repo) */ branchesLoaded: boolean; trunkBranch: string; @@ -33,8 +38,8 @@ interface CreationControlsProps { branchMode: BranchMode; onBranchModeChange: (mode: BranchMode) => void; /** Selected existing branch (when branchMode is "existing") */ - selectedExistingBranch: string; - onSelectedExistingBranchChange: (branch: string) => void; + selectedExistingBranch: ExistingBranchSelection | null; + onSelectedExistingBranchChange: (selection: ExistingBranchSelection | null) => void; } /** Runtime type button group with icons and colors */ @@ -176,9 +181,15 @@ 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 remoteGroups = + props.remoteBranchGroups.length > 0 + ? props.remoteBranchGroups + : props.remoteBranches.length > 0 + ? [{ remote: "origin", branches: props.remoteBranches, truncated: false }] + : []; + + const hasExistingBranches = + props.branches.length > 0 || remoteGroups.some((group) => group.branches.length > 0); const handleNameChange = useCallback( (e: React.ChangeEvent) => { @@ -307,13 +318,44 @@ export function CreationControls(props: CreationControlsProps) { {/* Existing branch mode: Branch selector */} {props.branchMode === "existing" && ( -