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