authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-07 00:32:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 00:32:19-07:00
log655dfbe8ba87f0f9ef1d6fad83c20634634653aa
tree74d3ced606fb22072a4d474616d3db3ddf40ece2
parent079e4bccdcffc56c54bd69523ed77c006553ea21
signature Signed by PGP key B5690EEEBB952194

feat(web): old Claude threads compact on send instead of stacking notices (#16631)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 251 insertions(+), 154 deletions(-)

apps/web/src/components/ChatView.tsx+111-143
......@@ -39,7 +39,6 @@ import { feedbackBannerItem } from "./chat/ComposerFeedback";
3939import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
4040import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
4141import * as Schema from "effect/Schema";
42import { Minimize2Icon } from "lucide-react";
4342import {
4443 questionAttachmentDraftId,
4544 questionAttachmentDraftPrefix,
......@@ -486,6 +485,8 @@ import {
486485} from "./chat/QueuedRunsControl";
487486import { useLinkedThreadPullRequest } from "./ThreadStatusIndicators";
488487import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
488import { ThreadStatusLine } from "./chat/ThreadStatusLine";
489import { formatRelativeTimeLabel, formatRelativeTimeUntilLabel } from "../timestampFormat";
489490import { ComposerSurface } from "./chat/ComposerSurface";
490491import { resolveThreadSyncPhase } from "../threadSync";
491492import {
......@@ -493,7 +494,7 @@ import {
493494 hasDismissedResumeCompaction,
494495 shouldOfferResumeCompaction,
495496} from "./chat/ContextWindowMeter.logic";
496import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
497import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";
497498import {
498499 DRAFT_HERO_TRANSITION_ANIMATION_ID,
499500 DRAFT_HERO_TRANSITION_EASING,
......@@ -7563,73 +7564,61 @@ export default function ChatView(props: ChatViewProps) {
75637564 isStoppingBackgroundWork,
75647565 onOpenRelatedThread,
75657566 ]);
7566 // A woken thread announces itself in the open view, not just the sidebar
7567 // pill. Dismissing marks the wake as seen (same acknowledgment as the
7568 // pill); sending a message clears it as a side effect of the send path.
7569 const wokeThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7570 if (!activeThreadWokeVisible) {
7571 return null;
7572 }
7573 return {
7574 id: `thread-woke:${activeThread?.id ?? "unknown"}`,
7575 variant: "info",
7576 icon: <AlarmClockIcon />,
7577 title: "Thread woke from snooze",
7578 description: "Send a message to continue",
7579 dismissLabel: "Dismiss Woke notification",
7580 onDismiss: acknowledgeActiveThreadWoke,
7581 };
7582 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
7583 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7584 if (!activeThreadSnoozed && !activeThreadSettled) {
7585 return null;
7586 }
7587 const isSnoozed = activeThreadSnoozed;
7588 return {
7589 id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`,
7590 variant: "info",
7591 icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />,
7592 title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`,
7593 description: `Send a message to ${isSnoozed ? "wake" : "unsettle"}`,
7594 actions: (
7595 <Button
7596 size="xs"
7597 variant="ghost"
7598 disabled={!canOperateThread || (isSnoozed ? isUnsnoozing : isUnsettling)}
7599 onClick={() =>
7600 void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread())
7601 }
7602 >
7603 {isSnoozed
7604 ? isUnsnoozing
7605 ? "Waking..."
7606 : "Wake now"
7607 : isUnsettling
7608 ? "Un-settling..."
7609 : "Un-settle"}
7610 </Button>
7611 ),
7612 };
7567 // Settled, snoozed, and woke are thread state, not composer actions: each
7568 // gets one quiet line after the last message instead of a banner. A woken
7569 // thread announces itself here, not just in the sidebar pill. Dismissing
7570 // marks the wake as seen (same acknowledgment as the pill); sending a
7571 // message clears it as a side effect of the send path.
7572 // Memoized: it is the timeline's list footer, and a new element re-renders that footer.
7573 // nowMinute keeps the relative time fresh.
7574 const threadStatusLine = useMemo(() => {
7575 void nowMinute;
7576 return activeThreadSnoozed ? (
7577 <ThreadStatusLine
7578 icon={<AlarmClockIcon />}
7579 label={
7580 activeThreadShell?.snoozedUntil
7581 ? `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}`
7582 : "Snoozed"
7583 }
7584 actionLabel={isUnsnoozing ? "Waking..." : "Wake now"}
7585 actionDisabled={!canOperateThread || isUnsnoozing}
7586 onAction={() => void handleUnsnoozeActiveThread()}
7587 />
7588 ) : activeThreadSettled ? (
7589 <ThreadStatusLine
7590 icon={<CheckCircle2Icon />}
7591 label={
7592 activeThreadShell?.settledAt
7593 ? `Settled ${formatRelativeTimeLabel(activeThreadShell.settledAt)}`
7594 : "Settled"
7595 }
7596 actionLabel={isUnsettling ? "Un-settling..." : "Un-settle"}
7597 actionDisabled={!canOperateThread || isUnsettling}
7598 onAction={() => void handleUnsettleActiveThread()}
7599 />
7600 ) : activeThreadWokeVisible ? (
7601 <ThreadStatusLine
7602 icon={<AlarmClockIcon />}
7603 label="Woke from snooze"
7604 actionLabel="Dismiss"
7605 onAction={acknowledgeActiveThreadWoke}
7606 />
7607 ) : null;
76137608 }, [
7614 activeThread?.id,
7609 acknowledgeActiveThreadWoke,
76157610 activeThreadSettled,
7611 activeThreadShell?.settledAt,
7612 activeThreadShell?.snoozedUntil,
76167613 activeThreadSnoozed,
7614 activeThreadWokeVisible,
76177615 canOperateThread,
7618 handleUnsnoozeActiveThread,
76197616 handleUnsettleActiveThread,
7620 isUnsnoozing,
7617 handleUnsnoozeActiveThread,
76217618 isUnsettling,
7619 isUnsnoozing,
7620 nowMinute,
76227621 ]);
7623 // Session-scoped dismissals, one key per (thread, snapshot). A set rather
7624 // than a single slot so dismissing the banner on one thread does not
7625 // resurface it on another thread dismissed earlier.
7626 const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState<
7627 ReadonlySet<string>
7628 >(new Set());
7629 const resumeCompactionKey =
7630 activeThread && activeContextWindow
7631 ? `${activeThread.id}:${activeContextWindow.updatedAt}`
7632 : null;
76337622 const activeThreadHasCompactableConversation = serverVisibleTurnItems.some(
76347623 ({ item }) =>
76357624 item.type === "user_message" &&
......@@ -7661,73 +7650,36 @@ export default function ChatView(props: ChatViewProps) {
76617650 ? "Compaction is unavailable for this provider"
76627651 : "Compacting is unavailable right now"
76637652 : null;
7664 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7665 if (
7666 !activeThread ||
7667 !activeContextWindow ||
7668 resumeCompactionKey === null ||
7669 dismissedResumeCompactionKeys.has(resumeCompactionKey) ||
7670 resumeCompactionPermanentlyDismissed ||
7671 nativeResumeCompactionDismissed ||
7672 pendingUserInputs.length > 0 ||
7673 phase === "running" ||
7674 !shouldOfferResumeCompaction({
7675 provider: selectedProvider,
7676 usedTokens: activeContextWindow.usedTokens,
7677 updatedAt: activeContextWindow.updatedAt,
7678 now: `${nowMinute}:00.000Z`,
7679 })
7680 ) {
7681 return null;
7653 // Tokens a stale Claude session would re-read on its next turn. While set,
7654 // Enter compacts first and the composer's send button says so; "Send with
7655 // full history" in its menu skips that once. Held queues and multi-model
7656 // sends never compact first, so the offer hides for them.
7657 const resumeCompactionTokens =
7658 activeContextWindow &&
7659 !resumeCompactionPermanentlyDismissed &&
7660 !nativeResumeCompactionDismissed &&
7661 !compactDisabled &&
7662 !hasHeldQueuedRuns &&
7663 multipleModelSelections === null &&
7664 shouldOfferResumeCompaction({
7665 provider: selectedProvider,
7666 usedTokens: activeContextWindow.usedTokens,
7667 updatedAt: activeContextWindow.updatedAt,
7668 now: `${nowMinute}:00.000Z`,
7669 })
7670 ? activeContextWindow.usedTokens
7671 : null;
7672 // Set only for the synchronous span of a "Send with full history" submit;
7673 // onSend reads it before its first await.
7674 const keepFullHistoryOnceRef = useRef(false);
7675 const sendWithFullHistory = useCallback((send: () => void) => {
7676 keepFullHistoryOnceRef.current = true;
7677 try {
7678 send();
7679 } finally {
7680 keepFullHistoryOnceRef.current = false;
76827681 }
7683
7684 const dismiss = () =>
7685 setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey));
7686 const compactAction = (
7687 <Button
7688 size="xs"
7689 variant="ghost"
7690 disabled={compactDisabled}
7691 onClick={() => {
7692 if (compactDisabled) return;
7693 composerRef.current?.compactContext();
7694 }}
7695 >
7696 Compact
7697 </Button>
7698 );
7699 return {
7700 id: `resume-compaction:${resumeCompactionKey}`,
7701 variant: "info",
7702 icon: <Minimize2Icon />,
7703 title: "Resume with less context",
7704 description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from earlier`,
7705 actions: compactDisabledReason ? (
7706 <Tooltip>
7707 <TooltipTrigger render={<span className="inline-flex">{compactAction}</span>} />
7708 <TooltipPopup side="top">{compactDisabledReason}</TooltipPopup>
7709 </Tooltip>
7710 ) : (
7711 compactAction
7712 ),
7713 dismissLabel: "Keep full history",
7714 onDismiss: dismiss,
7715 };
7716 }, [
7717 activeContextWindow,
7718 activeThread,
7719 compactDisabled,
7720 compactDisabledReason,
7721 composerRef,
7722 dismissedResumeCompactionKeys,
7723 nativeResumeCompactionDismissed,
7724 nowMinute,
7725 pendingUserInputs.length,
7726 phase,
7727 resumeCompactionKey,
7728 resumeCompactionPermanentlyDismissed,
7729 selectedProvider,
7730 ]);
7682 }, []);
77317683 const handleRestoreThreadBranch = useCallback(() => {
77327684 if (!canWriteSourceControl) return;
77337685 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
......@@ -7779,10 +7731,6 @@ export default function ChatView(props: ChatViewProps) {
77797731 const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter(
77807732 (item) => item !== null,
77817733 );
7782 const resumeCompactionItems =
7783 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
7784 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
7785 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
77867734 // The user asked for this one, so it leads the notice tier instead of trailing it.
77877735 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
77887736 const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];
......@@ -7794,9 +7742,6 @@ export default function ChatView(props: ChatViewProps) {
77947742 ...projectCloneItems,
77957743 ...systemComposerBannerItems,
77967744 ...backgroundWorkItems,
7797 ...resumeCompactionItems,
7798 ...wokeThreadItems,
7799 ...parkedThreadItems,
78007745 ];
78017746 }
78027747 return [
......@@ -7806,8 +7751,6 @@ export default function ChatView(props: ChatViewProps) {
78067751 ...projectCloneItems,
78077752 ...systemComposerBannerItems,
78087753 ...backgroundWorkItems,
7809 ...resumeCompactionItems,
7810 ...wokeThreadItems,
78117754 {
78127755 id: `branch-mismatch:${activeBranchMismatchKey}`,
78137756 variant: "info",
......@@ -7846,7 +7789,6 @@ export default function ChatView(props: ChatViewProps) {
78467789 setBranchMismatchDismissTick((tick) => tick + 1);
78477790 },
78487791 },
7849 ...parkedThreadItems,
78507792 ];
78517793 }, [
78527794 activeBranchMismatchKey,
......@@ -7860,13 +7802,10 @@ export default function ChatView(props: ChatViewProps) {
78607802 backgroundWorkBannerItem,
78617803 goalBannerItem,
78627804 localCheckoutBranchMismatch,
7863 parkedThreadBannerItem,
78647805 projectCloneBannerItem,
7865 resumeCompactionBannerItem,
78667806 showBranchMismatchBanner,
78677807 systemComposerBannerItems,
78687808 usageLimitsBanner,
7869 wokeThreadBannerItem,
78707809 ]);
78717810
78727811 useEffect(() => {
......@@ -8692,6 +8631,7 @@ export default function ChatView(props: ChatViewProps) {
86928631 },
86938632 ) => {
86948633 e?.preventDefault();
8634 const keepFullHistory = keepFullHistoryOnceRef.current;
86958635 // Typed out in full rather than picked from the menu. Attachments or contexts
86968636 // mean the user is sending a prompt, so those go through as usual.
86978637 if (
......@@ -9216,7 +9156,16 @@ export default function ChatView(props: ChatViewProps) {
92169156 );
92179157 const messageIdForSend = newMessageId();
92189158 const messageCreatedAt = new Date().toISOString();
9219 const shouldQueueBehindActiveRun = phase === "running" && dispatchMode === "queue";
9159 // A stale Claude session compacts before this message so the turn does not re-read the
9160 // old history. The message queues behind the /compact run, since steering into it is
9161 // rejected.
9162 const compactBeforeSend =
9163 resumeCompactionTokens !== null &&
9164 !keepFullHistory &&
9165 messageTextForSend.toLowerCase() !== "/compact";
9166 const turnDispatchMode = compactBeforeSend ? "queue" : dispatchMode;
9167 const shouldQueueBehindActiveRun =
9168 compactBeforeSend || (phase === "running" && dispatchMode === "queue");
92209169 const outgoingMessageText = formatOutgoingPrompt({
92219170 provider: ctxSelectedProvider,
92229171 model: ctxSelectedModel,
......@@ -9799,6 +9748,22 @@ export default function ChatView(props: ChatViewProps) {
97999748 failure = turnAttachmentsResult;
98009749 }
98019750
9751 if (failure === null && compactBeforeSend) {
9752 const compactResult = await startThreadTurn({
9753 environmentId,
9754 input: {
9755 threadId: threadIdForSend,
9756 message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] },
9757 modelSelection: ctxSelectedModelSelection,
9758 runtimeMode,
9759 interactionMode: sendInteractionMode,
9760 },
9761 });
9762 if (compactResult._tag === "Failure") {
9763 failure = compactResult;
9764 }
9765 }
9766
98029767 let backgroundDraftOpened = false;
98039768 let turnStartSucceeded = false;
98049769 if (failure === null && turnAttachmentsResult._tag === "Success") {
......@@ -9876,7 +9841,7 @@ export default function ChatView(props: ChatViewProps) {
98769841 titleSeed: title,
98779842 runtimeMode,
98789843 interactionMode: sendInteractionMode,
9879 dispatchMode,
9844 dispatchMode: turnDispatchMode,
98809845 ...(bootstrap ? { bootstrap } : {}),
98819846 createdAt: messageCreatedAt,
98829847 },
......@@ -11324,6 +11289,7 @@ export default function ChatView(props: ChatViewProps) {
1132411289 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}
1132511290 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}
1132611291 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
11292 footer={paintOnlyDisplayedTimeline ? null : threadStatusLine}
1132711293 listRef={legendListRef}
1132811294 timelineEntries={displayedTimeline.entries}
1132911295 providerStatuses={
......@@ -11575,6 +11541,8 @@ export default function ChatView(props: ChatViewProps) {
1157511541 ) : null
1157611542 }
1157711543 bannerItems={composerBannerItems}
11544 resumeCompactionTokens={resumeCompactionTokens}
11545 onSendWithFullHistory={sendWithFullHistory}
1157811546 // With attachments or contexts aboard the pick just inserts the
1157911547 // text, so it sends as a prompt like the typed path would.
1158011548 onUsageLimitsCommand={
apps/web/src/components/chat/ChatComposer.tsx+22-1
......@@ -1386,6 +1386,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
13861386 onCompactContext?: (() => void) | undefined;
13871387 compactDisabled: boolean;
13881388 compactDisabledReason: string | null;
1389 compactBeforeSendTokens: number | null;
1390 onSendWithFullHistory: () => void;
13891391}) {
13901392 return (
13911393 <>
......@@ -1424,6 +1426,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
14241426 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
14251427 onInterrupt={props.onInterrupt}
14261428 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
1429 compactBeforeSendTokens={props.compactBeforeSendTokens}
1430 onSendWithFullHistory={props.onSendWithFullHistory}
14271431 />
14281432 </>
14291433 );
......@@ -1543,6 +1547,10 @@ export interface ChatComposerProps {
15431547 sendDisabledReason: string | null;
15441548 isPreparingWorktree: boolean;
15451549 bannerItems: readonly ComposerBannerStackItem[];
1550 /** Tokens Enter compacts before sending; null when the next send keeps full history. */
1551 resumeCompactionTokens: number | null;
1552 /** Runs `send` as a one-off send that keeps full history instead of compacting first. */
1553 onSendWithFullHistory: (send: () => void) => void;
15461554 /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */
15471555 onUsageLimitsCommand?: (() => void) | undefined;
15481556 environmentUnavailable: {
......@@ -3002,7 +3010,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30023010 projectSelectionRequired ||
30033011 environmentUnavailable !== null ||
30043012 (!composerSendState.hasSendableContent && !showResumeAction);
3005 const collapsedComposerPrimaryActionLabel = showResumeAction ? "Resume thread" : "Send message";
3013 const collapsedComposerPrimaryActionLabel = showResumeAction
3014 ? "Resume thread"
3015 : props.resumeCompactionTokens !== null
3016 ? "Open composer to compact and send"
3017 : "Send message";
30063018 const showMobilePendingAnswerActions =
30073019 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
30083020
......@@ -4250,6 +4262,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42504262 },
42514263 [phase, settings.followUpBehavior, submitComposer],
42524264 );
4265 const { onSendWithFullHistory } = props;
4266 const sendWithFullHistory = useCallback(
4267 () => onSendWithFullHistory(() => submitComposer()),
4268 [onSendWithFullHistory, submitComposer],
4269 );
42534270 const submitCitationAndSend = useCallback(() => {
42544271 submitComposer(
42554272 undefined,
......@@ -6868,6 +6885,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
68686885 onClick={(event) => {
68696886 event.stopPropagation();
68706887 if (showResumeAction) onResume();
6888 // Compacting first is only sent from the labeled button, so expand to show it.
6889 else if (props.resumeCompactionTokens !== null) expandMobileComposer();
68716890 else submitComposer();
68726891 }}
68736892 >
......@@ -7584,6 +7603,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
75847603 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
75857604 onInterrupt={handleInterruptPrimaryAction}
75867605 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
7606 compactBeforeSendTokens={props.resumeCompactionTokens}
7607 onSendWithFullHistory={sendWithFullHistory}
75877608 compactDisabled={
75887609 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
75897610 }
apps/web/src/components/chat/ComposerPrimaryActions.tsx+59
......@@ -3,6 +3,7 @@ import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-re
33import { CornerUpRight, ListPlus } from "lucide";
44import { MorphIcon } from "~/components/MorphIcon";
55import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
6import { formatContextWindowTokens } from "~/lib/contextWindow";
67import { cn } from "~/lib/utils";
78import { useShortcutModifierState } from "../../shortcutModifierState";
89import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
......@@ -50,6 +51,9 @@ interface ComposerPrimaryActionsProps {
5051 onPreviousPendingQuestion: () => void;
5152 onInterrupt: () => void;
5253 onImplementPlanInNewThread: () => void;
54 /** Tokens a stale session would re-read. When set, Enter compacts first and the button says so. */
55 compactBeforeSendTokens?: number | null;
56 onSendWithFullHistory?: () => void;
5357}
5458
5559const formatPendingPrimaryActionLabel = (input: {
......@@ -103,6 +107,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
103107 onPreviousPendingQuestion,
104108 onInterrupt,
105109 onImplementPlanInNewThread,
110 compactBeforeSendTokens = null,
111 onSendWithFullHistory,
106112}: ComposerPrimaryActionsProps) {
107113 const pointerFocusProps = preserveComposerFocusOnPointerDown
108114 ? { onPointerDown: preventPointerFocus }
......@@ -260,6 +266,59 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
260266 }
261267
262268 const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage;
269 const sendBlocked = isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable;
270
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 }
321
263322 const submitLabel = showResume
264323 ? "Resume thread"
265324 : isEditingQueuedMessage
apps/web/src/components/chat/MessagesTimeline.tsx+23-7
......@@ -371,11 +371,18 @@ const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
371371const TIMELINE_LIST_FADE_HEADER = (
372372 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />
373373);
374function TimelineListFooter({ composerInset }: { readonly composerInset: number }) {
374function TimelineListFooter({
375 composerInset,
376 children,
377}: {
378 readonly composerInset: number;
379 readonly children?: ReactNode;
380}) {
375381 return (
376 <div aria-hidden>
377 <div style={{ height: composerInset }} />
378 <div className="h-3 sm:h-4" />
382 <div>
383 {children}
384 <div aria-hidden style={{ height: composerInset }} />
385 <div aria-hidden className="h-3 sm:h-4" />
379386 </div>
380387 );
381388}
......@@ -434,6 +441,8 @@ interface MessagesTimelineProps {
434441 onOpenWorktreeSetupTerminal?: (terminalId: string) => void;
435442 isPreparingWorktree?: boolean;
436443 isCompacting?: boolean;
444 /** Thread state shown after the last message, such as a settled or snoozed line. */
445 footer?: ReactNode;
437446
438447 listRef: React.RefObject<LegendListRef | null>;
439448 timelineEntries: ReadonlyArray<TimelineEntry>;
......@@ -512,6 +521,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
512521 activeTurnInProgress,
513522 activeTurnStartedAt = null,
514523 worktreeSetup = null,
524 footer = null,
515525 onCancelWorktreeSetup,
516526 retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS,
517527 onRetryWorkspacePreparation,
......@@ -988,8 +998,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
988998 [shouldRestoreVisibleContentPosition],
989999 );
9901000 const timelineListFooter = useMemo(
991 () => <TimelineListFooter composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment} />,
992 [anchoredEndSpace, contentInsetEndAdjustment],
1001 () => (
1002 <TimelineListFooter composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment}>
1003 {footer}
1004 </TimelineListFooter>
1005 ),
1006 [anchoredEndSpace, contentInsetEndAdjustment, footer],
9931007 );
9941008
9951009 const measureContentOverflow = useCallback(
......@@ -1308,7 +1322,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
13081322 rows.length === 0 &&
13091323 !isWorking &&
13101324 parentThreadLink === null &&
1311 historyControls === undefined
1325 historyControls === undefined &&
1326 // A status line (settled, snoozed) still needs the list, whose footer renders it.
1327 footer === null
13121328 ) {
13131329 if (hideEmptyPlaceholder) {
13141330 // Occupy the pane with the theme surface so a thread switch cannot
apps/web/src/components/chat/ThreadStatusLine.tsx created+30
......@@ -0,0 +1,30 @@
1import type { ReactNode } from "react";
2
3import { InlineButton } from "../ui/button";
4
5/**
6 * One quiet line after the last message for thread state that does not change
7 * what Enter does: settled, snoozed, or just woke. Sending a message clears each of
8 * them, so the action is only the explicit way out.
9 */
10export function ThreadStatusLine(props: {
11 readonly icon: ReactNode;
12 readonly label: string;
13 readonly actionLabel: string;
14 readonly actionDisabled?: boolean;
15 readonly onAction: () => void;
16}) {
17 return (
18 <div
19 data-chat-thread-status-line="true"
20 className="mx-auto flex w-full min-w-0 max-w-(--chat-content-max-width) items-center gap-1.5 px-4 pb-2 text-muted-foreground text-xs [&_svg]:size-3.5 [&_svg]:shrink-0"
21 >
22 {props.icon}
23 <span className="min-w-0 truncate">{props.label}</span>
24 <span aria-hidden="true">·</span>
25 <InlineButton tone="muted" disabled={props.actionDisabled} onClick={props.onAction}>
26 {props.actionLabel}
27 </InlineButton>
28 </div>
29 );
30}
docs/user/providers-claude.md+6-3
......@@ -49,9 +49,12 @@ Set **Auto-compact after** in the Claude provider settings to an integer between
4949window. Leave it empty for Claude Code's default.
5050
5151You can also send `/compact` in an existing conversation. Web and desktop offer
52**Compact context** from the context meter and may suggest it when you return to
53a large older thread. See [commands and skills](./composer.md#commands-and-skills)
54for using composer commands.
52**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
57composer commands.
5558
5659## Usage limits
5760