| ... | @@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar"; | ... | @@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar"; |
| 138 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 138 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 139 | import PlanSidebar from "./PlanSidebar"; | 139 | import PlanSidebar from "./PlanSidebar"; |
| 140 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 140 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 141 | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; | 141 | import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; |
| 142 | import { cn, randomHex } from "~/lib/utils"; | 142 | import { cn, randomHex } from "~/lib/utils"; |
| 143 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | 143 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; |
| 144 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 144 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| ... | @@ -231,13 +231,17 @@ import { | ... | @@ -231,13 +231,17 @@ import { |
| 231 | } from "./chat/draftHeroTransition"; | 231 | } from "./chat/draftHeroTransition"; |
| 232 | import { | 232 | import { |
| 233 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 233 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| | 234 | branchMismatchKey, |
| 234 | buildExpiredTerminalContextToastCopy, | 235 | buildExpiredTerminalContextToastCopy, |
| 235 | buildLocalDraftThread, | 236 | buildLocalDraftThread, |
| 236 | buildThreadTurnInterruptInput, | 237 | buildThreadTurnInterruptInput, |
| 237 | collectUserMessageBlobPreviewUrls, | 238 | collectUserMessageBlobPreviewUrls, |
| 238 | createLocalDispatchSnapshot, | 239 | createLocalDispatchSnapshot, |
| 239 | deriveComposerSendState, | 240 | deriveComposerSendState, |
| | 241 | dismissBranchMismatchForSession, |
| 240 | hasServerAcknowledgedLocalDispatch, | 242 | hasServerAcknowledgedLocalDispatch, |
| | 243 | isBranchMismatchDismissedForSession, |
| | 244 | shouldShowBranchMismatchBanner, |
| 241 | getStartedThreadModelChangeBlockReason, | 245 | getStartedThreadModelChangeBlockReason, |
| 242 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | 246 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 243 | LastInvokedScriptByProjectSchema, | 247 | LastInvokedScriptByProjectSchema, |
| ... | @@ -260,6 +264,16 @@ import { RightPanelSheet } from "./RightPanelSheet"; | ... | @@ -260,6 +264,16 @@ import { RightPanelSheet } from "./RightPanelSheet"; |
| 260 | import { previewEnvironment } from "../state/preview"; | 264 | import { previewEnvironment } from "../state/preview"; |
| 261 | import { useAtomCommand } from "../state/use-atom-command"; | 265 | import { useAtomCommand } from "../state/use-atom-command"; |
| 262 | import { Button } from "./ui/button"; | 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 | import { ServerUpdateAction } from "./ServerUpdateAction"; | 277 | import { ServerUpdateAction } from "./ServerUpdateAction"; |
| 264 | import { | 278 | import { |
| 265 | buildVersionMismatchDismissalKey, | 279 | buildVersionMismatchDismissalKey, |
| ... | @@ -3809,52 +3823,57 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3809,52 +3823,57 @@ function ChatViewContent(props: ChatViewProps) { |
| 3809 | : null, | 3823 | : null, |
| 3810 | [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], | 3824 | [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], |
| 3811 | ); | 3825 | ); |
| 3812 | const [branchRepairAction, setBranchRepairAction] = useState< | 3826 | const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); |
| 3813 | "update-thread" | "switch-checkout" | null | 3827 | const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); |
| 3814 | >(null); | 3828 | // Once revealed for a given mismatch, the banner stays mounted until the |
| 3815 | const handleUpdateThreadToCheckout = useCallback(async () => { | 3829 | // mismatch changes or resolves, so clearing the draft doesn't flicker it. |
| 3816 | if (!activeThread || !localCheckoutBranchMismatch || branchRepairAction !== null) { | 3830 | const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState<string | null>(null); |
| 3817 | return; | 3831 | // Dismissal lives in a module-level set (survives remounts); this tick just |
| 3818 | } | 3832 | // forces a re-render so the banner leaves immediately. |
| 3819 | setBranchRepairAction("update-thread"); | 3833 | const [, setBranchMismatchDismissTick] = useState(0); |
| 3820 | const updateResult = await updateThreadMetadata({ | 3834 | const composerHasDraftContent = useComposerDraftStore((store) => { |
| 3821 | environmentId, | 3835 | const draft = store.getComposerDraft(composerDraftTarget); |
| 3822 | input: { | 3836 | return Boolean( |
| 3823 | threadId: activeThread.id, | 3837 | draft && |
| 3824 | branch: localCheckoutBranchMismatch.currentBranch, | 3838 | (draft.prompt.trim().length > 0 || |
| 3825 | worktreePath: null, | 3839 | draft.images.length > 0 || |
| 3826 | }, | 3840 | draft.terminalContexts.length > 0 || |
| 3827 | }); | 3841 | draft.elementContexts.length > 0 || |
| 3828 | setBranchRepairAction(null); | 3842 | draft.previewAnnotations.length > 0 || |
| 3829 | if (updateResult._tag === "Failure" && !isAtomCommandInterrupted(updateResult)) { | 3843 | draft.reviewComments.length > 0), |
| 3830 | toastManager.add( | 3844 | ); |
| 3831 | stackedThreadToast({ | 3845 | }); |
| 3832 | type: "error", | 3846 | const activeBranchMismatchKey = branchMismatchKey( |
| 3833 | title: "Failed to update thread branch", | 3847 | activeThread?.id ?? null, |
| 3834 | description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), | | |
| 3835 | }), | | |
| 3836 | ); | | |
| 3837 | return; | | |
| 3838 | } | | |
| 3839 | scheduleComposerFocus(); | | |
| 3840 | }, [ | | |
| 3841 | activeThread, | | |
| 3842 | branchRepairAction, | | |
| 3843 | environmentId, | | |
| 3844 | localCheckoutBranchMismatch, | 3848 | localCheckoutBranchMismatch, |
| 3845 | scheduleComposerFocus, | 3849 | ); |
| 3846 | updateThreadMetadata, | 3850 | const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ |
| 3847 | ]); | 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 | const handleSwitchCheckoutToThread = useCallback(async () => { | 3867 | const handleSwitchCheckoutToThread = useCallback(async () => { |
| 3849 | if ( | 3868 | if ( |
| 3850 | !activeProjectCwd || | 3869 | !activeProjectCwd || |
| 3851 | !activeThread || | 3870 | !activeThread || |
| 3852 | !localCheckoutBranchMismatch || | 3871 | !localCheckoutBranchMismatch || |
| 3853 | branchRepairAction !== null | 3872 | isRestoringThreadBranch |
| 3854 | ) { | 3873 | ) { |
| 3855 | return; | 3874 | return; |
| 3856 | } | 3875 | } |
| 3857 | setBranchRepairAction("switch-checkout"); | 3876 | setIsRestoringThreadBranch(true); |
| 3858 | const checkoutResult = await switchGitRef({ | 3877 | const checkoutResult = await switchGitRef({ |
| 3859 | environmentId, | 3878 | environmentId, |
| 3860 | input: { | 3879 | input: { |
| ... | @@ -3863,7 +3882,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3863,7 +3882,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3863 | }, | 3882 | }, |
| 3864 | }); | 3883 | }); |
| 3865 | if (checkoutResult._tag === "Failure") { | 3884 | if (checkoutResult._tag === "Failure") { |
| 3866 | setBranchRepairAction(null); | 3885 | setIsRestoringThreadBranch(false); |
| 3867 | if (!isAtomCommandInterrupted(checkoutResult)) { | 3886 | if (!isAtomCommandInterrupted(checkoutResult)) { |
| 3868 | toastManager.add( | 3887 | toastManager.add( |
| 3869 | stackedThreadToast({ | 3888 | stackedThreadToast({ |
| ... | @@ -3883,7 +3902,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3883,7 +3902,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3883 | input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, | 3902 | input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, |
| 3884 | }); | 3903 | }); |
| 3885 | if (updateResult._tag === "Failure") { | 3904 | if (updateResult._tag === "Failure") { |
| 3886 | setBranchRepairAction(null); | 3905 | setIsRestoringThreadBranch(false); |
| 3887 | if (!isAtomCommandInterrupted(updateResult)) { | 3906 | if (!isAtomCommandInterrupted(updateResult)) { |
| 3888 | toastManager.add( | 3907 | toastManager.add( |
| 3889 | stackedThreadToast({ | 3908 | stackedThreadToast({ |
| ... | @@ -3898,76 +3917,78 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3898,76 +3917,78 @@ function ChatViewContent(props: ChatViewProps) { |
| 3898 | } | 3917 | } |
| 3899 | } | 3918 | } |
| 3900 | gitStatusQuery.refresh(); | 3919 | gitStatusQuery.refresh(); |
| 3901 | setBranchRepairAction(null); | 3920 | setIsRestoringThreadBranch(false); |
| 3902 | scheduleComposerFocus(); | 3921 | scheduleComposerFocus(); |
| 3903 | }, [ | 3922 | }, [ |
| 3904 | activeProjectCwd, | 3923 | activeProjectCwd, |
| 3905 | activeThread, | 3924 | activeThread, |
| 3906 | branchRepairAction, | | |
| 3907 | environmentId, | 3925 | environmentId, |
| 3908 | gitStatusQuery, | 3926 | gitStatusQuery, |
| | 3927 | isRestoringThreadBranch, |
| 3909 | localCheckoutBranchMismatch, | 3928 | localCheckoutBranchMismatch, |
| 3910 | scheduleComposerFocus, | 3929 | scheduleComposerFocus, |
| 3911 | switchGitRef, | 3930 | switchGitRef, |
| 3912 | updateThreadMetadata, | 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 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { | 3940 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 3915 | if (!localCheckoutBranchMismatch) { | 3941 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 3916 | return systemComposerBannerItems; | 3942 | return systemComposerBannerItems; |
| 3917 | } | 3943 | } |
| 3918 | const isRepairingBranch = branchRepairAction !== null; | | |
| 3919 | return [ | 3944 | return [ |
| 3920 | ...systemComposerBannerItems, | 3945 | ...systemComposerBannerItems, |
| 3921 | { | 3946 | { |
| 3922 | id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`, | 3947 | id: `branch-mismatch:${activeBranchMismatchKey}`, |
| 3923 | variant: "warning", | 3948 | variant: "info", |
| 3924 | icon: <TriangleAlertIcon />, | 3949 | icon: <GitBranchIcon />, |
| 3925 | title: "You're on a different branch", | 3950 | title: ( |
| 3926 | className: | 3951 | <span className="flex min-w-0 items-baseline gap-1.5"> |
| 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", | 3952 | <span className="shrink-0 font-normal text-muted-foreground">Branch changed — was</span> |
| 3928 | actionClassName: | 3953 | <Tooltip> |
| 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", | 3954 | <TooltipTrigger |
| 3930 | description: ( | 3955 | render={ |
| 3931 | <p className="text-pretty"> | 3956 | <code className="min-w-0 truncate font-medium text-foreground"> |
| 3932 | This thread is on{" "} | 3957 | {localCheckoutBranchMismatch.threadBranch} |
| 3933 | <code className="font-medium text-foreground"> | 3958 | </code> |
| 3934 | {localCheckoutBranchMismatch.threadBranch} | 3959 | } |
| 3935 | </code> | 3960 | /> |
| 3936 | , but you're currently checked out at{" "} | 3961 | <TooltipPopup side="top" className="max-w-80"> |
| 3937 | <code className="font-medium text-foreground"> | 3962 | This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will |
| 3938 | {localCheckoutBranchMismatch.currentBranch} | 3963 | continue on {localCheckoutBranchMismatch.currentBranch}. |
| 3939 | </code> | 3964 | </TooltipPopup> |
| 3940 | . Sending a message will update the thread. | 3965 | </Tooltip> |
| 3941 | </p> | 3966 | </span> |
| 3942 | ), | 3967 | ), |
| | 3968 | className: "dark:shadow-none", |
| 3943 | actions: ( | 3969 | actions: ( |
| 3944 | <> | 3970 | <Button |
| 3945 | <Button | 3971 | size="xs" |
| 3946 | size="xs" | 3972 | variant="ghost" |
| 3947 | variant="outline" | 3973 | disabled={isRestoringThreadBranch} |
| 3948 | disabled={isRepairingBranch} | 3974 | onClick={handleRestoreThreadBranch} |
| 3949 | onClick={() => void handleUpdateThreadToCheckout()} | 3975 | > |
| 3950 | > | 3976 | {isRestoringThreadBranch ? "Restoring..." : "Restore branch"} |
| 3951 | {branchRepairAction === "update-thread" ? "Moving..." : "Move thread here"} | 3977 | </Button> |
| 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 | </> | | |
| 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, | 3987 | activeBranchMismatchKey, |
| 3967 | branchRepairAction, | 3988 | handleRestoreThreadBranch, |
| 3968 | handleSwitchCheckoutToThread, | 3989 | isRestoringThreadBranch, |
| 3969 | handleUpdateThreadToCheckout, | | |
| 3970 | localCheckoutBranchMismatch, | 3990 | localCheckoutBranchMismatch, |
| | 3991 | showBranchMismatchBanner, |
| 3971 | systemComposerBannerItems, | 3992 | systemComposerBannerItems, |
| 3972 | ]); | 3993 | ]); |
| 3973 | | 3994 | |
| ... | @@ -5726,6 +5747,36 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5726,6 +5747,36 @@ function ChatViewContent(props: ChatViewProps) { |
| 5726 | </div> | 5747 | </div> |
| 5727 | </div> | 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 | {pullRequestDialogState ? ( | 5780 | {pullRequestDialogState ? ( |
| 5730 | <PullRequestThreadDialog | 5781 | <PullRequestThreadDialog |
| 5731 | key={pullRequestDialogState.key} | 5782 | key={pullRequestDialogState.key} |