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", () => {...@@ -413,6 +413,25 @@ describe("ClaudeAdapterLive", () => {
413 );413 );
414 });414 });
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
416 it.effect("forwards claude effort levels into query options", () => {435 it.effect("forwards claude effort levels into query options", () => {
417 const harness = makeHarness();436 const harness = makeHarness();
418 return Effect.gen(function* () {437 return Effect.gen(function* () {
...@@ -730,6 +749,39 @@ describe("ClaudeAdapterLive", () => {...@@ -730,6 +749,39 @@ describe("ClaudeAdapterLive", () => {
730 );749 );
731 });750 });
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
733 it.effect("embeds image attachments in Claude user messages", () => {785 it.effect("embeds image attachments in Claude user messages", () => {
734 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-"));786 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-"));
735 const harness = makeHarness({787 const harness = makeHarness({
...@@ -4400,6 +4452,62 @@ describe("ClaudeAdapterLive", () => {...@@ -4400,6 +4452,62 @@ describe("ClaudeAdapterLive", () => {
4400 );4452 );
4401 });4453 });
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
4403 it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => {4511 it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => {
4404 const harness = makeHarness();4512 const harness = makeHarness();
4405 return Effect.gen(function* () {4513 return Effect.gen(function* () {
...@@ -4689,6 +4797,73 @@ describe("ClaudeAdapterLive", () => {...@@ -4689,6 +4797,73 @@ describe("ClaudeAdapterLive", () => {
4689 );4797 );
4690 });4798 });
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
4692 it.effect("stopping a session settles pending user-input waits", () => {4867 it.effect("stopping a session settles pending user-input waits", () => {
4693 const harness = makeHarness();4868 const harness = makeHarness();
4694 return Effect.gen(function* () {4869 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+100
...@@ -57,6 +57,10 @@ import {...@@ -57,6 +57,10 @@ import {
57 getProviderOptionDescriptors,57 getProviderOptionDescriptors,
58 resolvePromptInjectedEffort,58 resolvePromptInjectedEffort,
59} from "@t3tools/shared/model";59} from "@t3tools/shared/model";
60import {
61 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
62 formatClaudeResumeCompactionQuestion,
63} from "@t3tools/shared/claudeCompaction";
60import * as Cause from "effect/Cause";64import * as Cause from "effect/Cause";
61import * as Crypto from "effect/Crypto";65import * as Crypto from "effect/Crypto";
62import * as DateTime from "effect/DateTime";66import * as DateTime from "effect/DateTime";
...@@ -543,6 +547,7 @@ function makeClaudeTokenUsageSnapshot(input: {...@@ -543,6 +547,7 @@ function makeClaudeTokenUsageSnapshot(input: {
543 readonly totalProcessedTokens?: number;547 readonly totalProcessedTokens?: number;
544 readonly lastUsedTokens?: number;548 readonly lastUsedTokens?: number;
545 readonly compactsAutomatically?: boolean;549 readonly compactsAutomatically?: boolean;
550 readonly autoCompactThreshold?: number;
546}): ThreadTokenUsageSnapshot | undefined {551}): ThreadTokenUsageSnapshot | undefined {
547 const activeTokens = finiteNonNegativeInteger(input.activeTokens);552 const activeTokens = finiteNonNegativeInteger(input.activeTokens);
548 if (activeTokens === undefined || activeTokens <= 0) {553 if (activeTokens === undefined || activeTokens <= 0) {
...@@ -570,6 +575,9 @@ function makeClaudeTokenUsageSnapshot(input: {...@@ -570,6 +575,9 @@ function makeClaudeTokenUsageSnapshot(input: {
570 ...(input.compactsAutomatically !== undefined575 ...(input.compactsAutomatically !== undefined
571 ? { compactsAutomatically: input.compactsAutomatically }576 ? { compactsAutomatically: input.compactsAutomatically }
572 : {}),577 : {}),
578 ...(input.autoCompactThreshold !== undefined
579 ? { autoCompactThreshold: input.autoCompactThreshold }
580 : {}),
573 };581 };
574}582}
575583
...@@ -604,11 +612,13 @@ function normalizeClaudeContextUsageApiSnapshot(...@@ -604,11 +612,13 @@ function normalizeClaudeContextUsageApiSnapshot(
604 value: SDKControlGetContextUsageResponse,612 value: SDKControlGetContextUsageResponse,
605 totalProcessedTokens?: number,613 totalProcessedTokens?: number,
606): ThreadTokenUsageSnapshot | undefined {614): ThreadTokenUsageSnapshot | undefined {
615 const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold);
607 return makeClaudeTokenUsageSnapshot({616 return makeClaudeTokenUsageSnapshot({
608 activeTokens: value.totalTokens,617 activeTokens: value.totalTokens,
609 contextWindow: value.maxTokens,618 contextWindow: value.maxTokens,
610 ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}),619 ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}),
611 compactsAutomatically: value.isAutoCompactEnabled,620 compactsAutomatically: value.isAutoCompactEnabled,
621 ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}),
612 });622 });
613}623}
614624
...@@ -3953,6 +3963,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3953,6 +3963,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3953 callbackOptions.signal.addEventListener("abort", onAbort, {3963 callbackOptions.signal.addEventListener("abort", onAbort, {
3954 once: true,3964 once: true,
3955 });3965 });
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
3957 // Block until the user provides answers.3973 // Block until the user provides answers.
3958 const answers = yield* Deferred.await(answersDeferred);3974 const answers = yield* Deferred.await(answersDeferred);
...@@ -4001,6 +4017,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4001,6 +4017,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4001 } satisfies PermissionResult;4017 } satisfies PermissionResult;
4002 });4018 });
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
4004 const canUseToolEffect = Effect.fn("canUseTool")(function* (4090 const canUseToolEffect = Effect.fn("canUseTool")(function* (
4005 toolName: Parameters<CanUseTool>[0],4091 toolName: Parameters<CanUseTool>[0],
4006 toolInput: Parameters<CanUseTool>[1],4092 toolInput: Parameters<CanUseTool>[1],
...@@ -4106,6 +4192,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4106,6 +4192,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4106 callbackOptions.signal.addEventListener("abort", onAbort, {4192 callbackOptions.signal.addEventListener("abort", onAbort, {
4107 once: true,4193 once: true,
4108 });4194 });
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
4110 const decision = yield* Deferred.await(decisionDeferred);4201 const decision = yield* Deferred.await(decisionDeferred);
4111 pendingApprovals.delete(requestId);4202 pendingApprovals.delete(requestId);
...@@ -4161,6 +4252,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4161,6 +4252,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
41614252
4162 const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>4253 const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
4163 runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));4254 runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));
4255 const onUserDialog: NonNullable<ClaudeQueryOptions["onUserDialog"]> = (
4256 request,
4257 callbackOptions,
4258 ) => runPromise(handleResumeDialog(request, callbackOptions));
41644259
4165 const claudeBinaryPath = claudeSdkExecutablePath;4260 const claudeBinaryPath = claudeSdkExecutablePath;
4166 const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;4261 const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
...@@ -4196,6 +4291,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4196,6 +4291,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4196 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),4291 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
4197 ...(fastMode ? { fastMode: true } : {}),4292 ...(fastMode ? { fastMode: true } : {}),
4198 ...(ultracode ? { ultracode: true } : {}),4293 ...(ultracode ? { ultracode: true } : {}),
4294 ...(claudeSettings.autoCompactWindow
4295 ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) }
4296 : {}),
4199 };4297 };
4200 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);4298 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
4201 // The attachments dir grant lets the agent Read/copy pasted images at4299 // The attachments dir grant lets the agent Read/copy pasted images at
...@@ -4228,6 +4326,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4228,6 +4326,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4228 ...(newSessionId ? { sessionId: newSessionId } : {}),4326 ...(newSessionId ? { sessionId: newSessionId } : {}),
4229 includePartialMessages: true,4327 includePartialMessages: true,
4230 canUseTool,4328 canUseTool,
4329 onUserDialog,
4330 supportedDialogKinds: ["resume_return"],
4231 env: claudeEnvironment,4331 env: claudeEnvironment,
4232 additionalDirectories,4332 additionalDirectories,
4233 ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),4333 ...(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")(...@@ -927,7 +927,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
927 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))927 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
928 : undefined;928 : undefined;
929 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);929 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 ];
931 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);937 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
932938
933 if (!capabilities) {939 if (!capabilities) {
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+1
...@@ -106,6 +106,7 @@ const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings =>...@@ -106,6 +106,7 @@ const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings =>
106 homePath: "",106 homePath: "",
107 customModels: [],107 customModels: [],
108 launchArgs: "",108 launchArgs: "",
109 autoCompactWindow: "",
109 ...overrides,110 ...overrides,
110});111});
111112
apps/server/src/provider/Layers/ProviderRegistry.test.ts+8
...@@ -2151,6 +2151,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2151,6 +2151,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2151 );2151 );
21522152
2153 assert.deepStrictEqual(status.slashCommands, [2153 assert.deepStrictEqual(status.slashCommands, [
2154 {
2155 name: "compact",
2156 description: "Summarize the conversation and reduce context usage",
2157 },
2154 {2158 {
2155 name: "review",2159 name: "review",
2156 description: "Review a pull request",2160 description: "Review a pull request",
...@@ -2194,6 +2198,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2194,6 +2198,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2194 );2198 );
21952199
2196 assert.deepStrictEqual(status.slashCommands, [2200 assert.deepStrictEqual(status.slashCommands, [
2201 {
2202 name: "compact",
2203 description: "Summarize the conversation and reduce context usage",
2204 },
2197 {2205 {
2198 name: "ui",2206 name: "ui",
2199 description: "Explore and refine UI",2207 description: "Explore and refine UI",
apps/server/src/serverSettings.test.ts+2
...@@ -190,6 +190,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -190,6 +190,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
190 homePath: "",190 homePath: "",
191 customModels: ["claude-custom"],191 customModels: ["claude-custom"],
192 launchArgs: "",192 launchArgs: "",
193 autoCompactWindow: "",
193 });194 });
194 assert.deepEqual(195 assert.deepEqual(
195 next.textGenerationModelSelection,196 next.textGenerationModelSelection,
...@@ -581,6 +582,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -581,6 +582,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
581 homePath: "",582 homePath: "",
582 customModels: [],583 customModels: [],
583 launchArgs: "",584 launchArgs: "",
585 autoCompactWindow: "",
584 });586 });
585 assert.deepEqual(next.providers.opencode, {587 assert.deepEqual(next.providers.opencode, {
586 // OpenCode is disabled by default; this update only touches paths.588 // OpenCode is disabled by default; this update only touches paths.
apps/web/src/components/ChatView.tsx+173-15
...@@ -82,6 +82,7 @@ import {...@@ -82,6 +82,7 @@ import {
82 type AtomCommandResult,82 type AtomCommandResult,
83} from "@t3tools/client-runtime/state/runtime";83} from "@t3tools/client-runtime/state/runtime";
84import * as Cause from "effect/Cause";84import * as Cause from "effect/Cause";
85import * as Schema from "effect/Schema";
85import { AsyncResult } from "effect/unstable/reactivity";86import { AsyncResult } from "effect/unstable/reactivity";
86import { isElectron } from "../env";87import { isElectron } from "../env";
87import { readLocalApi } from "../localApi";88import { readLocalApi } from "../localApi";
...@@ -179,6 +180,7 @@ import {...@@ -179,6 +180,7 @@ import {
179 CheckCircle2Icon,180 CheckCircle2Icon,
180 ChevronDownIcon,181 ChevronDownIcon,
181 GitBranchIcon,182 GitBranchIcon,
183 Minimize2Icon,
182 PaperclipIcon,184 PaperclipIcon,
183 WifiOffIcon,185 WifiOffIcon,
184} from "lucide-react";186} from "lucide-react";
...@@ -196,7 +198,11 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";...@@ -196,7 +198,11 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
196import { useBrowserHistoryStore } from "~/browserHistoryStore";198import { useBrowserHistoryStore } from "~/browserHistoryStore";
197import { registerFaviconProjectForThread } from "~/browserFaviconStore";199import { registerFaviconProjectForThread } from "~/browserFaviconStore";
198import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";200import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
199import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";201import {
202 applyProviderInstanceSettings,
203 deriveProviderInstanceEntries,
204 NO_PROVIDER_MODEL_SELECTION,
205} from "../providerInstances";
200import {206import {
201 useClientSettings,207 useClientSettings,
202 useClientSettingsHydrated,208 useClientSettingsHydrated,
...@@ -223,6 +229,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRo...@@ -223,6 +229,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRo
223import {229import {
224 beginBackgroundDraftSubmissionByRef,230 beginBackgroundDraftSubmissionByRef,
225 clearBackgroundDraftSubmissionByRef,231 clearBackgroundDraftSubmissionByRef,
232 composerDraftHasUserContent,
226 type ComposerImageAttachment,233 type ComposerImageAttachment,
227 type DraftThreadEnvMode,234 type DraftThreadEnvMode,
228 finalizePromotedDraftThreadByRef,235 finalizePromotedDraftThreadByRef,
...@@ -305,6 +312,12 @@ import {...@@ -305,6 +312,12 @@ import {
305 useLinkedThreadPullRequest,312 useLinkedThreadPullRequest,
306} from "./ThreadStatusIndicators";313} from "./ThreadStatusIndicators";
307import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";314import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
315import {
316 hasAvailableClaudeCompactionProvider,
317 hasDismissedResumeCompaction,
318 shouldOfferResumeCompaction,
319} from "./chat/ContextWindowMeter.logic";
320import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
308import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";321import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
309import {322import {
310 DRAFT_HERO_TRANSITION_ANIMATION_ID,323 DRAFT_HERO_TRANSITION_ANIMATION_ID,
...@@ -1367,6 +1380,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1367,6 +1380,9 @@ function ChatViewContent(props: ChatViewProps) {
1367 const composerActiveProvider = useComposerDraftStore(1380 const composerActiveProvider = useComposerDraftStore(
1368 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,1381 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
1369 );1382 );
1383 const composerHasUnsentContent = useComposerDraftStore((store) =>
1384 composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),
1385 );
1370 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);1386 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
1371 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);1387 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
1372 const setComposerDraftTerminalContexts = useComposerDraftStore(1388 const setComposerDraftTerminalContexts = useComposerDraftStore(
...@@ -2299,6 +2315,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2299,6 +2315,10 @@ function ChatViewContent(props: ChatViewProps) {
2299 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;2315 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
2300 const phase = derivePhase(activeThread?.session ?? null);2316 const phase = derivePhase(activeThread?.session ?? null);
2301 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;2317 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
2318 const activeContextWindow = useMemo(
2319 () => deriveLatestContextWindowSnapshot(threadActivities),
2320 [threadActivities],
2321 );
2302 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);2322 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
2303 const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]);2323 const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]);
2304 // Native subagent fold: memoized by activity-list identity, shared by the2324 // Native subagent fold: memoized by activity-list identity, shared by the
...@@ -2762,6 +2782,29 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2762,6 +2782,29 @@ function ChatViewContent(props: ChatViewProps) {
2762 activeThread?.modelSelection.instanceId ??2782 activeThread?.modelSelection.instanceId ??
2763 activeProject?.defaultModelSelection?.instanceId ??2783 activeProject?.defaultModelSelection?.instanceId ??
2764 null;2784 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 );
2765 const activeProviderStatus = useMemo(() => {2808 const activeProviderStatus = useMemo(() => {
2766 if (activeProviderInstanceId) {2809 if (activeProviderInstanceId) {
2767 return (2810 return (
...@@ -2771,6 +2814,25 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2771,6 +2814,25 @@ function ChatViewContent(props: ChatViewProps) {
2771 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);2814 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
2772 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;2815 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
2773 }, [activeProviderInstanceId, providerStatuses, selectedProvider]);2816 }, [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 ]);
2774 const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);2836 const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
2775 const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<2837 const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
2776 string | null2838 string | null
...@@ -4515,18 +4577,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4515,18 +4577,6 @@ function ChatViewContent(props: ChatViewProps) {
4515 // Dismissal lives in a module-level set (survives remounts); this tick just4577 // Dismissal lives in a module-level set (survives remounts); this tick just
4516 // forces a re-render so the banner leaves immediately.4578 // forces a re-render so the banner leaves immediately.
4517 const [, setBranchMismatchDismissTick] = useState(0);4579 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 });
4530 const activeBranchMismatchKey = branchMismatchKey(4580 const activeBranchMismatchKey = branchMismatchKey(
4531 activeThread?.id ?? null,4581 activeThread?.id ?? null,
4532 localCheckoutBranchMismatch,4582 localCheckoutBranchMismatch,
...@@ -4534,7 +4584,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4534,7 +4584,7 @@ function ChatViewContent(props: ChatViewProps) {
4534 const showBranchMismatchBanner = shouldShowBranchMismatchBanner({4584 const showBranchMismatchBanner = shouldShowBranchMismatchBanner({
4535 hasMismatch: localCheckoutBranchMismatch !== null,4585 hasMismatch: localCheckoutBranchMismatch !== null,
4536 isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey),4586 isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey),
4537 composerHasContent: composerHasDraftContent,4587 composerHasContent: composerHasUnsentContent,
4538 wasShownForCurrentMismatch:4588 wasShownForCurrentMismatch:
4539 revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey,4589 revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey,
4540 });4590 });
...@@ -4758,6 +4808,107 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4758,6 +4808,107 @@ function ChatViewContent(props: ChatViewProps) {
4758 isUnsnoozing,4808 isUnsnoozing,
4759 isUnsettling,4809 isUnsettling,
4760 ]);4810 ]);
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 ]);
4761 const handleRestoreThreadBranch = useCallback(() => {4912 const handleRestoreThreadBranch = useCallback(() => {
4762 if (gitStatusQuery.data?.hasWorkingTreeChanges) {4913 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
4763 setBranchRestoreConfirmOpen(true);4914 setBranchRestoreConfirmOpen(true);
...@@ -4772,6 +4923,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4772,6 +4923,8 @@ function ChatViewContent(props: ChatViewProps) {
4772 const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item));4923 const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item));
4773 const backgroundLivenessItems =4924 const backgroundLivenessItems =
4774 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];4925 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
4926 const resumeCompactionItems =
4927 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
4775 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];4928 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
4776 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];4929 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
4777 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {4930 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
...@@ -4779,6 +4932,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4779,6 +4932,7 @@ function ChatViewContent(props: ChatViewProps) {
4779 ...urgentSystemItems,4932 ...urgentSystemItems,
4780 ...backgroundLivenessItems,4933 ...backgroundLivenessItems,
4781 ...calmSystemItems,4934 ...calmSystemItems,
4935 ...resumeCompactionItems,
4782 ...wokeThreadItems,4936 ...wokeThreadItems,
4783 ...parkedThreadItems,4937 ...parkedThreadItems,
4784 ];4938 ];
...@@ -4787,6 +4941,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4787,6 +4941,7 @@ function ChatViewContent(props: ChatViewProps) {
4787 ...urgentSystemItems,4941 ...urgentSystemItems,
4788 ...backgroundLivenessItems,4942 ...backgroundLivenessItems,
4789 ...calmSystemItems,4943 ...calmSystemItems,
4944 ...resumeCompactionItems,
4790 ...wokeThreadItems,4945 ...wokeThreadItems,
4791 {4946 {
4792 id: `branch-mismatch:${activeBranchMismatchKey}`,4947 id: `branch-mismatch:${activeBranchMismatchKey}`,
...@@ -4836,6 +4991,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4836,6 +4991,7 @@ function ChatViewContent(props: ChatViewProps) {
4836 isRestoringThreadBranch,4991 isRestoringThreadBranch,
4837 localCheckoutBranchMismatch,4992 localCheckoutBranchMismatch,
4838 parkedThreadBannerItem,4993 parkedThreadBannerItem,
4994 resumeCompactionBannerItem,
4839 showBranchMismatchBanner,4995 showBranchMismatchBanner,
4840 systemComposerBannerItems,4996 systemComposerBannerItems,
4841 wokeThreadBannerItem,4997 wokeThreadBannerItem,
...@@ -6916,7 +7072,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6916,7 +7072,9 @@ function ChatViewContent(props: ChatViewProps) {
6916 activeProject?.defaultModelSelection7072 activeProject?.defaultModelSelection
6917 }7073 }
6918 activeThreadModelSelection={activeThread?.modelSelection}7074 activeThreadModelSelection={activeThread?.modelSelection}
6919 activeThreadActivities={activeThread?.activities}7075 activeContextWindow={activeContextWindow}
7076 compactDisabled={compactDisabled}
7077 compactDisabledReason={compactDisabledReason}
6920 resolvedTheme={resolvedTheme}7078 resolvedTheme={resolvedTheme}
6921 settings={settings}7079 settings={settings}
6922 keybindings={keybindings}7080 keybindings={keybindings}
apps/web/src/components/chat/ChatComposer.tsx+80-10
...@@ -266,7 +266,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings";...@@ -266,7 +266,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings";
266import type { SessionPhase, Thread } from "../../types";266import type { SessionPhase, Thread } from "../../types";
267import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";267import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
268import type { PendingApproval, PendingUserInput } from "../../session-logic";268import type { PendingApproval, PendingUserInput } from "../../session-logic";
269import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";269import type { ContextWindowSnapshot } from "../../lib/contextWindow";
270import {270import {
271 formatProviderSkillDisplayName,271 formatProviderSkillDisplayName,
272 getProviderSlashCommandsForSlashMenu,272 getProviderSlashCommandsForSlashMenu,
...@@ -438,7 +438,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -438,7 +438,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
438438
439const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {439const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
440 compact: boolean;440 compact: boolean;
441 activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;441 activeContextWindow: ContextWindowSnapshot | null;
442 activeThreadModelDisplayName: string | null;442 activeThreadModelDisplayName: string | null;
443 isPreparingWorktree: boolean;443 isPreparingWorktree: boolean;
444 pendingAction: {444 pendingAction: {
...@@ -461,6 +461,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -461,6 +461,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
461 onPreviousPendingQuestion: () => void;461 onPreviousPendingQuestion: () => void;
462 onInterrupt: () => void;462 onInterrupt: () => void;
463 onImplementPlanInNewThread: () => void;463 onImplementPlanInNewThread: () => void;
464 onCompactContext?: (() => void) | undefined;
465 compactDisabled: boolean;
466 compactDisabledReason: string | null;
464}) {467}) {
465 return (468 return (
466 <>469 <>
...@@ -468,6 +471,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -468,6 +471,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
468 <ContextWindowMeter471 <ContextWindowMeter
469 usage={props.activeContextWindow}472 usage={props.activeContextWindow}
470 modelDisplayName={props.activeThreadModelDisplayName}473 modelDisplayName={props.activeThreadModelDisplayName}
474 onCompact={props.onCompactContext}
475 compactDisabled={props.compactDisabled}
476 compactDisabledReason={props.compactDisabledReason}
471 />477 />
472 ) : null}478 ) : null}
473 {props.isPreparingWorktree ? (479 {props.isPreparingWorktree ? (
...@@ -507,6 +513,7 @@ export interface ChatComposerHandle {...@@ -507,6 +513,7 @@ export interface ChatComposerHandle {
507 openModelPicker: () => void;513 openModelPicker: () => void;
508 toggleModelPicker: () => void;514 toggleModelPicker: () => void;
509 isModelPickerOpen: () => boolean;515 isModelPickerOpen: () => boolean;
516 compactContext: () => void;
510 readSnapshot: () => {517 readSnapshot: () => {
511 value: string;518 value: string;
512 cursor: number;519 cursor: number;
...@@ -609,7 +616,9 @@ export interface ChatComposerProps {...@@ -609,7 +616,9 @@ export interface ChatComposerProps {
609 activeThreadModelSelection: ModelSelection | null | undefined;616 activeThreadModelSelection: ModelSelection | null | undefined;
610617
611 // Context window618 // Context window
612 activeThreadActivities: Thread["activities"] | undefined;619 activeContextWindow: ContextWindowSnapshot | null;
620 compactDisabled: boolean;
621 compactDisabledReason: string | null;
613622
614 // Misc623 // Misc
615 resolvedTheme: "light" | "dark";624 resolvedTheme: "light" | "dark";
...@@ -701,7 +710,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -701,7 +710,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
701 providerStatuses,710 providerStatuses,
702 activeProjectDefaultModelSelection,711 activeProjectDefaultModelSelection,
703 activeThreadModelSelection,712 activeThreadModelSelection,
704 activeThreadActivities,713 activeContextWindow,
714 compactDisabled,
715 compactDisabledReason,
705 resolvedTheme,716 resolvedTheme,
706 settings,717 settings,
707 keybindings,718 keybindings,
...@@ -914,6 +925,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -914,6 +925,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
914 [providerInstanceEntries, selectedInstanceId],925 [providerInstanceEntries, selectedInstanceId],
915 );926 );
916 const noProviderAvailable = selectedProviderEntry === undefined;927 const noProviderAvailable = selectedProviderEntry === undefined;
928 const resolvedCompactDisabledReason =
929 compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null);
917 // The driver kind follows the instance that will actually run the turn,930 // The driver kind follows the instance that will actually run the turn,
918 // which can differ from the persisted selection when that selection is931 // which can differ from the persisted selection when that selection is
919 // disabled.932 // disabled.
...@@ -1004,10 +1017,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1004,10 +1017,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1004 // ------------------------------------------------------------------1017 // ------------------------------------------------------------------
1005 // Context window1018 // Context window
1006 // ------------------------------------------------------------------1019 // ------------------------------------------------------------------
1007 const activeContextWindow = useMemo(
1008 () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
1009 [activeThreadActivities],
1010 );
1011 const activeThreadModelDisplayName = useMemo(1020 const activeThreadModelDisplayName = useMemo(
1012 () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance),1021 () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance),
1013 [activeThreadModelSelection, modelOptionsByInstance],1022 [activeThreadModelSelection, modelOptionsByInstance],
...@@ -1073,8 +1082,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1073,8 +1082,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1073 /**1082 /**
1074 * Count of pasted images still being compressed, per thread. Reserved1083 * Count of pasted images still being compressed, per thread. Reserved
1075 * against the attachment limit so concurrent pastes can't overshoot it,1084 * 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 the1085 * and checked before sending or compacting so an image cannot move into
1077 * next draft.1086 * the next draft.
1078 */1087 */
1079 const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());1088 const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());
10801089
...@@ -1986,6 +1995,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1986,6 +1995,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1986 shouldBlurMobileComposerOnSubmit,1995 shouldBlurMobileComposerOnSubmit,
1987 ],1996 ],
1988 );1997 );
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 ]);
1989 const expandMobileComposer = useCallback(() => {2050 const expandMobileComposer = useCallback(() => {
1990 if (composerBlurFrameRef.current !== null) {2051 if (composerBlurFrameRef.current !== null) {
1991 window.cancelAnimationFrame(composerBlurFrameRef.current);2052 window.cancelAnimationFrame(composerBlurFrameRef.current);
...@@ -2764,6 +2825,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2764,6 +2825,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2764 toggleModelPicker: () => {2825 toggleModelPicker: () => {
2765 setIsComposerModelPickerOpen((open) => !open);2826 setIsComposerModelPickerOpen((open) => !open);
2766 },2827 },
2828 compactContext: compactThreadContext,
2767 isModelPickerOpen: () => isComposerModelPickerOpen,2829 isModelPickerOpen: () => isComposerModelPickerOpen,
2768 readSnapshot: () => {2830 readSnapshot: () => {
2769 return readComposerSnapshot();2831 return readComposerSnapshot();
...@@ -2875,6 +2937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2875,6 +2937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2875 selectedPromptEffort,2937 selectedPromptEffort,
2876 selectedProvider,2938 selectedProvider,
2877 selectedProviderModels,2939 selectedProviderModels,
2940 compactThreadContext,
2878 ],2941 ],
2879 );2942 );
28802943
...@@ -3517,6 +3580,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3517,6 +3580,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3517 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}3580 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3518 onInterrupt={handleInterruptPrimaryAction}3581 onInterrupt={handleInterruptPrimaryAction}
3519 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}3582 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3583 compactDisabled={
3584 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
3585 }
3586 compactDisabledReason={resolvedCompactDisabledReason}
3587 {...(selectedProvider === "claudeAgent"
3588 ? { onCompactContext: compactThreadContext }
3589 : {})}
3520 />3590 />
3521 </div>3591 </div>
3522 </div>3592 </div>
apps/web/src/components/chat/ComposerBannerStack.test.tsx+28
...@@ -77,4 +77,32 @@ describe("ComposerBannerStack", () => {...@@ -77,4 +77,32 @@ describe("ComposerBannerStack", () => {
77 expect(markup).toContain("branch-surface");77 expect(markup).toContain("branch-surface");
78 expect(markup).toContain("branch-actions");78 expect(markup).toContain("branch-actions");
79 });79 });
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 });
80});108});
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+179-1
...@@ -1,10 +1,83 @@...@@ -1,10 +1,83 @@
1import { ProviderInstanceId } from "@t3tools/contracts";1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
3import { deriveProviderInstanceEntries } from "../../providerInstances";
3import {4import {
4 formatContextWindowCompactionMessage,5 formatContextWindowCompactionMessage,
6 hasAvailableClaudeCompactionProvider,
7 hasDismissedResumeCompaction,
5 resolveContextWindowModelDisplayName,8 resolveContextWindowModelDisplayName,
9 shouldOfferResumeCompaction,
6} from "./ContextWindowMeter.logic";10} 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
8describe("resolveContextWindowModelDisplayName", () => {81describe("resolveContextWindowModelDisplayName", () => {
9 it("uses the selected model from the exact provider instance", () => {82 it("uses the selected model from the exact provider instance", () => {
10 const primaryInstanceId = ProviderInstanceId.make("codex");83 const primaryInstanceId = ProviderInstanceId.make("codex");
...@@ -55,4 +128,109 @@ describe("formatContextWindowCompactionMessage", () => {...@@ -55,4 +128,109 @@ describe("formatContextWindowCompactionMessage", () => {
55 "Context compacts automatically when needed.",128 "Context compacts automatically when needed.",
56 );129 );
57 });130 });
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 });
58});236});
apps/web/src/components/chat/ContextWindowMeter.logic.ts+79
...@@ -1,6 +1,81 @@...@@ -1,6 +1,81 @@
1import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";1import 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";
2import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils";10import { 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
4export function resolveContextWindowModelDisplayName(79export function resolveContextWindowModelDisplayName(
5 selection: ModelSelection | null | undefined,80 selection: ModelSelection | null | undefined,
6 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>,81 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>,
...@@ -18,7 +93,11 @@ export function resolveContextWindowModelDisplayName(...@@ -18,7 +93,11 @@ export function resolveContextWindowModelDisplayName(
1893
19export function formatContextWindowCompactionMessage(94export function formatContextWindowCompactionMessage(
20 modelDisplayName: string | null | undefined,95 modelDisplayName: string | null | undefined,
96 autoCompactThreshold?: number | null,
21): string {97): string {
98 if (typeof autoCompactThreshold === "number" && autoCompactThreshold > 0) {
99 return `Compacts automatically at ${autoCompactThreshold.toLocaleString("en-US")} tokens.`;
100 }
22 return modelDisplayName101 return modelDisplayName
23 ? `Context for ${modelDisplayName} compacts automatically when needed.`102 ? `Context for ${modelDisplayName} compacts automatically when needed.`
24 : "Context compacts automatically when needed.";103 : "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";...@@ -2,6 +2,7 @@ import { Button } from "../ui/button";
2import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow";2import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow";
3import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";3import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
4import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic";4import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic";
5import { Minimize2Icon } from "lucide-react";
56
6function formatPercentage(value: number | null): string | null {7function formatPercentage(value: number | null): string | null {
7 if (value === null || !Number.isFinite(value)) {8 if (value === null || !Number.isFinite(value)) {
...@@ -16,8 +17,11 @@ function formatPercentage(value: number | null): string | null {...@@ -16,8 +17,11 @@ function formatPercentage(value: number | null): string | null {
16export function ContextWindowMeter(props: {17export function ContextWindowMeter(props: {
17 usage: ContextWindowSnapshot;18 usage: ContextWindowSnapshot;
18 modelDisplayName?: string | null;19 modelDisplayName?: string | null;
20 onCompact?: (() => void) | undefined;
21 compactDisabled?: boolean | undefined;
22 compactDisabledReason?: string | null | undefined;
19}) {23}) {
20 const { usage, modelDisplayName } = props;24 const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props;
21 const usedPercentage = formatPercentage(usage.usedPercentage);25 const usedPercentage = formatPercentage(usage.usedPercentage);
22 const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));26 const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));
23 const radius = 9.75;27 const radius = 9.75;
...@@ -35,7 +39,7 @@ export function ContextWindowMeter(props: {...@@ -35,7 +39,7 @@ export function ContextWindowMeter(props: {
35 <PopoverTrigger39 <PopoverTrigger
36 openOnHover40 openOnHover
37 delay={150}41 delay={150}
38 closeDelay={0}42 closeDelay={onCompact ? 150 : 0}
39 render={43 render={
40 <Button44 <Button
41 size="icon-sm"45 size="icon-sm"
...@@ -128,9 +132,28 @@ export function ContextWindowMeter(props: {...@@ -128,9 +132,28 @@ export function ContextWindowMeter(props: {
128 ) : null}132 ) : null}
129 {usage.compactsAutomatically ? (133 {usage.compactsAutomatically ? (
130 <div className="mt-1 text-pretty text-secondary-label text-[11px] font-medium">134 <div className="mt-1 text-pretty text-secondary-label text-[11px] font-medium">
131 {formatContextWindowCompactionMessage(modelDisplayName)}135 {formatContextWindowCompactionMessage(modelDisplayName, usage.autoCompactThreshold)}
132 </div>136 </div>
133 ) : null}137 ) : 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}
134 </div>157 </div>
135 </PopoverPopup>158 </PopoverPopup>
136 </Popover>159 </Popover>
apps/web/src/components/settings/ProviderSettingsForm.test.ts+12
...@@ -37,6 +37,18 @@ describe("ProviderSettingsForm helpers", () => {...@@ -37,6 +37,18 @@ describe("ProviderSettingsForm helpers", () => {
37 });37 });
38 });38 });
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
40 it("preserves unknown config keys while omitting empty configurable fields", () => {52 it("preserves unknown config keys while omitting empty configurable fields", () => {
41 const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];53 const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
42 expect(opencode).toBeDefined();54 expect(opencode).toBeDefined();
apps/web/src/lib/contextWindow.test.ts+2
...@@ -26,6 +26,7 @@ describe("contextWindow", () => {...@@ -26,6 +26,7 @@ describe("contextWindow", () => {
26 usedTokens: 14_000,26 usedTokens: 14_000,
27 maxTokens: 258_000,27 maxTokens: 258_000,
28 compactsAutomatically: true,28 compactsAutomatically: true,
29 autoCompactThreshold: 200_000,
29 }),30 }),
30 ]);31 ]);
3132
...@@ -34,6 +35,7 @@ describe("contextWindow", () => {...@@ -34,6 +35,7 @@ describe("contextWindow", () => {
34 expect(snapshot?.totalProcessedTokens).toBeNull();35 expect(snapshot?.totalProcessedTokens).toBeNull();
35 expect(snapshot?.maxTokens).toBe(258_000);36 expect(snapshot?.maxTokens).toBe(258_000);
36 expect(snapshot?.compactsAutomatically).toBe(true);37 expect(snapshot?.compactsAutomatically).toBe(true);
38 expect(snapshot?.autoCompactThreshold).toBe(200_000);
37 });39 });
3840
39 it("ignores malformed payloads", () => {41 it("ignores malformed payloads", () => {
apps/web/src/lib/contextWindow.ts+1
...@@ -88,6 +88,7 @@ export function deriveLatestContextWindowSnapshot(...@@ -88,6 +88,7 @@ export function deriveLatestContextWindowSnapshot(
88 toolUses: asFiniteNumber(payload?.toolUses),88 toolUses: asFiniteNumber(payload?.toolUses),
89 durationMs: asFiniteNumber(payload?.durationMs),89 durationMs: asFiniteNumber(payload?.durationMs),
90 compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,90 compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,
91 autoCompactThreshold: asFiniteNumber(payload?.autoCompactThreshold),
91 updatedAt: activity.createdAt,92 updatedAt: activity.createdAt,
92 };93 };
93 }94 }
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...@@ -34,6 +34,18 @@ When you set this field, T3 Code points Claude Code at that directory with the
34`CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and34`CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and
35the rest of your environment stay as they are.35the 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
37## Where Claude Skills Are Loaded49## Where Claude Skills Are Loaded
3850
39T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then51T3 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({...@@ -324,6 +324,7 @@ export const ThreadTokenUsageSnapshot = Schema.Struct({
324 toolUses: Schema.optional(NonNegativeInt),324 toolUses: Schema.optional(NonNegativeInt),
325 durationMs: Schema.optional(NonNegativeInt),325 durationMs: Schema.optional(NonNegativeInt),
326 compactsAutomatically: Schema.optional(Schema.Boolean),326 compactsAutomatically: Schema.optional(Schema.Boolean),
327 autoCompactThreshold: Schema.optional(PositiveInt),
327});328});
328export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type;329export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type;
329330
packages/contracts/src/settings.test.ts+31
...@@ -5,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";...@@ -5,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
5import {5import {
6 ClientSettingsSchema,6 ClientSettingsSchema,
7 ClientSettingsPatch,7 ClientSettingsPatch,
8 ClaudeSettings,
8 DEFAULT_SERVER_SETTINGS,9 DEFAULT_SERVER_SETTINGS,
9 defaultEnabledForDriver,10 defaultEnabledForDriver,
10 resolveProviderInstanceEnabled,11 resolveProviderInstanceEnabled,
...@@ -17,6 +18,36 @@ const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);...@@ -17,6 +18,36 @@ const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);
17const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);18const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
18const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);19const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
19const encodeServerSettings = Schema.encodeSync(ServerSettings);20const 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
21describe("ClientSettings word wrap", () => {52describe("ClientSettings word wrap", () => {
22 it("defaults word wrap on", () => {53 it("defaults word wrap on", () => {
packages/contracts/src/settings.ts+25-1
...@@ -373,6 +373,11 @@ export const CodexSettings = makeProviderSettingsSchema(...@@ -373,6 +373,11 @@ export const CodexSettings = makeProviderSettingsSchema(
373);373);
374export type CodexSettings = typeof CodexSettings.Type;374export 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
376export const ClaudeSettings = makeProviderSettingsSchema(381export const ClaudeSettings = makeProviderSettingsSchema(
377 {382 {
378 enabled: Schema.Boolean.pipe(383 enabled: Schema.Boolean.pipe(
...@@ -410,9 +415,23 @@ export const ClaudeSettings = makeProviderSettingsSchema(...@@ -410,9 +415,23 @@ export const ClaudeSettings = makeProviderSettingsSchema(
410 },415 },
411 }),416 }),
412 ),417 ),
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 ),
413 },432 },
414 {433 {
415 order: ["binaryPath", "homePath", "launchArgs"],434 order: ["binaryPath", "homePath", "autoCompactWindow", "launchArgs"],
416 },435 },
417);436);
418export type ClaudeSettings = typeof ClaudeSettings.Type;437export type ClaudeSettings = typeof ClaudeSettings.Type;
...@@ -797,6 +816,11 @@ const ClaudeSettingsPatch = Schema.Struct({...@@ -797,6 +816,11 @@ const ClaudeSettingsPatch = Schema.Struct({
797 homePath: Schema.optionalKey(TrimmedString),816 homePath: Schema.optionalKey(TrimmedString),
798 customModels: Schema.optionalKey(Schema.Array(Schema.String)),817 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
799 launchArgs: Schema.optionalKey(TrimmedString),818 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 ),
800});824});
801825
802const CursorSettingsPatch = Schema.Struct({826const CursorSettingsPatch = Schema.Struct({
packages/shared/package.json+4
...@@ -226,6 +226,10 @@...@@ -226,6 +226,10 @@
226 "./usageFormat": {226 "./usageFormat": {
227 "types": "./src/usageFormat.ts",227 "types": "./src/usageFormat.ts",
228 "import": "./src/usageFormat.ts"228 "import": "./src/usageFormat.ts"
229 },
230 "./claudeCompaction": {
231 "types": "./src/claudeCompaction.ts",
232 "import": "./src/claudeCompaction.ts"
229 }233 }
230 },234 },
231 "scripts": {235 "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";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
2import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";2import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
33
4import {4import {
5 applyClaudePromptEffortPrefix,
5 buildProviderOptionSelectionsFromDescriptors,6 buildProviderOptionSelectionsFromDescriptors,
6 createModelCapabilities,7 createModelCapabilities,
7 createModelSelection,8 createModelSelection,
...@@ -155,3 +156,33 @@ describe("model slug normalization", () => {...@@ -155,3 +156,33 @@ describe("model slug normalization", () => {
155 expect(normalizeCustomModelSlug(" opus ")).toBe("opus");156 expect(normalizeCustomModelSlug(" opus ")).toBe("opus");
156 });157 });
157});158});
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(...@@ -362,7 +362,11 @@ export function applyClaudePromptEffortPrefix(
362 if (!trimmed) {362 if (!trimmed) {
363 return trimmed;363 return trimmed;
364 }364 }
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)) {
366 return trimmed;370 return trimmed;
367 }371 }
368 if (trimmed.startsWith("Ultrathink:")) {372 if (trimmed.startsWith("Ultrathink:")) {