authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-08 02:01:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 02:01:11-07:00
log0647c481017f25ddff2ec1dff68ef3944489b6db
treefff5558d75d0f6bd83a9d9e121ba7d4eca2a36f9
parentd6f47ff9725f0902b526f854519cb7e92dcee52f
signature Signed by PGP key B5690EEEBB952194

feat(web): compact-before-send is a chip that shows the token count (#17127)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 104 insertions(+), 97 deletions(-)

apps/web/src/components/ChatView.tsx+24-15
...@@ -7672,9 +7672,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -7672,9 +7672,9 @@ export default function ChatView(props: ChatViewProps) {
7672 : "Compacting is unavailable right now"7672 : "Compacting is unavailable right now"
7673 : null;7673 : null;
7674 // Tokens a stale Claude session would re-read on its next turn. While set,7674 // Tokens a stale Claude session would re-read on its next turn. While set,
7675 // Enter compacts first and the composer's send button says so; "Send with7675 // the composer shows a Compact chip and Enter compacts first; turning the
7676 // full history" in its menu skips that once. Held queues and multi-model7676 // chip off sends the next message with full history. Held queues and
7677 // sends never compact first, so the offer hides for them.7677 // multi-model sends never compact first, so the offer hides for them.
7678 const resumeCompactionTokens =7678 const resumeCompactionTokens =
7679 activeContextWindow &&7679 activeContextWindow &&
7680 !resumeCompactionPermanentlyDismissed &&7680 !resumeCompactionPermanentlyDismissed &&
...@@ -7690,17 +7690,25 @@ export default function ChatView(props: ChatViewProps) {...@@ -7690,17 +7690,25 @@ export default function ChatView(props: ChatViewProps) {
7690 })7690 })
7691 ? activeContextWindow.usedTokens7691 ? activeContextWindow.usedTokens
7692 : null;7692 : null;
7693 // Set only for the synchronous span of a "Send with full history" submit;7693 // Threads whose Compact chip is turned off. A send that starts its turn
7694 // onSend reads it before its first await.7694 // clears its thread's entry; a failed send keeps it for the retry.
7695 const keepFullHistoryOnceRef = useRef(false);7695 const [fullHistoryThreadKeys, setFullHistoryThreadKeys] = useState<ReadonlySet<string>>(
7696 const sendWithFullHistory = useCallback((send: () => void) => {7696 () => new Set(),
7697 keepFullHistoryOnceRef.current = true;7697 );
7698 try {7698 const keepFullHistory = fullHistoryThreadKeys.has(routeThreadKey);
7699 send();7699 const setKeepFullHistory = useCallback((threadKey: string, keep: boolean) => {
7700 } finally {7700 setFullHistoryThreadKeys((current) => {
7701 keepFullHistoryOnceRef.current = false;7701 if (current.has(threadKey) === keep) return current;
7702 }7702 const next = new Set(current);
7703 if (keep) next.add(threadKey);
7704 else next.delete(threadKey);
7705 return next;
7706 });
7703 }, []);7707 }, []);
7708 const toggleKeepFullHistory = useCallback(
7709 () => setKeepFullHistory(routeThreadKey, !keepFullHistory),
7710 [keepFullHistory, routeThreadKey, setKeepFullHistory],
7711 );
7704 const handleRestoreThreadBranch = useCallback(() => {7712 const handleRestoreThreadBranch = useCallback(() => {
7705 if (!canWriteSourceControl) return;7713 if (!canWriteSourceControl) return;
7706 if (gitStatusQuery.data?.hasWorkingTreeChanges) {7714 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
...@@ -8652,7 +8660,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -8652,7 +8660,6 @@ export default function ChatView(props: ChatViewProps) {
8652 },8660 },
8653 ) => {8661 ) => {
8654 e?.preventDefault();8662 e?.preventDefault();
8655 const keepFullHistory = keepFullHistoryOnceRef.current;
8656 // Typed out in full rather than picked from the menu. Attachments or contexts8663 // Typed out in full rather than picked from the menu. Attachments or contexts
8657 // mean the user is sending a prompt, so those go through as usual.8664 // mean the user is sending a prompt, so those go through as usual.
8658 if (8665 if (
...@@ -9896,6 +9903,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -9896,6 +9903,7 @@ export default function ChatView(props: ChatViewProps) {
9896 failure = startResult;9903 failure = startResult;
9897 } else {9904 } else {
9898 turnStartSucceeded = true;9905 turnStartSucceeded = true;
9906 setKeepFullHistory(routeThreadKey, false);
9899 // The turn is under way and will spend quota, so that thread's limits9907 // The turn is under way and will spend quota, so that thread's limits
9900 // snapshot is stale. Uploads may have outlasted a navigation, so only9908 // snapshot is stale. Uploads may have outlasted a navigation, so only
9901 // the sending thread's panel clears.9909 // the sending thread's panel clears.
...@@ -11559,7 +11567,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -11559,7 +11567,8 @@ export default function ChatView(props: ChatViewProps) {
11559 }11567 }
11560 bannerItems={composerBannerItems}11568 bannerItems={composerBannerItems}
11561 resumeCompactionTokens={resumeCompactionTokens}11569 resumeCompactionTokens={resumeCompactionTokens}
11562 onSendWithFullHistory={sendWithFullHistory}11570 keepFullHistory={keepFullHistory}
11571 onToggleKeepFullHistory={toggleKeepFullHistory}
11563 // With attachments or contexts aboard the pick just inserts the11572 // With attachments or contexts aboard the pick just inserts the
11564 // text, so it sends as a prompt like the typed path would.11573 // text, so it sends as a prompt like the typed path would.
11565 onUsageLimitsCommand={11574 onUsageLimitsCommand={
apps/web/src/components/chat/ChatComposer.tsx+15-14
...@@ -1391,7 +1391,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -1391,7 +1391,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
1391 compactDisabled: boolean;1391 compactDisabled: boolean;
1392 compactDisabledReason: string | null;1392 compactDisabledReason: string | null;
1393 compactBeforeSendTokens: number | null;1393 compactBeforeSendTokens: number | null;
1394 onSendWithFullHistory: () => void;1394 keepFullHistory: boolean;
1395 onToggleKeepFullHistory: () => void;
1395}) {1396}) {
1396 return (1397 return (
1397 <>1398 <>
...@@ -1431,7 +1432,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -1431,7 +1432,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
1431 onInterrupt={props.onInterrupt}1432 onInterrupt={props.onInterrupt}
1432 onImplementPlanInNewThread={props.onImplementPlanInNewThread}1433 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
1433 compactBeforeSendTokens={props.compactBeforeSendTokens}1434 compactBeforeSendTokens={props.compactBeforeSendTokens}
1434 onSendWithFullHistory={props.onSendWithFullHistory}1435 keepFullHistory={props.keepFullHistory}
1436 onToggleKeepFullHistory={props.onToggleKeepFullHistory}
1435 />1437 />
1436 </>1438 </>
1437 );1439 );
...@@ -1551,10 +1553,12 @@ export interface ChatComposerProps {...@@ -1551,10 +1553,12 @@ export interface ChatComposerProps {
1551 sendDisabledReason: string | null;1553 sendDisabledReason: string | null;
1552 isPreparingWorktree: boolean;1554 isPreparingWorktree: boolean;
1553 bannerItems: readonly ComposerBannerStackItem[];1555 bannerItems: readonly ComposerBannerStackItem[];
1554 /** Tokens Enter compacts before sending; null when the next send keeps full history. */1556 /** Tokens a stale session would re-read; null when the thread is not offered compaction. */
1555 resumeCompactionTokens: number | null;1557 resumeCompactionTokens: number | null;
1556 /** Runs `send` as a one-off send that keeps full history instead of compacting first. */1558 /** The Compact chip is off, so the next send keeps full history. */
1557 onSendWithFullHistory: (send: () => void) => void;1559 keepFullHistory: boolean;
1560 /** Flips the Compact chip for the active thread. */
1561 onToggleKeepFullHistory: () => void;
1558 /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */1562 /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */
1559 onUsageLimitsCommand?: (() => void) | undefined;1563 onUsageLimitsCommand?: (() => void) | undefined;
1560 environmentUnavailable: {1564 environmentUnavailable: {
...@@ -3016,7 +3020,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3016,7 +3020,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3016 (!composerSendState.hasSendableContent && !showResumeAction);3020 (!composerSendState.hasSendableContent && !showResumeAction);
3017 const collapsedComposerPrimaryActionLabel = showResumeAction3021 const collapsedComposerPrimaryActionLabel = showResumeAction
3018 ? "Resume thread"3022 ? "Resume thread"
3019 : props.resumeCompactionTokens !== null3023 : props.resumeCompactionTokens !== null && !props.keepFullHistory
3020 ? "Open composer to compact and send"3024 ? "Open composer to compact and send"
3021 : "Send message";3025 : "Send message";
3022 const showMobilePendingAnswerActions =3026 const showMobilePendingAnswerActions =
...@@ -4271,11 +4275,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4271,11 +4275,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4271 },4275 },
4272 [phase, settings.followUpBehavior, submitComposer],4276 [phase, settings.followUpBehavior, submitComposer],
4273 );4277 );
4274 const { onSendWithFullHistory } = props;
4275 const sendWithFullHistory = useCallback(
4276 () => onSendWithFullHistory(() => submitComposer()),
4277 [onSendWithFullHistory, submitComposer],
4278 );
4279 const submitCitationAndSend = useCallback(() => {4278 const submitCitationAndSend = useCallback(() => {
4280 submitComposer(4279 submitComposer(
4281 undefined,4280 undefined,
...@@ -6896,8 +6895,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6896,8 +6895,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6896 onClick={(event) => {6895 onClick={(event) => {
6897 event.stopPropagation();6896 event.stopPropagation();
6898 if (showResumeAction) onResume();6897 if (showResumeAction) onResume();
6899 // Compacting first is only sent from the labeled button, so expand to show it.6898 // Compacting first only sends from the expanded composer, where the chip shows it.
6900 else if (props.resumeCompactionTokens !== null) expandMobileComposer();6899 else if (props.resumeCompactionTokens !== null && !props.keepFullHistory)
6900 expandMobileComposer();
6901 else submitComposer();6901 else submitComposer();
6902 }}6902 }}
6903 >6903 >
...@@ -7615,7 +7615,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7615,7 +7615,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7615 onInterrupt={handleInterruptPrimaryAction}7615 onInterrupt={handleInterruptPrimaryAction}
7616 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}7616 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
7617 compactBeforeSendTokens={props.resumeCompactionTokens}7617 compactBeforeSendTokens={props.resumeCompactionTokens}
7618 onSendWithFullHistory={sendWithFullHistory}7618 keepFullHistory={props.keepFullHistory}
7619 onToggleKeepFullHistory={props.onToggleKeepFullHistory}
7619 compactDisabled={7620 compactDisabled={
7620 compactDisabled || noProviderAvailable || isSendBusy || isConnecting7621 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
7621 }7622 }
apps/web/src/components/chat/ComposerPrimaryActions.tsx+61-64
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { memo, type MouseEventHandler, type PointerEventHandler } from "react";1import { memo, type MouseEventHandler, type PointerEventHandler } from "react";
2import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-react";2import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, Minimize2Icon, PlayIcon } from "lucide-react";
3import { CornerUpRight, ListPlus } from "lucide";3import { CornerUpRight, ListPlus } from "lucide";
4import { MorphIcon } from "~/components/MorphIcon";4import { MorphIcon } from "~/components/MorphIcon";
5import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";5import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
...@@ -51,9 +51,11 @@ interface ComposerPrimaryActionsProps {...@@ -51,9 +51,11 @@ interface ComposerPrimaryActionsProps {
51 onPreviousPendingQuestion: () => void;51 onPreviousPendingQuestion: () => void;
52 onInterrupt: () => void;52 onInterrupt: () => void;
53 onImplementPlanInNewThread: () => void;53 onImplementPlanInNewThread: () => void;
54 /** Tokens a stale session would re-read. When set, Enter compacts first and the button says so. */54 /** Tokens a stale session would re-read. When set, a Compact chip shows the count and Enter compacts first. */
55 compactBeforeSendTokens?: number | null;55 compactBeforeSendTokens?: number | null;
56 onSendWithFullHistory?: () => void;56 /** The Compact chip is turned off, so the next send keeps full history. */
57 keepFullHistory?: boolean;
58 onToggleKeepFullHistory?: () => void;
57}59}
5860
59const formatPendingPrimaryActionLabel = (input: {61const formatPendingPrimaryActionLabel = (input: {
...@@ -108,7 +110,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -108,7 +110,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
108 onInterrupt,110 onInterrupt,
109 onImplementPlanInNewThread,111 onImplementPlanInNewThread,
110 compactBeforeSendTokens = null,112 compactBeforeSendTokens = null,
111 onSendWithFullHistory,113 keepFullHistory = false,
114 onToggleKeepFullHistory,
112}: ComposerPrimaryActionsProps) {115}: ComposerPrimaryActionsProps) {
113 const pointerFocusProps = preserveComposerFocusOnPointerDown116 const pointerFocusProps = preserveComposerFocusOnPointerDown
114 ? { onPointerDown: preventPointerFocus }117 ? { onPointerDown: preventPointerFocus }
...@@ -266,68 +269,24 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -266,68 +269,24 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
266 }269 }
267270
268 const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage;271 const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage;
269 const sendBlocked = isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable;
270272
271 if (compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage) {273 const compactTokens =
272 const tokens = formatContextWindowTokens(compactBeforeSendTokens);274 compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage
273 return (275 ? formatContextWindowTokens(compactBeforeSendTokens)
274 <div data-chat-composer-compact-send="true" className="flex items-center justify-end">276 : null;
275 <Tooltip>277 const compactsBeforeSend = compactTokens !== null && !keepFullHistory;
276 <TooltipTrigger
277 render={
278 <button
279 type="submit"
280 className={cn(
281 messageActionPillClassName,
282 "h-9 rounded-r-none sm:h-8",
283 compact ? "px-3" : "px-4",
284 )}
285 {...pointerFocusProps}
286 onClick={onSubmitMessage}
287 disabled={sendBlocked || !hasSendableContent}
288 />
289 }
290 >
291 {isConnecting || isSendBusy ? "Sending..." : "Compact and send"}
292 </TooltipTrigger>
293 <TooltipPopup>Summarize {tokens} tokens of history, then send</TooltipPopup>
294 </Tooltip>
295 <Menu>
296 <MenuTrigger
297 render={
298 <button
299 type="button"
300 className={cn(
301 messageActionPillClassName,
302 "h-9 rounded-l-none border-l border-message-action-foreground/20 px-2 sm:h-8",
303 )}
304 aria-label="Send options"
305 {...pointerFocusProps}
306 disabled={sendBlocked || !hasSendableContent}
307 />
308 }
309 >
310 <ChevronDownIcon className="size-3.5" />
311 </MenuTrigger>
312 <MenuPopup align="end" side="top" {...composerFloatingLayerProps}>
313 <MenuItem disabled={sendBlocked} onClick={onSendWithFullHistory}>
314 Send with full history ({tokens} tokens)
315 </MenuItem>
316 </MenuPopup>
317 </Menu>
318 </div>
319 );
320 }
321278
322 const submitLabel = showResume279 const submitLabel = showResume
323 ? "Resume thread"280 ? "Resume thread"
324 : isEditingQueuedMessage281 : isEditingQueuedMessage
325 ? "Update queued message"282 ? "Update queued message"
326 : isQueuing283 : compactsBeforeSend
327 ? "Queue message"284 ? "Compact and send"
328 : isRunning285 : isQueuing
329 ? "Steer message"286 ? "Queue message"
330 : "Submit message";287 : isRunning
288 ? "Steer message"
289 : "Submit message";
331 const submitStatus = isEnvironmentUnavailable290 const submitStatus = isEnvironmentUnavailable
332 ? "Environment disconnected"291 ? "Environment disconnected"
333 : (sendDisabledReason ??292 : (sendDisabledReason ??
...@@ -342,9 +301,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -342,9 +301,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
342 : null));301 : null));
343 const submitTooltip =302 const submitTooltip =
344 submitStatus ??303 submitStatus ??
345 (isRunning && !isEditingQueuedMessage304 (compactsBeforeSend
346 ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}`305 ? `Summarize ${compactTokens} tokens of history, then send`
347 : submitLabel);306 : isRunning && !isEditingQueuedMessage
307 ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}`
308 : submitLabel);
348309
349 const sendButton = (310 const sendButton = (
350 <button311 <button
...@@ -393,10 +354,46 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -393,10 +354,46 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
393 </button>354 </button>
394 );355 );
395356
396 return (357 const submit = (
397 <Tooltip key="submit">358 <Tooltip key="submit">
398 <TooltipTrigger render={<span className="inline-flex" />}>{sendButton}</TooltipTrigger>359 <TooltipTrigger render={<span className="inline-flex" />}>{sendButton}</TooltipTrigger>
399 <TooltipPopup>{submitTooltip}</TooltipPopup>360 <TooltipPopup>{submitTooltip}</TooltipPopup>
400 </Tooltip>361 </Tooltip>
401 );362 );
363 if (compactTokens === null) return submit;
364
365 return (
366 <div data-chat-composer-compact-send="true" className="flex items-center gap-2">
367 <Tooltip>
368 <TooltipTrigger
369 render={
370 <button
371 type="button"
372 className={cn(
373 "flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 [&_svg]:pointer-events-none [&_svg]:size-3.5",
374 keepFullHistory
375 ? "border-border text-muted-foreground hover:text-foreground"
376 : "border-warning/40 text-warning hover:bg-warning/8",
377 )}
378 {...pointerFocusProps}
379 aria-pressed={!keepFullHistory}
380 aria-label={`Compact ${compactTokens} tokens of history before sending`}
381 disabled={!canOperateThread}
382 onClick={onToggleKeepFullHistory}
383 />
384 }
385 >
386 <Minimize2Icon aria-hidden="true" />
387 {keepFullHistory ? "Full" : "Compact"}
388 <span>{compactTokens}</span>
389 </TooltipTrigger>
390 <TooltipPopup>
391 {keepFullHistory
392 ? `Next send keeps all ${compactTokens} tokens. Click to compact first`
393 : `Next send compacts ${compactTokens} tokens first. Click to keep full history`}
394 </TooltipPopup>
395 </Tooltip>
396 {submit}
397 </div>
398 );
402});399});
docs/user/providers-claude.md+4-4
...@@ -50,10 +50,10 @@ window. Leave it empty for Claude Code's default....@@ -50,10 +50,10 @@ window. Leave it empty for Claude Code's default.
5050
51You can also send `/compact` in an existing conversation. Web and desktop offer51You can also send `/compact` in an existing conversation. Web and desktop offer
52**Compact context** from the context meter. When you return to a large thread52**Compact context** from the context meter. When you return to a large thread
53after more than an hour, the send button changes to **Compact and send**: Enter53after more than an hour, a **Compact** chip with the thread's token count shows
54summarizes the history first, then sends your message. To keep the full history54next to the send button. While it is on, Enter summarizes the history first, then
55for that message, open the menu next to the button and choose **Send with full55sends your message. Click the chip to switch it to **Full** and keep the full
56history**. See [commands and skills](./composer.md#commands-and-skills) for using56history for that message. See [commands and skills](./composer.md#commands-and-skills) for using
57composer commands.57composer commands.
5858
59## Usage limits59## Usage limits