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";...@@ -39,7 +39,6 @@ import { feedbackBannerItem } from "./chat/ComposerFeedback";
39import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";39import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
40import { getTerminalLabel } from "@t3tools/shared/terminalLabels";40import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
41import * as Schema from "effect/Schema";41import * as Schema from "effect/Schema";
42import { Minimize2Icon } from "lucide-react";
43import {42import {
44 questionAttachmentDraftId,43 questionAttachmentDraftId,
45 questionAttachmentDraftPrefix,44 questionAttachmentDraftPrefix,
...@@ -486,6 +485,8 @@ import {...@@ -486,6 +485,8 @@ import {
486} from "./chat/QueuedRunsControl";485} from "./chat/QueuedRunsControl";
487import { useLinkedThreadPullRequest } from "./ThreadStatusIndicators";486import { useLinkedThreadPullRequest } from "./ThreadStatusIndicators";
488import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";487import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
488import { ThreadStatusLine } from "./chat/ThreadStatusLine";
489import { formatRelativeTimeLabel, formatRelativeTimeUntilLabel } from "../timestampFormat";
489import { ComposerSurface } from "./chat/ComposerSurface";490import { ComposerSurface } from "./chat/ComposerSurface";
490import { resolveThreadSyncPhase } from "../threadSync";491import { resolveThreadSyncPhase } from "../threadSync";
491import {492import {
...@@ -493,7 +494,7 @@ import {...@@ -493,7 +494,7 @@ import {
493 hasDismissedResumeCompaction,494 hasDismissedResumeCompaction,
494 shouldOfferResumeCompaction,495 shouldOfferResumeCompaction,
495} from "./chat/ContextWindowMeter.logic";496} from "./chat/ContextWindowMeter.logic";
496import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";497import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";
497import {498import {
498 DRAFT_HERO_TRANSITION_ANIMATION_ID,499 DRAFT_HERO_TRANSITION_ANIMATION_ID,
499 DRAFT_HERO_TRANSITION_EASING,500 DRAFT_HERO_TRANSITION_EASING,
...@@ -7563,73 +7564,61 @@ export default function ChatView(props: ChatViewProps) {...@@ -7563,73 +7564,61 @@ export default function ChatView(props: ChatViewProps) {
7563 isStoppingBackgroundWork,7564 isStoppingBackgroundWork,
7564 onOpenRelatedThread,7565 onOpenRelatedThread,
7565 ]);7566 ]);
7566 // A woken thread announces itself in the open view, not just the sidebar7567 // Settled, snoozed, and woke are thread state, not composer actions: each
7567 // pill. Dismissing marks the wake as seen (same acknowledgment as the7568 // gets one quiet line after the last message instead of a banner. A woken
7568 // pill); sending a message clears it as a side effect of the send path.7569 // thread announces itself here, not just in the sidebar pill. Dismissing
7569 const wokeThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {7570 // marks the wake as seen (same acknowledgment as the pill); sending a
7570 if (!activeThreadWokeVisible) {7571 // message clears it as a side effect of the send path.
7571 return null;7572 // Memoized: it is the timeline's list footer, and a new element re-renders that footer.
7572 }7573 // nowMinute keeps the relative time fresh.
7573 return {7574 const threadStatusLine = useMemo(() => {
7574 id: `thread-woke:${activeThread?.id ?? "unknown"}`,7575 void nowMinute;
7575 variant: "info",7576 return activeThreadSnoozed ? (
7576 icon: <AlarmClockIcon />,7577 <ThreadStatusLine
7577 title: "Thread woke from snooze",7578 icon={<AlarmClockIcon />}
7578 description: "Send a message to continue",7579 label={
7579 dismissLabel: "Dismiss Woke notification",7580 activeThreadShell?.snoozedUntil
7580 onDismiss: acknowledgeActiveThreadWoke,7581 ? `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}`
7581 };7582 : "Snoozed"
7582 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);7583 }
7583 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {7584 actionLabel={isUnsnoozing ? "Waking..." : "Wake now"}
7584 if (!activeThreadSnoozed && !activeThreadSettled) {7585 actionDisabled={!canOperateThread || isUnsnoozing}
7585 return null;7586 onAction={() => void handleUnsnoozeActiveThread()}
7586 }7587 />
7587 const isSnoozed = activeThreadSnoozed;7588 ) : activeThreadSettled ? (
7588 return {7589 <ThreadStatusLine
7589 id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`,7590 icon={<CheckCircle2Icon />}
7590 variant: "info",7591 label={
7591 icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />,7592 activeThreadShell?.settledAt
7592 title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`,7593 ? `Settled ${formatRelativeTimeLabel(activeThreadShell.settledAt)}`
7593 description: `Send a message to ${isSnoozed ? "wake" : "unsettle"}`,7594 : "Settled"
7594 actions: (7595 }
7595 <Button7596 actionLabel={isUnsettling ? "Un-settling..." : "Un-settle"}
7596 size="xs"7597 actionDisabled={!canOperateThread || isUnsettling}
7597 variant="ghost"7598 onAction={() => void handleUnsettleActiveThread()}
7598 disabled={!canOperateThread || (isSnoozed ? isUnsnoozing : isUnsettling)}7599 />
7599 onClick={() =>7600 ) : activeThreadWokeVisible ? (
7600 void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread())7601 <ThreadStatusLine
7601 }7602 icon={<AlarmClockIcon />}
7602 >7603 label="Woke from snooze"
7603 {isSnoozed7604 actionLabel="Dismiss"
7604 ? isUnsnoozing7605 onAction={acknowledgeActiveThreadWoke}
7605 ? "Waking..."7606 />
7606 : "Wake now"7607 ) : null;
7607 : isUnsettling
7608 ? "Un-settling..."
7609 : "Un-settle"}
7610 </Button>
7611 ),
7612 };
7613 }, [7608 }, [
7614 activeThread?.id,7609 acknowledgeActiveThreadWoke,
7615 activeThreadSettled,7610 activeThreadSettled,
7611 activeThreadShell?.settledAt,
7612 activeThreadShell?.snoozedUntil,
7616 activeThreadSnoozed,7613 activeThreadSnoozed,
7614 activeThreadWokeVisible,
7617 canOperateThread,7615 canOperateThread,
7618 handleUnsnoozeActiveThread,
7619 handleUnsettleActiveThread,7616 handleUnsettleActiveThread,
7620 isUnsnoozing,7617 handleUnsnoozeActiveThread,
7621 isUnsettling,7618 isUnsettling,
7619 isUnsnoozing,
7620 nowMinute,
7622 ]);7621 ]);
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;
7633 const activeThreadHasCompactableConversation = serverVisibleTurnItems.some(7622 const activeThreadHasCompactableConversation = serverVisibleTurnItems.some(
7634 ({ item }) =>7623 ({ item }) =>
7635 item.type === "user_message" &&7624 item.type === "user_message" &&
...@@ -7661,73 +7650,36 @@ export default function ChatView(props: ChatViewProps) {...@@ -7661,73 +7650,36 @@ export default function ChatView(props: ChatViewProps) {
7661 ? "Compaction is unavailable for this provider"7650 ? "Compaction is unavailable for this provider"
7662 : "Compacting is unavailable right now"7651 : "Compacting is unavailable right now"
7663 : null;7652 : null;
7664 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {7653 // Tokens a stale Claude session would re-read on its next turn. While set,
7665 if (7654 // Enter compacts first and the composer's send button says so; "Send with
7666 !activeThread ||7655 // full history" in its menu skips that once. Held queues and multi-model
7667 !activeContextWindow ||7656 // sends never compact first, so the offer hides for them.
7668 resumeCompactionKey === null ||7657 const resumeCompactionTokens =
7669 dismissedResumeCompactionKeys.has(resumeCompactionKey) ||7658 activeContextWindow &&
7670 resumeCompactionPermanentlyDismissed ||7659 !resumeCompactionPermanentlyDismissed &&
7671 nativeResumeCompactionDismissed ||7660 !nativeResumeCompactionDismissed &&
7672 pendingUserInputs.length > 0 ||7661 !compactDisabled &&
7673 phase === "running" ||7662 !hasHeldQueuedRuns &&
7674 !shouldOfferResumeCompaction({7663 multipleModelSelections === null &&
7675 provider: selectedProvider,7664 shouldOfferResumeCompaction({
7676 usedTokens: activeContextWindow.usedTokens,7665 provider: selectedProvider,
7677 updatedAt: activeContextWindow.updatedAt,7666 usedTokens: activeContextWindow.usedTokens,
7678 now: `${nowMinute}:00.000Z`,7667 updatedAt: activeContextWindow.updatedAt,
7679 })7668 now: `${nowMinute}:00.000Z`,
7680 ) {7669 })
7681 return null;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;
7682 }7681 }
76837682 }, []);
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 ]);
7731 const handleRestoreThreadBranch = useCallback(() => {7683 const handleRestoreThreadBranch = useCallback(() => {
7732 if (!canWriteSourceControl) return;7684 if (!canWriteSourceControl) return;
7733 if (gitStatusQuery.data?.hasWorkingTreeChanges) {7685 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
...@@ -7779,10 +7731,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -7779,10 +7731,6 @@ export default function ChatView(props: ChatViewProps) {
7779 const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter(7731 const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter(
7780 (item) => item !== null,7732 (item) => item !== null,
7781 );7733 );
7782 const resumeCompactionItems =
7783 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
7784 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
7785 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
7786 // The user asked for this one, so it leads the notice tier instead of trailing it.7734 // The user asked for this one, so it leads the notice tier instead of trailing it.
7787 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];7735 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
7788 const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];7736 const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];
...@@ -7794,9 +7742,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -7794,9 +7742,6 @@ export default function ChatView(props: ChatViewProps) {
7794 ...projectCloneItems,7742 ...projectCloneItems,
7795 ...systemComposerBannerItems,7743 ...systemComposerBannerItems,
7796 ...backgroundWorkItems,7744 ...backgroundWorkItems,
7797 ...resumeCompactionItems,
7798 ...wokeThreadItems,
7799 ...parkedThreadItems,
7800 ];7745 ];
7801 }7746 }
7802 return [7747 return [
...@@ -7806,8 +7751,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -7806,8 +7751,6 @@ export default function ChatView(props: ChatViewProps) {
7806 ...projectCloneItems,7751 ...projectCloneItems,
7807 ...systemComposerBannerItems,7752 ...systemComposerBannerItems,
7808 ...backgroundWorkItems,7753 ...backgroundWorkItems,
7809 ...resumeCompactionItems,
7810 ...wokeThreadItems,
7811 {7754 {
7812 id: `branch-mismatch:${activeBranchMismatchKey}`,7755 id: `branch-mismatch:${activeBranchMismatchKey}`,
7813 variant: "info",7756 variant: "info",
...@@ -7846,7 +7789,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -7846,7 +7789,6 @@ export default function ChatView(props: ChatViewProps) {
7846 setBranchMismatchDismissTick((tick) => tick + 1);7789 setBranchMismatchDismissTick((tick) => tick + 1);
7847 },7790 },
7848 },7791 },
7849 ...parkedThreadItems,
7850 ];7792 ];
7851 }, [7793 }, [
7852 activeBranchMismatchKey,7794 activeBranchMismatchKey,
...@@ -7860,13 +7802,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -7860,13 +7802,10 @@ export default function ChatView(props: ChatViewProps) {
7860 backgroundWorkBannerItem,7802 backgroundWorkBannerItem,
7861 goalBannerItem,7803 goalBannerItem,
7862 localCheckoutBranchMismatch,7804 localCheckoutBranchMismatch,
7863 parkedThreadBannerItem,
7864 projectCloneBannerItem,7805 projectCloneBannerItem,
7865 resumeCompactionBannerItem,
7866 showBranchMismatchBanner,7806 showBranchMismatchBanner,
7867 systemComposerBannerItems,7807 systemComposerBannerItems,
7868 usageLimitsBanner,7808 usageLimitsBanner,
7869 wokeThreadBannerItem,
7870 ]);7809 ]);
78717810
7872 useEffect(() => {7811 useEffect(() => {
...@@ -8692,6 +8631,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8692,6 +8631,7 @@ export default function ChatView(props: ChatViewProps) {
8692 },8631 },
8693 ) => {8632 ) => {
8694 e?.preventDefault();8633 e?.preventDefault();
8634 const keepFullHistory = keepFullHistoryOnceRef.current;
8695 // Typed out in full rather than picked from the menu. Attachments or contexts8635 // Typed out in full rather than picked from the menu. Attachments or contexts
8696 // mean the user is sending a prompt, so those go through as usual.8636 // mean the user is sending a prompt, so those go through as usual.
8697 if (8637 if (
...@@ -9216,7 +9156,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -9216,7 +9156,16 @@ export default function ChatView(props: ChatViewProps) {
9216 );9156 );
9217 const messageIdForSend = newMessageId();9157 const messageIdForSend = newMessageId();
9218 const messageCreatedAt = new Date().toISOString();9158 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");
9220 const outgoingMessageText = formatOutgoingPrompt({9169 const outgoingMessageText = formatOutgoingPrompt({
9221 provider: ctxSelectedProvider,9170 provider: ctxSelectedProvider,
9222 model: ctxSelectedModel,9171 model: ctxSelectedModel,
...@@ -9799,6 +9748,22 @@ export default function ChatView(props: ChatViewProps) {...@@ -9799,6 +9748,22 @@ export default function ChatView(props: ChatViewProps) {
9799 failure = turnAttachmentsResult;9748 failure = turnAttachmentsResult;
9800 }9749 }
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
9802 let backgroundDraftOpened = false;9767 let backgroundDraftOpened = false;
9803 let turnStartSucceeded = false;9768 let turnStartSucceeded = false;
9804 if (failure === null && turnAttachmentsResult._tag === "Success") {9769 if (failure === null && turnAttachmentsResult._tag === "Success") {
...@@ -9876,7 +9841,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -9876,7 +9841,7 @@ export default function ChatView(props: ChatViewProps) {
9876 titleSeed: title,9841 titleSeed: title,
9877 runtimeMode,9842 runtimeMode,
9878 interactionMode: sendInteractionMode,9843 interactionMode: sendInteractionMode,
9879 dispatchMode,9844 dispatchMode: turnDispatchMode,
9880 ...(bootstrap ? { bootstrap } : {}),9845 ...(bootstrap ? { bootstrap } : {}),
9881 createdAt: messageCreatedAt,9846 createdAt: messageCreatedAt,
9882 },9847 },
...@@ -11324,6 +11289,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11324,6 +11289,7 @@ export default function ChatView(props: ChatViewProps) {
11324 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}11289 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}
11325 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}11290 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}
11326 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}11291 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
11292 footer={paintOnlyDisplayedTimeline ? null : threadStatusLine}
11327 listRef={legendListRef}11293 listRef={legendListRef}
11328 timelineEntries={displayedTimeline.entries}11294 timelineEntries={displayedTimeline.entries}
11329 providerStatuses={11295 providerStatuses={
...@@ -11575,6 +11541,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -11575,6 +11541,8 @@ export default function ChatView(props: ChatViewProps) {
11575 ) : null11541 ) : null
11576 }11542 }
11577 bannerItems={composerBannerItems}11543 bannerItems={composerBannerItems}
11544 resumeCompactionTokens={resumeCompactionTokens}
11545 onSendWithFullHistory={sendWithFullHistory}
11578 // With attachments or contexts aboard the pick just inserts the11546 // With attachments or contexts aboard the pick just inserts the
11579 // text, so it sends as a prompt like the typed path would.11547 // text, so it sends as a prompt like the typed path would.
11580 onUsageLimitsCommand={11548 onUsageLimitsCommand={
apps/web/src/components/chat/ChatComposer.tsx+22-1
...@@ -1386,6 +1386,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -1386,6 +1386,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
1386 onCompactContext?: (() => void) | undefined;1386 onCompactContext?: (() => void) | undefined;
1387 compactDisabled: boolean;1387 compactDisabled: boolean;
1388 compactDisabledReason: string | null;1388 compactDisabledReason: string | null;
1389 compactBeforeSendTokens: number | null;
1390 onSendWithFullHistory: () => void;
1389}) {1391}) {
1390 return (1392 return (
1391 <>1393 <>
...@@ -1424,6 +1426,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -1424,6 +1426,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
1424 onPreviousPendingQuestion={props.onPreviousPendingQuestion}1426 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
1425 onInterrupt={props.onInterrupt}1427 onInterrupt={props.onInterrupt}
1426 onImplementPlanInNewThread={props.onImplementPlanInNewThread}1428 onImplementPlanInNewThread={props.onImplementPlanInNewThread}
1429 compactBeforeSendTokens={props.compactBeforeSendTokens}
1430 onSendWithFullHistory={props.onSendWithFullHistory}
1427 />1431 />
1428 </>1432 </>
1429 );1433 );
...@@ -1543,6 +1547,10 @@ export interface ChatComposerProps {...@@ -1543,6 +1547,10 @@ export interface ChatComposerProps {
1543 sendDisabledReason: string | null;1547 sendDisabledReason: string | null;
1544 isPreparingWorktree: boolean;1548 isPreparingWorktree: boolean;
1545 bannerItems: readonly ComposerBannerStackItem[];1549 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;
1546 /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */1554 /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */
1547 onUsageLimitsCommand?: (() => void) | undefined;1555 onUsageLimitsCommand?: (() => void) | undefined;
1548 environmentUnavailable: {1556 environmentUnavailable: {
...@@ -3002,7 +3010,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3002,7 +3010,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3002 projectSelectionRequired ||3010 projectSelectionRequired ||
3003 environmentUnavailable !== null ||3011 environmentUnavailable !== null ||
3004 (!composerSendState.hasSendableContent && !showResumeAction);3012 (!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";
3006 const showMobilePendingAnswerActions =3018 const showMobilePendingAnswerActions =
3007 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;3019 isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
30083020
...@@ -4250,6 +4262,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4250,6 +4262,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4250 },4262 },
4251 [phase, settings.followUpBehavior, submitComposer],4263 [phase, settings.followUpBehavior, submitComposer],
4252 );4264 );
4265 const { onSendWithFullHistory } = props;
4266 const sendWithFullHistory = useCallback(
4267 () => onSendWithFullHistory(() => submitComposer()),
4268 [onSendWithFullHistory, submitComposer],
4269 );
4253 const submitCitationAndSend = useCallback(() => {4270 const submitCitationAndSend = useCallback(() => {
4254 submitComposer(4271 submitComposer(
4255 undefined,4272 undefined,
...@@ -6868,6 +6885,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6868,6 +6885,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6868 onClick={(event) => {6885 onClick={(event) => {
6869 event.stopPropagation();6886 event.stopPropagation();
6870 if (showResumeAction) onResume();6887 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();
6871 else submitComposer();6890 else submitComposer();
6872 }}6891 }}
6873 >6892 >
...@@ -7584,6 +7603,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7584,6 +7603,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7584 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}7603 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
7585 onInterrupt={handleInterruptPrimaryAction}7604 onInterrupt={handleInterruptPrimaryAction}
7586 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}7605 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
7606 compactBeforeSendTokens={props.resumeCompactionTokens}
7607 onSendWithFullHistory={sendWithFullHistory}
7587 compactDisabled={7608 compactDisabled={
7588 compactDisabled || noProviderAvailable || isSendBusy || isConnecting7609 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
7589 }7610 }
apps/web/src/components/chat/ComposerPrimaryActions.tsx+59
...@@ -3,6 +3,7 @@ import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-re...@@ -3,6 +3,7 @@ import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-re
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";
6import { formatContextWindowTokens } from "~/lib/contextWindow";
6import { cn } from "~/lib/utils";7import { cn } from "~/lib/utils";
7import { useShortcutModifierState } from "../../shortcutModifierState";8import { useShortcutModifierState } from "../../shortcutModifierState";
8import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";9import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
...@@ -50,6 +51,9 @@ interface ComposerPrimaryActionsProps {...@@ -50,6 +51,9 @@ interface ComposerPrimaryActionsProps {
50 onPreviousPendingQuestion: () => void;51 onPreviousPendingQuestion: () => void;
51 onInterrupt: () => void;52 onInterrupt: () => void;
52 onImplementPlanInNewThread: () => void;53 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;
53}57}
5458
55const formatPendingPrimaryActionLabel = (input: {59const formatPendingPrimaryActionLabel = (input: {
...@@ -103,6 +107,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -103,6 +107,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
103 onPreviousPendingQuestion,107 onPreviousPendingQuestion,
104 onInterrupt,108 onInterrupt,
105 onImplementPlanInNewThread,109 onImplementPlanInNewThread,
110 compactBeforeSendTokens = null,
111 onSendWithFullHistory,
106}: ComposerPrimaryActionsProps) {112}: ComposerPrimaryActionsProps) {
107 const pointerFocusProps = preserveComposerFocusOnPointerDown113 const pointerFocusProps = preserveComposerFocusOnPointerDown
108 ? { onPointerDown: preventPointerFocus }114 ? { onPointerDown: preventPointerFocus }
...@@ -260,6 +266,59 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -260,6 +266,59 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
260 }266 }
261267
262 const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage;268 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
263 const submitLabel = showResume322 const submitLabel = showResume
264 ? "Resume thread"323 ? "Resume thread"
265 : isEditingQueuedMessage324 : 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" />;...@@ -371,11 +371,18 @@ const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
371const TIMELINE_LIST_FADE_HEADER = (371const TIMELINE_LIST_FADE_HEADER = (
372 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />372 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />
373);373);
374function TimelineListFooter({ composerInset }: { readonly composerInset: number }) {374function TimelineListFooter({
375 composerInset,
376 children,
377}: {
378 readonly composerInset: number;
379 readonly children?: ReactNode;
380}) {
375 return (381 return (
376 <div aria-hidden>382 <div>
377 <div style={{ height: composerInset }} />383 {children}
378 <div className="h-3 sm:h-4" />384 <div aria-hidden style={{ height: composerInset }} />
385 <div aria-hidden className="h-3 sm:h-4" />
379 </div>386 </div>
380 );387 );
381}388}
...@@ -434,6 +441,8 @@ interface MessagesTimelineProps {...@@ -434,6 +441,8 @@ interface MessagesTimelineProps {
434 onOpenWorktreeSetupTerminal?: (terminalId: string) => void;441 onOpenWorktreeSetupTerminal?: (terminalId: string) => void;
435 isPreparingWorktree?: boolean;442 isPreparingWorktree?: boolean;
436 isCompacting?: boolean;443 isCompacting?: boolean;
444 /** Thread state shown after the last message, such as a settled or snoozed line. */
445 footer?: ReactNode;
437446
438 listRef: React.RefObject<LegendListRef | null>;447 listRef: React.RefObject<LegendListRef | null>;
439 timelineEntries: ReadonlyArray<TimelineEntry>;448 timelineEntries: ReadonlyArray<TimelineEntry>;
...@@ -512,6 +521,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -512,6 +521,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
512 activeTurnInProgress,521 activeTurnInProgress,
513 activeTurnStartedAt = null,522 activeTurnStartedAt = null,
514 worktreeSetup = null,523 worktreeSetup = null,
524 footer = null,
515 onCancelWorktreeSetup,525 onCancelWorktreeSetup,
516 retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS,526 retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS,
517 onRetryWorkspacePreparation,527 onRetryWorkspacePreparation,
...@@ -988,8 +998,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -988,8 +998,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
988 [shouldRestoreVisibleContentPosition],998 [shouldRestoreVisibleContentPosition],
989 );999 );
990 const timelineListFooter = useMemo(1000 const timelineListFooter = useMemo(
991 () => <TimelineListFooter composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment} />,1001 () => (
992 [anchoredEndSpace, contentInsetEndAdjustment],1002 <TimelineListFooter composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment}>
1003 {footer}
1004 </TimelineListFooter>
1005 ),
1006 [anchoredEndSpace, contentInsetEndAdjustment, footer],
993 );1007 );
9941008
995 const measureContentOverflow = useCallback(1009 const measureContentOverflow = useCallback(
...@@ -1308,7 +1322,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1308,7 +1322,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1308 rows.length === 0 &&1322 rows.length === 0 &&
1309 !isWorking &&1323 !isWorking &&
1310 parentThreadLink === null &&1324 parentThreadLink === null &&
1311 historyControls === undefined1325 historyControls === undefined &&
1326 // A status line (settled, snoozed) still needs the list, whose footer renders it.
1327 footer === null
1312 ) {1328 ) {
1313 if (hideEmptyPlaceholder) {1329 if (hideEmptyPlaceholder) {
1314 // Occupy the pane with the theme surface so a thread switch cannot1330 // 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...@@ -49,9 +49,12 @@ Set **Auto-compact after** in the Claude provider settings to an integer between
49window. Leave it empty for Claude Code's default.49window. 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 and may suggest it when you return to52**Compact context** from the context meter. When you return to a large thread
53a large older thread. See [commands and skills](./composer.md#commands-and-skills)53after more than an hour, the send button changes to **Compact and send**: Enter
54for using composer commands.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
56## Usage limits59## Usage limits
5760