From f2593309b1563b9681a86e2fe521bc5209c0301f Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 18:46:54 +0800 Subject: [PATCH 01/11] fix(web,server): picking the previous branch after an agent's checkout switches back Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 27 +++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 48 +++++++++++++++++-- .../BranchToolbarBranchSelector.tsx | 37 ++++++++++---- 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 53d031a7413e8..2515f29a7f105 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1189,6 +1189,33 @@ it.effect("invalidates a ref snapshot when a mutation fails after changing Git", ), ); +it.effect("follows a checkout made outside the driver after refs were cached", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "feature/agent"]); + yield* driver.listRefs({ cwd }); + + // A provider's own `git switch` bypasses the driver's cache invalidation. + yield* git(cwd, ["switch", "feature/agent"]); + const refs = yield* driver.listRefs({ cwd }); + const initialRef = refs.refs.find((ref) => ref.name === initialBranch); + assert.isNull(initialRef?.worktreePath); + assert.isFalse(initialRef?.current); + assert.isTrue(refs.refs.find((ref) => ref.name === "feature/agent")?.current); + + yield* driver.switchRef({ cwd, refName: initialBranch }); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + assert.isTrue( + (yield* driver.listRefs({ cwd })).refs.find((ref) => ref.name === initialBranch)?.current, + ); + + yield* git(cwd, ["switch", "--detach", "HEAD"]); + assert.isEmpty((yield* driver.listRefs({ cwd })).refs.filter((ref) => ref.current)); + }).pipe(Effect.provide(layerTest)), +); + it.effect("marks the current branch when worktree metadata is unavailable", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4ace91f8017d2..8af08a5b6fe6e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4088,15 +4088,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey); }); + // Undefined when HEAD cannot be read; null when detached. + const readHeadBranch = (cwd: string) => + executeGit("GitVcsDriver.listRefs.headBranch", cwd, ["symbolic-ref", "--quiet", "HEAD"], { + timeoutMs: 5_000, + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => + result.exitCode === 0 + ? branchNameFromHeadRef(result.stdout) + : result.exitCode === 1 + ? null + : undefined, + ), + Effect.orElseSucceed(() => undefined), + ); + const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( function* (input) { - const repositoryPaths = yield* resolveRepositoryPaths(input.cwd, input.refresh === true).pipe( + const cachedRepositoryPaths = yield* resolveRepositoryPaths( + input.cwd, + input.refresh === true, + ).pipe( Effect.catchTags({ GitCommandError: (error) => isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), }), ); - if (repositoryPaths === null) { + if (cachedRepositoryPaths === null) { return { refs: [], isRepo: false, @@ -4106,10 +4125,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - const snapshot = yield* resolveListRefsSnapshot( - repositoryPaths.gitCommonDir, - input.refresh === true, + // Providers run checkouts that skip invalidation, so check the cached HEAD before + // the picker treats a branch as already checked out here. + const [cachedSnapshot, headBranch] = yield* Effect.all( + [ + resolveListRefsSnapshot(cachedRepositoryPaths.gitCommonDir, input.refresh === true), + readHeadBranch(input.cwd), + ], + { concurrency: 2 }, ); + const cachedHeadBranch = + cachedSnapshot.localBranches.find( + (ref) => ref.worktreePath === cachedRepositoryPaths.worktreeRoot, + )?.name ?? cachedRepositoryPaths.currentBranch; + const isCachedHeadStale = headBranch !== undefined && headBranch !== cachedHeadBranch; + if (isCachedHeadStale) { + yield* invalidateListRefsSnapshot(input.cwd, cachedRepositoryPaths.gitCommonDir); + } + const repositoryPaths = isCachedHeadStale + ? { ...cachedRepositoryPaths, currentBranch: headBranch } + : cachedRepositoryPaths; + const snapshot = isCachedHeadStale + ? yield* resolveListRefsSnapshot(repositoryPaths.gitCommonDir, false) + : cachedSnapshot; const hasCurrentWorktreeBranch = repositoryPaths.worktreeRoot !== null && snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index fab250f6adaf8..c51fe17d8255a 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -125,6 +125,9 @@ export function BranchToolbarBranchSelector({ const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false, }); + const readRefs = useAtomCommand(vcsEnvironment.readRefs, { + reportFailure: false, + }); const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); @@ -377,16 +380,39 @@ export function BranchToolbarBranchSelector({ }); }; - const selectBranch = (refName: VcsRef) => { + // The picker's list can predate a checkout an agent ran itself, so confirm a worktree + // claim before skipping the switch. Unconfirmed claims fall back to a real checkout, + // which Git refuses if the branch is checked out elsewhere. + const readSelectedRef = async (cwd: string, listedRef: VcsRef): Promise => { + const result = await readRefs({ + environmentId, + input: { cwd, query: listedRef.name, limit: 100, refresh: true }, + }); + const freshRef = + result._tag === "Success" + ? result.value.refs.find( + (ref) => ref.name === listedRef.name && ref.isRemote === listedRef.isRemote, + ) + : undefined; + return freshRef ?? { ...listedRef, worktreePath: null }; + }; + + const selectBranch = async (listedRef: VcsRef) => { if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { - setThreadBranch(refName.name, null); + setThreadBranch(listedRef.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } + setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); + + const refName = listedRef.worktreePath + ? await readSelectedRef(branchCwd, listedRef) + : listedRef; const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, activeWorktreePath, @@ -395,8 +421,6 @@ export function BranchToolbarBranchSelector({ if (selectionTarget.reuseExistingWorktree) { setThreadBranch(refName.name, selectionTarget.nextWorktreePath); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); return; } @@ -404,9 +428,6 @@ export function BranchToolbarBranchSelector({ ? deriveLocalBranchNameFromRemoteRef(refName.name) : refName.name; - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - runBranchAction(async () => { const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); @@ -575,7 +596,7 @@ export function BranchToolbarBranchSelector({ createRef(trimmedBranchQuery); } else { const refName = branchByName.get(itemValue); - if (refName) selectBranch(refName); + if (refName) void selectBranch(refName); } } From 2eb0b71bf3a15ff8a0ec13e1c498fc97b4bf8c4d Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 19:22:24 +0800 Subject: [PATCH 02/11] fix(web,server): keep ref caching and worktree claims correct in edge cases listRefs no longer matches a null worktree root against branches without a worktree, which made every call reread refs when worktree metadata is missing. The picker keeps its worktree claim when its confirming read was truncated. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 14 +++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 8 ++++--- .../components/BranchToolbar.logic.test.ts | 24 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 14 +++++++++++ .../BranchToolbarBranchSelector.tsx | 9 ++----- 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 2515f29a7f105..1f515f0c9b382 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1220,6 +1220,7 @@ it.effect("marks the current branch when worktree metadata is unavailable", () = Effect.scoped( Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + let snapshotReads = 0; const incompleteMetadataSpawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { if (!ChildProcess.isStandardCommand(command)) { @@ -1232,6 +1233,9 @@ it.effect("marks the current branch when worktree metadata is unavailable", () = if (isWorktreeRoot || isWorktreeList) { return makeNonRepositoryHandle(); } + if (command.args.includes("for-each-ref") && command.args.includes("refs/remotes")) { + snapshotReads++; + } return yield* delegate.spawn(command); }), ); @@ -1243,10 +1247,20 @@ it.effect("marks the current branch when worktree metadata is unavailable", () = Effect.provideService(GitVcsDriver.GitVcsDriver, driver), ); + // Sorts before the current branch, so a worktree lookup keyed on a null root would pick it. + yield* git(cwd, ["branch", "aaa-other"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + const refs = yield* driver.listRefs({ cwd, refresh: true }); assert.isTrue(refs.isRepo); assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); + + const readsAfterRefresh = snapshotReads; + yield* driver.listRefs({ cwd }); + yield* driver.listRefs({ cwd }); + assert.equal(snapshotReads, readsAfterRefresh); }), ).pipe( Effect.provide( diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 8af08a5b6fe6e..567f144f98d18 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4134,10 +4134,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: 2 }, ); + const cachedWorktreeRoot = cachedRepositoryPaths.worktreeRoot; const cachedHeadBranch = - cachedSnapshot.localBranches.find( - (ref) => ref.worktreePath === cachedRepositoryPaths.worktreeRoot, - )?.name ?? cachedRepositoryPaths.currentBranch; + (cachedWorktreeRoot === null + ? undefined + : cachedSnapshot.localBranches.find((ref) => ref.worktreePath === cachedWorktreeRoot) + ?.name) ?? cachedRepositoryPaths.currentBranch; const isCachedHeadStale = headBranch !== undefined && headBranch !== cachedHeadBranch; if (isCachedHeadStale) { yield* invalidateListRefsSnapshot(input.cwd, cachedRepositoryPaths.gitCommonDir); diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 08e6f7633dee3..cf3f3c3f27e97 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -7,6 +7,7 @@ import { resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveBranchToolbarPrBranch, + resolveConfirmedSelectedRef, resolveLocalCheckoutBranchMismatch, sanitizeNewRefName, shouldIncludeBranchPickerItem, @@ -254,3 +255,26 @@ describe("sanitizeNewRefName", () => { expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); }); }); + +describe("resolveConfirmedSelectedRef", () => { + const listed: VcsRef = { + name: "main", + isRemote: false, + current: false, + isDefault: true, + worktreePath: "/repo", + }; + + it.each([ + ["a complete read without the ref drops the claim", { refs: [], nextCursor: null }, null], + ["a truncated read without the ref keeps the claim", { refs: [], nextCursor: 100 }, "/repo"], + ["a failed read drops the claim", null, null], + [ + "a read with the ref uses its fresh worktree", + { refs: [{ ...listed, worktreePath: "/other" }], nextCursor: 100 }, + "/other", + ], + ] as const)("%s", (_label, freshRead, expected) => { + expect(resolveConfirmedSelectedRef(listed, freshRead).worktreePath).toBe(expected); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index e22dcfaddedeb..2640aea7e8e68 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -245,6 +245,20 @@ export function resolveLocalCheckoutBranchMismatch(input: { return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; } +/** Confirms a picked ref's worktree claim against a fresh read, or `null` when the read failed. */ +export function resolveConfirmedSelectedRef( + listedRef: VcsRef, + freshRead: { readonly refs: ReadonlyArray; readonly nextCursor: number | null } | null, +): VcsRef { + if (freshRead === null) return { ...listedRef, worktreePath: null }; + const freshRef = freshRead.refs.find( + (ref) => ref.name === listedRef.name && ref.isRemote === listedRef.isRemote, + ); + if (freshRef) return freshRef; + // A truncated read can miss the ref without disproving its worktree, so keep the listed claim. + return freshRead.nextCursor === null ? { ...listedRef, worktreePath: null } : listedRef; +} + export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c51fe17d8255a..d0c75e9ce9c91 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -50,6 +50,7 @@ import { resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, resolveBranchToolbarValue, + resolveConfirmedSelectedRef, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, sanitizeNewRefName, @@ -388,13 +389,7 @@ export function BranchToolbarBranchSelector({ environmentId, input: { cwd, query: listedRef.name, limit: 100, refresh: true }, }); - const freshRef = - result._tag === "Success" - ? result.value.refs.find( - (ref) => ref.name === listedRef.name && ref.isRemote === listedRef.isRemote, - ) - : undefined; - return freshRef ?? { ...listedRef, worktreePath: null }; + return resolveConfirmedSelectedRef(listedRef, result._tag === "Success" ? result.value : null); }; const selectBranch = async (listedRef: VcsRef) => { From 7fb8a04c663051275cdb193f1f0f23931e3a5b1a Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 19:38:11 +0800 Subject: [PATCH 03/11] fix(web): the branch picker keeps the latest pick and confirms past one page Co-Authored-By: Claude Opus 5.5 --- .../BranchToolbarBranchSelector.tsx | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index d0c75e9ce9c91..65a03397cb7f4 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -21,6 +21,7 @@ import { useEffect, useImperativeHandle, useOptimistic, + useRef, useState, useTransition, type MouseEvent as ReactMouseEvent, @@ -69,6 +70,7 @@ import { MiddleTruncate } from "./ui/middle-truncate"; import { BranchPicker, BranchPickerRefItem } from "./BranchPicker"; import { stackedThreadToast, toastManager } from "./ui/toast"; +const MAX_SELECTED_REF_CONFIRM_PAGES = 10; export interface BranchToolbarBranchSelectorHandle { open: () => void; } @@ -384,16 +386,33 @@ export function BranchToolbarBranchSelector({ // The picker's list can predate a checkout an agent ran itself, so confirm a worktree // claim before skipping the switch. Unconfirmed claims fall back to a real checkout, // which Git refuses if the branch is checked out elsewhere. + const latestSelectionIdRef = useRef(0); const readSelectedRef = async (cwd: string, listedRef: VcsRef): Promise => { - const result = await readRefs({ - environmentId, - input: { cwd, query: listedRef.name, limit: 100, refresh: true }, - }); - return resolveConfirmedSelectedRef(listedRef, result._tag === "Success" ? result.value : null); + let cursor: number | undefined; + // Name matches aren't ranked, so page until the ref shows up or the list ends. + for (let page = 0; page < MAX_SELECTED_REF_CONFIRM_PAGES; page++) { + const result = await readRefs({ + environmentId, + input: { + cwd, + query: listedRef.name, + refKind: listedRef.isRemote ? "remote" : "local", + limit: 100, + ...(cursor === undefined ? { refresh: true } : { cursor }), + }, + }); + if (result._tag !== "Success") return resolveConfirmedSelectedRef(listedRef, null); + const confirmed = resolveConfirmedSelectedRef(listedRef, result.value); + // It returns `listedRef` itself only when this page was truncated without the ref. + if (confirmed !== listedRef || result.value.nextCursor === null) return confirmed; + cursor = result.value.nextCursor; + } + return listedRef; }; const selectBranch = async (listedRef: VcsRef) => { if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; + const selectionId = ++latestSelectionIdRef.current; if (isSelectingWorktreeBase) { setThreadBranch(listedRef.name, null); @@ -408,6 +427,8 @@ export function BranchToolbarBranchSelector({ const refName = listedRef.worktreePath ? await readSelectedRef(branchCwd, listedRef) : listedRef; + // A later pick made while this one was confirming its worktree wins. + if (selectionId !== latestSelectionIdRef.current) return; const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, activeWorktreePath, From 153f872740eb27d0a2b39d3e79eea502a3f5c8b4 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 19:59:32 +0800 Subject: [PATCH 04/11] fix(web): an unconfirmed worktree claim no longer skips the checkout Co-Authored-By: Claude Opus 5.5 --- .../components/BranchToolbar.logic.test.ts | 35 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 20 +++++++++++ .../BranchToolbarBranchSelector.tsx | 20 +++-------- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index cf3f3c3f27e97..b35136483474e 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -7,6 +7,8 @@ import { resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveBranchToolbarPrBranch, + confirmSelectedRef, + MAX_SELECTED_REF_CONFIRM_PAGES, resolveConfirmedSelectedRef, resolveLocalCheckoutBranchMismatch, sanitizeNewRefName, @@ -278,3 +280,36 @@ describe("resolveConfirmedSelectedRef", () => { expect(resolveConfirmedSelectedRef(listed, freshRead).worktreePath).toBe(expected); }); }); + +describe("confirmSelectedRef", () => { + const listed: VcsRef = { + name: "main", + isRemote: false, + current: false, + isDefault: true, + worktreePath: "/repo", + }; + const pagesUntil = (foundOnPage: number | null) => { + const cursors: Array = []; + const readPage = async (cursor: number | undefined) => { + cursors.push(cursor); + const page = cursors.length - 1; + return page === foundOnPage + ? { refs: [{ ...listed, worktreePath: "/other" }], nextCursor: (page + 1) * 100 } + : { refs: [], nextCursor: (page + 1) * 100 }; + }; + return { cursors, readPage }; + }; + + it("follows pages until it finds the ref", async () => { + const { cursors, readPage } = pagesUntil(2); + expect((await confirmSelectedRef(listed, readPage)).worktreePath).toBe("/other"); + expect(cursors).toEqual([undefined, 100, 200]); + }); + + it("drops the claim when the page limit runs out without the ref", async () => { + const { cursors, readPage } = pagesUntil(null); + expect((await confirmSelectedRef(listed, readPage)).worktreePath).toBeNull(); + expect(cursors).toHaveLength(MAX_SELECTED_REF_CONFIRM_PAGES); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 2640aea7e8e68..a4b3415ac2ae5 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -259,6 +259,26 @@ export function resolveConfirmedSelectedRef( return freshRead.nextCursor === null ? { ...listedRef, worktreePath: null } : listedRef; } +export const MAX_SELECTED_REF_CONFIRM_PAGES = 10; + +/** Pages through a fresh ref read until the picked ref is confirmed or ruled out. */ +export async function confirmSelectedRef( + listedRef: VcsRef, + readPage: ( + cursor: number | undefined, + ) => Promise<{ readonly refs: ReadonlyArray; readonly nextCursor: number | null } | null>, +): Promise { + let cursor: number | undefined; + for (let page = 0; page < MAX_SELECTED_REF_CONFIRM_PAGES; page++) { + const freshRead = await readPage(cursor); + const confirmed = resolveConfirmedSelectedRef(listedRef, freshRead); + // It returns `listedRef` itself only when this page was truncated without the ref. + if (confirmed !== listedRef || freshRead?.nextCursor == null) return confirmed; + cursor = freshRead.nextCursor; + } + return { ...listedRef, worktreePath: null }; +} + export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 65a03397cb7f4..9dfd8c573edde 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -51,7 +51,7 @@ import { resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, resolveBranchToolbarValue, - resolveConfirmedSelectedRef, + confirmSelectedRef, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, sanitizeNewRefName, @@ -69,8 +69,6 @@ import { ComposerControl } from "./chat/ComposerControl"; import { MiddleTruncate } from "./ui/middle-truncate"; import { BranchPicker, BranchPickerRefItem } from "./BranchPicker"; import { stackedThreadToast, toastManager } from "./ui/toast"; - -const MAX_SELECTED_REF_CONFIRM_PAGES = 10; export interface BranchToolbarBranchSelectorHandle { open: () => void; } @@ -387,10 +385,8 @@ export function BranchToolbarBranchSelector({ // claim before skipping the switch. Unconfirmed claims fall back to a real checkout, // which Git refuses if the branch is checked out elsewhere. const latestSelectionIdRef = useRef(0); - const readSelectedRef = async (cwd: string, listedRef: VcsRef): Promise => { - let cursor: number | undefined; - // Name matches aren't ranked, so page until the ref shows up or the list ends. - for (let page = 0; page < MAX_SELECTED_REF_CONFIRM_PAGES; page++) { + const readSelectedRef = (cwd: string, listedRef: VcsRef): Promise => + confirmSelectedRef(listedRef, async (cursor) => { const result = await readRefs({ environmentId, input: { @@ -401,14 +397,8 @@ export function BranchToolbarBranchSelector({ ...(cursor === undefined ? { refresh: true } : { cursor }), }, }); - if (result._tag !== "Success") return resolveConfirmedSelectedRef(listedRef, null); - const confirmed = resolveConfirmedSelectedRef(listedRef, result.value); - // It returns `listedRef` itself only when this page was truncated without the ref. - if (confirmed !== listedRef || result.value.nextCursor === null) return confirmed; - cursor = result.value.nextCursor; - } - return listedRef; - }; + return result._tag === "Success" ? result.value : null; + }); const selectBranch = async (listedRef: VcsRef) => { if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; From 81ed31d971ea5709385bf70e2c2decff25fcf2ba Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 22:01:36 +0000 Subject: [PATCH 05/11] fix(web): cancel pending branch selection when creating a ref --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 1b300d46b9b61..3d04ba901d349 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -530,6 +530,7 @@ export function BranchToolbarBranchSelector({ if (!canChangeThreadBranch) return; const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; + latestSelectionIdRef.current++; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); From 3e2f5241568cc806ac3de581208be7fe4a5145a6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 22:06:45 +0000 Subject: [PATCH 06/11] fix(server): use live head when another cwd refreshed refs --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 2 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index e4cab9e2b323a..330ee27aaab15 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1259,6 +1259,8 @@ it.effect("follows a checkout made outside the driver after refs were cached", ( // A provider's own `git switch` bypasses the driver's cache invalidation. yield* git(cwd, ["switch", "feature/agent"]); + yield* writeTextFile(cwd, "subdir/file.txt", "nested\n"); + yield* driver.listRefs({ cwd: NodePath.join(cwd, "subdir") }); const refs = yield* driver.listRefs({ cwd }); const initialRef = refs.refs.find((ref) => ref.name === initialBranch); assert.isNull(initialRef?.worktreePath); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index cc5ce48d5868a..d4c30fb2a44d4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4290,7 +4290,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (isCachedHeadStale) { yield* invalidateListRefsSnapshot(input.cwd, cachedRepositoryPaths.gitCommonDir); } - const repositoryPaths = isCachedHeadStale + const repositoryPaths = headBranch !== undefined ? { ...cachedRepositoryPaths, currentBranch: headBranch } : cachedRepositoryPaths; const snapshot = isCachedHeadStale From 03561a246432d8291b8bf76cdc7fa6ee79e0d601 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 22:08:27 +0000 Subject: [PATCH 07/11] style(server): format live head fallback --- apps/server/src/vcs/GitVcsDriverCore.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index d4c30fb2a44d4..0618dd5afad91 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4290,9 +4290,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (isCachedHeadStale) { yield* invalidateListRefsSnapshot(input.cwd, cachedRepositoryPaths.gitCommonDir); } - const repositoryPaths = headBranch !== undefined - ? { ...cachedRepositoryPaths, currentBranch: headBranch } - : cachedRepositoryPaths; + const repositoryPaths = + headBranch !== undefined + ? { ...cachedRepositoryPaths, currentBranch: headBranch } + : cachedRepositoryPaths; const snapshot = isCachedHeadStale ? yield* resolveListRefsSnapshot(repositoryPaths.gitCommonDir, false) : cachedSnapshot; From c9d8628fda3a8bfec7e68abc39487a0e96f2528a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 22:09:31 +0000 Subject: [PATCH 08/11] fix(web): cancel pending ref selection before pr checkout --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 3d04ba901d349..88baa01275f8c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -659,6 +659,7 @@ export function BranchToolbarBranchSelector({ function selectPickerItem(itemValue: string) { if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { + latestSelectionIdRef.current++; handleOpenChange(false); onComposerFocusRequest?.(); onCheckoutPullRequestRequest(prReference); From fbc7c75b71545db457412a7f4954d166373643c7 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Thu, 12 Nov 2026 12:20:52 +0800 Subject: [PATCH 09/11] fix(server): keep one ref per worktree when head moves mid-listing Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 64 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 4 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 330ee27aaab15..8f0d43cf613b8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1278,6 +1278,70 @@ it.effect("follows a checkout made outside the driver after refs were cached", ( }).pipe(Effect.provide(layerTest)), ); +it.effect("attributes the worktree to one ref when HEAD moves during a snapshot reread", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + let switchDuringSnapshot: { cwd: string; refName: string } | null = null; + const switchingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isSnapshotWorktreeList = + command.args.includes("--git-dir") && command.args.includes("worktree"); + if (isSnapshotWorktreeList && switchDuringSnapshot !== null) { + const { cwd, refName } = switchDuringSnapshot; + switchDuringSnapshot = null; + const handle = yield* delegate.spawn( + ChildProcess.make("git", ["switch", refName], { cwd }), + ); + assert.equal(Number(yield* handle.exitCode), 0); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, switchingSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* git(cwd, ["branch", "feature/first"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + yield* git(cwd, ["branch", "feature/second"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + yield* driver.listRefs({ cwd }); + + // The HEAD read sees feature/first, then the snapshot reread sees feature/second. + yield* git(cwd, ["switch", "feature/first"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + switchDuringSnapshot = { cwd, refName: "feature/second" }; + const refs = yield* driver.listRefs({ cwd }); + + assert.isNull(switchDuringSnapshot); + const worktreeRoot = refs.refs.find((ref) => ref.name === "feature/second")?.worktreePath; + assert.isString(worktreeRoot); + assert.deepEqual( + refs.refs.filter((ref) => ref.worktreePath === worktreeRoot).map((ref) => ref.name), + ["feature/second"], + ); + assert.deepEqual( + refs.refs.filter((ref) => ref.current).map((ref) => ref.name), + ["feature/second"], + ); + }), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), +); + it.effect("marks the current branch when worktree metadata is unavailable", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0618dd5afad91..25bf2fe989c2c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4304,7 +4304,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...ref, worktreePath: ref.worktreePath ?? - (ref.name === repositoryPaths.currentBranch ? repositoryPaths.worktreeRoot : null), + (!hasCurrentWorktreeBranch && ref.name === repositoryPaths.currentBranch + ? repositoryPaths.worktreeRoot + : null), current: hasCurrentWorktreeBranch ? ref.worktreePath === repositoryPaths.worktreeRoot : ref.name === repositoryPaths.currentBranch, From 09d608a164a22bd07aa97912989220eaf33ec2f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 22:06:17 +0000 Subject: [PATCH 10/11] chore(web): drop duplicated server tests and tidy branch confirmation Main already covers the outside-checkout case in GitVcsDriverCore tests, so the remaining change is the web-side worktree-claim confirmation only. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 43 ------------------- .../web/src/components/BranchToolbar.logic.ts | 2 +- .../BranchToolbarBranchSelector.tsx | 1 + 3 files changed, 2 insertions(+), 44 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 63526aacc471d..6e38c55435678 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1429,40 +1429,10 @@ it.effect("invalidates a ref snapshot when a mutation fails after changing Git", ), ); -it.effect("follows a checkout made outside the driver after refs were cached", () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "feature/agent"]); - yield* driver.listRefs({ cwd }); - - // A provider's own `git switch` bypasses the driver's cache invalidation. - yield* git(cwd, ["switch", "feature/agent"]); - yield* writeTextFile(cwd, "subdir/file.txt", "nested\n"); - yield* driver.listRefs({ cwd: NodePath.join(cwd, "subdir") }); - const refs = yield* driver.listRefs({ cwd }); - const initialRef = refs.refs.find((ref) => ref.name === initialBranch); - assert.isNull(initialRef?.worktreePath); - assert.isFalse(initialRef?.current); - assert.isTrue(refs.refs.find((ref) => ref.name === "feature/agent")?.current); - - yield* driver.switchRef({ cwd, refName: initialBranch }); - assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); - assert.isTrue( - (yield* driver.listRefs({ cwd })).refs.find((ref) => ref.name === initialBranch)?.current, - ); - - yield* git(cwd, ["switch", "--detach", "HEAD"]); - assert.isEmpty((yield* driver.listRefs({ cwd })).refs.filter((ref) => ref.current)); - }).pipe(Effect.provide(layerTest)), -); - it.effect("marks the current branch when worktree metadata is unavailable", () => Effect.scoped( Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - let snapshotReads = 0; const incompleteMetadataSpawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { if (!ChildProcess.isStandardCommand(command)) { @@ -1475,9 +1445,6 @@ it.effect("marks the current branch when worktree metadata is unavailable", () = if (isWorktreeRoot || isWorktreeList) { return makeNonRepositoryHandle(); } - if (command.args.includes("for-each-ref") && command.args.includes("refs/remotes")) { - snapshotReads++; - } return yield* delegate.spawn(command); }), ); @@ -1489,20 +1456,10 @@ it.effect("marks the current branch when worktree metadata is unavailable", () = Effect.provideService(GitVcsDriver.GitVcsDriver, driver), ); - // Sorts before the current branch, so a worktree lookup keyed on a null root would pick it. - yield* git(cwd, ["branch", "aaa-other"]).pipe( - Effect.provideService(GitVcsDriver.GitVcsDriver, driver), - ); - const refs = yield* driver.listRefs({ cwd, refresh: true }); assert.isTrue(refs.isRepo); assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); - - const readsAfterRefresh = snapshotReads; - yield* driver.listRefs({ cwd }); - yield* driver.listRefs({ cwd }); - assert.equal(snapshotReads, readsAfterRefresh); }), ).pipe( Effect.provide( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index fde7dafbce769..ce4c70117028e 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -288,7 +288,7 @@ export function resolveLocalCheckoutBranchMismatch(input: { return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; } -/** Confirms a picked ref's worktree claim against a fresh read, or `null` when the read failed. */ +/** Confirms a picked ref's worktree claim against a fresh read; `freshRead` is `null` when it failed. */ export function resolveConfirmedSelectedRef( listedRef: VcsRef, freshRead: { readonly refs: ReadonlyArray; readonly nextCursor: number | null } | null, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c964bab63eefd..ae5b2a8304f27 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -72,6 +72,7 @@ import { ComposerControl } from "./chat/ComposerControl"; import { MiddleTruncate } from "./ui/middle-truncate"; import { BranchPicker, BranchPickerRefItem } from "./BranchPicker"; import { stackedThreadToast, toastManager } from "./ui/toast"; + export interface BranchToolbarBranchSelectorHandle { open: () => void; } From b1a5a4b306fb3dadd71f9b8df346e039a9119f46 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 22:21:26 +0000 Subject: [PATCH 11/11] fix(web): confirm a worktree claim against that worktree's live HEAD A refresh of the ref listing is coalesced on the server for five seconds, so a branch's worktree claim read moments after an agent switched that worktree was still stale and the picker reused the worktree without a checkout. The picker now reads refs in the claimed worktree itself, where the server reads HEAD live on every call, and keeps the claim only when the branch is current there. This also removes the paging the old confirmation needed. Co-Authored-By: Claude Opus 5.5 --- .../components/BranchToolbar.logic.test.ts | 59 ++++--------------- .../web/src/components/BranchToolbar.logic.ts | 38 +++--------- .../BranchToolbarBranchSelector.tsx | 29 ++++----- 3 files changed, 33 insertions(+), 93 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ac1fcc8fa7971..99ae0bda81bba 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -8,8 +8,6 @@ import { resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveBranchToolbarPrBranch, - confirmSelectedRef, - MAX_SELECTED_REF_CONFIRM_PAGES, resolveConfirmedSelectedRef, resolveDefaultWorktreeBaseBranch, resolveLocalCheckoutBranchMismatch, @@ -412,56 +410,25 @@ describe("sanitizeNewRefName", () => { describe("resolveConfirmedSelectedRef", () => { const listed: VcsRef = { - name: "main", + name: "previous", isRemote: false, current: false, - isDefault: true, - worktreePath: "/repo", + isDefault: false, + worktreePath: "/worktree-b", }; it.each([ - ["a complete read without the ref drops the claim", { refs: [], nextCursor: null }, null], - ["a truncated read without the ref keeps the claim", { refs: [], nextCursor: 100 }, "/repo"], - ["a failed read drops the claim", null, null], [ - "a read with the ref uses its fresh worktree", - { refs: [{ ...listed, worktreePath: "/other" }], nextCursor: 100 }, - "/other", + "the claimed worktree still has it checked out", + [{ ...listed, current: true }], + "/worktree-b", ], - ] as const)("%s", (_label, freshRead, expected) => { - expect(resolveConfirmedSelectedRef(listed, freshRead).worktreePath).toBe(expected); - }); -}); - -describe("confirmSelectedRef", () => { - const listed: VcsRef = { - name: "main", - isRemote: false, - current: false, - isDefault: true, - worktreePath: "/repo", - }; - const pagesUntil = (foundOnPage: number | null) => { - const cursors: Array = []; - const readPage = async (cursor: number | undefined) => { - cursors.push(cursor); - const page = cursors.length - 1; - return page === foundOnPage - ? { refs: [{ ...listed, worktreePath: "/other" }], nextCursor: (page + 1) * 100 } - : { refs: [], nextCursor: (page + 1) * 100 }; - }; - return { cursors, readPage }; - }; - - it("follows pages until it finds the ref", async () => { - const { cursors, readPage } = pagesUntil(2); - expect((await confirmSelectedRef(listed, readPage)).worktreePath).toBe("/other"); - expect(cursors).toEqual([undefined, 100, 200]); - }); - - it("drops the claim when the page limit runs out without the ref", async () => { - const { cursors, readPage } = pagesUntil(null); - expect((await confirmSelectedRef(listed, readPage)).worktreePath).toBeNull(); - expect(cursors).toHaveLength(MAX_SELECTED_REF_CONFIRM_PAGES); + ["the claimed worktree has moved to another branch", [], null], + ["the claimed worktree lists it but not as current", [listed], null], + ["the read failed", null, null], + ] as const)("%s", (_label, refs, expected) => { + expect(resolveConfirmedSelectedRef(listed, refs === null ? null : { refs }).worktreePath).toBe( + expected, + ); }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index ce4c70117028e..f47ddd63348b2 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -288,38 +288,18 @@ export function resolveLocalCheckoutBranchMismatch(input: { return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; } -/** Confirms a picked ref's worktree claim against a fresh read; `freshRead` is `null` when it failed. */ +/** + * Keeps a picked ref's worktree claim only when a read in that worktree finds it checked out. + * `freshRead` is `null` when the read failed. + */ export function resolveConfirmedSelectedRef( listedRef: VcsRef, - freshRead: { readonly refs: ReadonlyArray; readonly nextCursor: number | null } | null, + freshRead: { readonly refs: ReadonlyArray } | null, ): VcsRef { - if (freshRead === null) return { ...listedRef, worktreePath: null }; - const freshRef = freshRead.refs.find( - (ref) => ref.name === listedRef.name && ref.isRemote === listedRef.isRemote, - ); - if (freshRef) return freshRef; - // A truncated read can miss the ref without disproving its worktree, so keep the listed claim. - return freshRead.nextCursor === null ? { ...listedRef, worktreePath: null } : listedRef; -} - -export const MAX_SELECTED_REF_CONFIRM_PAGES = 10; - -/** Pages through a fresh ref read until the picked ref is confirmed or ruled out. */ -export async function confirmSelectedRef( - listedRef: VcsRef, - readPage: ( - cursor: number | undefined, - ) => Promise<{ readonly refs: ReadonlyArray; readonly nextCursor: number | null } | null>, -): Promise { - let cursor: number | undefined; - for (let page = 0; page < MAX_SELECTED_REF_CONFIRM_PAGES; page++) { - const freshRead = await readPage(cursor); - const confirmed = resolveConfirmedSelectedRef(listedRef, freshRead); - // It returns `listedRef` itself only when this page was truncated without the ref. - if (confirmed !== listedRef || freshRead?.nextCursor == null) return confirmed; - cursor = freshRead.nextCursor; - } - return { ...listedRef, worktreePath: null }; + const checkedOut = + freshRead?.refs.some((ref) => ref.name === listedRef.name && !ref.isRemote && ref.current) ?? + false; + return checkedOut ? listedRef : { ...listedRef, worktreePath: null }; } export function resolveBranchSelectionTarget(input: { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index ae5b2a8304f27..535a075202019 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -54,7 +54,7 @@ import { resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, resolveBranchToolbarValue, - confirmSelectedRef, + resolveConfirmedSelectedRef, resolveDefaultWorktreeBaseBranch, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, @@ -428,24 +428,17 @@ export function BranchToolbarBranchSelector({ }); }; - // The picker's list can predate a checkout an agent ran itself, so confirm a worktree - // claim before skipping the switch. Unconfirmed claims fall back to a real checkout, - // which Git refuses if the branch is checked out elsewhere. + // The picker's list, and the server's coalesced ref listing, can predate a checkout an agent + // ran itself. Only the claimed worktree's live HEAD confirms the claim; an unconfirmed one + // falls back to a real checkout, which Git refuses if the branch is checked out elsewhere. const latestSelectionIdRef = useRef(0); - const readSelectedRef = (cwd: string, listedRef: VcsRef): Promise => - confirmSelectedRef(listedRef, async (cursor) => { - const result = await readRefs({ - environmentId, - input: { - cwd, - query: listedRef.name, - refKind: listedRef.isRemote ? "remote" : "local", - limit: 100, - ...(cursor === undefined ? { refresh: true } : { cursor }), - }, - }); - return result._tag === "Success" ? result.value : null; + const readSelectedRef = async (worktreePath: string, listedRef: VcsRef): Promise => { + const result = await readRefs({ + environmentId, + input: { cwd: worktreePath, query: listedRef.name, refKind: "local", limit: 1 }, }); + return resolveConfirmedSelectedRef(listedRef, result._tag === "Success" ? result.value : null); + }; const selectBranch = async (listedRef: VcsRef) => { if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; @@ -464,7 +457,7 @@ export function BranchToolbarBranchSelector({ onComposerFocusRequest?.(); const refName = listedRef.worktreePath - ? await readSelectedRef(branchCwd, listedRef) + ? await readSelectedRef(listedRef.worktreePath, listedRef) : listedRef; // A later pick made while this one was confirming its worktree wins. if (selectionId !== latestSelectionIdRef.current) return;