From edec0b7dd5fc9f1937d3911f11bdff0ae828b506 Mon Sep 17 00:00:00 2001 From: Dennis Kasper Date: Sun, 7 Jun 2026 15:59:16 +0200 Subject: [PATCH] fix(web): keep the branch toolbar in sync with the real checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related toolbar reconciliation fixes: 1. Refresh VCS status (not only the ref list) when the branch combobox opens and after branch actions. Previously opening the picker reloaded the refs but left the trigger label driven by stale status, so the list could update while the label did not. 2. When git authoritatively reports a detached HEAD (on no branch — e.g. the thread's branch was deleted out from under the checkout), stop falling back to the now-dangling thread branch and show "Select ref" instead of a branch name that is not actually checked out. Related to #2926. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/BranchToolbar.logic.test.ts | 23 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 13 ++++++++++- .../BranchToolbarBranchSelector.tsx | 15 ++++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) 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);