authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 18:38:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 18:38:05-07:00
logfc3f78f5d917fdeee6271d2f6fd42961369c7d9f
tree26b9e30707cc3bd9f6f8d3fcee98aaee79c23e46
parent2f41c073a2f846e9a96082e76cd1b2c3a7e44c18
signature Signed by PGP key B5690EEEBB952194

fix: tone down branch-mismatch banner (#4416)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 236 insertions(+), 86 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+59
......@@ -12,16 +12,20 @@ import type { Thread } from "../types";
1212import {
1313 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1414 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
15 branchMismatchKey,
1516 buildExpiredTerminalContextToastCopy,
1617 buildThreadTurnInterruptInput,
1718 createLocalDispatchSnapshot,
1819 deriveComposerSendState,
20 dismissBranchMismatchForSession,
1921 getStartedThreadModelChangeBlockReason,
2022 hasServerAcknowledgedLocalDispatch,
23 isBranchMismatchDismissedForSession,
2124 reconcileMountedTerminalThreadIds,
2225 reconcileRetainedMountedThreadIds,
2326 resolveThreadMetadataUpdateForNextTurn,
2427 resolveSendEnvMode,
28 shouldShowBranchMismatchBanner,
2529 shouldWriteThreadErrorToCurrentServerThread,
2630} from "./ChatView.logic";
2731
......@@ -292,6 +296,61 @@ describe("resolveSendEnvMode", () => {
292296 });
293297});
294298
299describe("branchMismatchKey", () => {
300 it("builds a key from thread id and both branches", () => {
301 expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(
302 "thread-1:feat/a:feat/b",
303 );
304 });
305
306 it("returns null without a thread or mismatch", () => {
307 expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull();
308 expect(branchMismatchKey("thread-1", null)).toBeNull();
309 });
310});
311
312describe("shouldShowBranchMismatchBanner", () => {
313 const base = {
314 hasMismatch: true,
315 isDismissed: false,
316 composerHasContent: false,
317 wasShownForCurrentMismatch: false,
318 };
319
320 it("stays hidden during passive browsing (even though the composer autofocuses)", () => {
321 expect(shouldShowBranchMismatchBanner(base)).toBe(false);
322 });
323
324 it("shows once the composer has draft content", () => {
325 expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true);
326 });
327
328 it("stays mounted after the draft clears once shown for the current mismatch", () => {
329 expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe(
330 true,
331 );
332 });
333
334 it("never shows when dismissed or without a mismatch", () => {
335 expect(
336 shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }),
337 ).toBe(false);
338 expect(
339 shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }),
340 ).toBe(false);
341 });
342});
343
344describe("session branch mismatch dismissal", () => {
345 it("tracks dismissed keys and treats other keys as active", () => {
346 expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false);
347 dismissBranchMismatchForSession("t1:a:b");
348 expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true);
349 expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false);
350 expect(isBranchMismatchDismissedForSession(null)).toBe(false);
351 });
352});
353
295354describe("reconcileMountedTerminalThreadIds", () => {
296355 it("keeps open threads and makes the active thread most recent", () => {
297356 expect(
apps/web/src/components/ChatView.logic.ts+40
......@@ -289,6 +289,46 @@ export function buildExpiredTerminalContextToastCopy(
289289 };
290290}
291291
292export function branchMismatchKey(
293 threadId: string | null,
294 mismatch: { threadBranch: string; currentBranch: string } | null,
295): string | null {
296 if (!threadId || !mismatch) {
297 return null;
298 }
299 return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`;
300}
301
302// The mismatch banner only matters when the user is about to send: passive
303// reading of an old thread carries no risk (the branch picker tint already
304// covers ambient awareness). Draft content is the intent signal — composer
305// focus is useless here because ChatView autofocuses the composer on every
306// thread open. `wasShownForCurrentMismatch` keeps the banner mounted once
307// revealed so it doesn't flicker away when the draft is cleared.
308export function shouldShowBranchMismatchBanner(input: {
309 hasMismatch: boolean;
310 isDismissed: boolean;
311 composerHasContent: boolean;
312 wasShownForCurrentMismatch: boolean;
313}): boolean {
314 if (!input.hasMismatch || input.isDismissed) {
315 return false;
316 }
317 return input.composerHasContent || input.wasShownForCurrentMismatch;
318}
319
320// Session-scoped (module-level so it survives ChatView remounts, e.g. route
321// changes). Durable cross-device dismissal is planned as a server-side ack.
322const sessionDismissedBranchMismatchKeys = new Set<string>();
323
324export function dismissBranchMismatchForSession(key: string): void {
325 sessionDismissedBranchMismatchKeys.add(key);
326}
327
328export function isBranchMismatchDismissedForSession(key: string | null): boolean {
329 return key !== null && sessionDismissedBranchMismatchKeys.has(key);
330}
331
292332export function threadHasStarted(thread: Thread | null | undefined): boolean {
293333 return Boolean(
294334 thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),
apps/web/src/components/ChatView.tsx+137-86
......@@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar";
138138import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
139139import PlanSidebar from "./PlanSidebar";
140140import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
141import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
141import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
142142import { cn, randomHex } from "~/lib/utils";
143143import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
144144import { stackedThreadToast, toastManager } from "./ui/toast";
......@@ -231,13 +231,17 @@ import {
231231} from "./chat/draftHeroTransition";
232232import {
233233 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
234 branchMismatchKey,
234235 buildExpiredTerminalContextToastCopy,
235236 buildLocalDraftThread,
236237 buildThreadTurnInterruptInput,
237238 collectUserMessageBlobPreviewUrls,
238239 createLocalDispatchSnapshot,
239240 deriveComposerSendState,
241 dismissBranchMismatchForSession,
240242 hasServerAcknowledgedLocalDispatch,
243 isBranchMismatchDismissedForSession,
244 shouldShowBranchMismatchBanner,
241245 getStartedThreadModelChangeBlockReason,
242246 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
243247 LastInvokedScriptByProjectSchema,
......@@ -260,6 +264,16 @@ import { RightPanelSheet } from "./RightPanelSheet";
260264import { previewEnvironment } from "../state/preview";
261265import { useAtomCommand } from "../state/use-atom-command";
262266import { Button } from "./ui/button";
267import {
268 AlertDialog,
269 AlertDialogClose,
270 AlertDialogDescription,
271 AlertDialogFooter,
272 AlertDialogHeader,
273 AlertDialogPopup,
274 AlertDialogTitle,
275} from "./ui/alert-dialog";
276import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
263277import { ServerUpdateAction } from "./ServerUpdateAction";
264278import {
265279 buildVersionMismatchDismissalKey,
......@@ -3809,52 +3823,57 @@ function ChatViewContent(props: ChatViewProps) {
38093823 : null,
38103824 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],
38113825 );
3812 const [branchRepairAction, setBranchRepairAction] = useState<
3813 "update-thread" | "switch-checkout" | null
3814 >(null);
3815 const handleUpdateThreadToCheckout = useCallback(async () => {
3816 if (!activeThread || !localCheckoutBranchMismatch || branchRepairAction !== null) {
3817 return;
3818 }
3819 setBranchRepairAction("update-thread");
3820 const updateResult = await updateThreadMetadata({
3821 environmentId,
3822 input: {
3823 threadId: activeThread.id,
3824 branch: localCheckoutBranchMismatch.currentBranch,
3825 worktreePath: null,
3826 },
3827 });
3828 setBranchRepairAction(null);
3829 if (updateResult._tag === "Failure" && !isAtomCommandInterrupted(updateResult)) {
3830 toastManager.add(
3831 stackedThreadToast({
3832 type: "error",
3833 title: "Failed to update thread branch",
3834 description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)),
3835 }),
3836 );
3837 return;
3838 }
3839 scheduleComposerFocus();
3840 }, [
3841 activeThread,
3842 branchRepairAction,
3843 environmentId,
3826 const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);
3827 const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);
3828 // Once revealed for a given mismatch, the banner stays mounted until the
3829 // mismatch changes or resolves, so clearing the draft doesn't flicker it.
3830 const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState<string | null>(null);
3831 // Dismissal lives in a module-level set (survives remounts); this tick just
3832 // forces a re-render so the banner leaves immediately.
3833 const [, setBranchMismatchDismissTick] = useState(0);
3834 const composerHasDraftContent = useComposerDraftStore((store) => {
3835 const draft = store.getComposerDraft(composerDraftTarget);
3836 return Boolean(
3837 draft &&
3838 (draft.prompt.trim().length > 0 ||
3839 draft.images.length > 0 ||
3840 draft.terminalContexts.length > 0 ||
3841 draft.elementContexts.length > 0 ||
3842 draft.previewAnnotations.length > 0 ||
3843 draft.reviewComments.length > 0),
3844 );
3845 });
3846 const activeBranchMismatchKey = branchMismatchKey(
3847 activeThread?.id ?? null,
38443848 localCheckoutBranchMismatch,
3845 scheduleComposerFocus,
3846 updateThreadMetadata,
3847 ]);
3849 );
3850 const showBranchMismatchBanner = shouldShowBranchMismatchBanner({
3851 hasMismatch: localCheckoutBranchMismatch !== null,
3852 isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey),
3853 composerHasContent: composerHasDraftContent,
3854 wasShownForCurrentMismatch:
3855 revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey,
3856 });
3857 useEffect(() => {
3858 setRevealedBranchMismatchKey((revealed) => {
3859 if (showBranchMismatchBanner) {
3860 return activeBranchMismatchKey;
3861 }
3862 // Hysteresis is scoped to an uninterrupted mismatch: reset when the
3863 // mismatch resolves or changes so a recurrence re-gates on intent.
3864 return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed;
3865 });
3866 }, [activeBranchMismatchKey, showBranchMismatchBanner]);
38483867 const handleSwitchCheckoutToThread = useCallback(async () => {
38493868 if (
38503869 !activeProjectCwd ||
38513870 !activeThread ||
38523871 !localCheckoutBranchMismatch ||
3853 branchRepairAction !== null
3872 isRestoringThreadBranch
38543873 ) {
38553874 return;
38563875 }
3857 setBranchRepairAction("switch-checkout");
3876 setIsRestoringThreadBranch(true);
38583877 const checkoutResult = await switchGitRef({
38593878 environmentId,
38603879 input: {
......@@ -3863,7 +3882,7 @@ function ChatViewContent(props: ChatViewProps) {
38633882 },
38643883 });
38653884 if (checkoutResult._tag === "Failure") {
3866 setBranchRepairAction(null);
3885 setIsRestoringThreadBranch(false);
38673886 if (!isAtomCommandInterrupted(checkoutResult)) {
38683887 toastManager.add(
38693888 stackedThreadToast({
......@@ -3883,7 +3902,7 @@ function ChatViewContent(props: ChatViewProps) {
38833902 input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null },
38843903 });
38853904 if (updateResult._tag === "Failure") {
3886 setBranchRepairAction(null);
3905 setIsRestoringThreadBranch(false);
38873906 if (!isAtomCommandInterrupted(updateResult)) {
38883907 toastManager.add(
38893908 stackedThreadToast({
......@@ -3898,76 +3917,78 @@ function ChatViewContent(props: ChatViewProps) {
38983917 }
38993918 }
39003919 gitStatusQuery.refresh();
3901 setBranchRepairAction(null);
3920 setIsRestoringThreadBranch(false);
39023921 scheduleComposerFocus();
39033922 }, [
39043923 activeProjectCwd,
39053924 activeThread,
3906 branchRepairAction,
39073925 environmentId,
39083926 gitStatusQuery,
3927 isRestoringThreadBranch,
39093928 localCheckoutBranchMismatch,
39103929 scheduleComposerFocus,
39113930 switchGitRef,
39123931 updateThreadMetadata,
39133932 ]);
3933 const handleRestoreThreadBranch = useCallback(() => {
3934 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
3935 setBranchRestoreConfirmOpen(true);
3936 return;
3937 }
3938 void handleSwitchCheckoutToThread();
3939 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
39143940 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
3915 if (!localCheckoutBranchMismatch) {
3941 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
39163942 return systemComposerBannerItems;
39173943 }
3918 const isRepairingBranch = branchRepairAction !== null;
39193944 return [
39203945 ...systemComposerBannerItems,
39213946 {
3922 id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`,
3923 variant: "warning",
3924 icon: <TriangleAlertIcon />,
3925 title: "You're on a different branch",
3926 className:
3927 "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",
3928 actionClassName:
3929 "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",
3930 description: (
3931 <p className="text-pretty">
3932 This thread is on{" "}
3933 <code className="font-medium text-foreground">
3934 {localCheckoutBranchMismatch.threadBranch}
3935 </code>
3936 , but you're currently checked out at{" "}
3937 <code className="font-medium text-foreground">
3938 {localCheckoutBranchMismatch.currentBranch}
3939 </code>
3940 . Sending a message will update the thread.
3941 </p>
3947 id: `branch-mismatch:${activeBranchMismatchKey}`,
3948 variant: "info",
3949 icon: <GitBranchIcon />,
3950 title: (
3951 <span className="flex min-w-0 items-baseline gap-1.5">
3952 <span className="shrink-0 font-normal text-muted-foreground">Branch changed — was</span>
3953 <Tooltip>
3954 <TooltipTrigger
3955 render={
3956 <code className="min-w-0 truncate font-medium text-foreground">
3957 {localCheckoutBranchMismatch.threadBranch}
3958 </code>
3959 }
3960 />
3961 <TooltipPopup side="top" className="max-w-80">
3962 This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will
3963 continue on {localCheckoutBranchMismatch.currentBranch}.
3964 </TooltipPopup>
3965 </Tooltip>
3966 </span>
39423967 ),
3968 className: "dark:shadow-none",
39433969 actions: (
3944 <>
3945 <Button
3946 size="xs"
3947 variant="outline"
3948 disabled={isRepairingBranch}
3949 onClick={() => void handleUpdateThreadToCheckout()}
3950 >
3951 {branchRepairAction === "update-thread" ? "Moving..." : "Move thread here"}
3952 </Button>
3953 <Button
3954 size="xs"
3955 variant="ghost"
3956 disabled={isRepairingBranch}
3957 onClick={() => void handleSwitchCheckoutToThread()}
3958 >
3959 {branchRepairAction === "switch-checkout" ? "Switching..." : "Checkout thread branch"}
3960 </Button>
3961 </>
3970 <Button
3971 size="xs"
3972 variant="ghost"
3973 disabled={isRestoringThreadBranch}
3974 onClick={handleRestoreThreadBranch}
3975 >
3976 {isRestoringThreadBranch ? "Restoring..." : "Restore branch"}
3977 </Button>
39623978 ),
3979 dismissLabel: "Dismiss branch change notice",
3980 onDismiss: () => {
3981 dismissBranchMismatchForSession(activeBranchMismatchKey);
3982 setBranchMismatchDismissTick((tick) => tick + 1);
3983 },
39633984 },
39643985 ];
39653986 }, [
3966 activeThread?.id,
3967 branchRepairAction,
3968 handleSwitchCheckoutToThread,
3969 handleUpdateThreadToCheckout,
3987 activeBranchMismatchKey,
3988 handleRestoreThreadBranch,
3989 isRestoringThreadBranch,
39703990 localCheckoutBranchMismatch,
3991 showBranchMismatchBanner,
39713992 systemComposerBannerItems,
39723993 ]);
39733994
......@@ -5726,6 +5747,36 @@ function ChatViewContent(props: ChatViewProps) {
57265747 </div>
57275748 </div>
57285749
5750 <AlertDialog open={branchRestoreConfirmOpen} onOpenChange={setBranchRestoreConfirmOpen}>
5751 <AlertDialogPopup>
5752 <AlertDialogHeader>
5753 <AlertDialogTitle>
5754 Switch to{" "}
5755 <code className="font-medium">
5756 {localCheckoutBranchMismatch?.threadBranch ?? ""}
5757 </code>
5758 ?
5759 </AlertDialogTitle>
5760 <AlertDialogDescription>
5761 You have uncommitted changes. They'll carry over to the other branch, or block
5762 the switch if they conflict.
5763 </AlertDialogDescription>
5764 </AlertDialogHeader>
5765 <AlertDialogFooter>
5766 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
5767 <Button
5768 variant="default"
5769 onClick={() => {
5770 setBranchRestoreConfirmOpen(false);
5771 void handleSwitchCheckoutToThread();
5772 }}
5773 >
5774 Switch branch
5775 </Button>
5776 </AlertDialogFooter>
5777 </AlertDialogPopup>
5778 </AlertDialog>
5779
57295780 {pullRequestDialogState ? (
57305781 <PullRequestThreadDialog
57315782 key={pullRequestDialogState.key}