diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 000a9d99c6b..256f10fb3b8 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"; @@ -2413,6 +2414,123 @@ 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": [ + `[{"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": ["[]"], + statemachine: ["[]"], + }, + }, + }); + + 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); + }), + 20_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: Option.none(), + 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: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(true); + }), + ); + + 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( + GitManager.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: Option.none(), + 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 e59613f7cc8..86e10d9f930 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -252,7 +252,38 @@ function resolvePullRequestHeadRepositoryNameWithOwner( return `${ownerLogin}/${repositoryName}`; } -function matchesBranchHeadContext( +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), + }; +} + +export function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< BranchHeadContext, @@ -263,44 +294,51 @@ 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) { + if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) { + return false; + } + } + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { + return false; + } } - if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { + } + + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + } + + if (headContext.isCrossRepository) { + if (pr.isCrossRepository === false) { return false; } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + if ( + (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) && + !pullRequestHead.repositoryNameWithOwner && + !pullRequestHead.ownerLogin + ) { return false; } return true; } if (pr.isCrossRepository === true) { - return false; - } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; - } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { - return false; + if ( + (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) || + (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin) + ) { + 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/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index cfd09272aae..08ee713a932 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -473,6 +473,7 @@ export function BranchToolbarBranchSelector({ const worktreeBaseBranchCandidate = isInitialBranchesLoadPending ? null : (defaultBranchName ?? currentGitBranch); + useEffect(() => { if ( effectiveEnvMode !== "worktree" || @@ -589,7 +590,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/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c578e69c450..2f865855598 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -20,6 +20,7 @@ import { hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -79,6 +80,34 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("resolveThreadMetadataUpdateForNextTurn", () => { + 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..12dd68f46f7 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,179 @@ 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: "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: ( +

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

+ ), + actions: ( + <> + + + + ), + }, + ]; + }, [ + activeThread?.id, + branchRepairAction, + handleSwitchCheckoutToThread, + handleUpdateThreadToCheckout, + localCheckoutBranchMismatch, + systemComposerBannerItems, + ]); useEffect(() => { setPendingServerThreadEnvMode(null); @@ -4313,6 +4493,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode, }); @@ -4694,6 +4877,9 @@ function ChatViewContent(props: ChatViewProps) { threadId: threadIdForSend, createdAt: messageCreatedAt, modelSelection: ctxSelectedModelSelection, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode: nextInteractionMode, }); @@ -4770,6 +4956,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting, isSendBusy, isServerThread, + localCheckoutBranchMismatch, persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, 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/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/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 9bc87eed64a..2b81c15c7ad 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 ? ( +
+ +
+ You're currently checked out on another branch. +
+
+ ) : null} {driverKind ? (
); diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts new file mode 100644 index 00000000000..24ccc6ef33f --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -0,0 +1,73 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { prStatusIndicator, 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", + baseRef: "main", + headRef: "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); + }); +}); + +describe("prStatusIndicator", () => { + it("formats PR tooltips with number, uppercase status, and title", () => { + 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 55f9fbfdc04..3c58d6ea989 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,26 @@ 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 +66,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 +76,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 +89,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; } @@ -199,14 +237,18 @@ 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 }, }) : 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 +275,9 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar > - {prStatus.tooltip} + + + ) : null} {threadStatus ? : null} 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 ? (