| ... | @@ -25,6 +25,7 @@ import { | ... | @@ -25,6 +25,7 @@ import { |
| 25 | connectionStatusTitle, | 25 | connectionStatusTitle, |
| 26 | type EnvironmentConnectionPresentation, | 26 | type EnvironmentConnectionPresentation, |
| 27 | } from "@t3tools/client-runtime/connection"; | 27 | } from "@t3tools/client-runtime/connection"; |
| | 28 | import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; |
| 28 | import { | 29 | import { |
| 29 | parseScopedThreadKey, | 30 | parseScopedThreadKey, |
| 30 | scopedThreadKey, | 31 | scopedThreadKey, |
| ... | @@ -138,7 +139,13 @@ import { BranchToolbar } from "./BranchToolbar"; | ... | @@ -138,7 +139,13 @@ import { BranchToolbar } from "./BranchToolbar"; |
| 138 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 139 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 139 | import PlanSidebar from "./PlanSidebar"; | 140 | import PlanSidebar from "./PlanSidebar"; |
| 140 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 141 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 141 | import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; | 142 | import { |
| | 143 | CheckCircle2Icon, |
| | 144 | ChevronDownIcon, |
| | 145 | GitBranchIcon, |
| | 146 | TriangleAlertIcon, |
| | 147 | WifiOffIcon, |
| | 148 | } from "lucide-react"; |
| 142 | import { cn, randomHex } from "~/lib/utils"; | 149 | import { cn, randomHex } from "~/lib/utils"; |
| 143 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | 150 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; |
| 144 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 151 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| ... | @@ -153,7 +160,8 @@ import { | ... | @@ -153,7 +160,8 @@ import { |
| 153 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | 160 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 154 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | 161 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 155 | import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; | 162 | import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; |
| 156 | import { useEnvironmentSettings } from "../hooks/useSettings"; | 163 | import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; |
| | 164 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 157 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 165 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 158 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; | 166 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| 159 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; | 167 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; |
| ... | @@ -202,6 +210,7 @@ import { | ... | @@ -202,6 +210,7 @@ import { |
| 202 | useThread, | 210 | useThread, |
| 203 | useThreadProposedPlans, | 211 | useThreadProposedPlans, |
| 204 | useThreadRefs, | 212 | useThreadRefs, |
| | 213 | useThreadShell, |
| 205 | } from "../state/entities"; | 214 | } from "../state/entities"; |
| 206 | import { environmentShell } from "../state/shell"; | 215 | import { environmentShell } from "../state/shell"; |
| 207 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; | 216 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; |
| ... | @@ -220,6 +229,7 @@ import { | ... | @@ -220,6 +229,7 @@ import { |
| 220 | shouldShowProviderStatusBanner, | 229 | shouldShowProviderStatusBanner, |
| 221 | } from "./chat/ProviderStatusBanner"; | 230 | } from "./chat/ProviderStatusBanner"; |
| 222 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 231 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| | 232 | import { resolveThreadPr } from "./ThreadStatusIndicators"; |
| 223 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | 233 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 224 | import { | 234 | import { |
| 225 | DRAFT_HERO_TRANSITION_ANIMATION_ID, | 235 | DRAFT_HERO_TRANSITION_ANIMATION_ID, |
| ... | @@ -3823,6 +3833,63 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3823,6 +3833,63 @@ function ChatViewContent(props: ChatViewProps) { |
| 3823 | : null, | 3833 | : null, |
| 3824 | [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], | 3834 | [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], |
| 3825 | ); | 3835 | ); |
| | 3836 | // Settled state of the open thread, resolved exactly like the sidebar |
| | 3837 | // partition (same shell, same capability gate, same PR auto-settle input) |
| | 3838 | // so the banner and the sidebar row never disagree. |
| | 3839 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| | 3840 | const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); |
| | 3841 | const activeThreadPr = resolveThreadPr({ |
| | 3842 | threadBranch: activeThread?.branch ?? null, |
| | 3843 | gitStatus: gitStatusQuery.data ?? null, |
| | 3844 | hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, |
| | 3845 | }); |
| | 3846 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; |
| | 3847 | const nowMinute = useNowMinute(); |
| | 3848 | const activeThreadSettled = useMemo(() => { |
| | 3849 | if (activeThreadShell === null || !supportsSettlement) return false; |
| | 3850 | return effectiveSettled(activeThreadShell, { |
| | 3851 | now: `${nowMinute}:00.000Z`, |
| | 3852 | autoSettleAfterDays, |
| | 3853 | changeRequestState: activeThreadPr?.state ?? null, |
| | 3854 | }); |
| | 3855 | }, [ |
| | 3856 | activeThreadPr?.state, |
| | 3857 | activeThreadShell, |
| | 3858 | autoSettleAfterDays, |
| | 3859 | nowMinute, |
| | 3860 | supportsSettlement, |
| | 3861 | ]); |
| | 3862 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { |
| | 3863 | reportFailure: false, |
| | 3864 | }); |
| | 3865 | // Keyed by thread, not a boolean: the pending state must follow the thread |
| | 3866 | // it belongs to across navigation, and a request resolving for thread A |
| | 3867 | // must never clear (or re-enable) thread B's button. |
| | 3868 | const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null); |
| | 3869 | const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; |
| | 3870 | const handleUnsettleActiveThread = useCallback(async () => { |
| | 3871 | if (!activeThreadRef) return; |
| | 3872 | const threadKey = scopedThreadKey(activeThreadRef); |
| | 3873 | setUnsettlingThreadKey(threadKey); |
| | 3874 | try { |
| | 3875 | const result = await unsettleThreadMutation({ |
| | 3876 | environmentId: activeThreadRef.environmentId, |
| | 3877 | input: { threadId: activeThreadRef.threadId, reason: "user" }, |
| | 3878 | }); |
| | 3879 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| | 3880 | const error = squashAtomCommandFailure(result); |
| | 3881 | toastManager.add( |
| | 3882 | stackedThreadToast({ |
| | 3883 | type: "error", |
| | 3884 | title: "Failed to un-settle thread", |
| | 3885 | description: error instanceof Error ? error.message : "An error occurred.", |
| | 3886 | }), |
| | 3887 | ); |
| | 3888 | } |
| | 3889 | } finally { |
| | 3890 | setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); |
| | 3891 | } |
| | 3892 | }, [activeThreadRef, unsettleThreadMutation]); |
| 3826 | const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); | 3893 | const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); |
| 3827 | const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); | 3894 | const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); |
| 3828 | // Once revealed for a given mismatch, the banner stays mounted until the | 3895 | // Once revealed for a given mismatch, the banner stays mounted until the |
| ... | @@ -3930,6 +3997,31 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3930,6 +3997,31 @@ function ChatViewContent(props: ChatViewProps) { |
| 3930 | switchGitRef, | 3997 | switchGitRef, |
| 3931 | updateThreadMetadata, | 3998 | updateThreadMetadata, |
| 3932 | ]); | 3999 | ]); |
| | 4000 | // The stack renders items[0] front-most and tucks the rest behind hover, so |
| | 4001 | // ordering is priority: system banners, then the branch-mismatch notice, |
| | 4002 | // and the informational settled banner last — it must never cover another. |
| | 4003 | const settledComposerBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| | 4004 | if (!activeThreadSettled) { |
| | 4005 | return null; |
| | 4006 | } |
| | 4007 | return { |
| | 4008 | id: `thread-settled:${activeThread?.id ?? "unknown"}`, |
| | 4009 | variant: "info", |
| | 4010 | icon: <CheckCircle2Icon />, |
| | 4011 | title: "This thread is settled", |
| | 4012 | description: "Sending a message moves it back to Active in the sidebar.", |
| | 4013 | actions: ( |
| | 4014 | <Button |
| | 4015 | size="xs" |
| | 4016 | variant="outline" |
| | 4017 | disabled={isUnsettling} |
| | 4018 | onClick={() => void handleUnsettleActiveThread()} |
| | 4019 | > |
| | 4020 | {isUnsettling ? "Un-settling..." : "Un-settle"} |
| | 4021 | </Button> |
| | 4022 | ), |
| | 4023 | }; |
| | 4024 | }, [activeThread?.id, activeThreadSettled, handleUnsettleActiveThread, isUnsettling]); |
| 3933 | const handleRestoreThreadBranch = useCallback(() => { | 4025 | const handleRestoreThreadBranch = useCallback(() => { |
| 3934 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { | 4026 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { |
| 3935 | setBranchRestoreConfirmOpen(true); | 4027 | setBranchRestoreConfirmOpen(true); |
| ... | @@ -3938,8 +4030,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3938,8 +4030,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 3938 | void handleSwitchCheckoutToThread(); | 4030 | void handleSwitchCheckoutToThread(); |
| 3939 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); | 4031 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); |
| 3940 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { | 4032 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| | 4033 | const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem]; |
| 3941 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { | 4034 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 3942 | return systemComposerBannerItems; | 4035 | return [...systemComposerBannerItems, ...settledItems]; |
| 3943 | } | 4036 | } |
| 3944 | return [ | 4037 | return [ |
| 3945 | ...systemComposerBannerItems, | 4038 | ...systemComposerBannerItems, |
| ... | @@ -3982,12 +4075,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3982,12 +4075,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 3982 | setBranchMismatchDismissTick((tick) => tick + 1); | 4075 | setBranchMismatchDismissTick((tick) => tick + 1); |
| 3983 | }, | 4076 | }, |
| 3984 | }, | 4077 | }, |
| | 4078 | ...settledItems, |
| 3985 | ]; | 4079 | ]; |
| 3986 | }, [ | 4080 | }, [ |
| 3987 | activeBranchMismatchKey, | 4081 | activeBranchMismatchKey, |
| 3988 | handleRestoreThreadBranch, | 4082 | handleRestoreThreadBranch, |
| 3989 | isRestoringThreadBranch, | 4083 | isRestoringThreadBranch, |
| 3990 | localCheckoutBranchMismatch, | 4084 | localCheckoutBranchMismatch, |
| | 4085 | settledComposerBannerItem, |
| 3991 | showBranchMismatchBanner, | 4086 | showBranchMismatchBanner, |
| 3992 | systemComposerBannerItems, | 4087 | systemComposerBannerItems, |
| 3993 | ]); | 4088 | ]); |