diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index cc86d2cbe50..d830c75a632 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -5,11 +5,14 @@ import { type ProviderOptionSelection, type ServerProviderModel, } from "@t3tools/contracts"; +import { getProviderOptionDescriptors } from "@t3tools/shared/model"; import { getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, + withImplicitFastModeDefault, } from "./composerProviderState"; +import { getProviderModelCapabilities } from "../../providerModels"; // Everything in composerProviderState is now data-driven by the model's // optionDescriptors, so these tests use a single synthetic provider/model and @@ -36,8 +39,16 @@ function selectDescriptor( }; } -function booleanDescriptor(id: string): Extract { - return { id, label: id, type: "boolean" }; +function booleanDescriptor( + id: string, + currentValue?: boolean, +): Extract { + return { + id, + label: id, + type: "boolean", + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + }; } function modelWith( @@ -205,6 +216,77 @@ describe("getComposerProviderState", () => { }); }); + it("defaults fastMode to false when the provider reports true but the user has not selected it", () => { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("cursor"), + model: MODEL, + models: modelWith([booleanDescriptor("fastMode", true)]), + prompt: "", + modelOptions: undefined, + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false])); + }); + + it("keeps explicit fastMode true when the user selected Fast", () => { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("cursor"), + model: MODEL, + models: modelWith([booleanDescriptor("fastMode", true)]), + prompt: "", + modelOptions: selections(["fastMode", true]), + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", true])); + }); + + it("keeps explicit fastMode false when the user selected Normal", () => { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("cursor"), + model: MODEL, + models: modelWith([booleanDescriptor("fastMode", true)]), + prompt: "", + modelOptions: selections(["fastMode", false]), + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false])); + }); +}); + +describe("withImplicitFastModeDefault", () => { + it("injects fastMode false only when the model exposes fastMode and no selection exists", () => { + expect( + withImplicitFastModeDefault( + { + optionDescriptors: [booleanDescriptor("fastMode", true)], + }, + undefined, + ), + ).toEqual(selections(["fastMode", false])); + + expect( + withImplicitFastModeDefault( + { + optionDescriptors: [booleanDescriptor("fastMode", true)], + }, + selections(["fastMode", true]), + ), + ).toEqual(selections(["fastMode", true])); + }); + + it("does not add fastMode when the model does not expose it", () => { + expect( + withImplicitFastModeDefault( + { + optionDescriptors: [booleanDescriptor("thinking", true)], + }, + undefined, + ), + ).toBeUndefined(); + }); +}); + +describe("getComposerProviderState ultrathink styling", () => { it("does not add ultrathink class names when the descriptor has no promptInjectedValues", () => { const state = getComposerProviderState({ provider: PROVIDER, @@ -222,6 +304,22 @@ describe("getComposerProviderState", () => { }); }); +describe("trait controls fastMode display", () => { + it("resolves traits fastMode to Normal when the provider defaults to true without a user selection", () => { + const models = modelWith([booleanDescriptor("fastMode", true)]); + const provider = ProviderDriverKind.make("cursor"); + const caps = getProviderModelCapabilities(models, MODEL, provider); + const resolved = withImplicitFastModeDefault(caps, undefined); + const descriptors = getProviderOptionDescriptors({ caps, selections: resolved }); + const fastMode = descriptors.find((descriptor) => descriptor.id === "fastMode"); + + expect(fastMode?.type).toBe("boolean"); + if (fastMode?.type === "boolean") { + expect(fastMode.currentValue).toBe(false); + } + }); +}); + describe("provider traits render guards", () => { it("returns null when no thread target is provided", () => { const models = modelWith([ diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 77269d0bd90..083a017557e 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -1,4 +1,5 @@ import { + type ModelCapabilities, type ProviderDriverKind, type ProviderInstanceId, type ProviderOptionSelection, @@ -46,10 +47,33 @@ type TraitsRenderInput = { onPromptChange: (prompt: string) => void; }; +/** + * Cursor ACP can report `fastMode: true` as the provider default. T3 should only + * use Fast when the user explicitly selected it (draft/sticky/settings); otherwise + * default to Normal so new chats do not inherit the provider default. + */ +export function withImplicitFastModeDefault( + caps: ModelCapabilities, + modelOptions: ReadonlyArray | null | undefined, +): ReadonlyArray | undefined { + const hasExplicitFastMode = modelOptions?.some((selection) => selection.id === "fastMode"); + if (hasExplicitFastMode) { + return modelOptions ?? undefined; + } + const hasFastModeDescriptor = caps.optionDescriptors?.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ); + if (!hasFastModeDescriptor) { + return modelOptions ?? undefined; + } + return [...(modelOptions ?? []), { id: "fastMode", value: false }]; +} + export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { const { provider, model, models, prompt, modelOptions } = input; const caps = getProviderModelCapabilities(models, model, provider); - const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); + const selections = withImplicitFastModeDefault(caps, modelOptions); + const descriptors = getProviderOptionDescriptors({ caps, selections }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => descriptor.type === "select", @@ -90,9 +114,17 @@ function renderTraitsControl( onPromptChange, } = input; const hasTarget = threadRef !== undefined || draftId !== undefined; + const caps = getProviderModelCapabilities(models, model, provider); + const resolvedModelOptions = withImplicitFastModeDefault(caps, modelOptions); if ( !hasTarget || - !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt }) + !shouldRenderTraitsControls({ + provider, + models, + model, + modelOptions: resolvedModelOptions, + prompt, + }) ) { return null; } @@ -104,7 +136,7 @@ function renderTraitsControl( {...(threadRef ? { threadRef } : {})} {...(draftId ? { draftId } : {})} model={model} - modelOptions={modelOptions} + modelOptions={resolvedModelOptions} prompt={prompt} onPromptChange={onPromptChange} /> diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index e6caedaa2f9..b33eecb31f5 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1344,6 +1344,25 @@ describe("composerDraftStore sticky composer settings", () => { expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor"); }); + it("preserves sticky provider options when model selection omits options", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelSelection( + modelSelection(CURSOR_DRIVER, "composer-2", { + fastMode: false, + }), + ); + store.setStickyModelSelection(modelSelection(CURSOR_DRIVER, "composer-2.5")); + + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE], + ).toEqual( + modelSelection(CURSOR_DRIVER, "composer-2.5", { + fastMode: false, + }), + ); + }); + it("applies sticky activeProvider to new drafts", () => { const store = useComposerDraftStore.getState(); const threadId = ThreadId.make("thread-sticky-active-provider"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3de3b5d706d..60bbc8196e4 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2252,9 +2252,14 @@ const composerDraftStore = create()( if (!normalized) { return state; } + const current = state.stickyModelSelectionByProvider[normalized.instanceId]; + const nextSelection = + normalized.options !== undefined + ? normalized + : createModelSelection(normalized.instanceId, normalized.model, current?.options); const nextMap: Partial> = { ...state.stickyModelSelectionByProvider, - [normalized.instanceId]: normalized, + [normalized.instanceId]: nextSelection, }; if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { return state.stickyActiveProvider === normalized.instanceId