diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6a021ef7e5e2e8b8650f5384473aa1aa73c0460c..792fbacb6198cd65ffaa27317c26657918b3d735 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7672,9 +7672,9 @@ export default function ChatView(props: ChatViewProps) { : "Compacting is unavailable right now" : null; // Tokens a stale Claude session would re-read on its next turn. While set, - // Enter compacts first and the composer's send button says so; "Send with - // full history" in its menu skips that once. Held queues and multi-model - // sends never compact first, so the offer hides for them. + // the composer shows a Compact chip and Enter compacts first; turning the + // chip off sends the next message with full history. Held queues and + // multi-model sends never compact first, so the offer hides for them. const resumeCompactionTokens = activeContextWindow && !resumeCompactionPermanentlyDismissed && @@ -7690,17 +7690,25 @@ export default function ChatView(props: ChatViewProps) { }) ? activeContextWindow.usedTokens : null; - // Set only for the synchronous span of a "Send with full history" submit; - // onSend reads it before its first await. - const keepFullHistoryOnceRef = useRef(false); - const sendWithFullHistory = useCallback((send: () => void) => { - keepFullHistoryOnceRef.current = true; - try { - send(); - } finally { - keepFullHistoryOnceRef.current = false; - } + // Threads whose Compact chip is turned off. A send that starts its turn + // clears its thread's entry; a failed send keeps it for the retry. + const [fullHistoryThreadKeys, setFullHistoryThreadKeys] = useState>( + () => new Set(), + ); + const keepFullHistory = fullHistoryThreadKeys.has(routeThreadKey); + const setKeepFullHistory = useCallback((threadKey: string, keep: boolean) => { + setFullHistoryThreadKeys((current) => { + if (current.has(threadKey) === keep) return current; + const next = new Set(current); + if (keep) next.add(threadKey); + else next.delete(threadKey); + return next; + }); }, []); + const toggleKeepFullHistory = useCallback( + () => setKeepFullHistory(routeThreadKey, !keepFullHistory), + [keepFullHistory, routeThreadKey, setKeepFullHistory], + ); const handleRestoreThreadBranch = useCallback(() => { if (!canWriteSourceControl) return; if (gitStatusQuery.data?.hasWorkingTreeChanges) { @@ -8652,7 +8660,6 @@ export default function ChatView(props: ChatViewProps) { }, ) => { e?.preventDefault(); - const keepFullHistory = keepFullHistoryOnceRef.current; // Typed out in full rather than picked from the menu. Attachments or contexts // mean the user is sending a prompt, so those go through as usual. if ( @@ -9896,6 +9903,7 @@ export default function ChatView(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + setKeepFullHistory(routeThreadKey, false); // The turn is under way and will spend quota, so that thread's limits // snapshot is stale. Uploads may have outlasted a navigation, so only // the sending thread's panel clears. @@ -11559,7 +11567,8 @@ export default function ChatView(props: ChatViewProps) { } bannerItems={composerBannerItems} resumeCompactionTokens={resumeCompactionTokens} - onSendWithFullHistory={sendWithFullHistory} + keepFullHistory={keepFullHistory} + onToggleKeepFullHistory={toggleKeepFullHistory} // With attachments or contexts aboard the pick just inserts the // text, so it sends as a prompt like the typed path would. onUsageLimitsCommand={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c7eb2628e1c9a66844ce39be8f93e29a50b66948..0f84927b5914dce74848f828af3334a00698a77e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1391,7 +1391,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compactDisabled: boolean; compactDisabledReason: string | null; compactBeforeSendTokens: number | null; - onSendWithFullHistory: () => void; + keepFullHistory: boolean; + onToggleKeepFullHistory: () => void; }) { return ( <> @@ -1431,7 +1432,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} compactBeforeSendTokens={props.compactBeforeSendTokens} - onSendWithFullHistory={props.onSendWithFullHistory} + keepFullHistory={props.keepFullHistory} + onToggleKeepFullHistory={props.onToggleKeepFullHistory} /> ); @@ -1551,10 +1553,12 @@ export interface ChatComposerProps { sendDisabledReason: string | null; isPreparingWorktree: boolean; bannerItems: readonly ComposerBannerStackItem[]; - /** Tokens Enter compacts before sending; null when the next send keeps full history. */ + /** Tokens a stale session would re-read; null when the thread is not offered compaction. */ resumeCompactionTokens: number | null; - /** Runs `send` as a one-off send that keeps full history instead of compacting first. */ - onSendWithFullHistory: (send: () => void) => void; + /** The Compact chip is off, so the next send keeps full history. */ + keepFullHistory: boolean; + /** Flips the Compact chip for the active thread. */ + onToggleKeepFullHistory: () => void; /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */ onUsageLimitsCommand?: (() => void) | undefined; environmentUnavailable: { @@ -3016,7 +3020,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (!composerSendState.hasSendableContent && !showResumeAction); const collapsedComposerPrimaryActionLabel = showResumeAction ? "Resume thread" - : props.resumeCompactionTokens !== null + : props.resumeCompactionTokens !== null && !props.keepFullHistory ? "Open composer to compact and send" : "Send message"; const showMobilePendingAnswerActions = @@ -4271,11 +4275,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [phase, settings.followUpBehavior, submitComposer], ); - const { onSendWithFullHistory } = props; - const sendWithFullHistory = useCallback( - () => onSendWithFullHistory(() => submitComposer()), - [onSendWithFullHistory, submitComposer], - ); const submitCitationAndSend = useCallback(() => { submitComposer( undefined, @@ -6896,8 +6895,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onClick={(event) => { event.stopPropagation(); if (showResumeAction) onResume(); - // Compacting first is only sent from the labeled button, so expand to show it. - else if (props.resumeCompactionTokens !== null) expandMobileComposer(); + // Compacting first only sends from the expanded composer, where the chip shows it. + else if (props.resumeCompactionTokens !== null && !props.keepFullHistory) + expandMobileComposer(); else submitComposer(); }} > @@ -7615,7 +7615,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} compactBeforeSendTokens={props.resumeCompactionTokens} - onSendWithFullHistory={sendWithFullHistory} + keepFullHistory={props.keepFullHistory} + onToggleKeepFullHistory={props.onToggleKeepFullHistory} compactDisabled={ compactDisabled || noProviderAvailable || isSendBusy || isConnecting } diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index f2bf2d18b455801c07793d5a51f29ac360830db5..9973b83056b07ff795c6c7ad98a367fecd9b2b34 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,5 +1,5 @@ import { memo, type MouseEventHandler, type PointerEventHandler } from "react"; -import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, Minimize2Icon, PlayIcon } from "lucide-react"; import { CornerUpRight, ListPlus } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; @@ -51,9 +51,11 @@ interface ComposerPrimaryActionsProps { onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; - /** Tokens a stale session would re-read. When set, Enter compacts first and the button says so. */ + /** Tokens a stale session would re-read. When set, a Compact chip shows the count and Enter compacts first. */ compactBeforeSendTokens?: number | null; - onSendWithFullHistory?: () => void; + /** The Compact chip is turned off, so the next send keeps full history. */ + keepFullHistory?: boolean; + onToggleKeepFullHistory?: () => void; } const formatPendingPrimaryActionLabel = (input: { @@ -108,7 +110,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ onInterrupt, onImplementPlanInNewThread, compactBeforeSendTokens = null, - onSendWithFullHistory, + keepFullHistory = false, + onToggleKeepFullHistory, }: ComposerPrimaryActionsProps) { const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } @@ -266,68 +269,24 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ } const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage; - const sendBlocked = isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable; - if (compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage) { - const tokens = formatContextWindowTokens(compactBeforeSendTokens); - return ( -
- - - } - > - {isConnecting || isSendBusy ? "Sending..." : "Compact and send"} - - Summarize {tokens} tokens of history, then send - - - - } - > - - - - - Send with full history ({tokens} tokens) - - - -
- ); - } + const compactTokens = + compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage + ? formatContextWindowTokens(compactBeforeSendTokens) + : null; + const compactsBeforeSend = compactTokens !== null && !keepFullHistory; const submitLabel = showResume ? "Resume thread" : isEditingQueuedMessage ? "Update queued message" - : isQueuing - ? "Queue message" - : isRunning - ? "Steer message" - : "Submit message"; + : compactsBeforeSend + ? "Compact and send" + : isQueuing + ? "Queue message" + : isRunning + ? "Steer message" + : "Submit message"; const submitStatus = isEnvironmentUnavailable ? "Environment disconnected" : (sendDisabledReason ?? @@ -342,9 +301,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : null)); const submitTooltip = submitStatus ?? - (isRunning && !isEditingQueuedMessage - ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}` - : submitLabel); + (compactsBeforeSend + ? `Summarize ${compactTokens} tokens of history, then send` + : isRunning && !isEditingQueuedMessage + ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}` + : submitLabel); const sendButton = (