From 4c4b93b6ec622cde94a908a4acde4e0e732f92d5 Mon Sep 17 00:00:00 2001 From: Arav Jain Date: Sat, 6 Jun 2026 08:24:27 -0500 Subject: [PATCH 1/2] fix(web): remember Composer Fast mode across new chats Default Cursor fastMode to Normal when the user has not chosen Fast, and preserve sticky fastMode options when switching models so new chats reuse the last manual Fast/Normal selection. Co-authored-by: Cursor Co-authored-by: codex --- .../chat/composerProviderState.test.tsx | 84 ++++++++++++++++++- .../components/chat/composerProviderState.tsx | 26 +++++- apps/web/src/composerDraftStore.test.ts | 19 +++++ apps/web/src/composerDraftStore.ts | 11 ++- 4 files changed, 136 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 07eec55de2c..9a3a5730732 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -9,6 +9,7 @@ import { getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, + withImplicitFastModeDefault, } from "./composerProviderState"; // Everything in composerProviderState is now data-driven by the model's @@ -36,8 +37,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 +214,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, diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index b5cc790538d..ff9066c7751 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", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b7107306..a84bea71c16 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1562,6 +1562,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 fdb8bfe7b18..195ba1ebd49 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2486,9 +2486,18 @@ 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 From 75885313bf4225022208bff1a283502acaa69f2c Mon Sep 17 00:00:00 2001 From: Arav Jain Date: Sun, 7 Jun 2026 08:43:10 -0500 Subject: [PATCH 2/2] fix(web): align traits picker with implicit Composer Fast default Apply withImplicitFastModeDefault in trait controls so the Fast/Normal badge matches dispatch when Cursor reports fastMode true as the provider default but the user has not explicitly chosen Fast. Co-authored-by: Cursor Co-authored-by: codex --- .../chat/composerProviderState.test.tsx | 18 ++++++++++++++++++ .../components/chat/composerProviderState.tsx | 12 ++++++++++-- apps/web/src/composerDraftStore.ts | 6 +----- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 9a3a5730732..cbcec319b3b 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -5,12 +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 @@ -302,6 +304,22 @@ describe("getComposerProviderState ultrathink styling", () => { }); }); +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 ff9066c7751..abd0c6e3482 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -114,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; } @@ -128,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.ts b/apps/web/src/composerDraftStore.ts index 195ba1ebd49..28886ef7ac0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2490,11 +2490,7 @@ const composerDraftStore = create()( const nextSelection = normalized.options !== undefined ? normalized - : createModelSelection( - normalized.instanceId, - normalized.model, - current?.options, - ); + : createModelSelection(normalized.instanceId, normalized.model, current?.options); const nextMap: Partial> = { ...state.stickyModelSelectionByProvider, [normalized.instanceId]: nextSelection,