diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a45eae6faf3e..46a7c02665cd 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -413,6 +413,25 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("passes the configured auto-compaction window to Claude", () => { + const harness = makeHarness({ claudeConfig: { autoCompactWindow: "300000" } }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const options = harness.getLastCreateQueryInput()?.options; + assert.deepEqual(options?.settings, { autoCompactWindow: 300000 }); + assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -730,6 +749,39 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps compact commands intact when ultrathink is selected", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const modelSelection = createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/compact", + attachments: [], + modelSelection, + }); + + const promptText = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.equal(promptText, "/compact"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("embeds image attachments in Claude user messages", () => { const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); const harness = makeHarness({ @@ -4400,6 +4452,62 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("routes Claude resume compaction through the shared user-input UI", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { resume: "550e8400-e29b-41d4-a716-446655440000" }, + runtimeMode: "full-access", + }); + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const onUserDialog = harness.getLastCreateQueryInput()?.options.onUserDialog; + assert.equal(typeof onUserDialog, "function"); + if (!onUserDialog) return; + + const dialogPromise = onUserDialog( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, + }, + { signal: new AbortController().signal }, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") return; + const question = requested.value.payload.questions[0]; + assert.equal(question?.header, "Resume session"); + assert.match(question?.question ?? "", /2h 25m/); + assert.match(question?.question ?? "", /275,123 tokens/); + assert.deepEqual( + question?.options.map((option) => option.label), + ["Compact and continue", "Keep full history", "Don't ask again"], + ); + if (!question || !requested.value.requestId) return; + + yield* adapter.respondToUserInput( + session.threadId, + ApprovalRequestId.make(requested.value.requestId), + { [question.id]: "Compact and continue" }, + ); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag === "Some") assert.equal(resolved.value.type, "user-input.resolved"); + assert.deepEqual(yield* Effect.promise(() => dialogPromise), { + behavior: "completed", + result: "compact", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4689,6 +4797,73 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("denies AskUserQuestion when the signal aborted before the listener registered", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + // Abort before the call so the adapter's listener registration can + // never observe the abort event, only the recheck can. + const controller = new AbortController(); + controller.abort(); + const permissionPromise = canUseTool( + "AskUserQuestion", + { + questions: [ + { + question: "Continue?", + header: "Continue", + options: [{ label: "Yes", description: "Proceed" }], + multiSelect: false, + }, + ], + }, + { + signal: controller.signal, + toolUseID: "tool-ask-pre-aborted", + }, + ); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.deepEqual(permissionResult, { + behavior: "deny", + message: "User cancelled tool execution.", + } satisfies PermissionResult); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + ["user-input.requested", "user-input.resolved"], + ); + const resolvedEvent = runtimeEvents[1]; + if (resolvedEvent?.type === "user-input.resolved") { + assert.deepEqual(resolvedEvent.payload.answers, {}); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("stopping a session settles pending user-input waits", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d42049ebefc3..173b7a2b0355 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -57,6 +57,10 @@ import { getProviderOptionDescriptors, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; +import { + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + formatClaudeResumeCompactionQuestion, +} from "@t3tools/shared/claudeCompaction"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -543,6 +547,7 @@ function makeClaudeTokenUsageSnapshot(input: { readonly totalProcessedTokens?: number; readonly lastUsedTokens?: number; readonly compactsAutomatically?: boolean; + readonly autoCompactThreshold?: number; }): ThreadTokenUsageSnapshot | undefined { const activeTokens = finiteNonNegativeInteger(input.activeTokens); if (activeTokens === undefined || activeTokens <= 0) { @@ -570,6 +575,9 @@ function makeClaudeTokenUsageSnapshot(input: { ...(input.compactsAutomatically !== undefined ? { compactsAutomatically: input.compactsAutomatically } : {}), + ...(input.autoCompactThreshold !== undefined + ? { autoCompactThreshold: input.autoCompactThreshold } + : {}), }; } @@ -604,11 +612,13 @@ function normalizeClaudeContextUsageApiSnapshot( value: SDKControlGetContextUsageResponse, totalProcessedTokens?: number, ): ThreadTokenUsageSnapshot | undefined { + const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold); return makeClaudeTokenUsageSnapshot({ activeTokens: value.totalTokens, contextWindow: value.maxTokens, ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), compactsAutomatically: value.isAutoCompactEnabled, + ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}), }); } @@ -3953,6 +3963,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // The signal may have aborted during the awaited event emissions + // above, before the listener existed; settle now so the dialog + // cannot hang with a lingering pending question. + if (callbackOptions.signal.aborted) { + yield* settleAsAborted; + } // Block until the user provides answers. const answers = yield* Deferred.await(answersDeferred); @@ -4001,6 +4017,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; }); + const handleResumeDialog = Effect.fn("handleResumeDialog")(function* ( + request: Parameters>[0], + callbackOptions: Parameters>[1], + ) { + if (request.dialogKind !== "resume_return") { + return { behavior: "cancelled" as const }; + } + + const context = yield* Ref.get(contextRef); + if (!context) { + return { behavior: "cancelled" as const }; + } + + // The question copy lives in @t3tools/shared/claudeCompaction because + // the web client recognizes this exact text (and the "never" answer) + // to mirror a permanent dismissal. + const question = formatClaudeResumeCompactionQuestion({ + ageMinutes: finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0, + estimatedTokens: finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0, + }); + const result = yield* handleAskUserQuestion( + context, + { + questions: [ + { + header: "Resume session", + question, + options: [ + { + label: "Compact and continue", + description: "Resume with a summary and use fewer tokens.", + }, + { + label: "Keep full history", + description: "Resume without changing the conversation.", + }, + { + label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + description: "Keep full history and skip future resume prompts.", + }, + ], + multiSelect: false, + }, + ], + }, + { + signal: callbackOptions.signal, + ...(request.toolUseID ? { toolUseID: request.toolUseID } : {}), + }, + ); + + if (result.behavior !== "allow") { + return { behavior: "cancelled" as const }; + } + + const answers = result.updatedInput.answers; + const selection = + answers && typeof answers === "object" && !Array.isArray(answers) + ? (answers as Record)[question] + : undefined; + const action = + selection === "Compact and continue" + ? "compact" + : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER + ? "never" + : "continue"; + + return { behavior: "completed" as const, result: action }; + }); + const canUseToolEffect = Effect.fn("canUseTool")(function* ( toolName: Parameters[0], toolInput: Parameters[1], @@ -4106,6 +4192,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // Same late-listener race as handleAskUserQuestion: the signal may + // have aborted while the request event emissions were awaited. + if (callbackOptions.signal.aborted) { + onAbort(); + } const decision = yield* Deferred.await(decisionDeferred); pendingApprovals.delete(requestId); @@ -4161,6 +4252,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + const onUserDialog: NonNullable = ( + request, + callbackOptions, + ) => runPromise(handleResumeDialog(request, callbackOptions)); const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; @@ -4196,6 +4291,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), + ...(claudeSettings.autoCompactWindow + ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) } + : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); // The attachments dir grant lets the agent Read/copy pasted images at @@ -4228,6 +4326,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], env: claudeEnvironment, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 806f7e19b905..f7bb7827d47d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -927,7 +927,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = capabilities?.slashCommands ?? []; + const slashCommands = [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, + ...(capabilities?.slashCommands ?? []), + ]; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb0..b6531a69c176 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -106,6 +106,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => homePath: "", customModels: [], launchArgs: "", + autoCompactWindow: "", ...overrides, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f7ae95d8a927..a27d9a5c4841 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2154,6 +2154,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "review", description: "Review a pull request", @@ -2197,6 +2201,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "ui", description: "Explore and refine UI", diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 35ef5e976223..554f41eb87ac 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -190,6 +190,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: ["claude-custom"], launchArgs: "", + autoCompactWindow: "", }); assert.deepEqual( next.textGenerationModelSelection, @@ -581,6 +582,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: [], launchArgs: "", + autoCompactWindow: "", }); assert.deepEqual(next.providers.opencode, { // OpenCode is disabled by default; this update only touches paths. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb1cf698535a..286847fbfcd0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -82,6 +82,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; @@ -179,6 +180,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + Minimize2Icon, PaperclipIcon, WifiOffIcon, } from "lucide-react"; @@ -196,7 +198,11 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, +} from "../providerInstances"; import { useClientSettings, useClientSettingsHydrated, @@ -222,6 +228,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRo import { beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, + composerDraftHasUserContent, type ComposerImageAttachment, type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, @@ -303,6 +310,12 @@ import { threadChangeRequestSnapshotsAtom, } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + hasAvailableClaudeCompactionProvider, + hasDismissedResumeCompaction, + shouldOfferResumeCompaction, +} from "./chat/ContextWindowMeter.logic"; +import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -1364,6 +1377,9 @@ function ChatViewContent(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerHasUnsentContent = useComposerDraftStore((store) => + composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), + ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( @@ -2296,6 +2312,10 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const activeContextWindow = useMemo( + () => deriveLatestContextWindowSnapshot(threadActivities), + [threadActivities], + ); const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the @@ -2759,6 +2779,29 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; + const compactionProviderAvailable = useMemo( + () => + hasAvailableClaudeCompactionProvider({ + providers: applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + ), + instanceId: activeProviderInstanceId, + lockedInstanceId: lockedProvider + ? (activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + null) + : null, + }), + [ + activeProviderInstanceId, + activeThread?.modelSelection.instanceId, + activeThread?.session?.providerInstanceId, + lockedProvider, + providerStatuses, + settings, + ], + ); const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { return ( @@ -2768,6 +2811,25 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = + useLocalStorage( + `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, + false, + Schema.Boolean, + ); + const nativeResumeCompactionDismissed = useMemo( + () => hasDismissedResumeCompaction(threadActivities), + [threadActivities], + ); + useEffect(() => { + if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) { + setResumeCompactionPermanentlyDismissed(true); + } + }, [ + nativeResumeCompactionDismissed, + resumeCompactionPermanentlyDismissed, + setResumeCompactionPermanentlyDismissed, + ]); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -4482,18 +4544,6 @@ function ChatViewContent(props: ChatViewProps) { // Dismissal lives in a module-level set (survives remounts); this tick just // forces a re-render so the banner leaves immediately. const [, setBranchMismatchDismissTick] = useState(0); - const composerHasDraftContent = useComposerDraftStore((store) => { - const draft = store.getComposerDraft(composerDraftTarget); - return Boolean( - draft && - (draft.prompt.trim().length > 0 || - draft.images.length > 0 || - draft.terminalContexts.length > 0 || - draft.elementContexts.length > 0 || - draft.previewAnnotations.length > 0 || - draft.reviewComments.length > 0), - ); - }); const activeBranchMismatchKey = branchMismatchKey( activeThread?.id ?? null, localCheckoutBranchMismatch, @@ -4501,7 +4551,7 @@ function ChatViewContent(props: ChatViewProps) { const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ hasMismatch: localCheckoutBranchMismatch !== null, isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), - composerHasContent: composerHasDraftContent, + composerHasContent: composerHasUnsentContent, wasShownForCurrentMismatch: revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, }); @@ -4725,6 +4775,107 @@ function ChatViewContent(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 compactDisabled = + !activeThread || + !activeProject || + !isServerThread || + selectedProvider !== "claudeAgent" || + !compactionProviderAvailable || + isWorking || + threadDetailLoading || + isPreparingWorktree || + activeEnvironmentUnavailable || + feedbackUploading || + pendingApprovals.length > 0 || + pendingUserInputs.length > 0 || + showPlanFollowUpPrompt || + composerHasUnsentContent; + const compactDisabledReason = compactDisabled + ? composerHasUnsentContent + ? "Send or clear your draft before compacting" + : !activeProject + ? "Choose a project before compacting" + : !compactionProviderAvailable + ? "Enable a Claude provider before compacting" + : "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 an older session`, + 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); @@ -4739,6 +4890,8 @@ function ChatViewContent(props: ChatViewProps) { const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; + const resumeCompactionItems = + resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { @@ -4746,6 +4899,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, ]; @@ -4754,6 +4908,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, @@ -4803,6 +4958,7 @@ function ChatViewContent(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, wokeThreadBannerItem, @@ -6854,7 +7010,9 @@ function ChatViewContent(props: ChatViewProps) { activeProject?.defaultModelSelection } activeThreadModelSelection={activeThread?.modelSelection} - activeThreadActivities={activeThread?.activities} + activeContextWindow={activeContextWindow} + compactDisabled={compactDisabled} + compactDisabledReason={compactDisabledReason} resolvedTheme={resolvedTheme} settings={settings} keybindings={keybindings} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1ec58e0de702..00fcac7cc548 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -262,7 +262,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; -import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; +import type { ContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, @@ -434,7 +434,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; - activeContextWindow: ReturnType; + activeContextWindow: ContextWindowSnapshot | null; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { @@ -457,6 +457,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onCompactContext?: (() => void) | undefined; + compactDisabled: boolean; + compactDisabledReason: string | null; }) { return ( <> @@ -464,6 +467,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ) : null} {props.isPreparingWorktree ? ( @@ -503,6 +509,7 @@ export interface ChatComposerHandle { openModelPicker: () => void; toggleModelPicker: () => void; isModelPickerOpen: () => boolean; + compactContext: () => void; readSnapshot: () => { value: string; cursor: number; @@ -605,7 +612,9 @@ export interface ChatComposerProps { activeThreadModelSelection: ModelSelection | null | undefined; // Context window - activeThreadActivities: Thread["activities"] | undefined; + activeContextWindow: ContextWindowSnapshot | null; + compactDisabled: boolean; + compactDisabledReason: string | null; // Misc resolvedTheme: "light" | "dark"; @@ -697,7 +706,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerStatuses, activeProjectDefaultModelSelection, activeThreadModelSelection, - activeThreadActivities, + activeContextWindow, + compactDisabled, + compactDisabledReason, resolvedTheme, settings, keybindings, @@ -910,6 +921,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [providerInstanceEntries, selectedInstanceId], ); const noProviderAvailable = selectedProviderEntry === 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. @@ -1000,10 +1013,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Context window // ------------------------------------------------------------------ - const activeContextWindow = useMemo( - () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), - [activeThreadActivities], - ); const activeThreadModelDisplayName = useMemo( () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], @@ -1069,8 +1078,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /** * Count of pasted images still being compressed, per thread. Reserved * against the attachment limit so concurrent pastes can't overshoot it, - * and checked by `submitComposer` so a send can't race an image into the - * next draft. + * and checked before sending or compacting so an image cannot move into + * the next draft. */ const pendingImageCompressionsRef = useRef>(new Map()); @@ -1982,6 +1991,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) shouldBlurMobileComposerOnSubmit, ], ); + const compactThreadContext = useCallback(() => { + if ( + compactDisabled || + noProviderAvailable || + composerSendState.hasSendableContent || + activePendingApproval !== null || + pendingUserInputs.length > 0 || + phase === "running" || + isSendBusy || + isConnecting || + !activeThreadId + ) { + return; + } + // The compact buttons cannot see the compression counter (it lives in + // a ref), so they render enabled during a paste; toast instead of + // silently ignoring the click. + if ((pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) { + toastManager.add({ + type: "info", + title: "Still compressing a pasted image.", + description: "Compact again once its thumbnail appears.", + }); + return; + } + + promptRef.current = "/compact"; + setComposerDraftPrompt(composerDraftTarget, "/compact"); + submitComposer(); + // A blocked dispatch (busy send ref, provider preflight rejection) + // would leave the injected "/compact" behind as if the user typed it. + // Clearing here is safe even when the send did dispatch: the send + // snapshots its prompt synchronously and clears the draft itself. + if (promptRef.current === "/compact") { + promptRef.current = ""; + setComposerDraftPrompt(composerDraftTarget, ""); + } + }, [ + activePendingApproval, + activeThreadId, + compactDisabled, + composerDraftTarget, + composerSendState.hasSendableContent, + isConnecting, + isSendBusy, + noProviderAvailable, + pendingUserInputs.length, + phase, + promptRef, + setComposerDraftPrompt, + submitComposer, + ]); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); @@ -2754,6 +2815,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleModelPicker: () => { setIsComposerModelPickerOpen((open) => !open); }, + compactContext: compactThreadContext, isModelPickerOpen: () => isComposerModelPickerOpen, readSnapshot: () => { return readComposerSnapshot(); @@ -2865,6 +2927,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedProvider, selectedProviderModels, + compactThreadContext, ], ); @@ -3507,6 +3570,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + compactDisabled={ + compactDisabled || noProviderAvailable || isSendBusy || isConnecting + } + compactDisabledReason={resolvedCompactDisabledReason} + {...(selectedProvider === "claudeAgent" + ? { onCompactContext: compactThreadContext } + : {})} /> diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index adbabba25c7d..33d0d17eed7d 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -77,4 +77,32 @@ describe("ComposerBannerStack", () => { expect(markup).toContain("branch-surface"); expect(markup).toContain("branch-actions"); }); + + it("renders a disabled compaction action on the shared accessible banner surface", () => { + const markup = renderToStaticMarkup( +