authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-11 01:46:33-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 01:46:33-03:00
logef6fa118749572da1905eca5d0543d1e98f0b1b2
treef1f2200f1a18893f3fa8ce5436a69b6f68bd7ae6
parent18c5a1d2dfbb02856ec51b54edac82952852a58a
signature Signed by PGP key B5690EEEBB952194

fix(web): preserve drafts when compacting context (#11103)


2 files changed, 84 insertions(+), 36 deletions(-)

apps/web/src/components/ChatView.tsx+79-8
...@@ -5881,15 +5881,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -5881,15 +5881,13 @@ export default function ChatView(props: ChatViewProps) {
5881 pendingApprovals.length > 0 ||5881 pendingApprovals.length > 0 ||
5882 pendingUserInputs.length > 0 ||5882 pendingUserInputs.length > 0 ||
5883 showPlanFollowUpPrompt;5883 showPlanFollowUpPrompt;
5884 const compactDisabled = compactThreadUnavailable || composerHasUnsentContent;5884 const compactDisabled = compactThreadUnavailable;
5885 const compactDisabledReason = compactDisabled5885 const compactDisabledReason = compactDisabled
5886 ? composerHasUnsentContent5886 ? !activeProject
5887 ? "Send or clear your draft before compacting"5887 ? "Choose a project before compacting"
5888 : !activeProject5888 : !manualCompactionProviderAvailable
5889 ? "Choose a project before compacting"5889 ? "Compaction is unavailable for this provider"
5890 : !manualCompactionProviderAvailable5890 : "Compacting is unavailable right now"
5891 ? "Compaction is unavailable for this provider"
5892 : "Compacting is unavailable right now"
5893 : null;5891 : null;
5894 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {5892 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
5895 if (5893 if (
...@@ -6479,6 +6477,78 @@ export default function ChatView(props: ChatViewProps) {...@@ -6479,6 +6477,78 @@ export default function ChatView(props: ChatViewProps) {
6479 ],6477 ],
6480 );6478 );
64816479
6480 const onCompactContext = async () => {
6481 if (compactDisabled || !activeThread || !clientSettingsHydrated || sendInFlightRef.current) {
6482 return;
6483 }
6484 const context = composerRef.current?.getSendContext();
6485 if (!context?.providerAvailable) return;
6486
6487 // Compaction is a standalone command; the draft and its attachments stay local.
6488 const threadId = activeThread.id;
6489 const messageId = newMessageId();
6490 const createdAt = new Date().toISOString();
6491 sendInFlightRef.current = true;
6492 beginLocalDispatch();
6493 setThreadError(threadId, null);
6494 setOptimisticUserMessages((messages) => [
6495 ...messages,
6496 {
6497 id: messageId,
6498 role: "user",
6499 text: "/compact",
6500 turnId: null,
6501 createdAt,
6502 updatedAt: createdAt,
6503 streaming: false,
6504 },
6505 ]);
6506 scrollToEnd();
6507 try {
6508 const settingsResult = await persistThreadSettingsForNextTurn({
6509 threadId,
6510 createdAt,
6511 modelSelection: context.selectedModelSelection,
6512 ...(localCheckoutBranchMismatch
6513 ? { branch: localCheckoutBranchMismatch.currentBranch }
6514 : {}),
6515 runtimeMode,
6516 interactionMode: context.interactionMode,
6517 });
6518 const result =
6519 settingsResult._tag === "Failure"
6520 ? settingsResult
6521 : await startThreadTurn({
6522 environmentId,
6523 input: {
6524 threadId,
6525 message: { messageId, role: "user", text: "/compact", attachments: [] },
6526 modelSelection: context.selectedModelSelection,
6527 runtimeMode,
6528 interactionMode: context.interactionMode,
6529 createdAt,
6530 },
6531 });
6532 if (result._tag === "Failure") {
6533 setOptimisticUserMessages((messages) =>
6534 messages.filter((message) => message.id !== messageId),
6535 );
6536 resetLocalDispatch();
6537 if (!isAtomCommandInterrupted(result)) {
6538 const error = squashAtomCommandFailure(result);
6539 setThreadError(
6540 threadId,
6541 error instanceof Error ? error.message : "Failed to compact context.",
6542 );
6543 }
6544 } else {
6545 clearUsageLimitsFor(routeThreadKey);
6546 }
6547 } finally {
6548 sendInFlightRef.current = false;
6549 }
6550 };
6551
6482 const onSend = async (6552 const onSend = async (
6483 e?: { preventDefault: () => void },6553 e?: { preventDefault: () => void },
6484 submissionIntent: ComposerSubmissionIntent = "foreground",6554 submissionIntent: ComposerSubmissionIntent = "foreground",
...@@ -8575,6 +8645,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8575,6 +8645,7 @@ export default function ChatView(props: ChatViewProps) {
8575 onPageScrollKeyDown={onComposerPageScrollKeyDown}8645 onPageScrollKeyDown={onComposerPageScrollKeyDown}
8576 onPageScrollKeyUp={onComposerPageScrollKeyUp}8646 onPageScrollKeyUp={onComposerPageScrollKeyUp}
8577 onPageScrollRelease={onComposerPageScrollRelease}8647 onPageScrollRelease={onComposerPageScrollRelease}
8648 onCompactContext={onCompactContext}
8578 onSend={onSend}8649 onSend={onSend}
8579 onInterrupt={onInterrupt}8650 onInterrupt={onInterrupt}
8580 onImplementPlanInNewThread={onImplementPlanInNewThread}8651 onImplementPlanInNewThread={onImplementPlanInNewThread}
apps/web/src/components/chat/ChatComposer.tsx+5-28
...@@ -1354,6 +1354,7 @@ export interface ChatComposerProps {...@@ -1354,6 +1354,7 @@ export interface ChatComposerProps {
1354 onPageScrollRelease: () => void;1354 onPageScrollRelease: () => void;
13551355
1356 // Callbacks1356 // Callbacks
1357 onCompactContext: () => void;
1357 onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void;1358 onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void;
1358 onInterrupt: () => void;1359 onInterrupt: () => void;
1359 onImplementPlanInNewThread: () => void;1360 onImplementPlanInNewThread: () => void;
...@@ -1460,6 +1461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1460,6 +1461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1460 onPageScrollKeyDown,1461 onPageScrollKeyDown,
1461 onPageScrollKeyUp,1462 onPageScrollKeyUp,
1462 onPageScrollRelease,1463 onPageScrollRelease,
1464 onCompactContext,
1463 onSend,1465 onSend,
1464 onInterrupt,1466 onInterrupt,
1465 onImplementPlanInNewThread,1467 onImplementPlanInNewThread,
...@@ -1995,7 +1997,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1995,7 +1997,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1995 /**1997 /**
1996 * Count of pasted images still being compressed, per thread. Reserved1998 * Count of pasted images still being compressed, per thread. Reserved
1997 * against the attachment limit so concurrent pastes can't overshoot it,1999 * against the attachment limit so concurrent pastes can't overshoot it,
1998 * and checked before sending or compacting so an image cannot move into2000 * and checked before sending so an image cannot move into
1999 * the next draft.2001 * the next draft.
2000 */2002 */
2001 const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());2003 const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
...@@ -3097,42 +3099,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3097,42 +3099,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3097 ) {3099 ) {
3098 return;3100 return;
3099 }3101 }
3100 // The compact buttons cannot see the compression counter (it lives in3102 onCompactContext();
3101 // a ref), so they render enabled during a paste; toast instead of
3102 // silently ignoring the click.
3103 if ((pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) {
3104 toastManager.add({
3105 type: "info",
3106 title: "Still compressing a pasted image.",
3107 description: "Compact again once its thumbnail appears.",
3108 });
3109 return;
3110 }
3111
3112 promptRef.current = "/compact";
3113 setComposerDraftPrompt(composerDraftTarget, "/compact");
3114 submitComposer();
3115 // A blocked dispatch (busy send ref, provider preflight rejection)
3116 // would leave the injected "/compact" behind as if the user typed it.
3117 // Clearing here is safe even when the send did dispatch: the send
3118 // snapshots its prompt synchronously and clears the draft itself.
3119 if (promptRef.current === "/compact") {
3120 promptRef.current = "";
3121 setComposerDraftPrompt(composerDraftTarget, "");
3122 }
3123 }, [3103 }, [
3124 activePendingApproval,3104 activePendingApproval,
3125 activeThreadId,3105 activeThreadId,
3126 compactDisabled,3106 compactDisabled,
3127 composerDraftTarget,
3128 isConnecting,3107 isConnecting,
3129 isSendBusy,3108 isSendBusy,
3130 noProviderAvailable,3109 noProviderAvailable,
3110 onCompactContext,
3131 pendingUserInputs.length,3111 pendingUserInputs.length,
3132 phase,3112 phase,
3133 promptRef,
3134 setComposerDraftPrompt,
3135 submitComposer,
3136 ]);3113 ]);
3137 const expandMobileComposer = useCallback(() => {3114 const expandMobileComposer = useCallback(() => {
3138 if (composerBlurFrameRef.current !== null) {3115 if (composerBlurFrameRef.current !== null) {