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) {
76727672 : "Compacting is unavailable right now"
76737673 : null;
76747674 // 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 with
7676 // full history" in its menu skips that once. Held queues and multi-model
7677 // sends never compact first, so the offer hides for them.
7675 // the composer shows a Compact chip and Enter compacts first; turning the
7676 // chip off sends the next message with full history. Held queues and
7677 // multi-model sends never compact first, so the offer hides for them.
76787678 const resumeCompactionTokens =
76797679 activeContextWindow &&
76807680 !resumeCompactionPermanentlyDismissed &&
......@@ -7690,17 +7690,25 @@ export default function ChatView(props: ChatViewProps) {
76907690 })
76917691 ? activeContextWindow.usedTokens
76927692 : null;
7693 // Set only for the synchronous span of a "Send with full history" submit;
7694 // onSend reads it before its first await.
7695 const keepFullHistoryOnceRef = useRef(false);
7696 const sendWithFullHistory = useCallback((send: () => void) => {
7697 keepFullHistoryOnceRef.current = true;
7698 try {
7699 send();
7700 } finally {
7701 keepFullHistoryOnceRef.current = false;
7702 }
7693 // Threads whose Compact chip is turned off. A send that starts its turn
7694 // clears its thread's entry; a failed send keeps it for the retry.
7695 const [fullHistoryThreadKeys, setFullHistoryThreadKeys] = useState<ReadonlySet<string>>(
7696 () => new Set(),
7697 );
7698 const keepFullHistory = fullHistoryThreadKeys.has(routeThreadKey);
7699 const setKeepFullHistory = useCallback((threadKey: string, keep: boolean) => {
7700 setFullHistoryThreadKeys((current) => {
7701 if (current.has(threadKey) === keep) return current;
7702 const next = new Set(current);
7703 if (keep) next.add(threadKey);
7704 else next.delete(threadKey);
7705 return next;
7706 });
77037707 }, []);
7708 const toggleKeepFullHistory = useCallback(
7709 () => setKeepFullHistory(routeThreadKey, !keepFullHistory),
7710 [keepFullHistory, routeThreadKey, setKeepFullHistory],
7711 );
77047712 const handleRestoreThreadBranch = useCallback(() => {
77057713 if (!canWriteSourceControl) return;
77067714 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
......@@ -8652,7 +8660,6 @@ export default function ChatView(props: ChatViewProps) {
86528660 },
86538661 ) => {
86548662 e?.preventDefault();
8655 const keepFullHistory = keepFullHistoryOnceRef.current;
86568663 // Typed out in full rather than picked from the menu. Attachments or contexts
86578664 // mean the user is sending a prompt, so those go through as usual.
86588665 if (
......@@ -9896,6 +9903,7 @@ export default function ChatView(props: ChatViewProps) {
98969903 failure = startResult;
98979904 } else {
98989905 turnStartSucceeded = true;
9906 setKeepFullHistory(routeThreadKey, false);
98999907 // The turn is under way and will spend quota, so that thread's limits
99009908 // snapshot is stale. Uploads may have outlasted a navigation, so only
99019909 // the sending thread's panel clears.
......@@ -11559,7 +11567,8 @@ export default function ChatView(props: ChatViewProps) {
1155911567 }
1156011568 bannerItems={composerBannerItems}
1156111569 resumeCompactionTokens={resumeCompactionTokens}
11562 onSendWithFullHistory={sendWithFullHistory}
11570 keepFullHistory={keepFullHistory}
11571 onToggleKeepFullHistory={toggleKeepFullHistory}
1156311572 // With attachments or contexts aboard the pick just inserts the
1156411573 // text, so it sends as a prompt like the typed path would.
1156511574 onUsageLimitsCommand={
apps/web/src/components/chat/ChatComposer.tsx+15-14
......@@ -1391,7 +1391,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
13911391 compactDisabled: boolean;
13921392 compactDisabledReason: string | null;
13931393 compactBeforeSendTokens: number | null;
1394 onSendWithFullHistory: () => void;
1394 keepFullHistory: boolean;
1395 onToggleKeepFullHistory: () => void;
13951396}) {
13961397 return (
13971398 <>
......@@ -1431,7 +1432,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
14311432 onInterrupt={props.onInterrupt}
14321433 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
14331434 compactBeforeSendTokens={props.compactBeforeSendTokens}
1434 onSendWithFullHistory={props.onSendWithFullHistory}
1435 keepFullHistory={props.keepFullHistory}
1436 onToggleKeepFullHistory={props.onToggleKeepFullHistory}
14351437 />
14361438 </>
14371439 );
......@@ -1551,10 +1553,12 @@ export interface ChatComposerProps {
15511553 sendDisabledReason: string | null;
15521554 isPreparingWorktree: boolean;
15531555 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. */
15551557 resumeCompactionTokens: number | null;
1556 /** Runs `send` as a one-off send that keeps full history instead of compacting first. */
1557 onSendWithFullHistory: (send: () => void) => void;
1558 /** The Compact chip is off, so the next send keeps full history. */
1559 keepFullHistory: boolean;
1560 /** Flips the Compact chip for the active thread. */
1561 onToggleKeepFullHistory: () => void;
15581562 /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */
15591563 onUsageLimitsCommand?: (() => void) | undefined;
15601564 environmentUnavailable: {
......@@ -3016,7 +3020,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30163020 (!composerSendState.hasSendableContent && !showResumeAction);
30173021 const collapsedComposerPrimaryActionLabel = showResumeAction
30183022 ? "Resume thread"
3019 : props.resumeCompactionTokens !== null
3023 : props.resumeCompactionTokens !== null && !props.keepFullHistory
30203024 ? "Open composer to compact and send"
30213025 : "Send message";
30223026 const showMobilePendingAnswerActions =
......@@ -4271,11 +4275,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42714275 },
42724276 [phase, settings.followUpBehavior, submitComposer],
42734277 );
4274 const { onSendWithFullHistory } = props;
4275 const sendWithFullHistory = useCallback(
4276 () => onSendWithFullHistory(() => submitComposer()),
4277 [onSendWithFullHistory, submitComposer],
4278 );
42794278 const submitCitationAndSend = useCallback(() => {
42804279 submitComposer(
42814280 undefined,
......@@ -6896,8 +6895,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
68966895 onClick={(event) => {
68976896 event.stopPropagation();
68986897 if (showResumeAction) onResume();
6899 // Compacting first is only sent from the labeled button, so expand to show it.
6900 else if (props.resumeCompactionTokens !== null) expandMobileComposer();
6898 // Compacting first only sends from the expanded composer, where the chip shows it.
6899 else if (props.resumeCompactionTokens !== null && !props.keepFullHistory)
6900 expandMobileComposer();
69016901 else submitComposer();
69026902 }}
69036903 >
......@@ -7615,7 +7615,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
76157615 onInterrupt={handleInterruptPrimaryAction}
76167616 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
76177617 compactBeforeSendTokens={props.resumeCompactionTokens}
7618 onSendWithFullHistory={sendWithFullHistory}
7618 keepFullHistory={props.keepFullHistory}
7619 onToggleKeepFullHistory={props.onToggleKeepFullHistory}
76197620 compactDisabled={
76207621 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
76217622 }
apps/web/src/components/chat/ComposerPrimaryActions.tsx+61-64
......@@ -1,5 +1,5 @@
11import { memo, type MouseEventHandler, type PointerEventHandler } from "react";
2import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-react";
2import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, Minimize2Icon, PlayIcon } from "lucide-react";
33import { CornerUpRight, ListPlus } from "lucide";
44import { MorphIcon } from "~/components/MorphIcon";
55import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
......@@ -51,9 +51,11 @@ interface ComposerPrimaryActionsProps {
5151 onPreviousPendingQuestion: () => void;
5252 onInterrupt: () => void;
5353 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. */
5555 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;
5759}
5860
5961const formatPendingPrimaryActionLabel = (input: {
......@@ -108,7 +110,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
108110 onInterrupt,
109111 onImplementPlanInNewThread,
110112 compactBeforeSendTokens = null,
111 onSendWithFullHistory,
113 keepFullHistory = false,
114 onToggleKeepFullHistory,
112115}: ComposerPrimaryActionsProps) {
113116 const pointerFocusProps = preserveComposerFocusOnPointerDown
114117 ? { onPointerDown: preventPointerFocus }
......@@ -266,68 +269,24 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
266269 }
267270
268271 const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage;
269 const sendBlocked = isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable;
270272
271 if (compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage) {
272 const tokens = formatContextWindowTokens(compactBeforeSendTokens);
273 return (
274 <div data-chat-composer-compact-send="true" className="flex items-center justify-end">
275 <Tooltip>
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 }
273 const compactTokens =
274 compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage
275 ? formatContextWindowTokens(compactBeforeSendTokens)
276 : null;
277 const compactsBeforeSend = compactTokens !== null && !keepFullHistory;
321278
322279 const submitLabel = showResume
323280 ? "Resume thread"
324281 : isEditingQueuedMessage
325282 ? "Update queued message"
326 : isQueuing
327 ? "Queue message"
328 : isRunning
329 ? "Steer message"
330 : "Submit message";
283 : compactsBeforeSend
284 ? "Compact and send"
285 : isQueuing
286 ? "Queue message"
287 : isRunning
288 ? "Steer message"
289 : "Submit message";
331290 const submitStatus = isEnvironmentUnavailable
332291 ? "Environment disconnected"
333292 : (sendDisabledReason ??
......@@ -342,9 +301,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
342301 : null));
343302 const submitTooltip =
344303 submitStatus ??
345 (isRunning && !isEditingQueuedMessage
346 ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}`
347 : submitLabel);
304 (compactsBeforeSend
305 ? `Summarize ${compactTokens} tokens of history, then send`
306 : isRunning && !isEditingQueuedMessage
307 ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}`
308 : submitLabel);
348309
349310 const sendButton = (
350311 <button
......@@ -393,10 +354,46 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
393354 </button>
394355 );
395356
396 return (
357 const submit = (
397358 <Tooltip key="submit">
398359 <TooltipTrigger render={<span className="inline-flex" />}>{sendButton}</TooltipTrigger>
399360 <TooltipPopup>{submitTooltip}</TooltipPopup>
400361 </Tooltip>
401362 );
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 );
402399});
docs/user/providers-claude.md+4-4
......@@ -50,10 +50,10 @@ window. Leave it empty for Claude Code's default.
5050
5151You can also send `/compact` in an existing conversation. Web and desktop offer
5252**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**: Enter
54summarizes the history first, then sends your message. To keep the full history
55for that message, open the menu next to the button and choose **Send with full
56history**. See [commands and skills](./composer.md#commands-and-skills) for using
53after more than an hour, a **Compact** chip with the thread's token count shows
54next to the send button. While it is on, Enter summarizes the history first, then
55sends your message. Click the chip to switch it to **Full** and keep the full
56history for that message. See [commands and skills](./composer.md#commands-and-skills) for using
5757composer commands.
5858
5959## Usage limits