From 67bdc58fb0e94f4f86682693d1cdead8e2a9770f Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:16:37 +0100 Subject: [PATCH 01/14] Improve branch mismatch warnings --- apps/server/src/git/GitManager.ts | 74 ++++++++++++------- .../components/BranchToolbar.logic.test.ts | 50 +++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 16 ++++ apps/web/src/components/GitActionsControl.tsx | 6 +- .../components/ThreadStatusIndicators.test.ts | 63 ++++++++++++++++ 5 files changed, 179 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/ThreadStatusIndicators.test.ts diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index e59613f7cc8..0dfd14119d2 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -252,6 +252,37 @@ function resolvePullRequestHeadRepositoryNameWithOwner( return `${ownerLogin}/${repositoryName}`; } +interface PullRequestHeadIdentity { + readonly repositoryNameWithOwner: string | null; + readonly ownerLogin: string | null; +} + +function resolveExpectedHeadIdentity( + headContext: Pick, +): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + headContext.headRepositoryNameWithOwner, + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + +function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + resolvePullRequestHeadRepositoryNameWithOwner(pr), + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< @@ -263,44 +294,33 @@ function matchesBranchHeadContext( return false; } - const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( - headContext.headRepositoryNameWithOwner, - ); - const expectedHeadOwner = - normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(expectedHeadRepository); - const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( - resolvePullRequestHeadRepositoryNameWithOwner(pr), - ); - const prHeadOwner = - normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(prHeadRepository); + const expectedHead = resolveExpectedHeadIdentity(headContext); + const pullRequestHead = resolvePullRequestHeadIdentity(pr); - if (headContext.isCrossRepository) { - if (pr.isCrossRepository === false) { - return false; + if (expectedHead.repositoryNameWithOwner) { + if (pullRequestHead.repositoryNameWithOwner) { + return expectedHead.repositoryNameWithOwner === pullRequestHead.repositoryNameWithOwner; } - if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { - return false; - } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + return expectedHead.ownerLogin === pullRequestHead.ownerLogin; } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + if (pr.isCrossRepository === true) { return false; } - return true; } - if (pr.isCrossRepository === true) { - return false; + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + return expectedHead.ownerLogin === pullRequestHead.ownerLogin; } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; + + if (headContext.isCrossRepository) { + return pr.isCrossRepository !== false; } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + + if (pr.isCrossRepository === true) { return false; } + return true; } diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 8291e5e006e..cbb9ec82b88 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -11,6 +11,7 @@ import { resolveEnvModeLabel, resolveBranchToolbarValue, resolveLockedWorkspaceLabel, + resolveLocalCheckoutBranchMismatch, shouldIncludeBranchPickerItem, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -85,6 +86,55 @@ describe("resolveBranchToolbarValue", () => { }); }); +describe("resolveLocalCheckoutBranchMismatch", () => { + it("detects when a local thread is associated with a different branch than the checkout", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/current", + }), + ).toEqual({ + threadBranch: "feature/thread", + currentBranch: "feature/current", + }); + }); + + it("ignores matching local checkout state", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/thread", + }), + ).toBeNull(); + }); + + it("ignores dedicated worktrees because their checkout is already thread-scoped", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "worktree", + activeWorktreePath: "/repo/.t3/worktrees/feature-thread", + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/current", + }), + ).toBeNull(); + }); + + it("ignores new-worktree base selection before a worktree exists", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "worktree", + activeWorktreePath: null, + activeThreadBranch: "feature/base", + currentGitBranch: "main", + }), + ).toBeNull(); + }); +}); + describe("resolveEnvironmentOptionLabel", () => { it("prefers the primary environment's machine label", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index b16e1f590a9..c083c335292 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -108,6 +108,22 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +export function resolveLocalCheckoutBranchMismatch(input: { + effectiveEnvMode: EnvMode; + activeWorktreePath: string | null; + activeThreadBranch: string | null; + currentGitBranch: string | null; +}): { threadBranch: string; currentBranch: string } | null { + const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; + if (effectiveEnvMode !== "local" || activeWorktreePath !== null) { + return null; + } + if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) { + return null; + } + return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; +} + export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f71e855a18f..d4cf4002a2c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1117,12 +1117,12 @@ export default function GitActionsControl({ activeDraftThread.worktreePath === null; useEffect(() => { - if (isGitActionRunning || isSelectingWorktreeBase) { + if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, + threadBranch: activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { @@ -1131,7 +1131,7 @@ export default function GitActionsControl({ persistThreadBranchSync(branchUpdate.branch); }, [ - activeServerThread?.branch, + activeServerThread, activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts new file mode 100644 index 00000000000..3be16329abb --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -0,0 +1,63 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { resolveThreadPr } from "./ThreadStatusIndicators"; + +function status(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/current", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: { + number: 42, + title: "PR branch", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseBranch: "main", + headBranch: "feature/current", + state: "open", + }, + ...overrides, + }; +} + +describe("resolveThreadPr", () => { + it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { + expect( + resolveThreadPr({ + threadBranch: "feature/other", + gitStatus: status(), + hasDedicatedWorktree: false, + }), + ).toBeNull(); + }); + + it("shows PR indicators for dedicated worktree threads even when branch metadata is stale", () => { + const gitStatus = status(); + + expect( + resolveThreadPr({ + threadBranch: "feature/old-name", + gitStatus, + hasDedicatedWorktree: true, + }), + ).toBe(gitStatus.pr); + }); + + it("shows PR indicators for dedicated worktree threads even when branch metadata is missing", () => { + const gitStatus = status(); + + expect( + resolveThreadPr({ + threadBranch: null, + gitStatus, + hasDedicatedWorktree: true, + }), + ).toBe(gitStatus.pr); + }); +}); From d05348314442fab56e5909c54c7e1b9712442b04 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:44:47 +0100 Subject: [PATCH 02/14] Fix cross-repo PR identity matching --- apps/server/src/git/GitManager.test.ts | 66 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 12 ++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 000a9d99c6b..62860d3dba7 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2413,6 +2413,72 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 12_000, ); + it.effect( + "does not reuse a cross-repo PR when GitHub omits head identity metadata", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequenceByHeadSelector: { + "octocat:statemachine": [ + JSON.stringify([ + { + number: 41, + title: "Ambiguous fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + }, + ]), + JSON.stringify([ + { + number: 142, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + ], + "fork-seed:statemachine": [JSON.stringify([])], + statemachine: [JSON.stringify([])], + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(142); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true); + }), + 12_000, + ); + it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 0dfd14119d2..a83ac817cac 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -314,7 +314,17 @@ function matchesBranchHeadContext( } if (headContext.isCrossRepository) { - return pr.isCrossRepository !== false; + if (pr.isCrossRepository === false) { + return false; + } + if ( + (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) && + !pullRequestHead.repositoryNameWithOwner && + !pullRequestHead.ownerLogin + ) { + return false; + } + return true; } if (pr.isCrossRepository === true) { From 649c108c6bcc2f408bd1dd17c6f3dcc3f5e132fc Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:25:04 +0100 Subject: [PATCH 03/14] Reject same-repo PRs for cross-repo heads --- apps/server/src/git/GitManager.test.ts | 47 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 17 ++++++---- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 62860d3dba7..be3c2527949 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2479,6 +2479,53 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 12_000, ); + it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () => + Effect.sync(() => { + const headContext = { + headBranch: "statemachine", + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + isCrossRepository: true, + }; + + expect( + GitManager.matchesBranchHeadContext( + { + number: 41, + title: "Same-repo PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: null, + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(false); + + expect( + GitManager.matchesBranchHeadContext( + { + number: 142, + title: "Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: null, + isCrossRepository: true, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(true); + }), + ); + it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index a83ac817cac..94ca3c30b69 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -283,7 +283,7 @@ function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIde }; } -function matchesBranchHeadContext( +export function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< BranchHeadContext, @@ -299,18 +299,21 @@ function matchesBranchHeadContext( if (expectedHead.repositoryNameWithOwner) { if (pullRequestHead.repositoryNameWithOwner) { - return expectedHead.repositoryNameWithOwner === pullRequestHead.repositoryNameWithOwner; + if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) { + return false; + } } if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { - return expectedHead.ownerLogin === pullRequestHead.ownerLogin; - } - if (pr.isCrossRepository === true) { - return false; + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { + return false; + } } } if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { - return expectedHead.ownerLogin === pullRequestHead.ownerLogin; + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { + return false; + } } if (headContext.isCrossRepository) { From 4fa5780c7842eb39304494575cd88abc6e4a18e5 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:41:18 +0100 Subject: [PATCH 04/14] Handle fork-origin PR status matching --- apps/server/src/git/GitManager.test.ts | 29 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 7 ++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index be3c2527949..1d295cf601f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2526,6 +2526,35 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("accepts fork PR metadata when origin is the fork checkout remote", () => + Effect.sync(() => { + const headContext = { + headBranch: "t3code/git-audit-stability", + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + isCrossRepository: false, + }; + + expect( + matchesBranchHeadContext( + { + number: 2284, + title: "Improve branch mismatch warnings", + url: "https://github.com/pingdotgg/t3code/pull/2284", + baseRefName: "main", + headRefName: "t3code/git-audit-stability", + state: "open", + updatedAt: null, + isCrossRepository: true, + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + }, + headContext, + ), + ).toBe(true); + }), + ); + it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 94ca3c30b69..86e10d9f930 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -331,7 +331,12 @@ export function matchesBranchHeadContext( } if (pr.isCrossRepository === true) { - return false; + if ( + (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) || + (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin) + ) { + return false; + } } return true; From d591baa2d48b5694dc159ab92ae5accff9939492 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:55:05 +0100 Subject: [PATCH 05/14] Improve PR status tooltip formatting --- .../BranchToolbarBranchSelector.tsx | 6 +- apps/web/src/components/Sidebar.tsx | 11 ++- .../components/ThreadStatusIndicators.test.ts | 12 +++- .../src/components/ThreadStatusIndicators.tsx | 67 ++++++++++++++++--- 4 files changed, 82 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index cfd09272aae..72c670d4a04 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -589,7 +589,11 @@ export function BranchToolbarBranchSelector({ }); // PR pill shown next to the branch selector when the active branch has one. - const branchPr = resolveThreadPr(resolvedActiveBranch, branchStatusQuery.data ?? null); + const branchPr = resolveThreadPr({ + threadBranch: resolvedActiveBranch, + gitStatus: branchStatusQuery.data ?? null, + hasDedicatedWorktree: activeWorktreePath !== null, + }); const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's // state-description tooltip. diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fabedfc80c7..b9bdc1c043b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { import { ChangeRequestStatusIcon, prStatusIndicator, + PrStatusTooltipContent, resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, @@ -460,7 +461,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr lastVisitedAt, }, }); - const pr = resolveThreadPr(thread.branch, gitStatus.data); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; @@ -700,7 +705,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr } /> - {prStatus.tooltip} + + + )} {threadStatus && } diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3be16329abb..7d18508d180 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,7 +1,7 @@ import type { VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { resolveThreadPr } from "./ThreadStatusIndicators"; +import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; function status(overrides: Partial = {}): VcsStatusResult { return { @@ -61,3 +61,13 @@ describe("resolveThreadPr", () => { ).toBe(gitStatus.pr); }); }); + +describe("prStatusIndicator", () => { + it("formats PR tooltips with number, uppercase status, and title", () => { + expect(prStatusIndicator(status().pr)).toMatchObject({ + tooltip: "PR #42 - Open: PR branch", + tooltipLead: "PR #42 - Open", + tooltipTitle: "PR branch", + }); + }); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 55f9fbfdc04..725bc96c347 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -22,6 +22,8 @@ export interface PrStatusIndicator { label: string; colorClass: string; tooltip: string; + tooltipLead: string; + tooltipTitle: string; url: string; } @@ -37,14 +39,29 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { +function formatPrState(state: NonNullable["state"]): string { + return state.charAt(0).toUpperCase() + state.slice(1); +} + +function formatPrStatusLead( + pr: NonNullable, + changeRequestShortName: string, +): string { + return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; +} if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); + const tooltipLead = formatPrStatusLead(pr, presentation.shortName); + const tooltip = `${tooltipLead}: ${pr.title}`; + if (pr.state === "open") { return { label: `${presentation.shortName} open`, colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -52,7 +69,9 @@ export function prStatusIndicator( return { label: `${presentation.shortName} closed`, colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -60,7 +79,9 @@ export function prStatusIndicator( return { label: `${presentation.shortName} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -71,11 +92,31 @@ export function ChangeRequestStatusIcon({ className }: { className?: string }) { return ; } -export function resolveThreadPr( - threadBranch: string | null, - gitStatus: VcsStatusResult | null, -): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { +export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { + return ( + + {status.tooltipLead} + + ); +} + +export function resolveThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + hasDedicatedWorktree: boolean; +}): ThreadPr | null { + const { threadBranch, gitStatus, hasDedicatedWorktree } = input; + if (gitStatus === null) { + return null; + } + + if (hasDedicatedWorktree) { + return gitStatus.pr ?? null; + } + + if (threadBranch === null || gitStatus.refName !== threadBranch) { return null; } @@ -206,7 +247,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar }) : null, ); - const pr = resolveThreadPr(thread.branch, gitStatus.data); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ thread: { @@ -233,7 +278,9 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar > - {prStatus.tooltip} + + + ) : null} {threadStatus ? : null} From 289a0d92310158699b4372b78df136c0c2be6bd9 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Mon, 4 May 2026 10:06:00 +0100 Subject: [PATCH 06/14] fix(web): align rebased vcs status handling --- .../BranchToolbarBranchSelector.tsx | 55 +++++++++++++++++++ .../components/ThreadStatusIndicators.test.ts | 6 +- .../src/components/ThreadStatusIndicators.tsx | 15 ++--- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 72c670d4a04..92486cc28f4 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -39,6 +39,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + resolveLocalCheckoutBranchMismatch, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { @@ -218,6 +219,7 @@ export function BranchToolbarBranchSelector({ // Git ref queries // --------------------------------------------------------------------------- const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); + const [isMismatchPopoverOpen, setIsMismatchPopoverOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); @@ -258,6 +260,12 @@ export function BranchToolbarBranchSelector({ activeThreadBranch, currentGitBranch, }); + const localCheckoutBranchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode, + activeWorktreePath, + activeThreadBranch, + currentGitBranch, + }); const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); const branchByName = useMemo( () => new Map(refs.map((refName) => [refName.name, refName] as const)), @@ -473,6 +481,53 @@ export function BranchToolbarBranchSelector({ const worktreeBaseBranchCandidate = isInitialBranchesLoadPending ? null : (defaultBranchName ?? currentGitBranch); + + const switchCheckoutToThreadBranch = () => { + if (!activeProjectCwd || !localCheckoutBranchMismatch || isBranchActionPending) { + return; + } + + runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; + setOptimisticBranch(localCheckoutBranchMismatch.threadBranch); + const checkoutResult = await switchRef({ + environmentId, + input: { + cwd: activeProjectCwd, + refName: localCheckoutBranchMismatch.threadBranch, + }, + }); + if (checkoutResult._tag === "Success") { + setOptimisticBranch( + checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch, + ); + setIsMismatchPopoverOpen(false); + onComposerFocusRequest?.(); + return; + } + setOptimisticBranch(previousBranch); + if (!isAtomCommandInterrupted(checkoutResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to switch checkout.", + description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)), + }), + ); + } + }); + }; + + const useCurrentCheckoutForThread = () => { + if (!localCheckoutBranchMismatch || isBranchActionPending) { + return; + } + + setThreadBranch(localCheckoutBranchMismatch.currentBranch, null); + setIsMismatchPopoverOpen(false); + onComposerFocusRequest?.(); + }; + useEffect(() => { if ( effectiveEnvMode !== "worktree" || diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 7d18508d180..6e358a3926c 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -18,8 +18,8 @@ function status(overrides: Partial = {}): VcsStatusResult { number: 42, title: "PR branch", url: "https://github.com/pingdotgg/t3code/pull/42", - baseBranch: "main", - headBranch: "feature/current", + baseRef: "main", + headRef: "feature/current", state: "open", }, ...overrides, @@ -64,7 +64,7 @@ describe("resolveThreadPr", () => { describe("prStatusIndicator", () => { it("formats PR tooltips with number, uppercase status, and title", () => { - expect(prStatusIndicator(status().pr)).toMatchObject({ + expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ tooltip: "PR #42 - Open: PR branch", tooltipLead: "PR #42 - Open", tooltipTitle: "PR branch", diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 725bc96c347..be7607c4564 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -39,16 +39,13 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { -function formatPrState(state: NonNullable["state"]): string { - return state.charAt(0).toUpperCase() + state.slice(1); -} + function formatPrState(state: NonNullable["state"]): string { + return state.charAt(0).toUpperCase() + state.slice(1); + } -function formatPrStatusLead( - pr: NonNullable, - changeRequestShortName: string, -): string { - return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; -} + function formatPrStatusLead(pr: NonNullable, changeRequestShortName: string): string { + return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; + } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); From 42645f947145db1a62e2bb3421d37c3b8628f5de Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Mon, 4 May 2026 10:30:41 +0100 Subject: [PATCH 07/14] test(server): align PR updatedAt fixtures --- apps/server/src/git/GitManager.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 1d295cf601f..770f9eb6f2a 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -8,6 +8,7 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -2497,7 +2498,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "main", headRefName: "statemachine", state: "open", - updatedAt: null, + updatedAt: Option.none(), isCrossRepository: false, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", @@ -2515,7 +2516,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "main", headRefName: "statemachine", state: "open", - updatedAt: null, + updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", @@ -2544,7 +2545,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "main", headRefName: "t3code/git-audit-stability", state: "open", - updatedAt: null, + updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "justsomelegs/t3code", headRepositoryOwnerLogin: "justsomelegs", From f57b38cdfd4391b6f2890de4a874375112ba213e Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Mon, 4 May 2026 10:38:53 +0100 Subject: [PATCH 08/14] fix(web): sync thread branch after mismatch checkout --- apps/web/src/components/BranchToolbarBranchSelector.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 92486cc28f4..01cd1f35588 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -498,9 +498,10 @@ export function BranchToolbarBranchSelector({ }, }); if (checkoutResult._tag === "Success") { - setOptimisticBranch( - checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch, - ); + const nextBranchName = + checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; + setOptimisticBranch(nextBranchName); + setThreadBranch(nextBranchName, null); setIsMismatchPopoverOpen(false); onComposerFocusRequest?.(); return; From 975d39d76eb8a62c623a319436f552ef8b3436d3 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Sat, 9 May 2026 01:40:07 +0100 Subject: [PATCH 09/14] test(server): fix GitManager CI fixtures --- apps/server/src/git/GitManager.test.ts | 33 ++++---------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 770f9eb6f2a..8b9051e56f7 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2434,36 +2434,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListSequenceByHeadSelector: { "octocat:statemachine": [ - JSON.stringify([ - { - number: 41, - title: "Ambiguous fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/41", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - }, - ]), - JSON.stringify([ - { - number: 142, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), + `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, + `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, ], - "fork-seed:statemachine": [JSON.stringify([])], - statemachine: [JSON.stringify([])], + "fork-seed:statemachine": ["[]"], + statemachine: ["[]"], }, }, }); From 586440253680bd2c4a904dc670330a60599fda35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 11 May 2026 13:17:31 +0200 Subject: [PATCH 10/14] Refine branch mismatch handling in selector - Surface mismatch state inside the branch picker - Replace the inline popover with embedded actions and tooltips - Tighten list height when a branch mismatch is shown --- .../BranchToolbarBranchSelector.tsx | 81 +++++++++++++++++-- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 01cd1f35588..44e5125d4b2 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -5,7 +5,13 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { + ChevronDownIcon, + GitBranchIcon, + RefreshCwIcon, + SearchIcon, + TriangleAlertIcon, +} from "lucide-react"; import { useCallback, useDeferredValue, @@ -219,7 +225,6 @@ export function BranchToolbarBranchSelector({ // Git ref queries // --------------------------------------------------------------------------- const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); - const [isMismatchPopoverOpen, setIsMismatchPopoverOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); @@ -502,7 +507,7 @@ export function BranchToolbarBranchSelector({ checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; setOptimisticBranch(nextBranchName); setThreadBranch(nextBranchName, null); - setIsMismatchPopoverOpen(false); + setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } @@ -519,13 +524,13 @@ export function BranchToolbarBranchSelector({ }); }; - const useCurrentCheckoutForThread = () => { + const updateThreadToCurrentCheckout = () => { if (!localCheckoutBranchMismatch || isBranchActionPending) { return; } setThreadBranch(localCheckoutBranchMismatch.currentBranch, null); - setIsMismatchPopoverOpen(false); + setIsBranchMenuOpen(false); onComposerFocusRequest?.(); }; @@ -786,7 +791,11 @@ export function BranchToolbarBranchSelector({ > } - className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" + className={cn( + "min-w-0 text-muted-foreground/70 hover:text-foreground/80", + localCheckoutBranchMismatch && + "border-warning/35 bg-warning/10 text-warning hover:bg-warning/15 hover:text-warning", + )} disabled={isInitialBranchesLoadPending || isBranchActionPending} > @@ -796,6 +805,66 @@ export function BranchToolbarBranchSelector({ + {localCheckoutBranchMismatch ? ( +
+
+
+
+
+ thread + + {localCheckoutBranchMismatch.threadBranch} + +
+
+ checkout + + {localCheckoutBranchMismatch.currentBranch} + +
+
+
+ + + Update thread + + } + /> + + Associate this thread with {localCheckoutBranchMismatch.currentBranch} + + + + + Switch checkout + + } + /> + + Checkout {localCheckoutBranchMismatch.threadBranch} + + +
+
+ ) : null}
Date: Thu, 9 Jul 2026 08:13:16 +0200 Subject: [PATCH 11/14] align rebased branch audit tests Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 2 +- apps/web/src/components/ThreadStatusIndicators.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 8b9051e56f7..5a5b58db81b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2512,7 +2512,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }; expect( - matchesBranchHeadContext( + GitManager.matchesBranchHeadContext( { number: 2284, title: "Improve branch mismatch warnings", diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 6e358a3926c..24ccc6ef33f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,5 +1,5 @@ import type { VcsStatusResult } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; From a749cead34ee4d5625ec78bb4a8eacf627bceeb9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 00:27:54 +0200 Subject: [PATCH 12/14] Move branch drift controls into composer banner Co-authored-by: codex --- .../BranchToolbarBranchSelector.tsx | 128 +---------- .../web/src/components/ChatView.logic.test.ts | 29 +++ apps/web/src/components/ChatView.logic.ts | 27 +++ apps/web/src/components/ChatView.tsx | 208 +++++++++++++++++- apps/web/src/components/SidebarV2.tsx | 30 ++- .../src/components/ThreadStatusIndicators.tsx | 2 +- .../chat/ComposerBannerStack.test.tsx | 18 ++ .../components/chat/ComposerBannerStack.tsx | 11 +- 8 files changed, 310 insertions(+), 143 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 44e5125d4b2..08ee713a932 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -5,13 +5,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { - ChevronDownIcon, - GitBranchIcon, - RefreshCwIcon, - SearchIcon, - TriangleAlertIcon, -} from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { useCallback, useDeferredValue, @@ -45,7 +39,6 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, - resolveLocalCheckoutBranchMismatch, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { @@ -265,12 +258,6 @@ export function BranchToolbarBranchSelector({ activeThreadBranch, currentGitBranch, }); - const localCheckoutBranchMismatch = resolveLocalCheckoutBranchMismatch({ - effectiveEnvMode, - activeWorktreePath, - activeThreadBranch, - currentGitBranch, - }); const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); const branchByName = useMemo( () => new Map(refs.map((refName) => [refName.name, refName] as const)), @@ -487,53 +474,6 @@ export function BranchToolbarBranchSelector({ ? null : (defaultBranchName ?? currentGitBranch); - const switchCheckoutToThreadBranch = () => { - if (!activeProjectCwd || !localCheckoutBranchMismatch || isBranchActionPending) { - return; - } - - runBranchAction(async () => { - const previousBranch = resolvedActiveBranch; - setOptimisticBranch(localCheckoutBranchMismatch.threadBranch); - const checkoutResult = await switchRef({ - environmentId, - input: { - cwd: activeProjectCwd, - refName: localCheckoutBranchMismatch.threadBranch, - }, - }); - if (checkoutResult._tag === "Success") { - const nextBranchName = - checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; - setOptimisticBranch(nextBranchName); - setThreadBranch(nextBranchName, null); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - return; - } - setOptimisticBranch(previousBranch); - if (!isAtomCommandInterrupted(checkoutResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to switch checkout.", - description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)), - }), - ); - } - }); - }; - - const updateThreadToCurrentCheckout = () => { - if (!localCheckoutBranchMismatch || isBranchActionPending) { - return; - } - - setThreadBranch(localCheckoutBranchMismatch.currentBranch, null); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - }; - useEffect(() => { if ( effectiveEnvMode !== "worktree" || @@ -791,11 +731,7 @@ export function BranchToolbarBranchSelector({ > } - className={cn( - "min-w-0 text-muted-foreground/70 hover:text-foreground/80", - localCheckoutBranchMismatch && - "border-warning/35 bg-warning/10 text-warning hover:bg-warning/15 hover:text-warning", - )} + className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" disabled={isInitialBranchesLoadPending || isBranchActionPending} > @@ -805,66 +741,6 @@ export function BranchToolbarBranchSelector({
- {localCheckoutBranchMismatch ? ( -
-
-
-
-
- thread - - {localCheckoutBranchMismatch.threadBranch} - -
-
- checkout - - {localCheckoutBranchMismatch.currentBranch} - -
-
-
- - - Update thread - - } - /> - - Associate this thread with {localCheckoutBranchMismatch.currentBranch} - - - - - Switch checkout - - } - /> - - Checkout {localCheckoutBranchMismatch.threadBranch} - - -
-
- ) : null}
{ + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }; + + it("updates a stale local thread branch to the active checkout", () => { + expect( + resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: modelSelection, + currentBranch: "feature/thread", + nextBranch: "feature/checkout", + }), + ).toEqual({ branch: "feature/checkout", worktreePath: null }); + }); + + it("does not write metadata when the model and branch are unchanged", () => { + expect( + resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: modelSelection, + nextModelSelection: modelSelection, + currentBranch: "feature/current", + nextBranch: "feature/current", + }), + ).toBeNull(); + }); +}); + describe("buildThreadTurnInterruptInput", () => { it("targets the session's active running turn", () => { const activeTurnId = TurnId.make("turn-running"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 6b74eae9ff4..591f56ea4c7 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -27,6 +27,33 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function resolveThreadMetadataUpdateForNextTurn(input: { + currentModelSelection: ModelSelection; + nextModelSelection?: ModelSelection; + currentBranch: string | null; + nextBranch?: string; +}): { + modelSelection?: ModelSelection; + branch?: string; + worktreePath?: null; +} | null { + const nextModelSelection = input.nextModelSelection; + const modelSelectionChanged = + nextModelSelection !== undefined && + (nextModelSelection.model !== input.currentModelSelection.model || + nextModelSelection.instanceId !== input.currentModelSelection.instanceId || + JSON.stringify(nextModelSelection.options ?? null) !== + JSON.stringify(input.currentModelSelection.options ?? null)); + const branchChanged = input.nextBranch !== undefined && input.nextBranch !== input.currentBranch; + if (!modelSelectionChanged && !branchChanged) { + return null; + } + return { + ...(modelSelectionChanged ? { modelSelection: nextModelSelection } : {}), + ...(branchChanged ? { branch: input.nextBranch, worktreePath: null } : {}), + }; +} + export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8b0fe01bd2b..a53f5f235a9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -212,7 +212,7 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; +import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; @@ -242,6 +242,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -1080,6 +1081,10 @@ type LocalThreadErrorEntry = { readonly at: number; }; +function chatActionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -1107,6 +1112,7 @@ function ChatViewContent(props: ChatViewProps) { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); @@ -1790,7 +1796,7 @@ function ChatViewContent(props: ChatViewProps) { const versionMismatchEnvironmentId = versionMismatch && activeThread ? activeThread.environmentId : null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); - const composerBannerItems = useMemo(() => { + const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { const connection = activeEnvironmentUnavailableState.connection; @@ -3250,6 +3256,7 @@ function ChatViewContent(props: ChatViewProps) { threadId: ThreadId; createdAt: string; modelSelection?: ModelSelection; + branch?: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }): Promise> => { @@ -3258,19 +3265,19 @@ function ChatViewContent(props: ChatViewProps) { } let result: AtomCommandResult = AsyncResult.success(undefined); - if ( - input.modelSelection !== undefined && - (input.modelSelection.model !== serverThread.modelSelection.model || - input.modelSelection.instanceId !== serverThread.modelSelection.instanceId || - JSON.stringify(input.modelSelection.options ?? null) !== - JSON.stringify(serverThread.modelSelection.options ?? null)) - ) { + const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: serverThread.modelSelection, + ...(input.modelSelection ? { nextModelSelection: input.modelSelection } : {}), + currentBranch: serverThread.branch, + ...(input.branch ? { nextBranch: input.branch } : {}), + }); + if (metadataUpdate) { result = mapAtomCommandResult( await updateThreadMetadata({ environmentId, input: { threadId: input.threadId, - modelSelection: input.modelSelection, + ...metadataUpdate, }, }), () => undefined, @@ -3764,6 +3771,180 @@ function ChatViewContent(props: ChatViewProps) { requestedEnvMode: envMode, isGitRepo, }); + const localCheckoutBranchMismatch = useMemo( + () => + isServerThread + ? resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: envMode, + activeWorktreePath, + activeThreadBranch, + currentGitBranch: gitStatusQuery.data?.refName ?? null, + }) + : null, + [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], + ); + const [branchRepairAction, setBranchRepairAction] = useState< + "update-thread" | "switch-checkout" | null + >(null); + const handleUpdateThreadToCheckout = useCallback(async () => { + if (!activeThread || !localCheckoutBranchMismatch || branchRepairAction !== null) { + return; + } + setBranchRepairAction("update-thread"); + const updateResult = await updateThreadMetadata({ + environmentId, + input: { + threadId: activeThread.id, + branch: localCheckoutBranchMismatch.currentBranch, + worktreePath: null, + }, + }); + setBranchRepairAction(null); + if (updateResult._tag === "Failure" && !isAtomCommandInterrupted(updateResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update thread branch", + description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), + }), + ); + return; + } + scheduleComposerFocus(); + }, [ + activeThread, + branchRepairAction, + environmentId, + localCheckoutBranchMismatch, + scheduleComposerFocus, + updateThreadMetadata, + ]); + const handleSwitchCheckoutToThread = useCallback(async () => { + if ( + !activeProjectCwd || + !activeThread || + !localCheckoutBranchMismatch || + branchRepairAction !== null + ) { + return; + } + setBranchRepairAction("switch-checkout"); + const checkoutResult = await switchGitRef({ + environmentId, + input: { + cwd: activeProjectCwd, + refName: localCheckoutBranchMismatch.threadBranch, + }, + }); + if (checkoutResult._tag === "Failure") { + setBranchRepairAction(null); + if (!isAtomCommandInterrupted(checkoutResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to switch checkout", + description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)), + }), + ); + } + return; + } + + const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; + if (nextBranch !== activeThread.branch) { + const updateResult = await updateThreadMetadata({ + environmentId, + input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, + }); + if (updateResult._tag === "Failure") { + setBranchRepairAction(null); + if (!isAtomCommandInterrupted(updateResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Checkout switched, but the thread could not be updated", + description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), + }), + ); + } + gitStatusQuery.refresh(); + return; + } + } + gitStatusQuery.refresh(); + setBranchRepairAction(null); + scheduleComposerFocus(); + }, [ + activeProjectCwd, + activeThread, + branchRepairAction, + environmentId, + gitStatusQuery, + localCheckoutBranchMismatch, + scheduleComposerFocus, + switchGitRef, + updateThreadMetadata, + ]); + const composerBannerItems = useMemo(() => { + if (!localCheckoutBranchMismatch) { + return systemComposerBannerItems; + } + const isRepairingBranch = branchRepairAction !== null; + return [ + ...systemComposerBannerItems, + { + id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`, + variant: "warning", + icon: , + title: "Branches diverged", + className: + "text-base sm:text-sm [&>div]:items-start max-sm:[&>div]:flex-wrap max-sm:[&>div>div:last-child]:w-full max-sm:[&>div>div:last-child]:self-start dark:shadow-none", + actionClassName: + "max-sm:w-full max-sm:border-t max-sm:border-border/60 max-sm:pt-2 max-sm:pl-6 sm:border-l sm:border-border/60 sm:pl-3", + description: ( +

+ Thread{" "} + + {localCheckoutBranchMismatch.threadBranch} + + + Checkout{" "} + + {localCheckoutBranchMismatch.currentBranch} + + . Sending a message will update the thread branch. +

+ ), + actions: ( + <> + + + + ), + }, + ]; + }, [ + activeThread?.id, + branchRepairAction, + handleSwitchCheckoutToThread, + handleUpdateThreadToCheckout, + localCheckoutBranchMismatch, + systemComposerBannerItems, + ]); useEffect(() => { setPendingServerThreadEnvMode(null); @@ -4313,6 +4494,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode, }); @@ -4694,6 +4878,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, modelSelection: ctxSelectedModelSelection, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode: nextInteractionMode, }); @@ -4770,6 +4957,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + localCheckoutBranchMismatch, persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 9bc87eed64a..f8950bb575b 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -83,6 +83,7 @@ import { resolveSidebarV2Status, sortThreadsForSidebarV2, } from "./Sidebar.logic"; +import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; @@ -127,6 +128,7 @@ function SidebarV2ThreadTooltip({ modelInstanceId, modelLabel, status, + branchMismatch, }: { thread: SidebarThreadSummary; projectTitle: string | null; @@ -140,6 +142,10 @@ function SidebarV2ThreadTooltip({ className: string; icon: "working" | "done" | null; } | null; + branchMismatch: { + threadBranch: string; + currentBranch: string; + } | null; }) { return (
) : null} + {branchMismatch ? ( +
+ +
+ Thread branch differs from active checkout {branchMismatch.currentBranch}; sending a + message will update the thread branch +
+
+ ) : null} {driverKind ? (
); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index be7607c4564..3c58d6ea989 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -237,7 +237,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd; const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index 16fdff64425..520f130416a 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -34,4 +34,22 @@ describe("ComposerBannerStack", () => { expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); }); + + it("applies item-specific surface and action layout classes", () => { + const markup = renderToStaticMarkup( + Repair, + }, + ]} + />, + ); + + expect(markup).toContain("branch-surface"); + expect(markup).toContain("branch-actions"); + }); }); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index d51c23a2f27..0b9acbcc997 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -30,6 +30,8 @@ export interface ComposerBannerStackItem { readonly title: ReactNode; readonly description?: ReactNode; readonly actions?: ReactNode; + readonly className?: string; + readonly actionClassName?: string; readonly dismissLabel?: string; readonly onDismiss?: () => void; } @@ -171,17 +173,18 @@ function ComposerBannerStackAlert({ const dismissOnly = item.onDismiss && !item.actions; return ( - + {item.icon} {item.title} {item.description ? {item.description} : null} {item.actions || item.onDismiss ? ( {item.actions} {item.onDismiss ? ( From e30be8e33017a92036def3fb442d780186c19185 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 00:41:31 +0200 Subject: [PATCH 13/14] Stabilize cross-repo PR identity test Co-authored-by: codex --- apps/server/src/git/GitManager.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 5a5b58db81b..256f10fb3b8 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2452,7 +2452,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.number).toBe(142); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true); }), - 12_000, + 20_000, ); it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () => From d3910e1c8f71c26722136a495e4a52fc90d7eac5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Jul 2026 01:01:53 +0200 Subject: [PATCH 14/14] Simplify branch drift copy Co-authored-by: codex --- apps/web/src/components/ChatView.tsx | 13 ++++++------- apps/web/src/components/SidebarV2.tsx | 3 +-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a53f5f235a9..12dd68f46f7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3896,23 +3896,22 @@ function ChatViewContent(props: ChatViewProps) { id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`, variant: "warning", icon: , - title: "Branches diverged", + title: "You're on a different branch", className: "text-base sm:text-sm [&>div]:items-start max-sm:[&>div]:flex-wrap max-sm:[&>div>div:last-child]:w-full max-sm:[&>div>div:last-child]:self-start dark:shadow-none", actionClassName: "max-sm:w-full max-sm:border-t max-sm:border-border/60 max-sm:pt-2 max-sm:pl-6 sm:border-l sm:border-border/60 sm:pl-3", description: (

- Thread{" "} + This thread is on{" "} {localCheckoutBranchMismatch.threadBranch} - - Checkout{" "} + , but you're currently checked out at{" "} {localCheckoutBranchMismatch.currentBranch} - . Sending a message will update the thread branch. + . Sending a message will update the thread.

), actions: ( @@ -3923,7 +3922,7 @@ function ChatViewContent(props: ChatViewProps) { disabled={isRepairingBranch} onClick={() => void handleUpdateThreadToCheckout()} > - {branchRepairAction === "update-thread" ? "Updating..." : "Update thread"} + {branchRepairAction === "update-thread" ? "Moving..." : "Move thread here"} ), diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f8950bb575b..2b81c15c7ad 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -210,8 +210,7 @@ function SidebarV2ThreadTooltip({
- Thread branch differs from active checkout {branchMismatch.currentBranch}; sending a - message will update the thread branch + You're currently checked out on another branch.
) : null}