From 8870103213e54298ef7cb5b14b5cfea32994790f Mon Sep 17 00:00:00 2001 From: taschaub Date: Tue, 5 May 2026 17:15:29 +0200 Subject: [PATCH 1/2] Implement thread branch tracking and mismatch handling in ChatView and Sidebar components - Introduced `useThreadBranchTracking` hook to manage branch auto-linking and mismatch notifications for chat threads. - Added `SidebarThreadBranchBadge` component to display the current branch for each chat in the sidebar. - Removed `resolveLiveThreadBranchUpdate` function to prevent silent overwrites of chat branches, enhancing user awareness of branch mismatches. - Updated `ChatView` to utilize the new branch tracking logic and display appropriate banners for branch mismatches. - Refactored related components and tests to support the new branch tracking functionality. --- apps/web/src/components/ChatView.tsx | 44 ++++- .../GitActionsControl.logic.test.ts | 50 ----- .../src/components/GitActionsControl.logic.ts | 35 +--- apps/web/src/components/GitActionsControl.tsx | 29 +-- apps/web/src/components/Sidebar.tsx | 2 + .../components/SidebarThreadBranchBadge.tsx | 39 ++++ .../chat/useThreadBranchTracking.tsx | 184 ++++++++++++++++++ apps/web/src/lib/threadBranchTracking.test.ts | 91 +++++++++ apps/web/src/lib/threadBranchTracking.ts | 55 ++++++ 9 files changed, 422 insertions(+), 107 deletions(-) create mode 100644 apps/web/src/components/SidebarThreadBranchBadge.tsx create mode 100644 apps/web/src/components/chat/useThreadBranchTracking.tsx create mode 100644 apps/web/src/lib/threadBranchTracking.test.ts create mode 100644 apps/web/src/lib/threadBranchTracking.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ef221e262a..c790c74bd8b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -39,6 +39,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; +import { resolveThreadBranchAutoLink } from "~/lib/threadBranchTracking"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; @@ -150,6 +151,7 @@ import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./Branch import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { useThreadBranchTracking } from "./chat/useThreadBranchTracking"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, @@ -1659,6 +1661,25 @@ export default function ChatView(props: ChatViewProps) { : (storeServerTerminalLaunchContext ?? null); // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + // Branch-tracking glue: auto-link the chat to its first observed branch + // and surface a mismatch banner with checkout/relink actions when the + // working tree drifts. Only meaningful for server threads inside a repo. + const { mismatchBannerItem: branchMismatchBannerItem } = useThreadBranchTracking({ + threadRef: isServerThread ? activeThreadRef : null, + threadBranch: activeThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? null, + projectCwd: activeProject?.cwd ?? null, + gitStatus: isGitRepo ? (gitStatusQuery.data ?? null) : null, + isSendInFlight: isSendBusy, + }); + const composerBannerItemsWithBranchMismatch = useMemo(() => { + if (!branchMismatchBannerItem) { + return composerBannerItems; + } + // Mismatch goes first: it actively blocks the user's mental model of + // "where will this run?" and we want it to be the front-of-stack item. + return [branchMismatchBannerItem, ...composerBannerItems]; + }, [branchMismatchBannerItem, composerBannerItems]); const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -2334,6 +2355,18 @@ export default function ChatView(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); + // First-send fallback: when the chat doesn't carry a branch yet, capture + // the working tree's current branch so the brand-new chat is auto-tagged. + // Older chats without a branch get auto-linked via useThreadBranchTracking + // when the user opens them, but new chats need this capture point because + // their server-side row is created here in the same dispatch. + const initialThreadBranch = + activeThreadBranch ?? + resolveThreadBranchAutoLink({ + threadBranch: null, + gitStatus: gitStatusQuery.data ?? null, + })?.branch ?? + null; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, @@ -2834,7 +2867,7 @@ export default function ChatView(props: ChatViewProps) { modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, - branch: activeThreadBranch, + branch: initialThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, }, @@ -3288,7 +3321,7 @@ export default function ChatView(props: ChatViewProps) { modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", - branch: activeThreadBranch, + branch: initialThreadBranch, worktreePath: activeThread.worktreePath, createdAt, }) @@ -3351,7 +3384,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, activeProposedPlan, - activeThreadBranch, + initialThreadBranch, activeThread, beginLocalDispatch, activeEnvironmentUnavailable, @@ -3602,7 +3635,10 @@ export default function ChatView(props: ChatViewProps) { )} >
- +
{ }); }); -describe("resolveLiveThreadBranchUpdate", () => { - it("returns a branch update when live git status differs from stored thread metadata", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-ref", - gitStatus: status({ refName: "effect-atom" }), - }); - - assert.deepEqual(update, { - branch: "effect-atom", - }); - }); - - it("returns null when live git status is unavailable", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-ref", - gitStatus: null, - }); - - assert.equal(update, null); - }); - - it("returns null when the stored thread ref already matches git status", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "effect-atom", - gitStatus: status({ refName: "effect-atom" }), - }); - - assert.equal(update, null); - }); - - it("returns null when git status is detached HEAD but the thread already has a ref", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "effect-atom", - gitStatus: status({ refName: null }), - }); - - assert.equal(update, null); - }); - - it("does not regress a semantic thread ref back to a temporary worktree ref", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "t3code/github-query-rate-limit", - gitStatus: status({ refName: "t3code/bda76797" }), - }); - - assert.equal(update, null); - }); -}); - describe("resolveAutoFeatureBranchName", () => { it("uses semantic preferred ref names when available", () => { const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 3f6bae61cdd..d8764c524e5 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -3,7 +3,6 @@ import type { GitStackedAction, VcsStatusResult, } from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { DEFAULT_CHANGE_REQUEST_TERMINOLOGY, getChangeRequestTerminology, @@ -373,35 +372,11 @@ export function resolveThreadBranchUpdate( }; } -export function resolveLiveThreadBranchUpdate(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; -}): { branch: string | null } | null { - if (!input.gitStatus) { - return null; - } - - if (input.gitStatus.refName === null && input.threadBranch !== null) { - return null; - } - - if (input.threadBranch === input.gitStatus.refName) { - return null; - } - - if ( - input.threadBranch !== null && - input.gitStatus.refName !== null && - !isTemporaryWorktreeBranch(input.threadBranch) && - isTemporaryWorktreeBranch(input.gitStatus.refName) - ) { - return null; - } - - return { - branch: input.gitStatus.refName, - }; -} +// NOTE: `resolveLiveThreadBranchUpdate` was removed. It silently rewrote a +// chat's `branch` whenever the working tree was on a different ref, which +// conflicts with explicit per-chat branch tracking. The mismatch is now +// surfaced to the user via the chat-view banner instead. See +// `apps/web/src/lib/threadBranchTracking.ts`. // Re-export from shared for backwards compatibility in this module's exports export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 01b84bd94a5..2411c1c7e60 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -39,7 +39,6 @@ import { type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, - resolveLiveThreadBranchUpdate, resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; @@ -1105,28 +1104,12 @@ export default function GitActionsControl({ activeDraftThread?.envMode === "worktree" && activeDraftThread.worktreePath === null; - useEffect(() => { - if (isGitActionRunning || isSelectingWorktreeBase) { - return; - } - - const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, - gitStatus: gitStatusForActions, - }); - if (!branchUpdate) { - return; - } - - persistThreadBranchSync(branchUpdate.branch); - }, [ - activeServerThread?.branch, - activeDraftThread?.branch, - gitStatusForActions, - isGitActionRunning, - isSelectingWorktreeBase, - persistThreadBranchSync, - ]); + // NOTE: We deliberately no longer auto-relink a chat's branch to whatever + // the working tree is currently on. Branch tracking now belongs to the chat + // (see lib/threadBranchTracking + chat/useThreadBranchTracking), and silent + // overwrites would defeat the mismatch banner's purpose. We still sync the + // branch *after explicit user actions* via syncThreadBranchAfterGitAction + // (e.g. when the user creates a feature branch through the menu). const isDefaultRef = useMemo(() => { return gitStatusForActions?.isDefaultRef ?? false; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1e740e5f38f..15e459e8ea3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -18,6 +18,7 @@ import { ThreadStatusLabel, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; +import { SidebarThreadBranchBadge } from "./SidebarThreadBranchBadge"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -593,6 +594,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} +
{terminalStatus && ( diff --git a/apps/web/src/components/SidebarThreadBranchBadge.tsx b/apps/web/src/components/SidebarThreadBranchBadge.tsx new file mode 100644 index 00000000000..733590f98bb --- /dev/null +++ b/apps/web/src/components/SidebarThreadBranchBadge.tsx @@ -0,0 +1,39 @@ +import { GitBranchIcon } from "lucide-react"; +import { memo } from "react"; + +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +interface SidebarThreadBranchBadgeProps { + readonly branch: string | null; +} + +/** + * Static branch label per chat row. Kept intentionally inert — no per-row + * git-status subscription — so we don't add N TanStack queries to the + * sidebar. Mismatch resolution happens in the chat header for the active + * thread, which is the only place the user can act on it anyway. + */ +export const SidebarThreadBranchBadge = memo(function SidebarThreadBranchBadge({ + branch, +}: SidebarThreadBranchBadgeProps) { + if (!branch) { + return null; + } + return ( + + + + ); +}); diff --git a/apps/web/src/components/chat/useThreadBranchTracking.tsx b/apps/web/src/components/chat/useThreadBranchTracking.tsx new file mode 100644 index 00000000000..14761ccc980 --- /dev/null +++ b/apps/web/src/components/chat/useThreadBranchTracking.tsx @@ -0,0 +1,184 @@ +import type { ScopedThreadRef, VcsStatusResult } from "@t3tools/contracts"; +import { GitBranchIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { readEnvironmentApi } from "../../environmentApi"; +import { newCommandId } from "../../lib/utils"; +import { + resolveThreadBranchAutoLink, + resolveThreadBranchMismatch, + type ThreadBranchMismatch, +} from "../../lib/threadBranchTracking"; +import { useStore } from "../../store"; +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; + +interface UseThreadBranchTrackingInput { + // Auto-link + relink only fire for server threads. Drafts capture their + // branch via the createThread bootstrap on first send (see ChatView). + readonly threadRef: ScopedThreadRef | null; + readonly threadBranch: string | null; + readonly worktreePath: string | null; + readonly projectCwd: string | null; + readonly gitStatus: VcsStatusResult | null; + // True while a turn is mid-send so we don't race with thread.turn.start. + readonly isSendInFlight: boolean; +} + +/** + * Per-chat branch tracking glue: + * - Auto-links a server thread to the current ref the first time we observe + * it without a branch (older chats get tagged transparently on open). + * - Builds a banner item for ComposerBannerStack when the chat's stored + * branch differs from the working tree's ref. The banner exposes two + * explicit choices: checkout the chat's branch, or relink to the current. + */ +export function useThreadBranchTracking(input: UseThreadBranchTrackingInput): { + readonly mismatchBannerItem: ComposerBannerStackItem | null; +} { + const setThreadBranch = useStore((store) => store.setThreadBranch); + // Dedupe key — guards StrictMode double-fires and effect re-runs while + // the dispatched event is still propagating back through the store. + const lastAutoLinkedRef = useRef(null); + const [isActionPending, setIsActionPending] = useState(false); + + useEffect(() => { + if (!input.threadRef || input.isSendInFlight) return; + const autoLink = resolveThreadBranchAutoLink({ + threadBranch: input.threadBranch, + gitStatus: input.gitStatus, + }); + if (!autoLink) return; + + const dedupeKey = `${input.threadRef.environmentId}:${input.threadRef.threadId}:${autoLink.branch}`; + if (lastAutoLinkedRef.current === dedupeKey) return; + lastAutoLinkedRef.current = dedupeKey; + + const api = readEnvironmentApi(input.threadRef.environmentId); + if (!api) return; + + // Optimistic local write so the badge updates immediately. Server + // command is fire-and-forget; if it loses, the next snapshot wins. + setThreadBranch(input.threadRef, autoLink.branch, input.worktreePath); + void api.orchestration + .dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: input.threadRef.threadId, + branch: autoLink.branch, + worktreePath: input.worktreePath, + }) + .catch(() => undefined); + }, [ + input.gitStatus, + input.isSendInFlight, + input.threadBranch, + input.threadRef, + input.worktreePath, + setThreadBranch, + ]); + + const mismatch = useMemo( + () => + resolveThreadBranchMismatch({ + threadBranch: input.threadBranch, + currentBranch: input.gitStatus?.refName ?? null, + }), + [input.gitStatus?.refName, input.threadBranch], + ); + + const handleCheckout = useCallback( + async (target: ThreadBranchMismatch) => { + if (!input.threadRef || !input.projectCwd) return; + const api = readEnvironmentApi(input.threadRef.environmentId); + if (!api) return; + // Run checkout against the same working tree the status query points + // at — worktree path if any, otherwise the project root. + const checkoutCwd = input.worktreePath ?? input.projectCwd; + setIsActionPending(true); + try { + await api.vcs.switchRef({ cwd: checkoutCwd, refName: target.threadBranch }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to switch branch.", + description: error instanceof Error ? error.message : "Unknown error.", + }), + ); + } finally { + setIsActionPending(false); + } + }, + [input.projectCwd, input.threadRef, input.worktreePath], + ); + + const handleRelink = useCallback( + async (target: ThreadBranchMismatch) => { + if (!input.threadRef) return; + const api = readEnvironmentApi(input.threadRef.environmentId); + if (!api) return; + setIsActionPending(true); + setThreadBranch(input.threadRef, target.currentBranch, input.worktreePath); + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: input.threadRef.threadId, + branch: target.currentBranch, + worktreePath: input.worktreePath, + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to relink chat to current branch.", + description: error instanceof Error ? error.message : "Unknown error.", + }), + ); + } finally { + setIsActionPending(false); + } + }, + [input.threadRef, input.worktreePath, setThreadBranch], + ); + + const mismatchBannerItem = useMemo(() => { + if (!mismatch) return null; + return { + id: `branch-mismatch:${mismatch.threadBranch}->${mismatch.currentBranch}`, + variant: "warning", + icon: , + title: ( + <> + Chat is on {mismatch.threadBranch}, + checkout is on {mismatch.currentBranch} + + ), + description: + "Continuing now would run the agent against a different branch. Switch the working tree, or relink this chat to the current branch.", + actions: ( + <> + + + + ), + }; + }, [handleCheckout, handleRelink, isActionPending, mismatch]); + + return { mismatchBannerItem }; +} diff --git a/apps/web/src/lib/threadBranchTracking.test.ts b/apps/web/src/lib/threadBranchTracking.test.ts new file mode 100644 index 00000000000..0570bc24a68 --- /dev/null +++ b/apps/web/src/lib/threadBranchTracking.test.ts @@ -0,0 +1,91 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { resolveThreadBranchAutoLink, resolveThreadBranchMismatch } from "./threadBranchTracking"; + +function status(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + ...overrides, + }; +} + +describe("resolveThreadBranchAutoLink", () => { + it("auto-links a chat without a branch to the current ref", () => { + expect( + resolveThreadBranchAutoLink({ + threadBranch: null, + gitStatus: status({ refName: "feature/x" }), + }), + ).toEqual({ branch: "feature/x" }); + }); + + it("never overwrites an existing chat-branch link", () => { + expect( + resolveThreadBranchAutoLink({ + threadBranch: "feature/y", + gitStatus: status({ refName: "feature/x" }), + }), + ).toBeNull(); + }); + + it("ignores temporary worktree-bootstrap branches", () => { + expect( + resolveThreadBranchAutoLink({ + threadBranch: null, + gitStatus: status({ refName: "t3code/abcd1234" }), + }), + ).toBeNull(); + }); + + it("does nothing in detached HEAD or without git status", () => { + expect( + resolveThreadBranchAutoLink({ + threadBranch: null, + gitStatus: status({ refName: null }), + }), + ).toBeNull(); + expect(resolveThreadBranchAutoLink({ threadBranch: null, gitStatus: null })).toBeNull(); + }); +}); + +describe("resolveThreadBranchMismatch", () => { + it("returns null when branches match", () => { + expect(resolveThreadBranchMismatch({ threadBranch: "main", currentBranch: "main" })).toBeNull(); + }); + + it("returns mismatch info when chat and checkout differ", () => { + expect( + resolveThreadBranchMismatch({ + threadBranch: "feature/a", + currentBranch: "feature/b", + }), + ).toEqual({ + threadBranch: "feature/a", + currentBranch: "feature/b", + }); + }); + + it("ignores transient temporary worktree branches", () => { + expect( + resolveThreadBranchMismatch({ + threadBranch: "feature/a", + currentBranch: "t3code/abcd1234", + }), + ).toBeNull(); + }); + + it("returns null when either side is missing", () => { + expect(resolveThreadBranchMismatch({ threadBranch: null, currentBranch: "main" })).toBeNull(); + expect(resolveThreadBranchMismatch({ threadBranch: "main", currentBranch: null })).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/threadBranchTracking.ts b/apps/web/src/lib/threadBranchTracking.ts new file mode 100644 index 00000000000..c0f423a6a1c --- /dev/null +++ b/apps/web/src/lib/threadBranchTracking.ts @@ -0,0 +1,55 @@ +// Pure helpers for tracking the relationship between a chat (thread) and the +// git branch it was first run against. Side-effect free so they can live +// outside React and be tested in isolation. + +import type { VcsStatusResult } from "@t3tools/contracts"; +import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; + +export interface ThreadBranchMismatch { + readonly threadBranch: string; + readonly currentBranch: string; +} + +/** + * Returns the branch we should auto-link a chat to when it has none yet. + * Only fills nulls — never silently overwrites an existing link, since the + * whole point of branch tracking is to make the user the decision-maker + * when chat-branch and checkout drift apart. + */ +export function resolveThreadBranchAutoLink(input: { + readonly threadBranch: string | null; + readonly gitStatus: VcsStatusResult | null; +}): { readonly branch: string } | null { + if (input.threadBranch !== null) return null; + const candidate = input.gitStatus?.refName ?? null; + if (candidate === null) return null; + // Skip ephemeral t3code/ worktree-bootstrap refs — binding to one + // would re-trigger the mismatch UX as soon as the user moves on. + if (isTemporaryWorktreeBranch(candidate)) return null; + return { branch: candidate }; +} + +/** + * Compares a chat's stored branch to the working tree's current branch. + * Returns mismatch info when they disagree so the chat header can offer + * "checkout chat branch" vs. "relink chat to current branch". + */ +export function resolveThreadBranchMismatch(input: { + readonly threadBranch: string | null; + readonly currentBranch: string | null; +}): ThreadBranchMismatch | null { + if (!input.threadBranch || !input.currentBranch) return null; + if (input.threadBranch === input.currentBranch) return null; + // Ignore the brief window where a worktree is bootstrapping on a + // temporary t3code/ ref — that's not a real divergence. + if ( + !isTemporaryWorktreeBranch(input.threadBranch) && + isTemporaryWorktreeBranch(input.currentBranch) + ) { + return null; + } + return { + threadBranch: input.threadBranch, + currentBranch: input.currentBranch, + }; +} From 9f69ecda485a688e30aca488c9560e2536b60220 Mon Sep 17 00:00:00 2001 From: taschaub Date: Tue, 5 May 2026 18:10:11 +0200 Subject: [PATCH 2/2] Refactor ChatView to improve thread branch handling - Renamed `initialThreadBranch` to `liveThreadBranch` for clarity in branch resolution logic. - Updated logic to determine the initial thread branch based on message presence and live thread branch. - Ensured that worktree mode checks for the correct base branch before sending messages, enhancing user experience and error handling. --- apps/web/src/components/ChatView.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c790c74bd8b..ca7f950f83a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2355,18 +2355,18 @@ export default function ChatView(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); - // First-send fallback: when the chat doesn't carry a branch yet, capture - // the working tree's current branch so the brand-new chat is auto-tagged. - // Older chats without a branch get auto-linked via useThreadBranchTracking - // when the user opens them, but new chats need this capture point because - // their server-side row is created here in the same dispatch. - const initialThreadBranch = - activeThreadBranch ?? + const liveThreadBranch = resolveThreadBranchAutoLink({ threadBranch: null, gitStatus: gitStatusQuery.data ?? null, - })?.branch ?? - null; + })?.branch ?? null; + // First-send binding happens here, not when a new draft is opened. Drafts + // may carry a seeded branch from the previous chat or from the toolbar, but + // the actual chat link should reflect the working tree at send time. + const initialThreadBranch = + activeThread?.messages.length === 0 + ? (liveThreadBranch ?? activeThreadBranch) + : (activeThreadBranch ?? liveThreadBranch); const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, @@ -2724,14 +2724,14 @@ export default function ChatView(props: ChatViewProps) { const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath - ? activeThreadBranch + ? initialThreadBranch : null; // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThreadBranch) { + if (shouldCreateWorktree && !initialThreadBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; }