diff --git a/src/browser/App.tsx b/src/browser/App.tsx index d85e0d71f..5172a952d 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -38,6 +38,7 @@ import { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/stor import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import type { BranchListResult } from "@/common/orpc/types"; +import type { ExistingBranchSelection } from "@/common/types/branchSelection"; import { useTelemetry } from "./hooks/useTelemetry"; import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; @@ -324,16 +325,43 @@ function AppInner() { [startWorkspaceCreation] ); + const openBranchAsWorkspaceFromPalette = useCallback( + (projectPath: string, selection: ExistingBranchSelection) => { + startWorkspaceCreation(projectPath, { projectPath, existingBranch: selection }); + }, + [startWorkspaceCreation] + ); + const getBranchesForProject = useCallback( async (projectPath: string): Promise => { if (!api) { - return { branches: [], recommendedTrunk: null }; + return { branches: [], remoteBranches: [], remoteBranchGroups: [], 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 sanitizedRemoteBranchGroups = Array.isArray(branchResult.remoteBranchGroups) + ? branchResult.remoteBranchGroups + .filter( + (group): group is { remote: string; branches: string[]; truncated: boolean } => + typeof group?.remote === "string" && + Array.isArray(group.branches) && + typeof group.truncated === "boolean" + ) + .map((group) => ({ + remote: group.remote, + branches: group.branches.filter((b): b is string => typeof b === "string"), + truncated: group.truncated, + })) + .filter((group) => group.remote.length > 0 && group.branches.length > 0) + : []; + const recommended = branchResult.recommendedTrunk && sanitizedBranches.includes(branchResult.recommendedTrunk) ? branchResult.recommendedTrunk @@ -341,6 +369,8 @@ function AppInner() { return { branches: sanitizedBranches, + remoteBranches: sanitizedRemoteBranches, + remoteBranchGroups: sanitizedRemoteBranchGroups, recommendedTrunk: recommended, }; }, @@ -394,6 +424,7 @@ function AppInner() { getThinkingLevel: getThinkingLevelForWorkspace, onSetThinkingLevel: setThinkingLevelFromPalette, onStartWorkspaceCreation: openNewWorkspaceFromPalette, + onStartWorkspaceCreationWithBranch: openBranchAsWorkspaceFromPalette, getBranchesForProject, onSelectWorkspace: selectWorkspaceFromPalette, onRemoveWorkspace: removeWorkspaceFromPalette, diff --git a/src/browser/components/BranchPickerPopover.tsx b/src/browser/components/BranchPickerPopover.tsx new file mode 100644 index 000000000..35f897458 --- /dev/null +++ b/src/browser/components/BranchPickerPopover.tsx @@ -0,0 +1,255 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Check, ChevronRight, Globe, Loader2 } from "lucide-react"; +import { cn } from "@/common/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; + +export type BranchPickerSelection = + | { kind: "local"; branch: string } + | { kind: "remote"; remote: string; branch: string }; + +export interface BranchPickerRemoteGroup { + remote: string; + branches: string[]; + isLoading?: boolean; + fetched?: boolean; + truncated?: boolean; +} + +interface BranchPickerPopoverProps { + trigger: React.ReactNode; + disabled?: boolean; + + isLoading?: boolean; + localBranches: string[]; + localBranchesTruncated?: boolean; + + remotes: BranchPickerRemoteGroup[]; + + selection: BranchPickerSelection | null; + + onOpen?: () => void | Promise; + onClose?: () => void; + onSelectLocalBranch: (branch: string) => void | Promise; + onSelectRemoteBranch: (remote: string, branch: string) => void | Promise; + onExpandRemote?: (remote: string) => void | Promise; +} + +export function BranchPickerPopover(props: BranchPickerPopoverProps) { + const { onClose, onExpandRemote, onOpen, onSelectLocalBranch, onSelectRemoteBranch } = props; + + const inputRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const [expandedRemotes, setExpandedRemotes] = useState>(new Set()); + + useEffect(() => { + if (!isOpen) return; + void onOpen?.(); + }, [isOpen, onOpen]); + + useEffect(() => { + if (!isOpen) { + onClose?.(); + setSearch(""); + setExpandedRemotes(new Set()); + return; + } + + const timer = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(timer); + }, [isOpen, onClose]); + + const searchLower = search.toLowerCase(); + + const filteredLocalBranches = useMemo( + () => props.localBranches.filter((b) => b.toLowerCase().includes(searchLower)), + [props.localBranches, searchLower] + ); + + const remoteGroups = useMemo(() => { + return props.remotes.map((remote) => ({ + remote: remote.remote, + branches: remote.branches, + isLoading: remote.isLoading ?? false, + fetched: remote.fetched ?? true, + truncated: remote.truncated ?? false, + })); + }, [props.remotes]); + + const getFilteredRemoteBranches = (remote: string) => { + const group = remoteGroups.find((g) => g.remote === remote); + if (!group) return []; + return group.branches.filter((b) => b.toLowerCase().includes(searchLower)); + }; + + const hasMatchingRemoteBranches = remoteGroups.some((group) => { + if (!group.fetched) return true; + return getFilteredRemoteBranches(group.remote).length > 0; + }); + + const toggleRemote = (remote: string) => { + setExpandedRemotes((prev) => { + const next = new Set(prev); + if (next.has(remote)) { + next.delete(remote); + } else { + next.add(remote); + void onExpandRemote?.(remote); + } + return next; + }); + }; + + const handleSelectLocalBranch = (branch: string) => { + setIsOpen(false); + void onSelectLocalBranch(branch); + }; + + const handleSelectRemoteBranch = (remote: string, branch: string) => { + setIsOpen(false); + void onSelectRemoteBranch(remote, branch); + }; + + const isRemoteBranchSelected = (remote: string, branch: string) => { + const selection = props.selection; + if (!selection) return false; + + if (selection.kind === "remote") { + return selection.remote === remote && selection.branch === branch; + } + + // Useful for workspaces where selection is the local branch, but we still want the + // remote list to indicate which remote branch corresponds to the current branch. + return selection.branch === branch; + }; + + return ( + + {props.trigger} + + {/* 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 */} + {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 8e1fded0b..151dacb3e 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 fc2f482d1..a915c71f2 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,15 +1,24 @@ 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 } 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 { ExistingBranchSelection } from "@/common/types/branchSelection"; +import type { BranchListResult } from "@/common/orpc/types"; 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[]; + /** 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; @@ -25,6 +34,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: ExistingBranchSelection | null; + onSelectedExistingBranchChange: (selection: ExistingBranchSelection | null) => void; } /** Runtime type button group with icons and colors */ @@ -166,6 +181,16 @@ export function CreationControls(props: CreationControlsProps) { } }, [isNonGitRepo, runtimeMode, onRuntimeModeChange]); + 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) => { nameState.setName(e.target.value); @@ -187,75 +212,157 @@ 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"} - - - - + {/* 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" && ( + - - - 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"} - - - )} -
-
+ > + + + {props.selectedExistingBranch + ? props.selectedExistingBranch.branch + : "Select branch"} + + {props.selectedExistingBranch?.kind === "remote" && ( + + @{props.selectedExistingBranch.remote} + + )} + + } + isLoading={!props.branchesLoaded} + localBranches={props.branches} + remotes={remoteGroups} + selection={props.selectedExistingBranch} + onSelectLocalBranch={(branch) => + props.onSelectedExistingBranchChange({ kind: "local", branch }) + } + onSelectRemoteBranch={(remote, branch) => + props.onSelectedExistingBranchChange({ kind: "remote", remote, branch }) + } + /> + )} {/* Error display */} - {nameState.error && {nameState.error}} + {nameState.error && props.branchMode === "new" && ( + {nameState.error} + )}
{/* Runtime type - button group */} diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 5f356f25c..78ada91a0 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1415,6 +1415,8 @@ const ChatInputInner: React.FC = (props) => { {variant === "creation" && ( = (props) => { disabled={isSendInFlight} projectName={props.projectName} nameState={creationState.nameState} + branchMode={creationState.branchMode} + onBranchModeChange={creationState.setBranchMode} + selectedExistingBranch={creationState.selectedExistingBranch} + onSelectedExistingBranchChange={creationState.setSelectedExistingBranch} /> )} diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index 43f22cf6e..0f255fae0 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -133,6 +133,8 @@ const setupWindow = ({ } return Promise.resolve({ branches: [FALLBACK_BRANCH], + remoteBranches: [], + remoteBranchGroups: [], recommendedTrunk: FALLBACK_BRANCH, }); }); @@ -316,6 +318,8 @@ describe("useCreationWorkspace", () => { (): Promise => Promise.resolve({ branches: ["main", "dev"], + remoteBranches: [], + remoteBranchGroups: [], recommendedTrunk: "dev", }) ); @@ -350,6 +354,8 @@ describe("useCreationWorkspace", () => { (): Promise => Promise.resolve({ branches: ["main"], + remoteBranches: [], + remoteBranchGroups: [], recommendedTrunk: "main", }) ); @@ -371,6 +377,8 @@ describe("useCreationWorkspace", () => { (): Promise => Promise.resolve({ branches: ["main"], + remoteBranches: [], + remoteBranchGroups: [], recommendedTrunk: "main", }) ); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 1fb685146..732a5bf3d 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -4,6 +4,10 @@ import type { RuntimeConfig, RuntimeMode } from "@/common/types/runtime"; import type { UIMode } from "@/common/types/mode"; import { parseRuntimeString } from "@/browser/utils/chatCommands"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; +import { + parseExistingBranchSelection, + type ExistingBranchSelection, +} from "@/common/types/branchSelection"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { @@ -12,15 +16,17 @@ import { getModeKey, getPendingScopeId, getProjectScopeId, + getPrefilledExistingBranchKey, } from "@/common/constants/storage"; import type { Toast } from "@/browser/components/ChatInputToast"; import { useAPI } from "@/browser/contexts/API"; -import type { ImagePart } from "@/common/orpc/types"; +import type { BranchListResult, ImagePart } from "@/common/orpc/types"; import { useWorkspaceName, type WorkspaceNameState, type WorkspaceIdentity, } from "@/browser/hooks/useWorkspaceName"; +import type { BranchMode } from "./CreationControls"; interface UseCreationWorkspaceOptions { projectPath: string; @@ -50,6 +56,10 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void interface UseCreationWorkspaceReturn { 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 listBranches has completed (to distinguish loading vs non-git repo) */ branchesLoaded: boolean; trunkBranch: string; @@ -71,6 +81,12 @@ interface UseCreationWorkspaceReturn { nameState: WorkspaceNameState; /** The confirmed identity being used for creation (null until generation resolves) */ creatingWithIdentity: WorkspaceIdentity | null; + /** Branch mode: "new" creates a new branch, "existing" uses an existing branch */ + branchMode: BranchMode; + setBranchMode: (mode: BranchMode) => void; + /** Selected existing branch (when branchMode is "existing") */ + selectedExistingBranch: ExistingBranchSelection | null; + setSelectedExistingBranch: (selection: ExistingBranchSelection | null) => void; } /** @@ -88,12 +104,21 @@ export function useCreationWorkspace({ }: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn { const { api } = useAPI(); const [branches, setBranches] = useState([]); + const [remoteBranches, setRemoteBranches] = useState([]); + const [remoteBranchGroups, setRemoteBranchGroups] = useState< + BranchListResult["remoteBranchGroups"] + >([]); const [branchesLoaded, setBranchesLoaded] = useState(false); const [recommendedTrunk, setRecommendedTrunk] = useState(null); const [toast, setToast] = useState(null); const [isSending, setIsSending] = useState(false); // The confirmed identity being used for workspace creation (set after waitForGeneration resolves) const [creatingWithIdentity, setCreatingWithIdentity] = useState(null); + // Branch mode: "new" creates a new branch, "existing" uses an existing branch + const [branchMode, setBranchMode] = useState("new"); + // Selected existing branch (when branchMode is "existing") + const [selectedExistingBranch, setSelectedExistingBranch] = + useState(null); // Centralized draft workspace settings with automatic persistence const { @@ -121,6 +146,25 @@ export function useCreationWorkspace({ // Destructure name state functions for use in callbacks const { waitForGeneration } = workspaceNameState; + // Check for prefilled existing branch on mount (from command palette "Open Branch as Workspace") + useEffect(() => { + if (!projectPath.length) return; + + const storedSelection = readPersistedState( + getPrefilledExistingBranchKey(projectPath), + null + ); + + const selection = parseExistingBranchSelection(storedSelection); + if (selection) { + // Set existing branch mode and select the branch + setBranchMode("existing"); + setSelectedExistingBranch(selection); + // Clear the prefill so it doesn't persist + updatePersistedState(getPrefilledExistingBranchKey(projectPath), undefined); + } + }, [projectPath]); + // Load branches on mount useEffect(() => { // This can be created with an empty project path when the user is @@ -133,6 +177,8 @@ export function useCreationWorkspace({ try { const result = await api.projects.listBranches({ projectPath }); setBranches(result.branches); + setRemoteBranches(result.remoteBranches); + setRemoteBranchGroups(result.remoteBranchGroups); setRecommendedTrunk(result.recommendedTrunk); } catch (err) { console.error("Failed to load branches:", err); @@ -152,16 +198,43 @@ export function useCreationWorkspace({ setCreatingWithIdentity(null); try { - // Wait for identity generation to complete (blocks if still in progress) - // Returns null if generation failed or manual name is empty (error already set in hook) - const identity = await waitForGeneration(); - if (!identity) { - setIsSending(false); - return false; - } + // Determine branch name and title based on mode + let branchName: string; + let title: string | undefined; + let startPointRef: string | undefined; - // Set the confirmed identity for splash UI display - setCreatingWithIdentity(identity); + if (branchMode === "existing") { + // Existing branch mode: use selected branch, no title (use branch name) + if (!selectedExistingBranch) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Please select an existing branch", + }); + setIsSending(false); + return false; + } + + branchName = selectedExistingBranch.branch; + startPointRef = + selectedExistingBranch.kind === "remote" + ? `${selectedExistingBranch.remote}/${selectedExistingBranch.branch}` + : undefined; + + title = undefined; // Will use branch name as title + // Set identity for UI display + setCreatingWithIdentity({ name: branchName, title: branchName }); + } else { + // New branch mode: use generated/manual name + const identity = await waitForGeneration(); + if (!identity) { + setIsSending(false); + return false; + } + branchName = identity.name; + title = identity.title; + setCreatingWithIdentity(identity); + } // Get runtime config from options const runtimeString = getRuntimeString(); @@ -174,12 +247,13 @@ export function useCreationWorkspace({ // in usePersistedState can delay state updates after model selection) const sendMessageOptions = getSendOptionsFromStorage(projectScopeId); - // Create the workspace with the generated name and title + // Create the workspace with the branch name and title const createResult = await api.workspace.create({ projectPath, - branchName: identity.name, + branchName, trunkBranch: settings.trunkBranch, - title: identity.title, + startPointRef, + title, runtimeConfig, }); @@ -232,11 +306,13 @@ export function useCreationWorkspace({ }, [ api, + branchMode, isSending, projectPath, projectScopeId, onWorkspaceCreated, getRuntimeString, + selectedExistingBranch, settings.trunkBranch, waitForGeneration, ] @@ -244,6 +320,8 @@ export function useCreationWorkspace({ return { branches, + remoteBranches, + remoteBranchGroups, branchesLoaded, trunkBranch: settings.trunkBranch, setTrunkBranch, @@ -261,5 +339,10 @@ export function useCreationWorkspace({ nameState: workspaceNameState, // The confirmed identity being used for creation (null until generation resolves) creatingWithIdentity, + // Branch mode state + branchMode, + setBranchMode, + selectedExistingBranch, + setSelectedExistingBranch, }; } diff --git a/src/browser/contexts/ProjectContext.test.tsx b/src/browser/contexts/ProjectContext.test.tsx index 518673262..e3086555e 100644 --- a/src/browser/contexts/ProjectContext.test.tsx +++ b/src/browser/contexts/ProjectContext.test.tsx @@ -6,6 +6,7 @@ import type { ProjectContext } from "./ProjectContext"; import { ProjectProvider, useProjectContext } from "./ProjectContext"; import type { RecursivePartial } from "@/browser/testUtils"; +import type { BranchListResult } from "@/common/orpc/types"; import type { APIClient } from "@/browser/contexts/API"; // Mock API @@ -40,7 +41,13 @@ describe("ProjectContext", () => { const projectsApi = createMockAPI({ list: () => Promise.resolve(initialProjects), remove: () => Promise.resolve({ success: true as const, data: undefined }), - listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), + listBranches: () => + Promise.resolve({ + branches: ["main"], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main", + }), secrets: { get: () => Promise.resolve([{ key: "A", value: "1" }]), update: () => Promise.resolve({ success: true as const, data: undefined }), @@ -73,7 +80,13 @@ describe("ProjectContext", () => { createMockAPI({ list: () => Promise.resolve([]), remove: () => Promise.resolve({ success: true as const, data: undefined }), - listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), + listBranches: () => + Promise.resolve({ + branches: ["main"], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main", + }), secrets: { get: () => Promise.resolve([]), update: () => Promise.resolve({ success: true as const, data: undefined }), @@ -101,7 +114,13 @@ describe("ProjectContext", () => { createMockAPI({ list: () => Promise.resolve([]), remove: () => Promise.resolve({ success: true as const, data: undefined }), - listBranches: () => Promise.resolve({ branches: ["main", "feat"], recommendedTrunk: "main" }), + listBranches: () => + Promise.resolve({ + branches: ["main", "feat"], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main", + }), secrets: { get: () => Promise.resolve([]), update: () => Promise.resolve({ success: true as const, data: undefined }), @@ -158,7 +177,13 @@ describe("ProjectContext", () => { const projectsApi = createMockAPI({ list: () => Promise.resolve([]), remove: () => Promise.resolve({ success: true as const, data: undefined }), - listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), + listBranches: () => + Promise.resolve({ + branches: ["main"], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main", + }), secrets: { get: () => Promise.resolve([{ key: "A", value: "1" }]), update: () => Promise.resolve({ success: true as const, data: undefined }), @@ -182,7 +207,13 @@ describe("ProjectContext", () => { const projectsApi = createMockAPI({ list: () => Promise.resolve([]), remove: () => Promise.resolve({ success: true as const, data: undefined }), - listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), + listBranches: () => + Promise.resolve({ + branches: ["main"], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main", + }), secrets: { get: () => Promise.resolve([]), update: () => Promise.resolve({ success: false, error: "something went wrong" }), @@ -203,7 +234,13 @@ describe("ProjectContext", () => { createMockAPI({ list: () => Promise.reject(new Error("network failure")), remove: () => Promise.resolve({ success: true as const, data: undefined }), - listBranches: () => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" }), + listBranches: () => + Promise.resolve({ + branches: ["main"], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main", + }), secrets: { get: () => Promise.resolve([]), update: () => Promise.resolve({ success: true as const, data: undefined }), @@ -225,6 +262,8 @@ describe("ProjectContext", () => { listBranches: () => Promise.resolve({ branches: ["main", 123, null, "dev", undefined, { name: "feat" }] as unknown as string[], + remoteBranches: [], + remoteBranchGroups: [], recommendedTrunk: "main", }), secrets: { @@ -248,6 +287,8 @@ describe("ProjectContext", () => { listBranches: () => Promise.resolve({ branches: null as unknown as string[], + remoteBranches: [], + remoteBranchGroups: [], recommendedTrunk: "main", }), secrets: { @@ -260,7 +301,7 @@ describe("ProjectContext", () => { const result = await ctx().getBranchesForProject("/project"); expect(result.branches).toEqual([]); - expect(result.recommendedTrunk).toBe(""); + expect(result.recommendedTrunk).toBe(null); }); test("getBranchesForProject falls back when recommendedTrunk not in branches", async () => { @@ -270,6 +311,8 @@ describe("ProjectContext", () => { listBranches: () => Promise.resolve({ branches: ["main", "dev"], + remoteBranches: [], + remoteBranchGroups: [], recommendedTrunk: "nonexistent", }), secrets: { @@ -287,14 +330,10 @@ describe("ProjectContext", () => { }); test("openWorkspaceModal cancels stale requests (race condition)", async () => { - let projectAResolver: - | ((value: { branches: string[]; recommendedTrunk: string }) => void) - | null = null; - const projectAPromise = new Promise<{ branches: string[]; recommendedTrunk: string }>( - (resolve) => { - projectAResolver = resolve; - } - ); + let projectAResolver: ((value: BranchListResult) => void) | null = null; + const projectAPromise = new Promise((resolve) => { + projectAResolver = resolve; + }); createMockAPI({ list: () => Promise.resolve([]), @@ -303,7 +342,12 @@ describe("ProjectContext", () => { if (projectPath === "/project-a") { return projectAPromise; } - return Promise.resolve({ branches: ["main-b"], recommendedTrunk: "main-b" }); + return Promise.resolve({ + branches: ["main-b"], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main-b", + }); }, secrets: { get: () => Promise.resolve([]), @@ -321,7 +365,12 @@ describe("ProjectContext", () => { await ctx().openWorkspaceModal("/project-b"); // Now resolve project A - projectAResolver!({ branches: ["main-a"], recommendedTrunk: "main-a" }); + projectAResolver!({ + branches: ["main-a"], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main-a", + }); await openA; }); @@ -360,7 +409,14 @@ function createMockAPI(overrides: RecursivePartial) { ), list: mock(overrides.list ?? (() => Promise.resolve([]))), listBranches: mock( - overrides.listBranches ?? (() => Promise.resolve({ branches: [], recommendedTrunk: "main" })) + overrides.listBranches ?? + (() => + Promise.resolve({ + branches: [], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main", + })) ), remove: mock( overrides.remove ?? diff --git a/src/browser/contexts/ProjectContext.tsx b/src/browser/contexts/ProjectContext.tsx index ee116c086..cd2ae7b08 100644 --- a/src/browser/contexts/ProjectContext.tsx +++ b/src/browser/contexts/ProjectContext.tsx @@ -127,7 +127,7 @@ export function ProjectProvider(props: { children: ReactNode }) { const getBranchesForProject = useCallback( async (projectPath: string): Promise => { if (!api) { - return { branches: [], recommendedTrunk: "" }; + return { branches: [], remoteBranches: [], remoteBranchGroups: [], recommendedTrunk: null }; } const branchResult = await api.projects.listBranches({ projectPath }); const branches = branchResult.branches; @@ -135,14 +135,38 @@ export function ProjectProvider(props: { children: ReactNode }) { ? branches.filter((branch): branch is string => typeof branch === "string") : []; + const sanitizedRemoteBranches = Array.isArray(branchResult.remoteBranches) + ? branchResult.remoteBranches.filter( + (branch): branch is string => typeof branch === "string" + ) + : []; + + const sanitizedRemoteBranchGroups = Array.isArray(branchResult.remoteBranchGroups) + ? branchResult.remoteBranchGroups + .filter( + (group): group is { remote: string; branches: string[]; truncated: boolean } => + typeof group?.remote === "string" && + Array.isArray(group.branches) && + typeof group.truncated === "boolean" + ) + .map((group) => ({ + remote: group.remote, + branches: group.branches.filter((b): b is string => typeof b === "string"), + truncated: group.truncated, + })) + .filter((group) => group.remote.length > 0 && group.branches.length > 0) + : []; + const recommended = typeof branchResult?.recommendedTrunk === "string" && sanitizedBranches.includes(branchResult.recommendedTrunk) ? branchResult.recommendedTrunk - : (sanitizedBranches[0] ?? ""); + : (sanitizedBranches[0] ?? null); return { branches: sanitizedBranches, + remoteBranches: sanitizedRemoteBranches, + remoteBranchGroups: sanitizedRemoteBranchGroups, recommendedTrunk: recommended, }; }, diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index b4c95a702..0078755d9 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -642,7 +642,14 @@ function createMockAPI(options: MockAPIOptions = {}) { const projects = { list: mock(options.projects?.list ?? (() => Promise.resolve([]))), - listBranches: mock(() => Promise.resolve({ branches: ["main"], recommendedTrunk: "main" })), + listBranches: mock(() => + Promise.resolve({ + branches: ["main"], + remoteBranches: [], + remoteBranchGroups: [], + recommendedTrunk: "main", + }) + ), secrets: { get: mock(() => Promise.resolve([])), }, diff --git a/src/browser/hooks/useStartWorkspaceCreation.ts b/src/browser/hooks/useStartWorkspaceCreation.ts index e5b7bda37..a3c3271a3 100644 --- a/src/browser/hooks/useStartWorkspaceCreation.ts +++ b/src/browser/hooks/useStartWorkspaceCreation.ts @@ -3,12 +3,14 @@ import type { ProjectConfig } from "@/node/config"; import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar"; import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { parseExistingBranchSelection } from "@/common/types/branchSelection"; import { getInputKey, getModelKey, getPendingScopeId, getProjectScopeId, getTrunkBranchKey, + getPrefilledExistingBranchKey, } from "@/common/constants/storage"; export type StartWorkspaceCreationDetail = @@ -46,6 +48,11 @@ export function persistWorkspaceCreationPrefill( ); } + if (detail.existingBranch !== undefined) { + const selection = parseExistingBranchSelection(detail.existingBranch); + persist(getPrefilledExistingBranchKey(projectPath), selection ?? undefined); + } + // Note: runtime is intentionally NOT persisted here - it's a one-time override. // The default runtime can only be changed via the icon selector. } diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index a9183704c..dbc370433 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -28,6 +28,7 @@ export const CommandIds = { workspaceRenameAny: () => "ws:rename-any" as const, workspaceOpenTerminal: () => "ws:open-terminal" as const, workspaceOpenTerminalCurrent: () => "ws:open-terminal-current" as const, + workspaceOpenBranch: () => "ws:open-branch" as const, // Navigation commands navNext: () => "nav:next" as const, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 78a5411f5..46ebfbc4a 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -41,6 +41,7 @@ const mk = (over: Partial[0]> = {}) => { getThinkingLevel: () => "off", onSetThinkingLevel: () => undefined, onStartWorkspaceCreation: () => undefined, + onStartWorkspaceCreationWithBranch: (_projectPath: string, _selection: unknown) => undefined, onSelectWorkspace: () => undefined, onRemoveWorkspace: () => Promise.resolve({ success: true }), onRenameWorkspace: () => Promise.resolve({ success: true }), @@ -60,6 +61,8 @@ const mk = (over: Partial[0]> = {}) => { getBranchesForProject: () => Promise.resolve({ branches: ["main"], + remoteBranches: [], + remoteBranchGroups: [], recommendedTrunk: "main", }), ...over, diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 3c78aa176..a4a1a2064 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -8,6 +8,7 @@ import { CommandIds } from "@/browser/utils/commandIds"; import type { ProjectConfig } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { ExistingBranchSelection } from "@/common/types/branchSelection"; import type { BranchListResult } from "@/common/orpc/types"; import type { WorkspaceState } from "@/browser/stores/WorkspaceStore"; import type { RuntimeConfig } from "@/common/types/runtime"; @@ -31,6 +32,11 @@ export interface BuildSourcesParams { onSetThinkingLevel: (workspaceId: string, level: ThinkingLevel) => void; onStartWorkspaceCreation: (projectPath: string) => void; + /** Start workspace creation flow with an existing branch pre-selected */ + onStartWorkspaceCreationWithBranch: ( + projectPath: string, + selection: ExistingBranchSelection + ) => void; getBranchesForProject: (projectPath: string) => Promise; onSelectWorkspace: (sel: { projectPath: string; @@ -107,6 +113,75 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const selected = p.selectedWorkspace; if (selected) { list.push(createWorkspaceForSelectedProjectAction(selected)); + + // Open Branch as Workspace - opens creation flow with existing branch pre-selected + list.push({ + id: CommandIds.workspaceOpenBranch(), + title: "Open Branch as Workspace…", + subtitle: `for ${selected.projectName}`, + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Open Branch as Workspace", + fields: [ + { + type: "select", + name: "branch", + label: "Branch", + placeholder: "Search branches…", + getOptions: async () => { + const result = await p.getBranchesForProject(selected.projectPath); + + const localOptions = result.branches.map((branch) => ({ + id: `local:${branch}`, + label: branch, + keywords: [branch], + })); + + const groups = + result.remoteBranchGroups.length > 0 + ? result.remoteBranchGroups + : result.remoteBranches.length > 0 + ? [{ remote: "origin", branches: result.remoteBranches, truncated: false }] + : []; + + const remoteOptions = groups.flatMap((group) => + group.branches.map((branch) => ({ + id: `remote:${group.remote}:${branch}`, + label: `${group.remote}/${branch}`, + keywords: [branch, group.remote, "remote"], + })) + ); + + return [...localOptions, ...remoteOptions]; + }, + }, + ], + onSubmit: (vals) => { + const raw = vals.branch; + + let selection: ExistingBranchSelection; + if (raw.startsWith("remote:")) { + const rest = raw.slice("remote:".length); + const firstColon = rest.indexOf(":"); + console.assert( + firstColon > 0, + "Expected remote branch id to include remote and branch" + ); + const remote = rest.slice(0, Math.max(0, firstColon)); + const branch = rest.slice(Math.max(0, firstColon + 1)); + selection = { kind: "remote", remote, branch }; + } else if (raw.startsWith("local:")) { + selection = { kind: "local", branch: raw.slice("local:".length) }; + } else { + // Back-compat: older prompt ids were raw branch names. + selection = { kind: "local", branch: raw }; + } + + p.onStartWorkspaceCreationWithBranch(selected.projectPath, selection); + }, + }, + }); } // Switch to workspace diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index a146e2f8c..76f6e4517 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -6,6 +6,7 @@ */ import type { ThinkingLevel } from "@/common/types/thinking"; +import type { ExistingBranchSelection } from "@/common/types/branchSelection"; import type { ImagePart } from "@/common/orpc/schemas"; export const CUSTOM_EVENTS = { @@ -99,6 +100,8 @@ export interface CustomEventPayloads { model?: string; trunkBranch?: string; runtime?: string; + /** Pre-select an existing branch (switches to "existing branch" mode) */ + existingBranch?: ExistingBranchSelection | string; }; [CUSTOM_EVENTS.TOGGLE_VOICE_INPUT]: never; // No payload } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 08eb2e968..35904c39b 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -128,6 +128,15 @@ export function getTrunkBranchKey(projectPath: string): string { return `trunkBranch:${projectPath}`; } +/** + * Get the localStorage key for a pre-filled existing branch during workspace creation. + * This is a one-time prefill that should be cleared after reading. + * Format: "prefilledExistingBranch:{projectPath}" + */ +export function getPrefilledExistingBranchKey(projectPath: string): string { + return `prefilledExistingBranch:${projectPath}`; +} + /** * Get the localStorage key for last SSH host preference for a project * Stores the last entered SSH host separately from runtime mode diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 8de78fbe3..f996fd2a4 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -194,6 +194,8 @@ export const workspace = { trunkBranch: z.string().optional(), /** Human-readable title (e.g., "Fix plan mode over SSH") - optional for backwards compat */ title: z.string().optional(), + /** Optional git ref to base the branch on (e.g. "origin/foo"). */ + startPointRef: z.string().optional(), runtimeConfig: RuntimeConfigSchema.optional(), }), output: z.discriminatedUnion("success", [ diff --git a/src/common/orpc/schemas/message.ts b/src/common/orpc/schemas/message.ts index 2f9689409..85e319d4f 100644 --- a/src/common/orpc/schemas/message.ts +++ b/src/common/orpc/schemas/message.ts @@ -93,8 +93,22 @@ export const MuxMessageSchema = z.object({ .optional(), }); +const RemoteBranchGroupSchema = z.object({ + remote: z.string(), + branches: z.array(z.string()), + truncated: z.boolean(), +}); + export const BranchListResultSchema = z.object({ branches: z.array(z.string()), + /** + * Remote-only branches, excluding those already in local branches. + * + * @deprecated Prefer remoteBranchGroups (keeps remote name for disambiguation) + */ + remoteBranches: z.array(z.string()), + /** Remote-only branches grouped by remote name (e.g. origin/upstream) */ + remoteBranchGroups: z.array(RemoteBranchGroupSchema).optional().default([]), /** Recommended trunk branch, or null for non-git directories */ recommendedTrunk: z.string().nullable(), }); diff --git a/src/common/types/branchSelection.ts b/src/common/types/branchSelection.ts new file mode 100644 index 000000000..6f9d2d21b --- /dev/null +++ b/src/common/types/branchSelection.ts @@ -0,0 +1,35 @@ +export type ExistingBranchSelection = + | { kind: "local"; branch: string } + | { kind: "remote"; remote: string; branch: string }; + +/** + * Best-effort parsing for persisted/IPC values. + * + * Back-compat: older versions stored the branch name as a plain string. + */ +export function parseExistingBranchSelection(value: unknown): ExistingBranchSelection | null { + if (typeof value === "string") { + const branch = value.trim(); + if (branch.length === 0) return null; + return { kind: "local", branch }; + } + + if (value && typeof value === "object") { + const record = value as Record; + + const kind = record.kind; + const branch = typeof record.branch === "string" ? record.branch.trim() : ""; + + if (kind === "local") { + return branch.length > 0 ? { kind: "local", branch } : null; + } + + if (kind === "remote") { + const remote = typeof record.remote === "string" ? record.remote.trim() : ""; + if (remote.length === 0 || branch.length === 0) return null; + return { kind: "remote", remote, branch }; + } + } + + return null; +} diff --git a/src/node/git.ts b/src/node/git.ts index 73a34f47c..db9697749 100644 --- a/src/node/git.ts +++ b/src/node/git.ts @@ -58,6 +58,72 @@ export async function listLocalBranches(projectPath: string): Promise .sort((a, b) => a.localeCompare(b)); } +/** List configured git remotes (e.g. ["origin", "upstream"]). */ +export async function listRemotes(projectPath: string): Promise { + using proc = execAsync(`git -C "${projectPath}" remote`); + const { stdout } = await proc.result; + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** + * List remote branches for a specific remote without the "/" prefix. + * + * Returns at most `limit` branches (sorted by most recent commit), plus a + * `truncated` flag if more branches exist. + */ +export async function listRemoteBranchesForRemote( + projectPath: string, + remote: string, + limit: number +): Promise<{ branches: string[]; truncated: boolean }> { + const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 1; + const count = safeLimit + 1; + + using proc = execAsync( + `git -C "${projectPath}" for-each-ref --sort=-committerdate --count=${count} --format="%(refname:short)" "refs/remotes/${remote}"` + ); + const { stdout } = await proc.result; + + const refNames = stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + // Remove remote HEAD pointer + .filter((line) => line !== `${remote}/HEAD`); + + const prefix = `${remote}/`; + const branchNames = refNames + .map((ref) => (ref.startsWith(prefix) ? ref.slice(prefix.length) : ref)) + .filter((name) => name.length > 0 && name !== "HEAD"); + + const truncated = branchNames.length > safeLimit; + const branches = truncated ? branchNames.slice(0, safeLimit) : branchNames; + return { branches, truncated }; +} + +/** + * List remote branches (from origin) without the origin/ prefix. + * Returns branch names like ["feature-x", "fix-bug-123", ...] + */ +export async function listRemoteBranches(projectPath: string): Promise { + using proc = execAsync( + `git -C "${projectPath}" for-each-ref --format="%(refname:short)" refs/remotes/origin` + ); + const { stdout } = await proc.result; + return ( + stdout + .split("\n") + .map((line) => line.trim()) + // Remove "origin/" prefix and filter out HEAD + .map((line) => line.replace(/^origin\//, "")) + .filter((line) => line.length > 0 && line !== "HEAD") + .sort((a, b) => a.localeCompare(b)) + ); +} + export async function getCurrentBranch(projectPath: string): Promise { try { using proc = execAsync(`git -C "${projectPath}" rev-parse --abbrev-ref HEAD`); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 2d8462de4..57027bd73 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -284,6 +284,7 @@ export const router = (authToken?: string) => { input.projectPath, input.branchName, input.trunkBranch, + input.startPointRef, input.title, input.runtimeConfig ); diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 71754f181..509fc3413 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -161,6 +161,8 @@ export interface WorkspaceCreationParams { branchName: string; /** Trunk branch to base new branches on */ trunkBranch: string; + /** Optional git ref to base the branch on (e.g. "origin/foo"). */ + startPointRef?: string; /** Directory name to use for workspace (typically branch name) */ directoryName: string; /** Logger for streaming creation progress and init hook output */ @@ -189,6 +191,8 @@ export interface WorkspaceInitParams { branchName: string; /** Trunk branch to base new branches on */ trunkBranch: string; + /** Optional git ref to base the branch on (e.g. "origin/foo"). */ + startPointRef?: string; /** Absolute path to workspace (from createWorkspace result) */ workspacePath: string; /** Logger for streaming initialization progress and output */ diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index a889b0209..67ed9de20 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -903,7 +903,15 @@ export class SSHRuntime implements Runtime { } async initWorkspace(params: WorkspaceInitParams): Promise { - const { projectPath, branchName, trunkBranch, workspacePath, initLogger, abortSignal } = params; + const { + projectPath, + branchName, + trunkBranch, + startPointRef, + workspacePath, + initLogger, + abortSignal, + } = params; try { // 1. Sync project to remote (opportunistic rsync with scp fallback) @@ -928,7 +936,9 @@ export class SSHRuntime implements Runtime { // Try to checkout existing branch, or create new branch from trunk // Since we've created local branches for all remote refs, we can use branch names directly - const checkoutCmd = `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(trunkBranch)}`; + const checkoutCmd = startPointRef + ? `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(startPointRef)}` + : `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(trunkBranch)}`; const checkoutStream = await this.exec(checkoutCmd, { cwd: workspacePath, // Use the full workspace path for git operations diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts index 60d69c123..a068eef57 100644 --- a/src/node/runtime/WorktreeRuntime.ts +++ b/src/node/runtime/WorktreeRuntime.ts @@ -42,7 +42,7 @@ export class WorktreeRuntime extends LocalBaseRuntime { } async createWorkspace(params: WorkspaceCreationParams): Promise { - const { projectPath, branchName, trunkBranch, initLogger } = params; + const { projectPath, branchName, trunkBranch, startPointRef, initLogger } = params; // Clean up stale lock before git operations on main repo cleanStaleLock(projectPath); @@ -82,6 +82,12 @@ export class WorktreeRuntime extends LocalBaseRuntime { `git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"` ); await proc.result; + } else if (startPointRef) { + // Branch doesn't exist locally; create it from the provided ref (e.g. origin/foo) + using proc = execAsync( + `git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}" "${startPointRef}"` + ); + await proc.result; } else { // Branch doesn't exist, create it from trunk using proc = execAsync( diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 8d4ca4eb0..f7acbc69d 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -1,6 +1,13 @@ import type { Config, ProjectConfig } from "@/node/config"; import { validateProjectPath, isGitRepository } from "@/node/utils/pathUtils"; -import { listLocalBranches, detectDefaultTrunkBranch } from "@/node/git"; +import { + listLocalBranches, + listRemoteBranches, + listRemotes, + listRemoteBranchesForRemote, + detectDefaultTrunkBranch, +} from "@/node/git"; +import { execAsync } from "@/node/utils/disposableExec"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { Secret } from "@/common/types/secrets"; @@ -141,12 +148,49 @@ export class ProjectService { // Non-git repos return empty branches - they're restricted to local runtime only if (!(await isGitRepository(normalizedPath))) { - return { branches: [], recommendedTrunk: null }; + return { branches: [], remoteBranches: [], remoteBranchGroups: [], recommendedTrunk: null }; + } + + // Best-effort fetch - ensures newly pushed PR branches are visible + try { + using proc = execAsync(`git -C "${normalizedPath}" fetch --prune`); + await proc.result; + } catch { + // Fetch failed (offline, no remote, etc.) - continue with cached remotes + log.debug("Failed to fetch remotes, continuing with cached branches"); } const branches = await listLocalBranches(normalizedPath); + // Exclude remote branches that already exist locally + const localBranchSet = new Set(branches); + + // Back-compat: origin-only remote branches, without origin/ prefix + const allOriginRemoteBranches = await listRemoteBranches(normalizedPath); + const remoteBranches = allOriginRemoteBranches.filter((b) => !localBranchSet.has(b)); + + // New: remote branches grouped by remote name (for UI disambiguation) + const REMOTE_BRANCH_LIMIT = 50; + const remotes = await listRemotes(normalizedPath); + const remoteBranchGroups = ( + await Promise.all( + remotes.map(async (remote) => { + const { branches: remoteBranchesForRemote, truncated } = + await listRemoteBranchesForRemote(normalizedPath, remote, REMOTE_BRANCH_LIMIT); + + const remoteOnlyBranches = remoteBranchesForRemote.filter( + (b) => !localBranchSet.has(b) + ); + if (remoteOnlyBranches.length === 0) { + return null; + } + + return { remote, branches: remoteOnlyBranches, truncated }; + }) + ) + ).filter((group): group is NonNullable => group !== null); + const recommendedTrunk = await detectDefaultTrunkBranch(normalizedPath, branches); - return { branches, recommendedTrunk }; + return { branches, remoteBranches, remoteBranchGroups, recommendedTrunk }; } catch (error) { log.error("Failed to list branches:", error); throw error instanceof Error ? error : new Error(String(error)); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 0e5f99d91..010623d40 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -466,6 +466,7 @@ export class WorkspaceService extends EventEmitter { projectPath: string, branchName: string, trunkBranch: string | undefined, + startPointRef: string | undefined, title?: string, runtimeConfig?: RuntimeConfig ): Promise> { @@ -526,6 +527,7 @@ export class WorkspaceService extends EventEmitter { projectPath, branchName: finalBranchName, trunkBranch: normalizedTrunkBranch, + startPointRef, directoryName: finalBranchName, initLogger, }); @@ -590,6 +592,7 @@ export class WorkspaceService extends EventEmitter { projectPath, branchName: finalBranchName, trunkBranch: normalizedTrunkBranch, + startPointRef, workspacePath: createResult!.workspacePath, initLogger, })