diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a961..47d73d93219 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -82,6 +82,29 @@ describe("resolveBranchToolbarValue", () => { }), ).toBe("main"); }); + + it("falls back to the thread branch while local status is still loading", () => { + expect( + resolveBranchToolbarValue({ + envMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/base", + currentGitBranch: null, + }), + ).toBe("feature/base"); + }); + + it("does not cling to a deleted thread branch when the checkout is detached", () => { + expect( + resolveBranchToolbarValue({ + envMode: "local", + activeWorktreePath: null, + activeThreadBranch: "throwaway/repro-error", + currentGitBranch: null, + isDetachedCheckout: true, + }), + ).toBeNull(); + }); }); describe("resolveEnvironmentOptionLabel", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c08..33ffe4087fd 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -89,11 +89,22 @@ export function resolveBranchToolbarValue(input: { activeWorktreePath: string | null; activeThreadBranch: string | null; currentGitBranch: string | null; + /** + * True when git authoritatively reports the checkout is on no branch + * (detached HEAD) — e.g. the thread's branch was deleted out from under us. + * In that case the local-mode trigger must reflect reality instead of + * clinging to the now-dangling thread branch. + */ + isDetachedCheckout?: boolean; }): string | null { - const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; + const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch, isDetachedCheckout } = + input; if (envMode === "worktree" && !activeWorktreePath) { return activeThreadBranch ?? currentGitBranch; } + if (isDetachedCheckout) { + return currentGitBranch; + } return currentGitBranch ?? activeThreadBranch; } diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 152df1bf3e5..9ce5aac15e7 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -15,7 +15,7 @@ import { import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { readEnvironmentApi } from "../environmentApi"; -import { useVcsStatus } from "../lib/vcsStatusState"; +import { refreshVcsStatus, useVcsStatus } from "../lib/vcsStatusState"; import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState"; import { newCommandId } from "../lib/utils"; import { cn } from "../lib/utils"; @@ -220,6 +220,11 @@ export function BranchToolbarBranchSelector({ const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; const currentGitBranch = branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; + // Git reports an in-repo checkout with no branch (detached HEAD) — e.g. the + // thread's branch was deleted externally. Only trust this once status data has + // loaded, so we don't flash "Select ref" while the initial status is pending. + const isDetachedCheckout = + branchStatusQuery.data?.isRepo === true && (branchStatusQuery.data?.refName ?? null) === null; const sourceControlPresentation = useMemo( () => getSourceControlPresentation(branchStatusQuery.data?.sourceControlProvider), [branchStatusQuery.data?.sourceControlProvider], @@ -230,6 +235,7 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, activeThreadBranch, currentGitBranch, + isDetachedCheckout, }); const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); const branchByName = useMemo( @@ -300,6 +306,7 @@ export function BranchToolbarBranchSelector({ await vcsRefManager .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) .catch(() => undefined); + void refreshVcsStatus({ environmentId, cwd: branchCwd }); }); }; @@ -414,11 +421,15 @@ export function BranchToolbarBranchSelector({ setBranchQuery(""); return; } + // Reload refs AND status together: opening the picker is the moment to + // reconcile with reality, including branch changes made outside the app + // (the status stream has no .git watcher, so it won't push those on its own). void vcsRefManager .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) .catch(() => undefined); + void refreshVcsStatus({ environmentId, cwd: branchCwd }); }, - [branchRefTarget], + [branchCwd, branchRefTarget, environmentId], ); const branchListScrollElementRef = useRef(null);