diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2f865855598..57c12959ffb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,16 +12,20 @@ import type { Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + branchMismatchKey, buildExpiredTerminalContextToastCopy, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, + isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -292,6 +296,61 @@ describe("resolveSendEnvMode", () => { }); }); +describe("branchMismatchKey", () => { + it("builds a key from thread id and both branches", () => { + expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( + "thread-1:feat/a:feat/b", + ); + }); + + it("returns null without a thread or mismatch", () => { + expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull(); + expect(branchMismatchKey("thread-1", null)).toBeNull(); + }); +}); + +describe("shouldShowBranchMismatchBanner", () => { + const base = { + hasMismatch: true, + isDismissed: false, + composerHasContent: false, + wasShownForCurrentMismatch: false, + }; + + it("stays hidden during passive browsing (even though the composer autofocuses)", () => { + expect(shouldShowBranchMismatchBanner(base)).toBe(false); + }); + + it("shows once the composer has draft content", () => { + expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true); + }); + + it("stays mounted after the draft clears once shown for the current mismatch", () => { + expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe( + true, + ); + }); + + it("never shows when dismissed or without a mismatch", () => { + expect( + shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }), + ).toBe(false); + expect( + shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }), + ).toBe(false); + }); +}); + +describe("session branch mismatch dismissal", () => { + it("tracks dismissed keys and treats other keys as active", () => { + expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); + dismissBranchMismatchForSession("t1:a:b"); + expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true); + expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false); + expect(isBranchMismatchDismissedForSession(null)).toBe(false); + }); +}); + describe("reconcileMountedTerminalThreadIds", () => { it("keeps open threads and makes the active thread most recent", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 591f56ea4c7..466c9b24c87 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -289,6 +289,46 @@ export function buildExpiredTerminalContextToastCopy( }; } +export function branchMismatchKey( + threadId: string | null, + mismatch: { threadBranch: string; currentBranch: string } | null, +): string | null { + if (!threadId || !mismatch) { + return null; + } + return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`; +} + +// The mismatch banner only matters when the user is about to send: passive +// reading of an old thread carries no risk (the branch picker tint already +// covers ambient awareness). Draft content is the intent signal — composer +// focus is useless here because ChatView autofocuses the composer on every +// thread open. `wasShownForCurrentMismatch` keeps the banner mounted once +// revealed so it doesn't flicker away when the draft is cleared. +export function shouldShowBranchMismatchBanner(input: { + hasMismatch: boolean; + isDismissed: boolean; + composerHasContent: boolean; + wasShownForCurrentMismatch: boolean; +}): boolean { + if (!input.hasMismatch || input.isDismissed) { + return false; + } + return input.composerHasContent || input.wasShownForCurrentMismatch; +} + +// Session-scoped (module-level so it survives ChatView remounts, e.g. route +// changes). Durable cross-device dismissal is planned as a server-side ack. +const sessionDismissedBranchMismatchKeys = new Set(); + +export function dismissBranchMismatchForSession(key: string): void { + sessionDismissedBranchMismatchKeys.add(key); +} + +export function isBranchMismatchDismissedForSession(key: string | null): boolean { + return key !== null && sessionDismissedBranchMismatchKeys.has(key); +} + export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean( thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9a3eb10a18d..764ad164214 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -231,13 +231,17 @@ import { } from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, + dismissBranchMismatchForSession, hasServerAcknowledgedLocalDispatch, + isBranchMismatchDismissedForSession, + shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -260,6 +264,16 @@ import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ServerUpdateAction } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, @@ -3809,52 +3823,57 @@ function ChatViewContent(props: ChatViewProps) { : 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, + const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); + const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); + // Once revealed for a given mismatch, the banner stays mounted until the + // mismatch changes or resolves, so clearing the draft doesn't flicker it. + const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState(null); + // Dismissal lives in a module-level set (survives remounts); this tick just + // forces a re-render so the banner leaves immediately. + const [, setBranchMismatchDismissTick] = useState(0); + const composerHasDraftContent = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return Boolean( + draft && + (draft.prompt.trim().length > 0 || + draft.images.length > 0 || + draft.terminalContexts.length > 0 || + draft.elementContexts.length > 0 || + draft.previewAnnotations.length > 0 || + draft.reviewComments.length > 0), + ); + }); + const activeBranchMismatchKey = branchMismatchKey( + activeThread?.id ?? null, localCheckoutBranchMismatch, - scheduleComposerFocus, - updateThreadMetadata, - ]); + ); + const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ + hasMismatch: localCheckoutBranchMismatch !== null, + isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), + composerHasContent: composerHasDraftContent, + wasShownForCurrentMismatch: + revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, + }); + useEffect(() => { + setRevealedBranchMismatchKey((revealed) => { + if (showBranchMismatchBanner) { + return activeBranchMismatchKey; + } + // Hysteresis is scoped to an uninterrupted mismatch: reset when the + // mismatch resolves or changes so a recurrence re-gates on intent. + return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed; + }); + }, [activeBranchMismatchKey, showBranchMismatchBanner]); const handleSwitchCheckoutToThread = useCallback(async () => { if ( !activeProjectCwd || !activeThread || !localCheckoutBranchMismatch || - branchRepairAction !== null + isRestoringThreadBranch ) { return; } - setBranchRepairAction("switch-checkout"); + setIsRestoringThreadBranch(true); const checkoutResult = await switchGitRef({ environmentId, input: { @@ -3863,7 +3882,7 @@ function ChatViewContent(props: ChatViewProps) { }, }); if (checkoutResult._tag === "Failure") { - setBranchRepairAction(null); + setIsRestoringThreadBranch(false); if (!isAtomCommandInterrupted(checkoutResult)) { toastManager.add( stackedThreadToast({ @@ -3883,7 +3902,7 @@ function ChatViewContent(props: ChatViewProps) { input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, }); if (updateResult._tag === "Failure") { - setBranchRepairAction(null); + setIsRestoringThreadBranch(false); if (!isAtomCommandInterrupted(updateResult)) { toastManager.add( stackedThreadToast({ @@ -3898,76 +3917,78 @@ function ChatViewContent(props: ChatViewProps) { } } gitStatusQuery.refresh(); - setBranchRepairAction(null); + setIsRestoringThreadBranch(false); scheduleComposerFocus(); }, [ activeProjectCwd, activeThread, - branchRepairAction, environmentId, gitStatusQuery, + isRestoringThreadBranch, localCheckoutBranchMismatch, scheduleComposerFocus, switchGitRef, updateThreadMetadata, ]); + const handleRestoreThreadBranch = useCallback(() => { + if (gitStatusQuery.data?.hasWorkingTreeChanges) { + setBranchRestoreConfirmOpen(true); + return; + } + void handleSwitchCheckoutToThread(); + }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { - if (!localCheckoutBranchMismatch) { + if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { 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. -

+ id: `branch-mismatch:${activeBranchMismatchKey}`, + variant: "info", + icon: , + title: ( + + Branch changed — was + + + {localCheckoutBranchMismatch.threadBranch} + + } + /> + + This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will + continue on {localCheckoutBranchMismatch.currentBranch}. + + + ), + className: "dark:shadow-none", actions: ( - <> - - - + ), + dismissLabel: "Dismiss branch change notice", + onDismiss: () => { + dismissBranchMismatchForSession(activeBranchMismatchKey); + setBranchMismatchDismissTick((tick) => tick + 1); + }, }, ]; }, [ - activeThread?.id, - branchRepairAction, - handleSwitchCheckoutToThread, - handleUpdateThreadToCheckout, + activeBranchMismatchKey, + handleRestoreThreadBranch, + isRestoringThreadBranch, localCheckoutBranchMismatch, + showBranchMismatchBanner, systemComposerBannerItems, ]); @@ -5726,6 +5747,36 @@ function ChatViewContent(props: ChatViewProps) { + + + + + Switch to{" "} + + {localCheckoutBranchMismatch?.threadBranch ?? ""} + + ? + + + You have uncommitted changes. They'll carry over to the other branch, or block + the switch if they conflict. + + + + }>Cancel + + + + + {pullRequestDialogState ? (