| 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,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 | 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 | }); |
| 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 | 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 | }); |
| 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 | 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 | }); |
| 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 | 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"; |
| 60 | import { | ||
| 61 | CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, | ||
| 62 | formatClaudeResumeCompactionQuestion, | ||
| 63 | } from "@t3tools/shared/claudeCompaction"; | ||
| 60 | import * as Cause from "effect/Cause"; | 64 | import * as Cause from "effect/Cause"; |
| 61 | import * as Crypto from "effect/Crypto"; | 65 | import * as Crypto from "effect/Crypto"; |
| 62 | import * as DateTime from "effect/DateTime"; | 66 | import * 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 !== undefined | 575 | ...(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 | } |
| 575 | 583 | ||
| ... | @@ -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 | } |
| 614 | 624 | ||
| ... | @@ -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 | } | ||
| 3956 | 3972 | ||
| 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 | }); |
| 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 | 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 | } | ||
| 4109 | 4200 | ||
| 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* ( |
| 4161 | 4252 | ||
| 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)); | ||
| 4164 | 4259 | ||
| 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 at | 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,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); |
| 932 | 938 | ||
| 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 | }); |
| 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,6 +2151,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 2151 | ); | 2151 | ); |
| 2152 | 2152 | ||
| 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 | ); |
| 2195 | 2199 | ||
| 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"; |
| 84 | import * as Cause from "effect/Cause"; | 84 | import * as Cause from "effect/Cause"; |
| 85 | import * as Schema from "effect/Schema"; | ||
| 85 | import { AsyncResult } from "effect/unstable/reactivity"; | 86 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 86 | import { isElectron } from "../env"; | 87 | import { isElectron } from "../env"; |
| 87 | import { readLocalApi } from "../localApi"; | 88 | import { 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"; |
| 196 | import { useBrowserHistoryStore } from "~/browserHistoryStore"; | 198 | import { useBrowserHistoryStore } from "~/browserHistoryStore"; |
| 197 | import { registerFaviconProjectForThread } from "~/browserFaviconStore"; | 199 | import { registerFaviconProjectForThread } from "~/browserFaviconStore"; |
| 198 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | 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 | import { | 206 | import { |
| 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 |
| 223 | import { | 229 | import { |
| 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"; |
| 307 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | 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 | import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; | 321 | import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; |
| 309 | import { | 322 | import { |
| 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 the | 2324 | // 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 | null | 2838 | 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 just | 4577 | // 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?.defaultModelSelection | 7072 | 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"; |
| 266 | import type { SessionPhase, Thread } from "../../types"; | 266 | import type { SessionPhase, Thread } from "../../types"; |
| 267 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; | 267 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; |
| 268 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; | 268 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; |
| 269 | import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; | 269 | import type { ContextWindowSnapshot } from "../../lib/contextWindow"; |
| 270 | import { | 270 | import { |
| 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 |
| 438 | 438 | ||
| 439 | const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { | 439 | const 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 | <ContextWindowMeter | 471 | <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; |
| 610 | 617 | ||
| 611 | // Context window | 618 | // Context window |
| 612 | activeThreadActivities: Thread["activities"] | undefined; | 619 | activeContextWindow: ContextWindowSnapshot | null; |
| 620 | compactDisabled: boolean; | ||
| 621 | compactDisabledReason: string | null; | ||
| 613 | 622 | ||
| 614 | // Misc | 623 | // 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 is | 931 | // 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 window | 1018 | // 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. Reserved | 1083 | * 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 the | 1085 | * 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()); |
| 1080 | 1089 | ||
| ... | @@ -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 | ); |
| 2880 | 2943 | ||
| ... | @@ -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 @@ |
| 1 | import { ProviderInstanceId } from "@t3tools/contracts"; | 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { deriveProviderInstanceEntries } from "../../providerInstances"; | ||
| 3 | import { | 4 | import { |
| 4 | formatContextWindowCompactionMessage, | 5 | formatContextWindowCompactionMessage, |
| 6 | hasAvailableClaudeCompactionProvider, | ||
| 7 | hasDismissedResumeCompaction, | ||
| 5 | resolveContextWindowModelDisplayName, | 8 | resolveContextWindowModelDisplayName, |
| 9 | shouldOfferResumeCompaction, | ||
| 6 | } from "./ContextWindowMeter.logic"; | 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 | describe("resolveContextWindowModelDisplayName", () => { | 81 | describe("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 | |||
| 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,6 +1,81 @@ |
| 1 | import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; | 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 | import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; | 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 | export function resolveContextWindowModelDisplayName( | 79 | export 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( |
| 18 | 93 | ||
| 19 | export function formatContextWindowCompactionMessage( | 94 | export 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 modelDisplayName | 101 | 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 @@ | ||
| 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,6 +2,7 @@ import { Button } from "../ui/button"; |
| 2 | import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; | 2 | import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; |
| 3 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 3 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 4 | import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; | 4 | import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; |
| 5 | import { Minimize2Icon } from "lucide-react"; | ||
| 5 | 6 | ||
| 6 | function formatPercentage(value: number | null): string | null { | 7 | function 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 { |
| 16 | export function ContextWindowMeter(props: { | 17 | export 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 | <PopoverTrigger | 39 | <PopoverTrigger |
| 36 | openOnHover | 40 | openOnHover |
| 37 | delay={150} | 41 | delay={150} |
| 38 | closeDelay={0} | 42 | closeDelay={onCompact ? 150 : 0} |
| 39 | render={ | 43 | render={ |
| 40 | <Button | 44 | <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 | }); |
| 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 | 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 | ]); |
| 31 | 32 | ||
| ... | @@ -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 | }); |
| 38 | 40 | ||
| 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 and | 34 | `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and |
| 35 | the rest of your environment stay as they are. | 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 | ## Where Claude Skills Are Loaded | 49 | ## Where Claude Skills Are Loaded |
| 38 | 50 | ||
| 39 | T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then | 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,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 | }); |
| 328 | export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type; | 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,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 5 | import { | 5 | import { |
| 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); |
| 17 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); | 18 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); |
| 18 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); | 19 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 19 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | 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 | describe("ClientSettings word wrap", () => { | 52 | describe("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 | ); |
| 374 | export type CodexSettings = typeof CodexSettings.Type; | 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 | export const ClaudeSettings = makeProviderSettingsSchema( | 381 | export 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 | ); |
| 418 | export type ClaudeSettings = typeof ClaudeSettings.Type; | 437 | export 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 | }); |
| 801 | 825 | ||
| 802 | const CursorSettingsPatch = Schema.Struct({ | 826 | const 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 @@ | ||
| 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,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; | 2 | import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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 | |||
| 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,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:")) { |