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) {
58815881 pendingApprovals.length > 0 ||
58825882 pendingUserInputs.length > 0 ||
58835883 showPlanFollowUpPrompt;
5884 const compactDisabled = compactThreadUnavailable || composerHasUnsentContent;
5884 const compactDisabled = compactThreadUnavailable;
58855885 const compactDisabledReason = compactDisabled
5886 ? composerHasUnsentContent
5887 ? "Send or clear your draft before compacting"
5888 : !activeProject
5889 ? "Choose a project before compacting"
5890 : !manualCompactionProviderAvailable
5891 ? "Compaction is unavailable for this provider"
5892 : "Compacting is unavailable right now"
5886 ? !activeProject
5887 ? "Choose a project before compacting"
5888 : !manualCompactionProviderAvailable
5889 ? "Compaction is unavailable for this provider"
5890 : "Compacting is unavailable right now"
58935891 : null;
58945892 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
58955893 if (
......@@ -6479,6 +6477,78 @@ export default function ChatView(props: ChatViewProps) {
64796477 ],
64806478 );
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
64826552 const onSend = async (
64836553 e?: { preventDefault: () => void },
64846554 submissionIntent: ComposerSubmissionIntent = "foreground",
......@@ -8575,6 +8645,7 @@ export default function ChatView(props: ChatViewProps) {
85758645 onPageScrollKeyDown={onComposerPageScrollKeyDown}
85768646 onPageScrollKeyUp={onComposerPageScrollKeyUp}
85778647 onPageScrollRelease={onComposerPageScrollRelease}
8648 onCompactContext={onCompactContext}
85788649 onSend={onSend}
85798650 onInterrupt={onInterrupt}
85808651 onImplementPlanInNewThread={onImplementPlanInNewThread}
apps/web/src/components/chat/ChatComposer.tsx+5-28
......@@ -1354,6 +1354,7 @@ export interface ChatComposerProps {
13541354 onPageScrollRelease: () => void;
13551355
13561356 // Callbacks
1357 onCompactContext: () => void;
13571358 onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void;
13581359 onInterrupt: () => void;
13591360 onImplementPlanInNewThread: () => void;
......@@ -1460,6 +1461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14601461 onPageScrollKeyDown,
14611462 onPageScrollKeyUp,
14621463 onPageScrollRelease,
1464 onCompactContext,
14631465 onSend,
14641466 onInterrupt,
14651467 onImplementPlanInNewThread,
......@@ -1995,7 +1997,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19951997 /**
19961998 * Count of pasted images still being compressed, per thread. Reserved
19971999 * against the attachment limit so concurrent pastes can't overshoot it,
1998 * and checked before sending or compacting so an image cannot move into
2000 * and checked before sending so an image cannot move into
19992001 * the next draft.
20002002 */
20012003 const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
......@@ -3097,42 +3099,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30973099 ) {
30983100 return;
30993101 }
3100 // The compact buttons cannot see the compression counter (it lives in
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 }
3102 onCompactContext();
31233103 }, [
31243104 activePendingApproval,
31253105 activeThreadId,
31263106 compactDisabled,
3127 composerDraftTarget,
31283107 isConnecting,
31293108 isSendBusy,
31303109 noProviderAvailable,
3110 onCompactContext,
31313111 pendingUserInputs.length,
31323112 phase,
3133 promptRef,
3134 setComposerDraftPrompt,
3135 submitComposer,
31363113 ]);
31373114 const expandMobileComposer = useCallback(() => {
31383115 if (composerBlurFrameRef.current !== null) {