Skip to content

Commit 0647c48

Browse files
t3dotggclaude
andauthored
feat(web): compact-before-send is a chip that shows the token count (pingdotgg#17127)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d6f47ff commit 0647c48

4 files changed

Lines changed: 104 additions & 97 deletions

File tree

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 61 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { memo, type MouseEventHandler, type PointerEventHandler } from "react";
2-
import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-react";
2+
import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, Minimize2Icon, PlayIcon } from "lucide-react";
33
import { CornerUpRight, ListPlus } from "lucide";
44
import { MorphIcon } from "~/components/MorphIcon";
55
import { 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

5961
const 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‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ window. Leave it empty for Claude Code's default.
5050

5151
You 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
53-
after more than an hour, the send button changes to **Compact and send**: Enter
54-
summarizes the history first, then sends your message. To keep the full history
55-
for that message, open the menu next to the button and choose **Send with full
56-
history**. See [commands and skills](./composer.md#commands-and-skills) for using
53+
after more than an hour, a **Compact** chip with the thread's token count shows
54+
next to the send button. While it is on, Enter summarizes the history first, then
55+
sends your message. Click the chip to switch it to **Full** and keep the full
56+
history for that message. See [commands and skills](./composer.md#commands-and-skills) for using
5757
composer commands.
5858

5959
## Usage limits

0 commit comments

Comments
 (0)