From fa8e587dca467b6efa12a3f89a9da216d2f851c3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 10 Sep 2026 18:01:43 +0000 Subject: [PATCH] fix(web): preserve drafts when compacting context --- apps/web/src/components/ChatView.tsx | 87 +++++++++++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 33 ++----- 2 files changed, 84 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f1b5afba94e9..9394d0c43ec0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5804,15 +5804,13 @@ export default function ChatView(props: ChatViewProps) { pendingApprovals.length > 0 || pendingUserInputs.length > 0 || showPlanFollowUpPrompt; - const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; + const compactDisabled = compactThreadUnavailable; const compactDisabledReason = compactDisabled - ? composerHasUnsentContent - ? "Send or clear your draft before compacting" - : !activeProject - ? "Choose a project before compacting" - : !manualCompactionProviderAvailable - ? "Compaction is unavailable for this provider" - : "Compacting is unavailable right now" + ? !activeProject + ? "Choose a project before compacting" + : !manualCompactionProviderAvailable + ? "Compaction is unavailable for this provider" + : "Compacting is unavailable right now" : null; const resumeCompactionBannerItem = useMemo(() => { if ( @@ -6402,6 +6400,78 @@ export default function ChatView(props: ChatViewProps) { ], ); + const onCompactContext = async () => { + if (compactDisabled || !activeThread || !clientSettingsHydrated || sendInFlightRef.current) { + return; + } + const context = composerRef.current?.getSendContext(); + if (!context?.providerAvailable) return; + + // Compaction is a standalone command; the draft and its attachments stay local. + const threadId = activeThread.id; + const messageId = newMessageId(); + const createdAt = new Date().toISOString(); + sendInFlightRef.current = true; + beginLocalDispatch(); + setThreadError(threadId, null); + setOptimisticUserMessages((messages) => [ + ...messages, + { + id: messageId, + role: "user", + text: "/compact", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + ]); + scrollToEnd(); + try { + const settingsResult = await persistThreadSettingsForNextTurn({ + threadId, + createdAt, + modelSelection: context.selectedModelSelection, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), + runtimeMode, + interactionMode: context.interactionMode, + }); + const result = + settingsResult._tag === "Failure" + ? settingsResult + : await startThreadTurn({ + environmentId, + input: { + threadId, + message: { messageId, role: "user", text: "/compact", attachments: [] }, + modelSelection: context.selectedModelSelection, + runtimeMode, + interactionMode: context.interactionMode, + createdAt, + }, + }); + if (result._tag === "Failure") { + setOptimisticUserMessages((messages) => + messages.filter((message) => message.id !== messageId), + ); + resetLocalDispatch(); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + threadId, + error instanceof Error ? error.message : "Failed to compact context.", + ); + } + } else { + clearUsageLimitsFor(routeThreadKey); + } + } finally { + sendInFlightRef.current = false; + } + }; + const onSend = async ( e?: { preventDefault: () => void }, submissionIntent: ComposerSubmissionIntent = "foreground", @@ -8461,6 +8531,7 @@ export default function ChatView(props: ChatViewProps) { onPageScrollKeyDown={onComposerPageScrollKeyDown} onPageScrollKeyUp={onComposerPageScrollKeyUp} onPageScrollRelease={onComposerPageScrollRelease} + onCompactContext={onCompactContext} onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7291f8cead22..7ecb5e2dc635 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1354,6 +1354,7 @@ export interface ChatComposerProps { onPageScrollRelease: () => void; // Callbacks + onCompactContext: () => void; onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -1460,6 +1461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCompactContext, onSend, onInterrupt, onImplementPlanInNewThread, @@ -1995,7 +1997,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /** * Count of pasted images still being compressed, per thread. Reserved * against the attachment limit so concurrent pastes can't overshoot it, - * and checked before sending or compacting so an image cannot move into + * and checked before sending so an image cannot move into * the next draft. */ const pendingImageCompressionsRef = useRef>(new Map()); @@ -3097,42 +3099,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } - // The compact buttons cannot see the compression counter (it lives in - // a ref), so they render enabled during a paste; toast instead of - // silently ignoring the click. - if ((pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) { - toastManager.add({ - type: "info", - title: "Still compressing a pasted image.", - description: "Compact again once its thumbnail appears.", - }); - return; - } - - promptRef.current = "/compact"; - setComposerDraftPrompt(composerDraftTarget, "/compact"); - submitComposer(); - // A blocked dispatch (busy send ref, provider preflight rejection) - // would leave the injected "/compact" behind as if the user typed it. - // Clearing here is safe even when the send did dispatch: the send - // snapshots its prompt synchronously and clears the draft itself. - if (promptRef.current === "/compact") { - promptRef.current = ""; - setComposerDraftPrompt(composerDraftTarget, ""); - } + onCompactContext(); }, [ activePendingApproval, activeThreadId, compactDisabled, - composerDraftTarget, isConnecting, isSendBusy, noProviderAvailable, + onCompactContext, pendingUserInputs.length, phase, - promptRef, - setComposerDraftPrompt, - submitComposer, ]); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) {