diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 87d7ac6fdcb8d27f5a7b8d1115eb3d77a8d8f686..44cd77b7e8d68c358ee80021bd52cf5b316a669f 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 { getProviderModelCapabilities } from "../../providerModels"; import { getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, + withImplicitFastModeDefault, } from "./composerProviderState"; // Everything in composerProviderState is now data-driven by the model's @@ -37,8 +40,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( @@ -373,6 +384,91 @@ describe("getComposerProviderState", () => { expect(state).not.toHaveProperty("composerSurfaceClassName"); expect(state).not.toHaveProperty("modelPickerIconClassName"); }); + + 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)]), + modelOptions: undefined, + planModeEnabled: true, + }); + + 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)]), + modelOptions: selections(["fastMode", true]), + planModeEnabled: 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)]), + modelOptions: selections(["fastMode", false]), + planModeEnabled: true, + }); + + 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("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", () => { diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index e2fee32d6d06a2457d234362d954c052621919e5..c6837700cdfaf83b1449dfd2795723a7623231f9 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, @@ -63,6 +64,44 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; } +/** + * Cursor ACP can report `fastMode: true` as the provider default. T3 only + * treats Fast as selected when the user chose it (draft/sticky/settings). + * Otherwise inject an explicit `false` so new chats stay Normal and the + * send path can overwrite a prior Fast session — descriptor defaults are + * otherwise omitted by `buildExplicitProviderOptionSelectionsFromDescriptors`. + */ +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 }]; +} + +function resolveComposerOptionSelections( + models: ReadonlyArray, + model: string, + provider: ProviderDriverKind, + modelOptions: ReadonlyArray | null | undefined, + planModeEnabled: boolean, +): { + caps: ModelCapabilities; + selections: ReadonlyArray | undefined; +} { + const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); + return { caps, selections: withImplicitFastModeDefault(caps, modelOptions) }; +} + export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { const { provider, @@ -87,8 +126,14 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com }; } } - const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); - const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); + const { caps, selections } = resolveComposerOptionSelections( + models, + model, + provider, + modelOptions, + planModeEnabled, + ); + const descriptors = getProviderOptionDescriptors({ caps, selections }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => descriptor.type === "select", @@ -104,7 +149,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com promptEffort, modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors( descriptors, - modelOptions, + selections, ), ...(ultrathinkActive ? { @@ -138,13 +183,20 @@ function renderTraitsControl( isComposerOwned, } = input; const hasTarget = threadRef !== undefined || draftId !== undefined; + const { selections: resolvedModelOptions } = resolveComposerOptionSelections( + models, + model, + provider, + modelOptions, + planModeEnabled, + ); if ( !hasTarget || !shouldRenderTraitsControls({ provider, models, model, - modelOptions, + modelOptions: resolvedModelOptions, prompt, planModeEnabled, }) @@ -159,7 +211,7 @@ function renderTraitsControl( {...(threadRef ? { threadRef } : {})} {...(draftId ? { draftId } : {})} model={model} - modelOptions={modelOptions} + modelOptions={resolvedModelOptions} prompt={prompt} onPromptChange={onPromptChange} planModeEnabled={planModeEnabled} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 4a682b97910deb3c251159916312cd479158b995..02f9a4285ae45a3c30e21b361931db39a2cddb01 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -2177,6 +2177,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 5bdd823b4d88bec53447af4e54946ab3a7b5ba36..741ecbdc22d1e5877e84ce9ca0ee345236890158 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2914,9 +2914,17 @@ const composerDraftStore = create()( if (!normalized) { return state; } + const current = state.stickyModelSelectionByProvider[normalized.instanceId]; + // Model-only picker updates omit options (same contract as + // setModelSelection). Keep the last sticky traits so Fast/Normal + // survives Composer 2 → 2.5 and new chats. + 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