From ce33581735be2c3e8659a3c014e84bdd2cef3913 Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:04:07 +0700 Subject: [PATCH] fix(web,mobile): T3 no longer offers or starts context compaction Harnesses compact on their own. Remove the idle resume-compaction banner, the context meter's Compact context action, the composer compact handle, and the provider /compact slash-menu entry on web, desktop and mobile. Native compaction events, history rows and the context meter are unchanged. --- .../features/threads/NewTaskDraftScreen.tsx | 1 - .../src/features/threads/ThreadComposer.tsx | 2 - .../features/threads/ThreadDetailScreen.tsx | 11 - .../threads/use-composer-command-menu.ts | 6 - apps/web/src/components/ChatView.tsx | 242 +----------------- apps/web/src/components/chat/ChatComposer.tsx | 67 +---- .../chat/ContextWindowMeter.logic.test.ts | 176 +------------ .../chat/ContextWindowMeter.logic.ts | 83 +----- .../components/chat/ContextWindowMeter.tsx | 27 +- docs/user/providers-claude.md | 6 +- .../client-runtime/src/providerSkills.test.ts | 15 +- packages/client-runtime/src/providerSkills.ts | 9 +- packages/shared/src/claudeCompaction.test.ts | 23 +- packages/shared/src/claudeCompaction.ts | 15 +- 14 files changed, 31 insertions(+), 652 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 44d02f90935f..09ea9e5eedaa 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -523,7 +523,6 @@ export function NewTaskDraftScreen(props: { projectCwd: composerWorkspaceCwd, selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, - hasCompactableConversation: false, offersUsageLimits: offersUsageLimits, enabled: isComposerFocused && !isComposerInteractionLocked, onChangeDraftMessage: flow.setPrompt, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 9e697832d023..b7717f5a5035 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -153,7 +153,6 @@ export interface ThreadComposerProps { readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: EnvironmentThreadShell; readonly reportedModelSelection?: ModelSelection | null; - readonly hasCompactableConversation: boolean; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly activeThreadBusy: boolean; @@ -495,7 +494,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer pullRequestRepository: project?.repositoryIdentity?.displayName ?? null, selectedProviderStatus, hasThread: true, - hasCompactableConversation: props.hasCompactableConversation, onChangeDraftMessage: props.onChangeDraftMessage, onUpdateInteractionMode: selectedProviderStatus?.showInteractionModeToggle === false diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 8b9e061f39a2..603fb971db16 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -498,16 +498,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread (entry) => "acknowledged" in entry && entry.acknowledged === true, ); const selectedThreadFeed = props.selectedThreadFeed; - const hasCompactableConversation = - selectedThreadFeed.some( - (entry) => - entry.type === "message" && - entry.message.role === "user" && - ((entry.message.attachments?.length ?? 0) > 0 || - entry.message.text.trim().toLowerCase() !== "/compact"), - ) || - (props.historyControls?.hasMoreHistory === true && - props.selectedThread.latestUserMessageAt !== null); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; // While a user-input request is pending, the questionnaire owns the @@ -1309,7 +1299,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread connectionState={props.connectionStateLabel} environmentLabel={props.environmentLabel} selectedThread={props.selectedThread} - hasCompactableConversation={hasCompactableConversation && !props.isCompacting} serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} activeThreadBusy={props.activeThreadBusy} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index d42f16ff71c2..afa10ad27cbf 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -58,7 +58,6 @@ export function buildComposerSlashCommandItems(input: { readonly query: string; readonly atMessageStart: boolean; readonly hasThread: boolean; - readonly hasCompactableConversation?: boolean; /** Whether T3 itself offers /usage-limits for the selected provider. */ readonly offersUsageLimits?: boolean; readonly allowInteractionMode: boolean; @@ -102,7 +101,6 @@ export function buildComposerSlashCommandItems(input: { if (!input.atMessageStart) return items; for (const command of input.selectedProviderStatus?.slashCommands ?? []) { if (!command.name.toLowerCase().includes(query)) continue; - if (command.name === "compact" && !input.hasCompactableConversation) continue; // T3's own limits command is answered by the thread composer; New Task has // nowhere to show it. A provider's same-named command is left alone. if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) { @@ -176,7 +174,6 @@ export function useComposerCommandMenu({ pullRequestRepository = null, selectedProviderStatus, hasThread, - hasCompactableConversation, offersUsageLimits = false, enabled = true, onChangeDraftMessage, @@ -195,7 +192,6 @@ export function useComposerCommandMenu({ readonly pullRequestRepository?: string | null; readonly selectedProviderStatus: ServerProvider | null; readonly hasThread: boolean; - readonly hasCompactableConversation: boolean; /** Whether T3 itself offers /usage-limits for the selected provider. */ readonly offersUsageLimits?: boolean; readonly enabled?: boolean; @@ -347,7 +343,6 @@ export function useComposerCommandMenu({ query: q, atMessageStart: trigger.rangeStart === 0, hasThread, - hasCompactableConversation, offersUsageLimits, allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus: selectedProviderStatus @@ -488,7 +483,6 @@ export function useComposerCommandMenu({ environmentId, threadShells, hasThread, - hasCompactableConversation, onUpdateInteractionMode, pathSearch.entries, pullRequestSearch.entries, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fcfe8b1b96b..f0a9faad1f37 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -40,8 +40,7 @@ import { import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import * as Schema from "effect/Schema"; -import { ArrowLeftIcon, Minimize2Icon } from "lucide-react"; +import { ArrowLeftIcon } from "lucide-react"; import { questionAttachmentDraftId, questionAttachmentDraftPrefix, @@ -321,7 +320,6 @@ import { useClientSettingsHydrated, useEnvironmentSettings, } from "../hooks/useSettings"; -import { useNowMinute } from "../hooks/useNowMinute"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; @@ -472,12 +470,7 @@ import { useLinkedThreadPullRequest } from "./ThreadStatusIndicators"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; -import { - hasAvailableCompactionProvider, - hasDismissedResumeCompaction, - shouldOfferResumeCompaction, -} from "./chat/ContextWindowMeter.logic"; -import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; +import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_EASING, @@ -4060,52 +4053,6 @@ export default function ChatView(props: ChatViewProps) { availableEditors, openInCwd: gitCwd, }); - const manualCompactionProviderAvailable = useMemo( - () => - hasAvailableCompactionProvider({ - providers: providerInstanceEntries, - driverKind: selectedProvider, - instanceId: activeProviderInstanceId, - lockedInstanceId: lockedProvider - ? (activeRuntime?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? null) - : null, - }), - [ - activeProviderInstanceId, - activeThread?.modelSelection.instanceId, - activeRuntime?.providerInstanceId, - lockedProvider, - providerInstanceEntries, - selectedProvider, - ], - ); - const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = - useLocalStorage( - `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, - false, - Schema.Boolean, - ); - const nativeResumeCompactionDismissed = useMemo( - () => - hasDismissedResumeCompaction( - (serverProjection?.runtimeRequests ?? []) - .filter((request) => request.kind === "user_input" && request.status === "resolved") - .map((request) => ({ - kind: "user-input.resolved", - payload: { answers: request.answers }, - })), - ), - [serverProjection?.runtimeRequests], - ); - useEffect(() => { - if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) { - setResumeCompactionPermanentlyDismissed(true); - } - }, [ - nativeResumeCompactionDismissed, - resumeCompactionPermanentlyDismissed, - setResumeCompactionPermanentlyDismissed, - ]); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -7025,7 +6972,6 @@ export default function ChatView(props: ChatViewProps) { const composerHasDraftContent = useComposerDraftStore((store) => composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), ); - const nowMinute = useNowMinute(); const activeBranchMismatchKey = branchMismatchKey( activeThread?.id ?? null, localCheckoutBranchMismatch, @@ -7273,111 +7219,6 @@ export default function ChatView(props: ChatViewProps) { isUnsnoozing, isUnsettling, ]); - // Session-scoped dismissals, one key per (thread, snapshot). A set rather - // than a single slot so dismissing the banner on one thread does not - // resurface it on another thread dismissed earlier. - const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState< - ReadonlySet - >(new Set()); - const resumeCompactionKey = - activeThread && activeContextWindow - ? `${activeThread.id}:${activeContextWindow.updatedAt}` - : null; - const activeThreadHasCompactableConversation = serverVisibleTurnItems.some( - ({ item }) => - item.type === "user_message" && - (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0), - ); - const compactThreadUnavailable = - !activeThread || - !activeThreadHasCompactableConversation || - !activeProject || - !isServerThread || - !manualCompactionProviderAvailable || - isWorking || - isRevertingCheckpoint || - threadDetailLoading || - isPreparingWorktree || - activeEnvironmentUnavailable || - feedbackUploading || - pendingApprovals.length > 0 || - pendingUserInputs.length > 0 || - showPlanFollowUpPrompt; - const compactDisabled = compactThreadUnavailable; - const compactDisabledReason = compactDisabled - ? !activeProject - ? "Choose a project before compacting" - : !manualCompactionProviderAvailable - ? "Compaction is unavailable for this provider" - : "Compacting is unavailable right now" - : null; - const resumeCompactionBannerItem = useMemo(() => { - if ( - !activeThread || - !activeContextWindow || - resumeCompactionKey === null || - dismissedResumeCompactionKeys.has(resumeCompactionKey) || - resumeCompactionPermanentlyDismissed || - nativeResumeCompactionDismissed || - pendingUserInputs.length > 0 || - phase === "running" || - !shouldOfferResumeCompaction({ - provider: selectedProvider, - usedTokens: activeContextWindow.usedTokens, - updatedAt: activeContextWindow.updatedAt, - now: `${nowMinute}:00.000Z`, - }) - ) { - return null; - } - - const dismiss = () => - setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey)); - const compactAction = ( - - ); - return { - id: `resume-compaction:${resumeCompactionKey}`, - variant: "info", - icon: , - title: "Resume with less context", - description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from earlier`, - actions: compactDisabledReason ? ( - - {compactAction}} /> - {compactDisabledReason} - - ) : ( - compactAction - ), - dismissLabel: "Keep full history", - onDismiss: dismiss, - }; - }, [ - activeContextWindow, - activeThread, - compactDisabled, - compactDisabledReason, - composerRef, - dismissedResumeCompactionKeys, - nativeResumeCompactionDismissed, - nowMinute, - pendingUserInputs.length, - phase, - resumeCompactionKey, - resumeCompactionPermanentlyDismissed, - selectedProvider, - ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -7422,8 +7263,6 @@ export default function ChatView(props: ChatViewProps) { const composerBannerItems = useMemo(() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; const backgroundWorkItems = backgroundWorkBannerItem === null ? [] : [backgroundWorkBannerItem]; - const resumeCompactionItems = - resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. @@ -7437,7 +7276,6 @@ export default function ChatView(props: ChatViewProps) { ...projectCloneItems, ...systemComposerBannerItems, ...backgroundWorkItems, - ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, ]; @@ -7449,7 +7287,6 @@ export default function ChatView(props: ChatViewProps) { ...projectCloneItems, ...systemComposerBannerItems, ...backgroundWorkItems, - ...resumeCompactionItems, ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, @@ -7503,7 +7340,6 @@ export default function ChatView(props: ChatViewProps) { localCheckoutBranchMismatch, parkedThreadBannerItem, projectCloneBannerItem, - resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, usageLimitsBanner, @@ -8222,76 +8058,6 @@ export default function ChatView(props: ChatViewProps) { setThreadError, ], ); - const onCompactContext = async () => { - if (compactDisabled || !activeThread || !clientSettingsHydrated || sendInFlightRef.current) { - return; - } - const context = composerRef.current?.getSendContext(); - if (!context?.providerAvailable) return; - - // Compaction is a standalone command; the draft and its attachments stay local. - const threadId = activeThread.id; - const messageId = newMessageId(); - const createdAt = new Date().toISOString(); - sendInFlightRef.current = true; - beginLocalDispatch(); - setThreadError(threadId, null); - setOptimisticUserMessages((messages) => [ - ...messages, - { - id: messageId, - role: "user", - text: "/compact", - runId: null, - createdAt, - updatedAt: createdAt, - streaming: false, - }, - ]); - scrollToEnd(); - try { - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId, - createdAt, - ...(localCheckoutBranchMismatch - ? { branch: localCheckoutBranchMismatch.currentBranch } - : {}), - runtimeMode, - interactionMode: context.interactionMode, - }); - const result = - settingsResult._tag === "Failure" - ? settingsResult - : await startThreadTurn({ - environmentId, - input: { - threadId, - message: { messageId, role: "user", text: "/compact", attachments: [] }, - modelSelection: context.selectedModelSelection, - runtimeMode, - interactionMode: context.interactionMode, - createdAt, - }, - }); - if (result._tag === "Failure") { - setOptimisticUserMessages((messages) => - messages.filter((message) => message.id !== messageId), - ); - resetLocalDispatch(); - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - threadId, - error instanceof Error ? error.message : "Failed to compact context.", - ); - } - } else { - clearUsageLimitsFor(routeThreadKey); - } - } finally { - sendInFlightRef.current = false; - } - }; const onResume = async () => { if ( @@ -10672,9 +10438,6 @@ export default function ChatView(props: ChatViewProps) { activeContextWindow={activeContextWindow} activeTasksProgress={activeComposerTasksProgress} activeTaskSteps={activeComposerTaskSteps} - compactThreadUnavailable={compactThreadUnavailable} - compactDisabled={compactDisabled} - compactDisabledReason={compactDisabledReason} resolvedTheme={resolvedTheme} settings={settings} keybindings={keybindings} @@ -10699,7 +10462,6 @@ export default function ChatView(props: ChatViewProps) { onPageScrollKeyDown={onComposerPageScrollKeyDown} onPageScrollKeyUp={onComposerPageScrollKeyUp} onPageScrollRelease={onComposerPageScrollRelease} - onCompactContext={onCompactContext} onSend={onSend} onResume={onResume} onInterrupt={onInterrupt} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1ae951afb618..0efa77600dc2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -291,7 +291,6 @@ import { } from "./composerProviderState"; import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; import { - providerSupportsManualCompaction, resolveContextWindowModelDisplayName, shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; @@ -1378,9 +1377,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; - onCompactContext?: (() => void) | undefined; - compactDisabled: boolean; - compactDisabledReason: string | null; }) { return ( <> @@ -1388,9 +1384,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ) : props.reserveContextWindowMeter ? ( @@ -1452,7 +1445,6 @@ export interface ChatComposerHandle { isModelPickerOpen: () => boolean; /** True when a collapsed caret sits before everything in the draft, including when it is empty. */ isCaretAtStart: () => boolean; - compactContext: () => void; readSnapshot: () => { value: string; cursor: number; @@ -1586,9 +1578,6 @@ export interface ChatComposerProps { // Context window activeContextWindow: ContextWindowSnapshot | null; - compactThreadUnavailable: boolean; - compactDisabled: boolean; - compactDisabledReason: string | null; // Misc resolvedTheme: "light" | "dark"; @@ -1634,7 +1623,6 @@ export interface ChatComposerProps { onRemoveEditingQueuedAttachment: (attachmentId: string) => void; // Callbacks - onCompactContext: () => void; onSend: ( e?: { preventDefault: () => void }, dispatchMode?: ComposerDispatchMode, @@ -1732,9 +1720,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadModelSelection, reportedModelSelection, activeContextWindow, - compactThreadUnavailable, - compactDisabled, - compactDisabledReason, resolvedTheme, settings, keybindings, @@ -1758,7 +1743,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, - onCompactContext, onSend, onResume, onInterrupt, @@ -2123,8 +2107,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? providerInstanceEntries.find((entry) => hasProviderSetup(entry.snapshot))?.instanceId : undefined)) : undefined; - const resolvedCompactDisabledReason = - compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null); // The driver kind follows the instance that will actually run the turn, // which can differ from the persisted selection when that selection is // disabled. @@ -2169,7 +2151,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); - const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry); const selectedProviderSkills = selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) : []; @@ -2495,16 +2476,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); - const compactSlashCommandAvailable = - composerTrigger?.kind === "slash-command" && - prompt.slice(0, composerTrigger.rangeStart).trim() === "" && - !compactThreadUnavailable && - prompt.slice(composerTrigger.rangeEnd).trim() === "" && - composerImages.length + composerFiles.length === 0 && - composerDraft.persistedAttachments.length === 0 && - composerTerminalContexts.length === 0 && - composerPreviewAnnotations.length === 0 && - composerReviewComments.length === 0; const pullRequestListTargets = useMemo( () => @@ -2645,11 +2616,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) skill.description ?? (skill.scope ? `${skill.scope} skill` : ""), })); - const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( - (item) => item.command.name !== "compact" || compactSlashCommandAvailable, - ); const slashCommandItems = slashCommandItemsForPromptPosition( - [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], + [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems], composerTrigger.rangeStart === 0, ); return searchSlashCommandItems(slashCommandItems, query); @@ -2721,7 +2689,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return []; }, [ activeThreadId, - compactSlashCommandAvailable, composerTrigger, environmentId, environmentThreadShells, @@ -4198,31 +4165,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), ); }, [phase, settings.followUpBehavior, submitComposer]); - const compactThreadContext = useCallback(() => { - if ( - compactDisabled || - noProviderAvailable || - activePendingApproval !== null || - pendingUserInputs.length > 0 || - phase === "running" || - isSendBusy || - isConnecting || - !activeThreadId - ) { - return; - } - onCompactContext(); - }, [ - activePendingApproval, - activeThreadId, - compactDisabled, - isConnecting, - isSendBusy, - noProviderAvailable, - onCompactContext, - pendingUserInputs.length, - phase, - ]); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); @@ -6336,7 +6278,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) trigger.focus({ preventScroll: true }); trigger.click(); }, - compactContext: compactThreadContext, isModelPickerOpen: () => isComposerModelPickerOpen, isCaretAtStart: () => { const range = composerEditorRef.current?.readSelectionRange(); @@ -6479,7 +6420,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderModels, interactionMode, planModeUiEnabled, - compactThreadContext, restoreAfterTimelineReachedEnd, getTimelineScrollableNode, isTimelineAtLogicalEnd, @@ -7517,11 +7457,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} - compactDisabled={ - compactDisabled || noProviderAvailable || isSendBusy || isConnecting - } - compactDisabledReason={resolvedCompactDisabledReason} - {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})} /> diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index d97e9393486a..23b6de0fd642 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -1,87 +1,12 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { deriveProviderInstanceEntries } from "../../providerInstances"; import { formatContextWindowCompactionMessage, - hasAvailableCompactionProvider, - hasDismissedResumeCompaction, formatContextWindowCost, resolveContextWindowModelDisplayName, - shouldOfferResumeCompaction, shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; -function claudeProvider(input: { - instanceId: string; - continuationGroupKey: string; - enabled?: boolean; -}): ServerProvider { - return { - instanceId: ProviderInstanceId.make(input.instanceId), - driver: ProviderDriverKind.make("claudeAgent"), - continuation: { groupKey: input.continuationGroupKey }, - enabled: input.enabled ?? true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-08-24T12:00:00.000Z", - models: [], - slashCommands: [{ name: "compact", description: "" }], - skills: [], - }; -} - -describe("hasAvailableCompactionProvider", () => { - const originalInstanceId = ProviderInstanceId.make("claude_original"); - - it("rejects a fallback in a different locked continuation group", () => { - const providers = deriveProviderInstanceEntries([ - claudeProvider({ - instanceId: originalInstanceId, - continuationGroupKey: "claude:home:/original", - enabled: false, - }), - claudeProvider({ - instanceId: "claude_other", - continuationGroupKey: "claude:home:/other", - }), - ]); - - expect( - hasAvailableCompactionProvider({ - providers, - driverKind: ProviderDriverKind.make("claudeAgent"), - instanceId: originalInstanceId, - lockedInstanceId: originalInstanceId, - }), - ).toBe(false); - }); - - it("accepts an enabled fallback in the locked continuation group", () => { - const providers = deriveProviderInstanceEntries([ - claudeProvider({ - instanceId: originalInstanceId, - continuationGroupKey: "claude:home:/original", - enabled: false, - }), - claudeProvider({ - instanceId: "claude_fallback", - continuationGroupKey: "claude:home:/original", - }), - ]); - - expect( - hasAvailableCompactionProvider({ - providers, - driverKind: ProviderDriverKind.make("claudeAgent"), - instanceId: originalInstanceId, - lockedInstanceId: originalInstanceId, - }), - ).toBe(true); - }); -}); - describe("resolveContextWindowModelDisplayName", () => { it("uses the selected model from the exact provider instance", () => { const primaryInstanceId = ProviderInstanceId.make("codex"); @@ -140,105 +65,6 @@ describe("formatContextWindowCompactionMessage", () => { }); }); -describe("shouldOfferResumeCompaction", () => { - const now = "2026-08-24T12:00:00.000Z"; - - it("matches Claude's old-session age and context thresholds", () => { - expect( - shouldOfferResumeCompaction({ - provider: "claudeAgent", - usedTokens: 100_000, - updatedAt: "2026-08-24T10:50:00.000Z", - now, - }), - ).toBe(true); - }); - - it("does not prompt for recent or smaller sessions", () => { - expect( - shouldOfferResumeCompaction({ - provider: "claudeAgent", - usedTokens: 99_999, - updatedAt: "2026-08-24T10:00:00.000Z", - now, - }), - ).toBe(false); - expect( - shouldOfferResumeCompaction({ - provider: "claudeAgent", - usedTokens: 200_000, - updatedAt: "2026-08-24T10:51:00.000Z", - now, - }), - ).toBe(false); - }); - - it("does not show Claude's resume prompt for another provider", () => { - expect( - shouldOfferResumeCompaction({ - provider: "codex", - usedTokens: 300_000, - updatedAt: "2026-08-24T09:00:00.000Z", - now, - }), - ).toBe(false); - }); -}); - -describe("hasDismissedResumeCompaction", () => { - it("recognizes the native resume dialog's permanent dismissal", () => { - expect( - hasDismissedResumeCompaction([ - { - kind: "user-input.resolved", - payload: { - answers: { - "This session is 2h 0m old and uses 250,000 tokens. Compact it before continuing?": - "Don't ask again", - }, - }, - }, - ]), - ).toBe(true); - }); - - it("ignores the same answer on an unrelated question", () => { - expect( - hasDismissedResumeCompaction([ - { - kind: "user-input.resolved", - payload: { answers: { "Show this setup reminder?": "Don't ask again" } }, - }, - ]), - ).toBe(false); - }); - - it("ignores unrelated questions that end with Claude's compaction prompt", () => { - expect( - hasDismissedResumeCompaction([ - { - kind: "user-input.resolved", - payload: { - answers: { - "The build cache is large. Compact it before continuing?": "Don't ask again", - }, - }, - }, - ]), - ).toBe(false); - }); - - it("ignores pending questions and malformed resolved payloads", () => { - expect( - hasDismissedResumeCompaction([ - { kind: "user-input.requested", payload: { answers: { question: "Don't ask again" } } }, - { kind: "user-input.resolved", payload: null }, - { kind: "user-input.resolved", payload: { answers: ["Don't ask again"] } }, - ]), - ).toBe(false); - }); -}); - describe("shouldReserveContextWindowMeter", () => { const loadingStartedThread = { meterEnabled: true, diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 390caab18fbf..a34f409f8037 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,87 +1,6 @@ -import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import { - CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, - isClaudeResumeCompactionQuestion, -} from "@t3tools/shared/claudeCompaction"; -import { - resolveSelectableProviderInstanceEntry, - type ProviderInstanceEntry, -} from "../../providerInstances"; +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; -const CLAUDE_RESUME_COMPACTION_MINUTES = 70; -const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; - -export function providerSupportsManualCompaction( - provider: ProviderInstanceEntry | null | undefined, -): boolean { - return provider?.snapshot.slashCommands.some((command) => command.name === "compact") ?? false; -} - -export function hasAvailableCompactionProvider(input: { - readonly providers: ReadonlyArray; - readonly driverKind: ProviderDriverKind; - readonly instanceId: ProviderInstanceId | null; - readonly lockedInstanceId: ProviderInstanceId | null; -}): boolean { - const driverProviders = input.providers.filter( - (provider) => provider.driverKind === input.driverKind, - ); - const lockedContinuationGroupKey = input.lockedInstanceId - ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId) - ?.continuationGroupKey - : undefined; - const compatibleProviders = lockedContinuationGroupKey - ? driverProviders.filter( - (provider) => provider.continuationGroupKey === lockedContinuationGroupKey, - ) - : driverProviders; - - return providerSupportsManualCompaction( - resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined), - ); -} - -export function hasDismissedResumeCompaction( - activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, -): boolean { - return activities.some((activity) => { - if (activity.kind !== "user-input.resolved") return false; - const payload = activity.payload; - if (!payload || typeof payload !== "object") return false; - const answers = (payload as { readonly answers?: unknown }).answers; - if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false; - - return Object.entries(answers).some( - ([question, answer]) => - isClaudeResumeCompactionQuestion(question) && - answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, - ); - }); -} - -export function shouldOfferResumeCompaction(input: { - readonly provider: string | null | undefined; - readonly usedTokens: number | null | undefined; - readonly updatedAt: string | null | undefined; - readonly now: string; -}): boolean { - if ( - input.provider !== "claudeAgent" || - (input.usedTokens ?? 0) < CLAUDE_RESUME_COMPACTION_TOKENS - ) { - return false; - } - - const updatedAt = Date.parse(input.updatedAt ?? ""); - const now = Date.parse(input.now); - return ( - Number.isFinite(updatedAt) && - Number.isFinite(now) && - now - updatedAt >= CLAUDE_RESUME_COMPACTION_MINUTES * 60_000 - ); -} - export function resolveContextWindowModelDisplayName( selection: ModelSelection | null | undefined, modelOptionsByInstance: ReadonlyMap>, diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 5c9fc8bb7507..2197cce588ff 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -5,7 +5,6 @@ import { formatContextWindowCompactionMessage, formatContextWindowCost, } from "./ContextWindowMeter.logic"; -import { Minimize2Icon } from "lucide-react"; import { composerFloatingLayerProps } from "./composerEventScope"; function formatPercentage(value: number | null): string | null { @@ -21,11 +20,8 @@ function formatPercentage(value: number | null): string | null { export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; modelDisplayName?: string | null; - onCompact?: (() => void) | undefined; - compactDisabled?: boolean | undefined; - compactDisabledReason?: string | null | undefined; }) { - const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props; + const { usage, modelDisplayName } = props; const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -43,7 +39,7 @@ export function ContextWindowMeter(props: { ) : null} - {onCompact ? ( - <> - - {compactDisabled && compactDisabledReason ? ( -
- {compactDisabledReason} -
- ) : null} - - ) : null} diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index cce194dbd2b4..24c3ef390b5b 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -48,10 +48,8 @@ Set **Auto-compact after** in the Claude provider settings to an integer between 300,000 tokens. This changes when compaction happens, not the model's context window. Leave it empty for Claude Code's default. -You can also send `/compact` in an existing conversation. Web and desktop offer -**Compact context** from the context meter and may suggest it when you return to -a large older thread. See [commands and skills](./composer.md#commands-and-skills) -for using composer commands. +Claude compacts on its own as the conversation grows. T3 Code shows when it +happens but does not start compaction for you. ## Usage limits diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 62d0319db1bc..d60cdb448468 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -160,7 +160,7 @@ describe("getProviderSkillsForSlashMenu", () => { describe("getProviderSlashCommandsForSlashMenu", () => { const commands = [ { name: "ask-matt", description: "Ask which skill fits your situation." }, - { name: "compact", description: "Compact the conversation." }, + { name: "review", description: "Review the current changes." }, ]; const skills = [ { @@ -173,7 +173,7 @@ describe("getProviderSlashCommandsForSlashMenu", () => { it("lets the skill alias win when a provider command has the same name", () => { expect( getProviderSlashCommandsForSlashMenu(commands, skills).map((command) => command.name), - ).toEqual(["compact"]); + ).toEqual(["review"]); }); it("keeps the provider command when the matching skill alias is hidden", () => { @@ -181,7 +181,16 @@ describe("getProviderSlashCommandsForSlashMenu", () => { expect( getProviderSlashCommandsForSlashMenu(commands, visibleSkills).map((command) => command.name), - ).toEqual(["ask-matt", "compact"]); + ).toEqual(["ask-matt", "review"]); + }); + + it("never offers the provider's compact command", () => { + expect( + getProviderSlashCommandsForSlashMenu( + [...commands, { name: "compact", description: "Compact the conversation." }], + [], + ).map((command) => command.name), + ).toEqual(["ask-matt", "review"]); }); }); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index b80cd5803809..9315829050ea 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -65,12 +65,19 @@ export function getProviderSkillsForSlashMenu( : []; } +/** + * Provider commands offered in the slash menu. Compaction is never offered: + * harnesses compact on their own, and T3 does not start compaction. + */ export function getProviderSlashCommandsForSlashMenu( slashCommands: ReadonlyArray, visibleSkills: ReadonlyArray, ): ServerProviderSlashCommand[] { const skillNames = new Set(visibleSkills.map((skill) => skill.name.trim().toLowerCase())); - return slashCommands.filter((command) => !skillNames.has(command.name.trim().toLowerCase())); + return slashCommands.filter((command) => { + const name = command.name.trim().toLowerCase(); + return name !== "compact" && !skillNames.has(name); + }); } export function resolveProviderSkillSourceKind( diff --git a/packages/shared/src/claudeCompaction.test.ts b/packages/shared/src/claudeCompaction.test.ts index 9eef52520719..09a4020d71b8 100644 --- a/packages/shared/src/claudeCompaction.test.ts +++ b/packages/shared/src/claudeCompaction.test.ts @@ -1,23 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { - formatClaudeResumeCompactionQuestion, - isClaudeResumeCompactionQuestion, -} from "./claudeCompaction.ts"; +import { formatClaudeResumeCompactionQuestion } from "./claudeCompaction.ts"; describe("claude resume compaction copy", () => { - // The matcher must recognize every question the formatter can produce. - // This is the drift guard: rewording one side fails here. - it.each([ - { ageMinutes: 145, estimatedTokens: 275_123 }, - { ageMinutes: 70, estimatedTokens: 100_000 }, - { ageMinutes: 59, estimatedTokens: 1_234_567 }, - { ageMinutes: 0, estimatedTokens: 0 }, - ])("matches its own formatted question (%o)", (input) => { - const question = formatClaudeResumeCompactionQuestion(input); - expect(isClaudeResumeCompactionQuestion(question)).toBe(true); - }); - it("formats ages above and below one hour", () => { expect( formatClaudeResumeCompactionQuestion({ ageMinutes: 145, estimatedTokens: 275_123 }), @@ -26,10 +11,4 @@ describe("claude resume compaction copy", () => { "This session is 45m old and uses 1,000 tokens. Compact it before continuing?", ); }); - - it("does not match unrelated questions", () => { - expect( - isClaudeResumeCompactionQuestion("The build cache is large. Compact it before continuing?"), - ).toBe(false); - }); }); diff --git a/packages/shared/src/claudeCompaction.ts b/packages/shared/src/claudeCompaction.ts index cadb870e8dc2..7497c23c6101 100644 --- a/packages/shared/src/claudeCompaction.ts +++ b/packages/shared/src/claudeCompaction.ts @@ -1,10 +1,4 @@ -/** - * Copy for Claude's resume compaction dialog, shared by the server adapter - * (which asks the question) and the web client (which recognizes the - * question and its "never" answer in resolved user-input activities to - * mirror the dismissal). Both sides must agree on these strings, so they - * live here: reword the question or the answer label in this file only. - */ +/** Copy for Claude's resume compaction dialog, which the server adapter asks. */ export const CLAUDE_RESUME_COMPACTION_NEVER_ANSWER = "Don't ask again"; export function formatClaudeResumeCompactionQuestion(input: { @@ -17,10 +11,3 @@ export function formatClaudeResumeCompactionQuestion(input: { : `${input.ageMinutes}m`; return `This session is ${ageLabel} old and uses ${input.estimatedTokens.toLocaleString("en-US")} tokens. Compact it before continuing?`; } - -const CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN = - /^This session is (?:\d+h \d+m|\d+m) old and uses \d{1,3}(?:,\d{3})* tokens\. Compact it before continuing\?$/u; - -export function isClaudeResumeCompactionQuestion(question: string): boolean { - return CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN.test(question); -}