| ... | ... | @@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar"; |
| 138 | 138 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 139 | 139 | import PlanSidebar from "./PlanSidebar"; |
| 140 | 140 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 141 | | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; |
| 141 | import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; |
| 142 | 142 | import { cn, randomHex } from "~/lib/utils"; |
| 143 | 143 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; |
| 144 | 144 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| ... | ... | @@ -231,13 +231,17 @@ import { |
| 231 | 231 | } from "./chat/draftHeroTransition"; |
| 232 | 232 | import { |
| 233 | 233 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 234 | branchMismatchKey, |
| 234 | 235 | buildExpiredTerminalContextToastCopy, |
| 235 | 236 | buildLocalDraftThread, |
| 236 | 237 | buildThreadTurnInterruptInput, |
| 237 | 238 | collectUserMessageBlobPreviewUrls, |
| 238 | 239 | createLocalDispatchSnapshot, |
| 239 | 240 | deriveComposerSendState, |
| 241 | dismissBranchMismatchForSession, |
| 240 | 242 | hasServerAcknowledgedLocalDispatch, |
| 243 | isBranchMismatchDismissedForSession, |
| 244 | shouldShowBranchMismatchBanner, |
| 241 | 245 | getStartedThreadModelChangeBlockReason, |
| 242 | 246 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 243 | 247 | LastInvokedScriptByProjectSchema, |
| ... | ... | @@ -260,6 +264,16 @@ import { RightPanelSheet } from "./RightPanelSheet"; |
| 260 | 264 | import { previewEnvironment } from "../state/preview"; |
| 261 | 265 | import { useAtomCommand } from "../state/use-atom-command"; |
| 262 | 266 | import { Button } from "./ui/button"; |
| 267 | import { |
| 268 | AlertDialog, |
| 269 | AlertDialogClose, |
| 270 | AlertDialogDescription, |
| 271 | AlertDialogFooter, |
| 272 | AlertDialogHeader, |
| 273 | AlertDialogPopup, |
| 274 | AlertDialogTitle, |
| 275 | } from "./ui/alert-dialog"; |
| 276 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 263 | 277 | import { ServerUpdateAction } from "./ServerUpdateAction"; |
| 264 | 278 | import { |
| 265 | 279 | buildVersionMismatchDismissalKey, |
| ... | ... | @@ -3809,52 +3823,57 @@ function ChatViewContent(props: ChatViewProps) { |
| 3809 | 3823 | : null, |
| 3810 | 3824 | [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], |
| 3811 | 3825 | ); |
| 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, |
| 3844 | 3848 | 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]); |
| 3848 | 3867 | const handleSwitchCheckoutToThread = useCallback(async () => { |
| 3849 | 3868 | if ( |
| 3850 | 3869 | !activeProjectCwd || |
| 3851 | 3870 | !activeThread || |
| 3852 | 3871 | !localCheckoutBranchMismatch || |
| 3853 | | branchRepairAction !== null |
| 3872 | isRestoringThreadBranch |
| 3854 | 3873 | ) { |
| 3855 | 3874 | return; |
| 3856 | 3875 | } |
| 3857 | | setBranchRepairAction("switch-checkout"); |
| 3876 | setIsRestoringThreadBranch(true); |
| 3858 | 3877 | const checkoutResult = await switchGitRef({ |
| 3859 | 3878 | environmentId, |
| 3860 | 3879 | input: { |
| ... | ... | @@ -3863,7 +3882,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3863 | 3882 | }, |
| 3864 | 3883 | }); |
| 3865 | 3884 | if (checkoutResult._tag === "Failure") { |
| 3866 | | setBranchRepairAction(null); |
| 3885 | setIsRestoringThreadBranch(false); |
| 3867 | 3886 | if (!isAtomCommandInterrupted(checkoutResult)) { |
| 3868 | 3887 | toastManager.add( |
| 3869 | 3888 | stackedThreadToast({ |
| ... | ... | @@ -3883,7 +3902,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3883 | 3902 | input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, |
| 3884 | 3903 | }); |
| 3885 | 3904 | if (updateResult._tag === "Failure") { |
| 3886 | | setBranchRepairAction(null); |
| 3905 | setIsRestoringThreadBranch(false); |
| 3887 | 3906 | if (!isAtomCommandInterrupted(updateResult)) { |
| 3888 | 3907 | toastManager.add( |
| 3889 | 3908 | stackedThreadToast({ |
| ... | ... | @@ -3898,76 +3917,78 @@ function ChatViewContent(props: ChatViewProps) { |
| 3898 | 3917 | } |
| 3899 | 3918 | } |
| 3900 | 3919 | gitStatusQuery.refresh(); |
| 3901 | | setBranchRepairAction(null); |
| 3920 | setIsRestoringThreadBranch(false); |
| 3902 | 3921 | scheduleComposerFocus(); |
| 3903 | 3922 | }, [ |
| 3904 | 3923 | activeProjectCwd, |
| 3905 | 3924 | activeThread, |
| 3906 | | branchRepairAction, |
| 3907 | 3925 | environmentId, |
| 3908 | 3926 | gitStatusQuery, |
| 3927 | isRestoringThreadBranch, |
| 3909 | 3928 | localCheckoutBranchMismatch, |
| 3910 | 3929 | scheduleComposerFocus, |
| 3911 | 3930 | switchGitRef, |
| 3912 | 3931 | updateThreadMetadata, |
| 3913 | 3932 | ]); |
| 3933 | const handleRestoreThreadBranch = useCallback(() => { |
| 3934 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { |
| 3935 | setBranchRestoreConfirmOpen(true); |
| 3936 | return; |
| 3937 | } |
| 3938 | void handleSwitchCheckoutToThread(); |
| 3939 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); |
| 3914 | 3940 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 3915 | | if (!localCheckoutBranchMismatch) { |
| 3941 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 3916 | 3942 | return systemComposerBannerItems; |
| 3917 | 3943 | } |
| 3918 | | const isRepairingBranch = branchRepairAction !== null; |
| 3919 | 3944 | return [ |
| 3920 | 3945 | ...systemComposerBannerItems, |
| 3921 | 3946 | { |
| 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> |
| 3942 | 3967 | ), |
| 3968 | className: "dark:shadow-none", |
| 3943 | 3969 | 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> |
| 3962 | 3978 | ), |
| 3979 | dismissLabel: "Dismiss branch change notice", |
| 3980 | onDismiss: () => { |
| 3981 | dismissBranchMismatchForSession(activeBranchMismatchKey); |
| 3982 | setBranchMismatchDismissTick((tick) => tick + 1); |
| 3983 | }, |
| 3963 | 3984 | }, |
| 3964 | 3985 | ]; |
| 3965 | 3986 | }, [ |
| 3966 | | activeThread?.id, |
| 3967 | | branchRepairAction, |
| 3968 | | handleSwitchCheckoutToThread, |
| 3969 | | handleUpdateThreadToCheckout, |
| 3987 | activeBranchMismatchKey, |
| 3988 | handleRestoreThreadBranch, |
| 3989 | isRestoringThreadBranch, |
| 3970 | 3990 | localCheckoutBranchMismatch, |
| 3991 | showBranchMismatchBanner, |
| 3971 | 3992 | systemComposerBannerItems, |
| 3972 | 3993 | ]); |
| 3973 | 3994 | |
| ... | ... | @@ -5726,6 +5747,36 @@ function ChatViewContent(props: ChatViewProps) { |
| 5726 | 5747 | </div> |
| 5727 | 5748 | </div> |
| 5728 | 5749 | |
| 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 | |
| 5729 | 5780 | {pullRequestDialogState ? ( |
| 5730 | 5781 | <PullRequestThreadDialog |
| 5731 | 5782 | key={pullRequestDialogState.key} |