authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-25 01:08:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-25 01:08:57-07:00
logc7222ca4dfdea50df1d02ca33710aac5daddbeb7
treef0f4e803bb05d31437164ad149841184053285e0
parent1baf99195a552572e24f907cd7f72ce01c6c81c8
signature Signed by PGP key B5690EEEBB952194

feat(claude): compact old threads before they burn through usage (#8144)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

25 files changed, 1105 insertions(+), 32 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+175
......@@ -413,6 +413,25 @@ describe("ClaudeAdapterLive", () => {
413413 );
414414 });
415415
416 it.effect("passes the configured auto-compaction window to Claude", () => {
417 const harness = makeHarness({ claudeConfig: { autoCompactWindow: "300000" } });
418 return Effect.gen(function* () {
419 const adapter = yield* ClaudeAdapter;
420 yield* adapter.startSession({
421 threadId: THREAD_ID,
422 provider: ProviderDriverKind.make("claudeAgent"),
423 runtimeMode: "full-access",
424 });
425
426 const options = harness.getLastCreateQueryInput()?.options;
427 assert.deepEqual(options?.settings, { autoCompactWindow: 300000 });
428 assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]);
429 }).pipe(
430 Effect.provideService(Random.Random, makeDeterministicRandomService()),
431 Effect.provide(harness.layer),
432 );
433 });
434
416435 it.effect("forwards claude effort levels into query options", () => {
417436 const harness = makeHarness();
418437 return Effect.gen(function* () {
......@@ -730,6 +749,39 @@ describe("ClaudeAdapterLive", () => {
730749 );
731750 });
732751
752 it.effect("keeps compact commands intact when ultrathink is selected", () => {
753 const harness = makeHarness();
754 return Effect.gen(function* () {
755 const adapter = yield* ClaudeAdapter;
756 const modelSelection = createModelSelection(
757 ProviderInstanceId.make("claudeAgent"),
758 "claude-sonnet-4-6",
759 [{ id: "effort", value: "ultrathink" }],
760 );
761 const session = yield* adapter.startSession({
762 threadId: THREAD_ID,
763 provider: ProviderDriverKind.make("claudeAgent"),
764 modelSelection,
765 runtimeMode: "full-access",
766 });
767
768 yield* adapter.sendTurn({
769 threadId: session.threadId,
770 input: "/compact",
771 attachments: [],
772 modelSelection,
773 });
774
775 const promptText = yield* Effect.promise(() =>
776 readFirstPromptText(harness.getLastCreateQueryInput()),
777 );
778 assert.equal(promptText, "/compact");
779 }).pipe(
780 Effect.provideService(Random.Random, makeDeterministicRandomService()),
781 Effect.provide(harness.layer),
782 );
783 });
784
733785 it.effect("embeds image attachments in Claude user messages", () => {
734786 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-"));
735787 const harness = makeHarness({
......@@ -4400,6 +4452,62 @@ describe("ClaudeAdapterLive", () => {
44004452 );
44014453 });
44024454
4455 it.effect("routes Claude resume compaction through the shared user-input UI", () => {
4456 const harness = makeHarness();
4457 return Effect.gen(function* () {
4458 const adapter = yield* ClaudeAdapter;
4459 const session = yield* adapter.startSession({
4460 threadId: RESUME_THREAD_ID,
4461 provider: ProviderDriverKind.make("claudeAgent"),
4462 resumeCursor: { resume: "550e8400-e29b-41d4-a716-446655440000" },
4463 runtimeMode: "full-access",
4464 });
4465 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
4466
4467 const onUserDialog = harness.getLastCreateQueryInput()?.options.onUserDialog;
4468 assert.equal(typeof onUserDialog, "function");
4469 if (!onUserDialog) return;
4470
4471 const dialogPromise = onUserDialog(
4472 {
4473 dialogKind: "resume_return",
4474 payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 },
4475 },
4476 { signal: new AbortController().signal },
4477 );
4478
4479 const requested = yield* Stream.runHead(adapter.streamEvents);
4480 assert.equal(requested._tag, "Some");
4481 if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") return;
4482 const question = requested.value.payload.questions[0];
4483 assert.equal(question?.header, "Resume session");
4484 assert.match(question?.question ?? "", /2h 25m/);
4485 assert.match(question?.question ?? "", /275,123 tokens/);
4486 assert.deepEqual(
4487 question?.options.map((option) => option.label),
4488 ["Compact and continue", "Keep full history", "Don't ask again"],
4489 );
4490 if (!question || !requested.value.requestId) return;
4491
4492 yield* adapter.respondToUserInput(
4493 session.threadId,
4494 ApprovalRequestId.make(requested.value.requestId),
4495 { [question.id]: "Compact and continue" },
4496 );
4497
4498 const resolved = yield* Stream.runHead(adapter.streamEvents);
4499 assert.equal(resolved._tag, "Some");
4500 if (resolved._tag === "Some") assert.equal(resolved.value.type, "user-input.resolved");
4501 assert.deepEqual(yield* Effect.promise(() => dialogPromise), {
4502 behavior: "completed",
4503 result: "compact",
4504 });
4505 }).pipe(
4506 Effect.provideService(Random.Random, makeDeterministicRandomService()),
4507 Effect.provide(harness.layer),
4508 );
4509 });
4510
44034511 it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => {
44044512 const harness = makeHarness();
44054513 return Effect.gen(function* () {
......@@ -4689,6 +4797,73 @@ describe("ClaudeAdapterLive", () => {
46894797 );
46904798 });
46914799
4800 it.effect("denies AskUserQuestion when the signal aborted before the listener registered", () => {
4801 const harness = makeHarness();
4802 return Effect.gen(function* () {
4803 const adapter = yield* ClaudeAdapter;
4804
4805 yield* adapter.startSession({
4806 threadId: THREAD_ID,
4807 provider: ProviderDriverKind.make("claudeAgent"),
4808 runtimeMode: "approval-required",
4809 });
4810
4811 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
4812
4813 const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool;
4814 assert.equal(typeof canUseTool, "function");
4815 if (!canUseTool) {
4816 return;
4817 }
4818
4819 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe(
4820 Stream.runCollect,
4821 Effect.forkChild,
4822 );
4823
4824 // Abort before the call so the adapter's listener registration can
4825 // never observe the abort event, only the recheck can.
4826 const controller = new AbortController();
4827 controller.abort();
4828 const permissionPromise = canUseTool(
4829 "AskUserQuestion",
4830 {
4831 questions: [
4832 {
4833 question: "Continue?",
4834 header: "Continue",
4835 options: [{ label: "Yes", description: "Proceed" }],
4836 multiSelect: false,
4837 },
4838 ],
4839 },
4840 {
4841 signal: controller.signal,
4842 toolUseID: "tool-ask-pre-aborted",
4843 },
4844 );
4845
4846 const permissionResult = yield* Effect.promise(() => permissionPromise);
4847 assert.deepEqual(permissionResult, {
4848 behavior: "deny",
4849 message: "User cancelled tool execution.",
4850 } satisfies PermissionResult);
4851
4852 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
4853 assert.deepEqual(
4854 runtimeEvents.map((event) => event.type),
4855 ["user-input.requested", "user-input.resolved"],
4856 );
4857 const resolvedEvent = runtimeEvents[1];
4858 if (resolvedEvent?.type === "user-input.resolved") {
4859 assert.deepEqual(resolvedEvent.payload.answers, {});
4860 }
4861 }).pipe(
4862 Effect.provideService(Random.Random, makeDeterministicRandomService()),
4863 Effect.provide(harness.layer),
4864 );
4865 });
4866
46924867 it.effect("stopping a session settles pending user-input waits", () => {
46934868 const harness = makeHarness();
46944869 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+100
......@@ -57,6 +57,10 @@ import {
5757 getProviderOptionDescriptors,
5858 resolvePromptInjectedEffort,
5959} from "@t3tools/shared/model";
60import {
61 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
62 formatClaudeResumeCompactionQuestion,
63} from "@t3tools/shared/claudeCompaction";
6064import * as Cause from "effect/Cause";
6165import * as Crypto from "effect/Crypto";
6266import * as DateTime from "effect/DateTime";
......@@ -543,6 +547,7 @@ function makeClaudeTokenUsageSnapshot(input: {
543547 readonly totalProcessedTokens?: number;
544548 readonly lastUsedTokens?: number;
545549 readonly compactsAutomatically?: boolean;
550 readonly autoCompactThreshold?: number;
546551}): ThreadTokenUsageSnapshot | undefined {
547552 const activeTokens = finiteNonNegativeInteger(input.activeTokens);
548553 if (activeTokens === undefined || activeTokens <= 0) {
......@@ -570,6 +575,9 @@ function makeClaudeTokenUsageSnapshot(input: {
570575 ...(input.compactsAutomatically !== undefined
571576 ? { compactsAutomatically: input.compactsAutomatically }
572577 : {}),
578 ...(input.autoCompactThreshold !== undefined
579 ? { autoCompactThreshold: input.autoCompactThreshold }
580 : {}),
573581 };
574582}
575583
......@@ -604,11 +612,13 @@ function normalizeClaudeContextUsageApiSnapshot(
604612 value: SDKControlGetContextUsageResponse,
605613 totalProcessedTokens?: number,
606614): ThreadTokenUsageSnapshot | undefined {
615 const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold);
607616 return makeClaudeTokenUsageSnapshot({
608617 activeTokens: value.totalTokens,
609618 contextWindow: value.maxTokens,
610619 ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}),
611620 compactsAutomatically: value.isAutoCompactEnabled,
621 ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}),
612622 });
613623}
614624
......@@ -3953,6 +3963,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
39533963 callbackOptions.signal.addEventListener("abort", onAbort, {
39543964 once: true,
39553965 });
3966 // The signal may have aborted during the awaited event emissions
3967 // above, before the listener existed; settle now so the dialog
3968 // cannot hang with a lingering pending question.
3969 if (callbackOptions.signal.aborted) {
3970 yield* settleAsAborted;
3971 }
39563972
39573973 // Block until the user provides answers.
39583974 const answers = yield* Deferred.await(answersDeferred);
......@@ -4001,6 +4017,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
40014017 } satisfies PermissionResult;
40024018 });
40034019
4020 const handleResumeDialog = Effect.fn("handleResumeDialog")(function* (
4021 request: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[0],
4022 callbackOptions: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[1],
4023 ) {
4024 if (request.dialogKind !== "resume_return") {
4025 return { behavior: "cancelled" as const };
4026 }
4027
4028 const context = yield* Ref.get(contextRef);
4029 if (!context) {
4030 return { behavior: "cancelled" as const };
4031 }
4032
4033 // The question copy lives in @t3tools/shared/claudeCompaction because
4034 // the web client recognizes this exact text (and the "never" answer)
4035 // to mirror a permanent dismissal.
4036 const question = formatClaudeResumeCompactionQuestion({
4037 ageMinutes: finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0,
4038 estimatedTokens: finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0,
4039 });
4040 const result = yield* handleAskUserQuestion(
4041 context,
4042 {
4043 questions: [
4044 {
4045 header: "Resume session",
4046 question,
4047 options: [
4048 {
4049 label: "Compact and continue",
4050 description: "Resume with a summary and use fewer tokens.",
4051 },
4052 {
4053 label: "Keep full history",
4054 description: "Resume without changing the conversation.",
4055 },
4056 {
4057 label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
4058 description: "Keep full history and skip future resume prompts.",
4059 },
4060 ],
4061 multiSelect: false,
4062 },
4063 ],
4064 },
4065 {
4066 signal: callbackOptions.signal,
4067 ...(request.toolUseID ? { toolUseID: request.toolUseID } : {}),
4068 },
4069 );
4070
4071 if (result.behavior !== "allow") {
4072 return { behavior: "cancelled" as const };
4073 }
4074
4075 const answers = result.updatedInput.answers;
4076 const selection =
4077 answers && typeof answers === "object" && !Array.isArray(answers)
4078 ? (answers as Record<string, unknown>)[question]
4079 : undefined;
4080 const action =
4081 selection === "Compact and continue"
4082 ? "compact"
4083 : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER
4084 ? "never"
4085 : "continue";
4086
4087 return { behavior: "completed" as const, result: action };
4088 });
4089
40044090 const canUseToolEffect = Effect.fn("canUseTool")(function* (
40054091 toolName: Parameters<CanUseTool>[0],
40064092 toolInput: Parameters<CanUseTool>[1],
......@@ -4106,6 +4192,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
41064192 callbackOptions.signal.addEventListener("abort", onAbort, {
41074193 once: true,
41084194 });
4195 // Same late-listener race as handleAskUserQuestion: the signal may
4196 // have aborted while the request event emissions were awaited.
4197 if (callbackOptions.signal.aborted) {
4198 onAbort();
4199 }
41094200
41104201 const decision = yield* Deferred.await(decisionDeferred);
41114202 pendingApprovals.delete(requestId);
......@@ -4161,6 +4252,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
41614252
41624253 const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
41634254 runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));
4255 const onUserDialog: NonNullable<ClaudeQueryOptions["onUserDialog"]> = (
4256 request,
4257 callbackOptions,
4258 ) => runPromise(handleResumeDialog(request, callbackOptions));
41644259
41654260 const claudeBinaryPath = claudeSdkExecutablePath;
41664261 const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
......@@ -4196,6 +4291,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
41964291 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
41974292 ...(fastMode ? { fastMode: true } : {}),
41984293 ...(ultracode ? { ultracode: true } : {}),
4294 ...(claudeSettings.autoCompactWindow
4295 ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) }
4296 : {}),
41994297 };
42004298 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
42014299 // The attachments dir grant lets the agent Read/copy pasted images at
......@@ -4228,6 +4326,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
42284326 ...(newSessionId ? { sessionId: newSessionId } : {}),
42294327 includePartialMessages: true,
42304328 canUseTool,
4329 onUserDialog,
4330 supportedDialogKinds: ["resume_return"],
42314331 env: claudeEnvironment,
42324332 additionalDirectories,
42334333 ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
apps/server/src/provider/Layers/ClaudeProvider.ts+7-1
......@@ -927,7 +927,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
927927 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
928928 : undefined;
929929 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
930 const slashCommands = capabilities?.slashCommands ?? [];
930 const slashCommands = [
931 {
932 name: "compact",
933 description: "Summarize the conversation and reduce context usage",
934 },
935 ...(capabilities?.slashCommands ?? []),
936 ];
931937 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
932938
933939 if (!capabilities) {
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+1
......@@ -106,6 +106,7 @@ const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings =>
106106 homePath: "",
107107 customModels: [],
108108 launchArgs: "",
109 autoCompactWindow: "",
109110 ...overrides,
110111});
111112
apps/server/src/provider/Layers/ProviderRegistry.test.ts+8
......@@ -2151,6 +2151,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
21512151 );
21522152
21532153 assert.deepStrictEqual(status.slashCommands, [
2154 {
2155 name: "compact",
2156 description: "Summarize the conversation and reduce context usage",
2157 },
21542158 {
21552159 name: "review",
21562160 description: "Review a pull request",
......@@ -2194,6 +2198,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
21942198 );
21952199
21962200 assert.deepStrictEqual(status.slashCommands, [
2201 {
2202 name: "compact",
2203 description: "Summarize the conversation and reduce context usage",
2204 },
21972205 {
21982206 name: "ui",
21992207 description: "Explore and refine UI",
apps/server/src/serverSettings.test.ts+2
......@@ -190,6 +190,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
190190 homePath: "",
191191 customModels: ["claude-custom"],
192192 launchArgs: "",
193 autoCompactWindow: "",
193194 });
194195 assert.deepEqual(
195196 next.textGenerationModelSelection,
......@@ -581,6 +582,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
581582 homePath: "",
582583 customModels: [],
583584 launchArgs: "",
585 autoCompactWindow: "",
584586 });
585587 assert.deepEqual(next.providers.opencode, {
586588 // OpenCode is disabled by default; this update only touches paths.
apps/web/src/components/ChatView.tsx+173-15
......@@ -82,6 +82,7 @@ import {
8282 type AtomCommandResult,
8383} from "@t3tools/client-runtime/state/runtime";
8484import * as Cause from "effect/Cause";
85import * as Schema from "effect/Schema";
8586import { AsyncResult } from "effect/unstable/reactivity";
8687import { isElectron } from "../env";
8788import { readLocalApi } from "../localApi";
......@@ -179,6 +180,7 @@ import {
179180 CheckCircle2Icon,
180181 ChevronDownIcon,
181182 GitBranchIcon,
183 Minimize2Icon,
182184 PaperclipIcon,
183185 WifiOffIcon,
184186} from "lucide-react";
......@@ -196,7 +198,11 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
196198import { useBrowserHistoryStore } from "~/browserHistoryStore";
197199import { registerFaviconProjectForThread } from "~/browserFaviconStore";
198200import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
199import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
201import {
202 applyProviderInstanceSettings,
203 deriveProviderInstanceEntries,
204 NO_PROVIDER_MODEL_SELECTION,
205} from "../providerInstances";
200206import {
201207 useClientSettings,
202208 useClientSettingsHydrated,
......@@ -223,6 +229,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRo
223229import {
224230 beginBackgroundDraftSubmissionByRef,
225231 clearBackgroundDraftSubmissionByRef,
232 composerDraftHasUserContent,
226233 type ComposerImageAttachment,
227234 type DraftThreadEnvMode,
228235 finalizePromotedDraftThreadByRef,
......@@ -305,6 +312,12 @@ import {
305312 useLinkedThreadPullRequest,
306313} from "./ThreadStatusIndicators";
307314import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
315import {
316 hasAvailableClaudeCompactionProvider,
317 hasDismissedResumeCompaction,
318 shouldOfferResumeCompaction,
319} from "./chat/ContextWindowMeter.logic";
320import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
308321import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
309322import {
310323 DRAFT_HERO_TRANSITION_ANIMATION_ID,
......@@ -1367,6 +1380,9 @@ function ChatViewContent(props: ChatViewProps) {
13671380 const composerActiveProvider = useComposerDraftStore(
13681381 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
13691382 );
1383 const composerHasUnsentContent = useComposerDraftStore((store) =>
1384 composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),
1385 );
13701386 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
13711387 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
13721388 const setComposerDraftTerminalContexts = useComposerDraftStore(
......@@ -2299,6 +2315,10 @@ function ChatViewContent(props: ChatViewProps) {
22992315 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
23002316 const phase = derivePhase(activeThread?.session ?? null);
23012317 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
2318 const activeContextWindow = useMemo(
2319 () => deriveLatestContextWindowSnapshot(threadActivities),
2320 [threadActivities],
2321 );
23022322 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
23032323 const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]);
23042324 // Native subagent fold: memoized by activity-list identity, shared by the
......@@ -2762,6 +2782,29 @@ function ChatViewContent(props: ChatViewProps) {
27622782 activeThread?.modelSelection.instanceId ??
27632783 activeProject?.defaultModelSelection?.instanceId ??
27642784 null;
2785 const compactionProviderAvailable = useMemo(
2786 () =>
2787 hasAvailableClaudeCompactionProvider({
2788 providers: applyProviderInstanceSettings(
2789 deriveProviderInstanceEntries(providerStatuses),
2790 settings,
2791 ),
2792 instanceId: activeProviderInstanceId,
2793 lockedInstanceId: lockedProvider
2794 ? (activeThread?.session?.providerInstanceId ??
2795 activeThread?.modelSelection.instanceId ??
2796 null)
2797 : null,
2798 }),
2799 [
2800 activeProviderInstanceId,
2801 activeThread?.modelSelection.instanceId,
2802 activeThread?.session?.providerInstanceId,
2803 lockedProvider,
2804 providerStatuses,
2805 settings,
2806 ],
2807 );
27652808 const activeProviderStatus = useMemo(() => {
27662809 if (activeProviderInstanceId) {
27672810 return (
......@@ -2771,6 +2814,25 @@ function ChatViewContent(props: ChatViewProps) {
27712814 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
27722815 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
27732816 }, [activeProviderInstanceId, providerStatuses, selectedProvider]);
2817 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
2818 useLocalStorage(
2819 `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,
2820 false,
2821 Schema.Boolean,
2822 );
2823 const nativeResumeCompactionDismissed = useMemo(
2824 () => hasDismissedResumeCompaction(threadActivities),
2825 [threadActivities],
2826 );
2827 useEffect(() => {
2828 if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) {
2829 setResumeCompactionPermanentlyDismissed(true);
2830 }
2831 }, [
2832 nativeResumeCompactionDismissed,
2833 resumeCompactionPermanentlyDismissed,
2834 setResumeCompactionPermanentlyDismissed,
2835 ]);
27742836 const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
27752837 const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
27762838 string | null
......@@ -4515,18 +4577,6 @@ function ChatViewContent(props: ChatViewProps) {
45154577 // Dismissal lives in a module-level set (survives remounts); this tick just
45164578 // forces a re-render so the banner leaves immediately.
45174579 const [, setBranchMismatchDismissTick] = useState(0);
4518 const composerHasDraftContent = useComposerDraftStore((store) => {
4519 const draft = store.getComposerDraft(composerDraftTarget);
4520 return Boolean(
4521 draft &&
4522 (draft.prompt.trim().length > 0 ||
4523 draft.images.length > 0 ||
4524 draft.terminalContexts.length > 0 ||
4525 draft.elementContexts.length > 0 ||
4526 draft.previewAnnotations.length > 0 ||
4527 draft.reviewComments.length > 0),
4528 );
4529 });
45304580 const activeBranchMismatchKey = branchMismatchKey(
45314581 activeThread?.id ?? null,
45324582 localCheckoutBranchMismatch,
......@@ -4534,7 +4584,7 @@ function ChatViewContent(props: ChatViewProps) {
45344584 const showBranchMismatchBanner = shouldShowBranchMismatchBanner({
45354585 hasMismatch: localCheckoutBranchMismatch !== null,
45364586 isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey),
4537 composerHasContent: composerHasDraftContent,
4587 composerHasContent: composerHasUnsentContent,
45384588 wasShownForCurrentMismatch:
45394589 revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey,
45404590 });
......@@ -4758,6 +4808,107 @@ function ChatViewContent(props: ChatViewProps) {
47584808 isUnsnoozing,
47594809 isUnsettling,
47604810 ]);
4811 // Session-scoped dismissals, one key per (thread, snapshot). A set rather
4812 // than a single slot so dismissing the banner on one thread does not
4813 // resurface it on another thread dismissed earlier.
4814 const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState<
4815 ReadonlySet<string>
4816 >(new Set());
4817 const resumeCompactionKey =
4818 activeThread && activeContextWindow
4819 ? `${activeThread.id}:${activeContextWindow.updatedAt}`
4820 : null;
4821 const compactDisabled =
4822 !activeThread ||
4823 !activeProject ||
4824 !isServerThread ||
4825 selectedProvider !== "claudeAgent" ||
4826 !compactionProviderAvailable ||
4827 isWorking ||
4828 threadDetailLoading ||
4829 isPreparingWorktree ||
4830 activeEnvironmentUnavailable ||
4831 feedbackUploading ||
4832 pendingApprovals.length > 0 ||
4833 pendingUserInputs.length > 0 ||
4834 showPlanFollowUpPrompt ||
4835 composerHasUnsentContent;
4836 const compactDisabledReason = compactDisabled
4837 ? composerHasUnsentContent
4838 ? "Send or clear your draft before compacting"
4839 : !activeProject
4840 ? "Choose a project before compacting"
4841 : !compactionProviderAvailable
4842 ? "Enable a Claude provider before compacting"
4843 : "Compacting is unavailable right now"
4844 : null;
4845 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
4846 if (
4847 !activeThread ||
4848 !activeContextWindow ||
4849 resumeCompactionKey === null ||
4850 dismissedResumeCompactionKeys.has(resumeCompactionKey) ||
4851 resumeCompactionPermanentlyDismissed ||
4852 nativeResumeCompactionDismissed ||
4853 pendingUserInputs.length > 0 ||
4854 phase === "running" ||
4855 !shouldOfferResumeCompaction({
4856 provider: selectedProvider,
4857 usedTokens: activeContextWindow.usedTokens,
4858 updatedAt: activeContextWindow.updatedAt,
4859 now: `${nowMinute}:00.000Z`,
4860 })
4861 ) {
4862 return null;
4863 }
4864
4865 const dismiss = () =>
4866 setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey));
4867 const compactAction = (
4868 <Button
4869 size="xs"
4870 variant="outline"
4871 disabled={compactDisabled}
4872 onClick={() => {
4873 if (compactDisabled) return;
4874 composerRef.current?.compactContext();
4875 }}
4876 >
4877 Compact
4878 </Button>
4879 );
4880 return {
4881 id: `resume-compaction:${resumeCompactionKey}`,
4882 variant: "info",
4883 icon: <Minimize2Icon />,
4884 title: "Resume with less context",
4885 description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`,
4886 actions: compactDisabledReason ? (
4887 <Tooltip>
4888 <TooltipTrigger render={<span className="inline-flex">{compactAction}</span>} />
4889 <TooltipPopup side="top">{compactDisabledReason}</TooltipPopup>
4890 </Tooltip>
4891 ) : (
4892 compactAction
4893 ),
4894 dismissLabel: "Keep full history",
4895 onDismiss: dismiss,
4896 };
4897 }, [
4898 activeContextWindow,
4899 activeThread,
4900 compactDisabled,
4901 compactDisabledReason,
4902 composerRef,
4903 dismissedResumeCompactionKeys,
4904 nativeResumeCompactionDismissed,
4905 nowMinute,
4906 pendingUserInputs.length,
4907 phase,
4908 resumeCompactionKey,
4909 resumeCompactionPermanentlyDismissed,
4910 selectedProvider,
4911 ]);
47614912 const handleRestoreThreadBranch = useCallback(() => {
47624913 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
47634914 setBranchRestoreConfirmOpen(true);
......@@ -4772,6 +4923,8 @@ function ChatViewContent(props: ChatViewProps) {
47724923 const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item));
47734924 const backgroundLivenessItems =
47744925 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
4926 const resumeCompactionItems =
4927 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
47754928 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
47764929 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
47774930 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
......@@ -4779,6 +4932,7 @@ function ChatViewContent(props: ChatViewProps) {
47794932 ...urgentSystemItems,
47804933 ...backgroundLivenessItems,
47814934 ...calmSystemItems,
4935 ...resumeCompactionItems,
47824936 ...wokeThreadItems,
47834937 ...parkedThreadItems,
47844938 ];
......@@ -4787,6 +4941,7 @@ function ChatViewContent(props: ChatViewProps) {
47874941 ...urgentSystemItems,
47884942 ...backgroundLivenessItems,
47894943 ...calmSystemItems,
4944 ...resumeCompactionItems,
47904945 ...wokeThreadItems,
47914946 {
47924947 id: `branch-mismatch:${activeBranchMismatchKey}`,
......@@ -4836,6 +4991,7 @@ function ChatViewContent(props: ChatViewProps) {
48364991 isRestoringThreadBranch,
48374992 localCheckoutBranchMismatch,
48384993 parkedThreadBannerItem,
4994 resumeCompactionBannerItem,
48394995 showBranchMismatchBanner,
48404996 systemComposerBannerItems,
48414997 wokeThreadBannerItem,
......@@ -6916,7 +7072,9 @@ function ChatViewContent(props: ChatViewProps) {
69167072 activeProject?.defaultModelSelection
69177073 }
69187074 activeThreadModelSelection={activeThread?.modelSelection}
6919 activeThreadActivities={activeThread?.activities}
7075 activeContextWindow={activeContextWindow}
7076 compactDisabled={compactDisabled}
7077 compactDisabledReason={compactDisabledReason}
69207078 resolvedTheme={resolvedTheme}
69217079 settings={settings}
69227080 keybindings={keybindings}
apps/web/src/components/chat/ChatComposer.tsx+80-10
......@@ -266,7 +266,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings";
266266import type { SessionPhase, Thread } from "../../types";
267267import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
268268import type { PendingApproval, PendingUserInput } from "../../session-logic";
269import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
269import type { ContextWindowSnapshot } from "../../lib/contextWindow";
270270import {
271271 formatProviderSkillDisplayName,
272272 getProviderSlashCommandsForSlashMenu,
......@@ -438,7 +438,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
438438
439439const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
440440 compact: boolean;
441 activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;
441 activeContextWindow: ContextWindowSnapshot | null;
442442 activeThreadModelDisplayName: string | null;
443443 isPreparingWorktree: boolean;
444444 pendingAction: {
......@@ -461,6 +461,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
461461 onPreviousPendingQuestion: () => void;
462462 onInterrupt: () => void;
463463 onImplementPlanInNewThread: () => void;
464 onCompactContext?: (() => void) | undefined;
465 compactDisabled: boolean;
466 compactDisabledReason: string | null;
464467}) {
465468 return (
466469 <>
......@@ -468,6 +471,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
468471 <ContextWindowMeter
469472 usage={props.activeContextWindow}
470473 modelDisplayName={props.activeThreadModelDisplayName}
474 onCompact={props.onCompactContext}
475 compactDisabled={props.compactDisabled}
476 compactDisabledReason={props.compactDisabledReason}
471477 />
472478 ) : null}
473479 {props.isPreparingWorktree ? (
......@@ -507,6 +513,7 @@ export interface ChatComposerHandle {
507513 openModelPicker: () => void;
508514 toggleModelPicker: () => void;
509515 isModelPickerOpen: () => boolean;
516 compactContext: () => void;
510517 readSnapshot: () => {
511518 value: string;
512519 cursor: number;
......@@ -609,7 +616,9 @@ export interface ChatComposerProps {
609616 activeThreadModelSelection: ModelSelection | null | undefined;
610617
611618 // Context window
612 activeThreadActivities: Thread["activities"] | undefined;
619 activeContextWindow: ContextWindowSnapshot | null;
620 compactDisabled: boolean;
621 compactDisabledReason: string | null;
613622
614623 // Misc
615624 resolvedTheme: "light" | "dark";
......@@ -701,7 +710,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
701710 providerStatuses,
702711 activeProjectDefaultModelSelection,
703712 activeThreadModelSelection,
704 activeThreadActivities,
713 activeContextWindow,
714 compactDisabled,
715 compactDisabledReason,
705716 resolvedTheme,
706717 settings,
707718 keybindings,
......@@ -914,6 +925,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
914925 [providerInstanceEntries, selectedInstanceId],
915926 );
916927 const noProviderAvailable = selectedProviderEntry === undefined;
928 const resolvedCompactDisabledReason =
929 compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null);
917930 // The driver kind follows the instance that will actually run the turn,
918931 // which can differ from the persisted selection when that selection is
919932 // disabled.
......@@ -1004,10 +1017,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
10041017 // ------------------------------------------------------------------
10051018 // Context window
10061019 // ------------------------------------------------------------------
1007 const activeContextWindow = useMemo(
1008 () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
1009 [activeThreadActivities],
1010 );
10111020 const activeThreadModelDisplayName = useMemo(
10121021 () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance),
10131022 [activeThreadModelSelection, modelOptionsByInstance],
......@@ -1073,8 +1082,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
10731082 /**
10741083 * Count of pasted images still being compressed, per thread. Reserved
10751084 * against the attachment limit so concurrent pastes can't overshoot it,
1076 * and checked by `submitComposer` so a send can't race an image into the
1077 * next draft.
1085 * and checked before sending or compacting so an image cannot move into
1086 * the next draft.
10781087 */
10791088 const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());
10801089
......@@ -1986,6 +1995,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19861995 shouldBlurMobileComposerOnSubmit,
19871996 ],
19881997 );
1998 const compactThreadContext = useCallback(() => {
1999 if (
2000 compactDisabled ||
2001 noProviderAvailable ||
2002 composerSendState.hasSendableContent ||
2003 activePendingApproval !== null ||
2004 pendingUserInputs.length > 0 ||
2005 phase === "running" ||
2006 isSendBusy ||
2007 isConnecting ||
2008 !activeThreadId
2009 ) {
2010 return;
2011 }
2012 // The compact buttons cannot see the compression counter (it lives in
2013 // a ref), so they render enabled during a paste; toast instead of
2014 // silently ignoring the click.
2015 if ((pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) {
2016 toastManager.add({
2017 type: "info",
2018 title: "Still compressing a pasted image.",
2019 description: "Compact again once its thumbnail appears.",
2020 });
2021 return;
2022 }
2023
2024 promptRef.current = "/compact";
2025 setComposerDraftPrompt(composerDraftTarget, "/compact");
2026 submitComposer();
2027 // A blocked dispatch (busy send ref, provider preflight rejection)
2028 // would leave the injected "/compact" behind as if the user typed it.
2029 // Clearing here is safe even when the send did dispatch: the send
2030 // snapshots its prompt synchronously and clears the draft itself.
2031 if (promptRef.current === "/compact") {
2032 promptRef.current = "";
2033 setComposerDraftPrompt(composerDraftTarget, "");
2034 }
2035 }, [
2036 activePendingApproval,
2037 activeThreadId,
2038 compactDisabled,
2039 composerDraftTarget,
2040 composerSendState.hasSendableContent,
2041 isConnecting,
2042 isSendBusy,
2043 noProviderAvailable,
2044 pendingUserInputs.length,
2045 phase,
2046 promptRef,
2047 setComposerDraftPrompt,
2048 submitComposer,
2049 ]);
19892050 const expandMobileComposer = useCallback(() => {
19902051 if (composerBlurFrameRef.current !== null) {
19912052 window.cancelAnimationFrame(composerBlurFrameRef.current);
......@@ -2764,6 +2825,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27642825 toggleModelPicker: () => {
27652826 setIsComposerModelPickerOpen((open) => !open);
27662827 },
2828 compactContext: compactThreadContext,
27672829 isModelPickerOpen: () => isComposerModelPickerOpen,
27682830 readSnapshot: () => {
27692831 return readComposerSnapshot();
......@@ -2875,6 +2937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28752937 selectedPromptEffort,
28762938 selectedProvider,
28772939 selectedProviderModels,
2940 compactThreadContext,
28782941 ],
28792942 );
28802943
......@@ -3517,6 +3580,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35173580 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
35183581 onInterrupt={handleInterruptPrimaryAction}
35193582 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3583 compactDisabled={
3584 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
3585 }
3586 compactDisabledReason={resolvedCompactDisabledReason}
3587 {...(selectedProvider === "claudeAgent"
3588 ? { onCompactContext: compactThreadContext }
3589 : {})}
35203590 />
35213591 </div>
35223592 </div>
apps/web/src/components/chat/ComposerBannerStack.test.tsx+28
......@@ -77,4 +77,32 @@ describe("ComposerBannerStack", () => {
7777 expect(markup).toContain("branch-surface");
7878 expect(markup).toContain("branch-actions");
7979 });
80
81 it("renders a disabled compaction action on the shared accessible banner surface", () => {
82 const markup = renderToStaticMarkup(
83 <ComposerBannerStack
84 items={[
85 {
86 id: "resume-compaction",
87 variant: "info",
88 icon: <span aria-hidden="true">!</span>,
89 title: "Resume with less context",
90 description: "250k tokens from an older session",
91 actions: (
92 <button type="button" disabled>
93 Compact
94 </button>
95 ),
96 dismissLabel: "Keep full history",
97 onDismiss: () => {},
98 },
99 ]}
100 />,
101 );
102
103 expect(markup).toContain('role="alert"');
104 expect(markup).toContain("chat-composer-drawer-attached");
105 expect(markup).toContain('disabled=""');
106 expect(markup).toContain('aria-label="Keep full history"');
107 });
80108});
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+179-1
......@@ -1,10 +1,83 @@
1import { ProviderInstanceId } from "@t3tools/contracts";
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
3import { deriveProviderInstanceEntries } from "../../providerInstances";
34import {
45 formatContextWindowCompactionMessage,
6 hasAvailableClaudeCompactionProvider,
7 hasDismissedResumeCompaction,
58 resolveContextWindowModelDisplayName,
9 shouldOfferResumeCompaction,
610} from "./ContextWindowMeter.logic";
711
12function claudeProvider(input: {
13 instanceId: string;
14 continuationGroupKey: string;
15 enabled?: boolean;
16}): ServerProvider {
17 return {
18 instanceId: ProviderInstanceId.make(input.instanceId),
19 driver: ProviderDriverKind.make("claudeAgent"),
20 continuation: { groupKey: input.continuationGroupKey },
21 enabled: input.enabled ?? true,
22 installed: true,
23 version: null,
24 status: "ready",
25 auth: { status: "authenticated" },
26 checkedAt: "2026-08-24T12:00:00.000Z",
27 models: [],
28 slashCommands: [],
29 skills: [],
30 };
31}
32
33describe("hasAvailableClaudeCompactionProvider", () => {
34 const originalInstanceId = ProviderInstanceId.make("claude_original");
35
36 it("rejects a fallback in a different locked continuation group", () => {
37 const providers = deriveProviderInstanceEntries([
38 claudeProvider({
39 instanceId: originalInstanceId,
40 continuationGroupKey: "claude:home:/original",
41 enabled: false,
42 }),
43 claudeProvider({
44 instanceId: "claude_other",
45 continuationGroupKey: "claude:home:/other",
46 }),
47 ]);
48
49 expect(
50 hasAvailableClaudeCompactionProvider({
51 providers,
52 instanceId: originalInstanceId,
53 lockedInstanceId: originalInstanceId,
54 }),
55 ).toBe(false);
56 });
57
58 it("accepts an enabled fallback in the locked continuation group", () => {
59 const providers = deriveProviderInstanceEntries([
60 claudeProvider({
61 instanceId: originalInstanceId,
62 continuationGroupKey: "claude:home:/original",
63 enabled: false,
64 }),
65 claudeProvider({
66 instanceId: "claude_fallback",
67 continuationGroupKey: "claude:home:/original",
68 }),
69 ]);
70
71 expect(
72 hasAvailableClaudeCompactionProvider({
73 providers,
74 instanceId: originalInstanceId,
75 lockedInstanceId: originalInstanceId,
76 }),
77 ).toBe(true);
78 });
79});
80
881describe("resolveContextWindowModelDisplayName", () => {
982 it("uses the selected model from the exact provider instance", () => {
1083 const primaryInstanceId = ProviderInstanceId.make("codex");
......@@ -55,4 +128,109 @@ describe("formatContextWindowCompactionMessage", () => {
55128 "Context compacts automatically when needed.",
56129 );
57130 });
131
132 it("shows the configured auto-compaction threshold", () => {
133 expect(formatContextWindowCompactionMessage("Claude Sonnet 5", 300_000)).toBe(
134 "Compacts automatically at 300,000 tokens.",
135 );
136 });
137});
138
139describe("shouldOfferResumeCompaction", () => {
140 const now = "2026-08-24T12:00:00.000Z";
141
142 it("matches Claude's old-session age and context thresholds", () => {
143 expect(
144 shouldOfferResumeCompaction({
145 provider: "claudeAgent",
146 usedTokens: 100_000,
147 updatedAt: "2026-08-24T10:50:00.000Z",
148 now,
149 }),
150 ).toBe(true);
151 });
152
153 it("does not prompt for recent or smaller sessions", () => {
154 expect(
155 shouldOfferResumeCompaction({
156 provider: "claudeAgent",
157 usedTokens: 99_999,
158 updatedAt: "2026-08-24T10:00:00.000Z",
159 now,
160 }),
161 ).toBe(false);
162 expect(
163 shouldOfferResumeCompaction({
164 provider: "claudeAgent",
165 usedTokens: 200_000,
166 updatedAt: "2026-08-24T10:51:00.000Z",
167 now,
168 }),
169 ).toBe(false);
170 });
171
172 it("does not show Claude's resume prompt for another provider", () => {
173 expect(
174 shouldOfferResumeCompaction({
175 provider: "codex",
176 usedTokens: 300_000,
177 updatedAt: "2026-08-24T09:00:00.000Z",
178 now,
179 }),
180 ).toBe(false);
181 });
182});
183
184describe("hasDismissedResumeCompaction", () => {
185 it("recognizes the native resume dialog's permanent dismissal", () => {
186 expect(
187 hasDismissedResumeCompaction([
188 {
189 kind: "user-input.resolved",
190 payload: {
191 answers: {
192 "This session is 2h 0m old and uses 250,000 tokens. Compact it before continuing?":
193 "Don't ask again",
194 },
195 },
196 },
197 ]),
198 ).toBe(true);
199 });
200
201 it("ignores the same answer on an unrelated question", () => {
202 expect(
203 hasDismissedResumeCompaction([
204 {
205 kind: "user-input.resolved",
206 payload: { answers: { "Show this setup reminder?": "Don't ask again" } },
207 },
208 ]),
209 ).toBe(false);
210 });
211
212 it("ignores unrelated questions that end with Claude's compaction prompt", () => {
213 expect(
214 hasDismissedResumeCompaction([
215 {
216 kind: "user-input.resolved",
217 payload: {
218 answers: {
219 "The build cache is large. Compact it before continuing?": "Don't ask again",
220 },
221 },
222 },
223 ]),
224 ).toBe(false);
225 });
226
227 it("ignores pending questions and malformed resolved payloads", () => {
228 expect(
229 hasDismissedResumeCompaction([
230 { kind: "user-input.requested", payload: { answers: { question: "Don't ask again" } } },
231 { kind: "user-input.resolved", payload: null },
232 { kind: "user-input.resolved", payload: { answers: ["Don't ask again"] } },
233 ]),
234 ).toBe(false);
235 });
58236});
apps/web/src/components/chat/ContextWindowMeter.logic.ts+79
......@@ -1,6 +1,81 @@
11import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
2import {
3 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
4 isClaudeResumeCompactionQuestion,
5} from "@t3tools/shared/claudeCompaction";
6import {
7 resolveSelectableProviderInstanceEntry,
8 type ProviderInstanceEntry,
9} from "../../providerInstances";
210import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils";
311
12export const CLAUDE_RESUME_COMPACTION_MINUTES = 70;
13export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;
14
15export function hasAvailableClaudeCompactionProvider(input: {
16 readonly providers: ReadonlyArray<ProviderInstanceEntry>;
17 readonly instanceId: ProviderInstanceId | null;
18 readonly lockedInstanceId: ProviderInstanceId | null;
19}): boolean {
20 const claudeProviders = input.providers.filter(
21 (provider) => provider.driverKind === "claudeAgent",
22 );
23 const lockedContinuationGroupKey = input.lockedInstanceId
24 ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
25 ?.continuationGroupKey
26 : undefined;
27 const compatibleProviders = lockedContinuationGroupKey
28 ? claudeProviders.filter(
29 (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,
30 )
31 : claudeProviders;
32
33 return (
34 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !==
35 undefined
36 );
37}
38
39export function hasDismissedResumeCompaction(
40 activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>,
41): boolean {
42 return activities.some((activity) => {
43 if (activity.kind !== "user-input.resolved") return false;
44 const payload = activity.payload;
45 if (!payload || typeof payload !== "object") return false;
46 const answers = (payload as { readonly answers?: unknown }).answers;
47 if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false;
48
49 return Object.entries(answers).some(
50 ([question, answer]) =>
51 isClaudeResumeCompactionQuestion(question) &&
52 answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
53 );
54 });
55}
56
57export function shouldOfferResumeCompaction(input: {
58 readonly provider: string | null | undefined;
59 readonly usedTokens: number | null | undefined;
60 readonly updatedAt: string | null | undefined;
61 readonly now: string;
62}): boolean {
63 if (
64 input.provider !== "claudeAgent" ||
65 (input.usedTokens ?? 0) < CLAUDE_RESUME_COMPACTION_TOKENS
66 ) {
67 return false;
68 }
69
70 const updatedAt = Date.parse(input.updatedAt ?? "");
71 const now = Date.parse(input.now);
72 return (
73 Number.isFinite(updatedAt) &&
74 Number.isFinite(now) &&
75 now - updatedAt >= CLAUDE_RESUME_COMPACTION_MINUTES * 60_000
76 );
77}
78
479export function resolveContextWindowModelDisplayName(
580 selection: ModelSelection | null | undefined,
681 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>,
......@@ -18,7 +93,11 @@ export function resolveContextWindowModelDisplayName(
1893
1994export function formatContextWindowCompactionMessage(
2095 modelDisplayName: string | null | undefined,
96 autoCompactThreshold?: number | null,
2197): string {
98 if (typeof autoCompactThreshold === "number" && autoCompactThreshold > 0) {
99 return `Compacts automatically at ${autoCompactThreshold.toLocaleString("en-US")} tokens.`;
100 }
22101 return modelDisplayName
23102 ? `Context for ${modelDisplayName} compacts automatically when needed.`
24103 : "Context compacts automatically when needed.";
apps/web/src/components/chat/ContextWindowMeter.test.tsx created+62
......@@ -0,0 +1,62 @@
1import { EventId, TurnId } from "@t3tools/contracts";
2import type { ReactNode } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6import { deriveLatestContextWindowSnapshot } from "~/lib/contextWindow";
7import { ContextWindowMeter } from "./ContextWindowMeter";
8
9vi.mock("../ui/popover", () => ({
10 Popover: ({ children }: { children: ReactNode }) => children,
11 PopoverPopup: ({ children }: { children: ReactNode }) => children,
12 PopoverTrigger: ({ closeDelay, render }: { closeDelay: number; render: ReactNode }) => (
13 <div data-close-delay={closeDelay}>{render}</div>
14 ),
15}));
16
17const usage = deriveLatestContextWindowSnapshot([
18 {
19 id: EventId.make("activity-1"),
20 tone: "info",
21 kind: "context-window.updated",
22 summary: "Context updated",
23 payload: { usedTokens: 100_000, maxTokens: 1_000_000 },
24 turnId: TurnId.make("turn-1"),
25 createdAt: "2026-08-24T12:00:00.000Z",
26 },
27]);
28
29if (!usage) {
30 throw new Error("The context window test fixture did not produce a snapshot.");
31}
32
33describe("ContextWindowMeter", () => {
34 it("keeps the hover popover open while the pointer moves to the compact button", () => {
35 const markup = renderToStaticMarkup(<ContextWindowMeter usage={usage} onCompact={() => {}} />);
36
37 expect(markup).toContain('data-close-delay="150"');
38 expect(markup).toContain("Compact context");
39 });
40
41 it("closes an informational hover popover without delay", () => {
42 const markup = renderToStaticMarkup(<ContextWindowMeter usage={usage} />);
43
44 expect(markup).toContain('data-close-delay="0"');
45 expect(markup).not.toContain("Compact context");
46 });
47
48 it("explains why the compact action is disabled", () => {
49 const markup = renderToStaticMarkup(
50 <ContextWindowMeter
51 usage={usage}
52 onCompact={() => {}}
53 compactDisabled
54 compactDisabledReason="Send or clear your draft before compacting"
55 />,
56 );
57
58 expect(markup).toContain('disabled=""');
59 expect(markup).toContain(">Send or clear your draft before compacting<");
60 expect(markup).not.toContain('aria-label="Send or clear your draft before compacting"');
61 });
62});
apps/web/src/components/chat/ContextWindowMeter.tsx+26-3
......@@ -2,6 +2,7 @@ import { Button } from "../ui/button";
22import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow";
33import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
44import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic";
5import { Minimize2Icon } from "lucide-react";
56
67function formatPercentage(value: number | null): string | null {
78 if (value === null || !Number.isFinite(value)) {
......@@ -16,8 +17,11 @@ function formatPercentage(value: number | null): string | null {
1617export function ContextWindowMeter(props: {
1718 usage: ContextWindowSnapshot;
1819 modelDisplayName?: string | null;
20 onCompact?: (() => void) | undefined;
21 compactDisabled?: boolean | undefined;
22 compactDisabledReason?: string | null | undefined;
1923}) {
20 const { usage, modelDisplayName } = props;
24 const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props;
2125 const usedPercentage = formatPercentage(usage.usedPercentage);
2226 const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));
2327 const radius = 9.75;
......@@ -35,7 +39,7 @@ export function ContextWindowMeter(props: {
3539 <PopoverTrigger
3640 openOnHover
3741 delay={150}
38 closeDelay={0}
42 closeDelay={onCompact ? 150 : 0}
3943 render={
4044 <Button
4145 size="icon-sm"
......@@ -128,9 +132,28 @@ export function ContextWindowMeter(props: {
128132 ) : null}
129133 {usage.compactsAutomatically ? (
130134 <div className="mt-1 text-pretty text-secondary-label text-[11px] font-medium">
131 {formatContextWindowCompactionMessage(modelDisplayName)}
135 {formatContextWindowCompactionMessage(modelDisplayName, usage.autoCompactThreshold)}
132136 </div>
133137 ) : null}
138 {onCompact ? (
139 <>
140 <Button
141 size="xs"
142 variant="outline"
143 className="mt-1 w-full justify-center"
144 disabled={compactDisabled}
145 onClick={onCompact}
146 >
147 <Minimize2Icon aria-hidden="true" />
148 Compact context
149 </Button>
150 {compactDisabled && compactDisabledReason ? (
151 <div className="text-pretty text-secondary-label text-[11px]">
152 {compactDisabledReason}
153 </div>
154 ) : null}
155 </>
156 ) : null}
134157 </div>
135158 </PopoverPopup>
136159 </Popover>
apps/web/src/components/settings/ProviderSettingsForm.test.ts+12
......@@ -37,6 +37,18 @@ describe("ProviderSettingsForm helpers", () => {
3737 });
3838 });
3939
40 it("shows the auto-compaction threshold for Claude providers", () => {
41 const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")];
42 expect(claude).toBeDefined();
43
44 expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([
45 "binaryPath",
46 "homePath",
47 "autoCompactWindow",
48 "launchArgs",
49 ]);
50 });
51
4052 it("preserves unknown config keys while omitting empty configurable fields", () => {
4153 const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
4254 expect(opencode).toBeDefined();
apps/web/src/lib/contextWindow.test.ts+2
......@@ -26,6 +26,7 @@ describe("contextWindow", () => {
2626 usedTokens: 14_000,
2727 maxTokens: 258_000,
2828 compactsAutomatically: true,
29 autoCompactThreshold: 200_000,
2930 }),
3031 ]);
3132
......@@ -34,6 +35,7 @@ describe("contextWindow", () => {
3435 expect(snapshot?.totalProcessedTokens).toBeNull();
3536 expect(snapshot?.maxTokens).toBe(258_000);
3637 expect(snapshot?.compactsAutomatically).toBe(true);
38 expect(snapshot?.autoCompactThreshold).toBe(200_000);
3739 });
3840
3941 it("ignores malformed payloads", () => {
apps/web/src/lib/contextWindow.ts+1
......@@ -88,6 +88,7 @@ export function deriveLatestContextWindowSnapshot(
8888 toolUses: asFiniteNumber(payload?.toolUses),
8989 durationMs: asFiniteNumber(payload?.durationMs),
9090 compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,
91 autoCompactThreshold: asFiniteNumber(payload?.autoCompactThreshold),
9192 updatedAt: activity.createdAt,
9293 };
9394 }
docs/user/providers-claude.md+12
......@@ -34,6 +34,18 @@ When you set this field, T3 Code points Claude Code at that directory with the
3434`CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and
3535the rest of your environment stay as they are.
3636
37## Reduce Context Usage
38
39In Settings, open your Claude provider and set **Auto-compact after** to a token count between
40`100000` and `1000000`. For example, `300000` compacts the conversation into a summary once it
41reaches about 300,000 tokens, without changing the model's context window. Leave the field
42empty to keep Claude Code's default behavior.
43
44On web and desktop, when you return to an older Claude thread with a large context, T3 Code
45offers to compact the conversation before you continue. You can also select **Compact context**
46from the context meter. On every client, you can enter `/compact` in the message composer, and
47Claude can show its own resume prompt when you continue an old session.
48
3749## Where Claude Skills Are Loaded
3850
3951T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then
packages/contracts/src/providerRuntime.ts+1
......@@ -324,6 +324,7 @@ export const ThreadTokenUsageSnapshot = Schema.Struct({
324324 toolUses: Schema.optional(NonNegativeInt),
325325 durationMs: Schema.optional(NonNegativeInt),
326326 compactsAutomatically: Schema.optional(Schema.Boolean),
327 autoCompactThreshold: Schema.optional(PositiveInt),
327328});
328329export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type;
329330
packages/contracts/src/settings.test.ts+31
......@@ -5,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
55import {
66 ClientSettingsSchema,
77 ClientSettingsPatch,
8 ClaudeSettings,
89 DEFAULT_SERVER_SETTINGS,
910 defaultEnabledForDriver,
1011 resolveProviderInstanceEnabled,
......@@ -17,6 +18,36 @@ const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);
1718const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
1819const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
1920const encodeServerSettings = Schema.encodeSync(ServerSettings);
21const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
22
23describe("ClaudeSettings auto-compaction", () => {
24 it("uses Claude's default threshold when no override is configured", () => {
25 expect(decodeClaudeSettings({}).autoCompactWindow).toBe("");
26 });
27
28 it.each(["100000", "300000", "1000000"])(
29 "accepts a supported auto-compaction threshold: %s",
30 (value) => {
31 expect(decodeClaudeSettings({ autoCompactWindow: value }).autoCompactWindow).toBe(value);
32 },
33 );
34
35 it.each(["99999", "1000001", "300k", "invalid"])(
36 "rejects an unsupported auto-compaction threshold: %s",
37 (value) => {
38 expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow();
39 },
40 );
41
42 it("rejects an unsupported threshold at the settings patch boundary", () => {
43 expect(() =>
44 decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300k" } } }),
45 ).toThrow();
46 expect(
47 decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300000" } } }),
48 ).toBeDefined();
49 });
50});
2051
2152describe("ClientSettings word wrap", () => {
2253 it("defaults word wrap on", () => {
packages/contracts/src/settings.ts+25-1
......@@ -373,6 +373,11 @@ export const CodexSettings = makeProviderSettingsSchema(
373373);
374374export type CodexSettings = typeof CodexSettings.Type;
375375
376// Empty, or an integer from 100,000 to 1,000,000. Shared by the full
377// Claude settings schema and its patch so an out-of-range value fails at
378// the update that introduced it.
379const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/;
380
376381export const ClaudeSettings = makeProviderSettingsSchema(
377382 {
378383 enabled: Schema.Boolean.pipe(
......@@ -410,9 +415,23 @@ export const ClaudeSettings = makeProviderSettingsSchema(
410415 },
411416 }),
412417 ),
418 autoCompactWindow: TrimmedString.check(
419 Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN),
420 ).pipe(
421 Schema.withDecodingDefault(Effect.succeed("")),
422 Schema.annotateKey({
423 title: "Auto-compact after",
424 description:
425 "Compact after 100,000 to 1,000,000 tokens. Leave empty to use Claude's default.",
426 providerSettingsForm: {
427 placeholder: "e.g. 300000",
428 clearWhenEmpty: "omit",
429 },
430 }),
431 ),
413432 },
414433 {
415 order: ["binaryPath", "homePath", "launchArgs"],
434 order: ["binaryPath", "homePath", "autoCompactWindow", "launchArgs"],
416435 },
417436);
418437export type ClaudeSettings = typeof ClaudeSettings.Type;
......@@ -797,6 +816,11 @@ const ClaudeSettingsPatch = Schema.Struct({
797816 homePath: Schema.optionalKey(TrimmedString),
798817 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
799818 launchArgs: Schema.optionalKey(TrimmedString),
819 // Validated at the patch boundary so a typo fails the one update with a
820 // schema error instead of a generic whole-settings failure.
821 autoCompactWindow: Schema.optionalKey(
822 TrimmedString.check(Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN)),
823 ),
800824});
801825
802826const CursorSettingsPatch = Schema.Struct({
packages/shared/package.json+4
......@@ -226,6 +226,10 @@
226226 "./usageFormat": {
227227 "types": "./src/usageFormat.ts",
228228 "import": "./src/usageFormat.ts"
229 },
230 "./claudeCompaction": {
231 "types": "./src/claudeCompaction.ts",
232 "import": "./src/claudeCompaction.ts"
229233 }
230234 },
231235 "scripts": {
packages/shared/src/claudeCompaction.test.ts created+35
......@@ -0,0 +1,35 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 formatClaudeResumeCompactionQuestion,
5 isClaudeResumeCompactionQuestion,
6} from "./claudeCompaction.ts";
7
8describe("claude resume compaction copy", () => {
9 // The matcher must recognize every question the formatter can produce.
10 // This is the drift guard: rewording one side fails here.
11 it.each([
12 { ageMinutes: 145, estimatedTokens: 275_123 },
13 { ageMinutes: 70, estimatedTokens: 100_000 },
14 { ageMinutes: 59, estimatedTokens: 1_234_567 },
15 { ageMinutes: 0, estimatedTokens: 0 },
16 ])("matches its own formatted question (%o)", (input) => {
17 const question = formatClaudeResumeCompactionQuestion(input);
18 expect(isClaudeResumeCompactionQuestion(question)).toBe(true);
19 });
20
21 it("formats ages above and below one hour", () => {
22 expect(
23 formatClaudeResumeCompactionQuestion({ ageMinutes: 145, estimatedTokens: 275_123 }),
24 ).toBe("This session is 2h 25m old and uses 275,123 tokens. Compact it before continuing?");
25 expect(formatClaudeResumeCompactionQuestion({ ageMinutes: 45, estimatedTokens: 1_000 })).toBe(
26 "This session is 45m old and uses 1,000 tokens. Compact it before continuing?",
27 );
28 });
29
30 it("does not match unrelated questions", () => {
31 expect(
32 isClaudeResumeCompactionQuestion("The build cache is large. Compact it before continuing?"),
33 ).toBe(false);
34 });
35});
packages/shared/src/claudeCompaction.ts created+26
......@@ -0,0 +1,26 @@
1/**
2 * Copy for Claude's resume compaction dialog, shared by the server adapter
3 * (which asks the question) and the web client (which recognizes the
4 * question and its "never" answer in resolved user-input activities to
5 * mirror the dismissal). Both sides must agree on these strings, so they
6 * live here: reword the question or the answer label in this file only.
7 */
8export const CLAUDE_RESUME_COMPACTION_NEVER_ANSWER = "Don't ask again";
9
10export function formatClaudeResumeCompactionQuestion(input: {
11 readonly ageMinutes: number;
12 readonly estimatedTokens: number;
13}): string {
14 const ageLabel =
15 input.ageMinutes >= 60
16 ? `${Math.floor(input.ageMinutes / 60)}h ${input.ageMinutes % 60}m`
17 : `${input.ageMinutes}m`;
18 return `This session is ${ageLabel} old and uses ${input.estimatedTokens.toLocaleString("en-US")} tokens. Compact it before continuing?`;
19}
20
21const CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN =
22 /^This session is (?:\d+h \d+m|\d+m) old and uses \d{1,3}(?:,\d{3})* tokens\. Compact it before continuing\?$/u;
23
24export function isClaudeResumeCompactionQuestion(question: string): boolean {
25 return CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN.test(question);
26}
packages/shared/src/model.test.ts+31
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
33
44import {
5 applyClaudePromptEffortPrefix,
56 buildProviderOptionSelectionsFromDescriptors,
67 createModelCapabilities,
78 createModelSelection,
......@@ -155,3 +156,33 @@ describe("model slug normalization", () => {
155156 expect(normalizeCustomModelSlug(" opus ")).toBe("opus");
156157 });
157158});
159
160describe("applyClaudePromptEffortPrefix", () => {
161 it("keeps slash commands intact when ultrathink is selected", () => {
162 expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact");
163 expect(applyClaudePromptEffortPrefix(" /compact keep recent errors ", "ultrathink")).toBe(
164 "/compact keep recent errors",
165 );
166 expect(applyClaudePromptEffortPrefix(" /review src/model.ts ", "ultrathink")).toBe(
167 "/review src/model.ts",
168 );
169 expect(applyClaudePromptEffortPrefix("/security-review", "ultrathink")).toBe(
170 "/security-review",
171 );
172 expect(applyClaudePromptEffortPrefix("/plugin:skill run", "ultrathink")).toBe(
173 "/plugin:skill run",
174 );
175 expect(applyClaudePromptEffortPrefix("/deploy.prod to staging", "ultrathink")).toBe(
176 "/deploy.prod to staging",
177 );
178 });
179
180 it("still adds the ultrathink prefix to ordinary prompts", () => {
181 expect(applyClaudePromptEffortPrefix("Investigate this failure", "ultrathink")).toBe(
182 "Ultrathink:\nInvestigate this failure",
183 );
184 expect(applyClaudePromptEffortPrefix("/home/theo/app.ts crashed on load", "ultrathink")).toBe(
185 "Ultrathink:\n/home/theo/app.ts crashed on load",
186 );
187 });
188});
packages/shared/src/model.ts+5-1
......@@ -362,7 +362,11 @@ export function applyClaudePromptEffortPrefix(
362362 if (!trimmed) {
363363 return trimmed;
364364 }
365 if (effort !== "ultrathink") {
365 // Prefixing a slash command turns it into plain prose, so Claude never
366 // runs it. Command names come from arbitrary file names ("/deploy.prod",
367 // "/plugin:skill"), so accept any first token without a second slash;
368 // absolute paths like "/home/theo/app.ts" keep the prefix.
369 if (effort !== "ultrathink" || /^\/[^\s/]+(?:\s|$)/u.test(trimmed)) {
366370 return trimmed;
367371 }
368372 if (trimmed.startsWith("Ultrathink:")) {