diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index d5b74f1cb116..478216c4b10e 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -10,6 +10,7 @@ import { resolveBranchToolbarPrBranch, resolveLocalCheckoutBranchMismatch, resolveLockedWorkspaceLabel, + resolveWorktreeBaseBranchSeed, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -255,6 +256,38 @@ describe("resolveBranchSelectionTarget", () => { }); }); +describe("resolveWorktreeBaseBranchSeed", () => { + const ref = (name: string, flags: { current?: boolean; isDefault?: boolean }): VcsRef => ({ + name, + current: flags.current ?? false, + isDefault: flags.isDefault ?? false, + worktreePath: null, + }); + const seed = ( + refs: ReadonlyArray, + activeThreadBranch: string | null = null, + activeThreadBranchListed: boolean | null = null, + ) => resolveWorktreeBaseBranchSeed({ activeThreadBranch, activeThreadBranchListed, refs }); + + it("prefers the default branch, then the current listed branch", () => { + expect(seed([ref("feature", { current: true }), ref("staging", { isDefault: true })])).toBe( + "staging", + ); + expect(seed([ref("feature", { current: true })])).toBe("feature"); + }); + + it("has no candidate while the repository has no refs yet, such as mid-clone", () => { + expect(seed([])).toBeNull(); + }); + + it("replaces a base the repository does not list, such as an unborn main after a clone", () => { + const refs = [ref("staging", { current: true, isDefault: true })]; + expect(seed(refs, "main", false)).toBe("staging"); + expect(seed(refs, "main", null)).toBeNull(); + expect(seed(refs, "feature", true)).toBeNull(); + }); +}); + describe("isWorktreeChangeBlocked", () => { const runtime = (status: "preparing" | "running" | "completed") => ({ status, diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 5dec87e3d73d..0bc88de2d1b5 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -186,6 +186,19 @@ export function resolveDraftEnvModeAfterBranchChange(input: { return "local"; } +// Only listed refs qualify: an unborn HEAD (e.g. mid-clone) names a branch that does not exist yet. +// A base the repository confirms it does not list, such as that unborn HEAD synced from the current +// checkout, yields to the listed candidate. +export function resolveWorktreeBaseBranchSeed(input: { + activeThreadBranch: string | null; + activeThreadBranchListed: boolean | null; + refs: ReadonlyArray; +}): string | null { + if (input.activeThreadBranch !== null && input.activeThreadBranchListed !== false) return null; + const { refs } = input; + return (refs.find((ref) => ref.isDefault) ?? refs.find((ref) => ref.current))?.name ?? null; +} + export function resolveBranchToolbarValue(input: { envMode: EnvMode; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index da845e95fc0b..bde3be59315b 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -19,6 +19,7 @@ import { import { GitBranchIcon } from "lucide-react"; import { useEffect, + useEffectEvent, useImperativeHandle, useOptimistic, useState, @@ -33,6 +34,7 @@ import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; import { readThreadShell, useProject, useQuietThreadShell } from "../state/entities"; +import { useProjectClone } from "../state/projectClones"; import { useEnvironmentQuery } from "../state/query"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { threadEnvironment } from "../state/threads"; @@ -53,6 +55,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + resolveWorktreeBaseBranchSeed, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -264,6 +267,16 @@ export function BranchToolbarBranchSelector({ query: sanitizeNewRefName(trimmedBranchQuery), }); const refs = branchRefState.refs; + const isProjectCloneDone = useProjectClone(activeProjectRef)?.phase === "done"; + // An Effect Event: refreshStatus is a new closure each render, and its settlement re-renders us. + const refreshClonedBranchStatus = useEffectEvent((target: EnvironmentId, cwd: string) => { + void refreshStatus({ environmentId: target, input: { cwd, localOnly: true } }); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refs listed while the project was cloning are empty; refetch them from the server once the clone lands + useEffect(() => { + if (!isProjectCloneDone || branchCwd === null) return; + refreshClonedBranchStatus(environmentId, branchCwd); + }, [branchCwd, environmentId, isProjectCloneDone]); const branchByName = new Map(refs.map((refName) => [refName.name, refName] as const)); const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); @@ -294,7 +307,6 @@ export function BranchToolbarBranchSelector({ const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` : null; - const defaultBranchName = refs.find((refName) => refName.isDefault)?.name ?? null; const pickerCreateBranchItemValue = hasExactBranchMatch ? null : createBranchItemValue; const branchPickerItems = [ ...(checkoutPullRequestItemValue ? [checkoutPullRequestItemValue] : []), @@ -536,9 +548,17 @@ export function BranchToolbarBranchSelector({ }); }; - const worktreeBaseBranchCandidate = isInitialBranchesLoadPending + const activeThreadBranchListed = + activeThreadBranch === null || activeThreadBranch !== resolvedActiveBranch + ? null + : listedActiveBranch !== null || queriedActiveBranch !== undefined + ? true + : activeBranchRefQuery.data !== null + ? false + : null; + const worktreeBaseBranchSeed = isInitialBranchesLoadPending ? null - : (defaultBranchName ?? currentGitBranch); + : resolveWorktreeBaseBranchSeed({ activeThreadBranch, activeThreadBranchListed, refs }); // The server sets started threads' branches; defaulting here would detach the agent from its worktree. const threadStarted = serverSession !== null; @@ -548,19 +568,17 @@ export function BranchToolbarBranchSelector({ threadStarted || effectiveEnvMode !== "worktree" || activeWorktreePath || - activeThreadBranch || - !worktreeBaseBranchCandidate + !worktreeBaseBranchSeed ) { return; } - setThreadBranch(worktreeBaseBranchCandidate, null, true); + setThreadBranch(worktreeBaseBranchSeed, null, true); }, [ - activeThreadBranch, activeWorktreePath, effectiveEnvMode, setThreadBranch, threadStarted, - worktreeBaseBranchCandidate, + worktreeBaseBranchSeed, ]); const handleOpenChange = (open: boolean) => {