From a13de33d331061ae4b8c12bb81a0f4d5101df960 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:26:42 +0000 Subject: [PATCH 1/4] fix(web): new worktree base no longer sticks to an unborn branch while a project clones The base-branch seed fell back to the status ref name, which during a clone is the unborn HEAD (main/master) and never existed. Seed only from listed refs so the base is set once the clone's real default branch appears. Fixes pingdotgg/t3code#25719 AI-assisted; verified with the tests and tools listed above. --- .../components/BranchToolbar.logic.test.ts | 24 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 5 ++++ .../BranchToolbarBranchSelector.tsx | 4 ++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index d5b74f1cb116..e58fb486b582 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, + resolveWorktreeBaseBranchCandidate, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -255,6 +256,29 @@ describe("resolveBranchSelectionTarget", () => { }); }); +describe("resolveWorktreeBaseBranchCandidate", () => { + const ref = (name: string, flags: { current?: boolean; isDefault?: boolean }): VcsRef => ({ + name, + current: flags.current ?? false, + isDefault: flags.isDefault ?? false, + worktreePath: null, + }); + + it("prefers the default branch, then the current listed branch", () => { + expect( + resolveWorktreeBaseBranchCandidate([ + ref("feature", { current: true }), + ref("staging", { isDefault: true }), + ]), + ).toBe("staging"); + expect(resolveWorktreeBaseBranchCandidate([ref("feature", { current: true })])).toBe("feature"); + }); + + it("has no candidate while the repository has no refs yet, such as mid-clone", () => { + expect(resolveWorktreeBaseBranchCandidate([])).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..4c5eb62fcccf 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -186,6 +186,11 @@ 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. +export function resolveWorktreeBaseBranchCandidate(refs: ReadonlyArray): string | null { + 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..b3fd4961a092 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -53,6 +53,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + resolveWorktreeBaseBranchCandidate, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -294,7 +295,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] : []), @@ -538,7 +538,7 @@ export function BranchToolbarBranchSelector({ const worktreeBaseBranchCandidate = isInitialBranchesLoadPending ? null - : (defaultBranchName ?? currentGitBranch); + : resolveWorktreeBaseBranchCandidate(refs); // The server sets started threads' branches; defaulting here would detach the agent from its worktree. const threadStarted = serverSession !== null; From 7c373531e71a10f7c0c0b80da6e3b89878871152 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:38:09 +0000 Subject: [PATCH 2/4] fix(web): refetch branch refs when a project's clone lands Refs listed while the project was still cloning are empty, and nothing refetched them when the clone finished, so the new worktree base stayed unset and sending was blocked. Refresh status (which invalidates refs and forces a server relist) once the project's clone reaches done. AI-assisted; verified with the tests and tools listed above. --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index b3fd4961a092..201c972b0e60 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -33,6 +33,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"; @@ -265,6 +266,12 @@ export function BranchToolbarBranchSelector({ query: sanitizeNewRefName(trimmedBranchQuery), }); const refs = branchRefState.refs; + const isProjectCloneDone = useProjectClone(activeProjectRef)?.phase === "done"; + // 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; + void refreshStatus({ environmentId, input: { cwd: branchCwd, localOnly: true } }); + }, [branchCwd, environmentId, isProjectCloneDone, refreshStatus]); const branchByName = new Map(refs.map((refName) => [refName.name, refName] as const)); const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); From 35e739ad6480eca323226770a8ca39a87a2ea102 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:48:26 +0000 Subject: [PATCH 3/4] fix(web): clone-landed ref refresh runs once, not after every render refreshStatus is a fresh closure each render, so listing it as an effect dependency re-ran the refresh after each render it caused. Call it from an Effect Event so the effect depends only on the clone state and target. AI-assisted; verified with the tests and tools listed above. --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 201c972b0e60..a512a6655814 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, @@ -267,11 +268,15 @@ export function BranchToolbarBranchSelector({ }); 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; - void refreshStatus({ environmentId, input: { cwd: branchCwd, localOnly: true } }); - }, [branchCwd, environmentId, isProjectCloneDone, refreshStatus]); + 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); From 32b0d45d449dbe3161b9c0154286afbfa2e13a0a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 10:19:35 +0000 Subject: [PATCH 4/4] fix(web): an unborn base synced from the current checkout yields to the real branch In Current checkout mode the live git status copies the unborn HEAD (main) into the draft, so after switching to New worktree the base seed saw a branch already set and never replaced it once the clone's real default appeared. Seed the base when the draft's branch is one the repository confirms it does not list, using the existing active-branch ref query. AI-assisted; verified with the tests and tools listed above. --- .../components/BranchToolbar.logic.test.ts | 29 ++++++++++++------- .../web/src/components/BranchToolbar.logic.ts | 10 ++++++- .../BranchToolbarBranchSelector.tsx | 22 +++++++++----- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index e58fb486b582..478216c4b10e 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -10,7 +10,7 @@ import { resolveBranchToolbarPrBranch, resolveLocalCheckoutBranchMismatch, resolveLockedWorkspaceLabel, - resolveWorktreeBaseBranchCandidate, + resolveWorktreeBaseBranchSeed, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -256,26 +256,35 @@ describe("resolveBranchSelectionTarget", () => { }); }); -describe("resolveWorktreeBaseBranchCandidate", () => { +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( - resolveWorktreeBaseBranchCandidate([ - ref("feature", { current: true }), - ref("staging", { isDefault: true }), - ]), - ).toBe("staging"); - expect(resolveWorktreeBaseBranchCandidate([ref("feature", { current: true })])).toBe("feature"); + 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(resolveWorktreeBaseBranchCandidate([])).toBeNull(); + 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(); }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 4c5eb62fcccf..0bc88de2d1b5 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -187,7 +187,15 @@ export function resolveDraftEnvModeAfterBranchChange(input: { } // Only listed refs qualify: an unborn HEAD (e.g. mid-clone) names a branch that does not exist yet. -export function resolveWorktreeBaseBranchCandidate(refs: ReadonlyArray): string | null { +// 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; } diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index a512a6655814..bde3be59315b 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -55,7 +55,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, - resolveWorktreeBaseBranchCandidate, + resolveWorktreeBaseBranchSeed, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -548,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 - : resolveWorktreeBaseBranchCandidate(refs); + : resolveWorktreeBaseBranchSeed({ activeThreadBranch, activeThreadBranchListed, refs }); // The server sets started threads' branches; defaulting here would detach the agent from its worktree. const threadStarted = serverSession !== null; @@ -560,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) => {