authorgravatar for aravhawk@gmail.comArav Jain <aravhawk@gmail.com> 2026-09-07 17:46:15-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 22:46:15+00:00
logdadba6d95d2c4515956a6c7fef184378987e13b7
tree7986edd858d38ed994f22078769a00505619dbf2
parent02443335b155e46248f96d80e037603abc742a61
signature Signed by PGP key B5690EEEBB952194

fix(web): remember Composer Fast mode across new chats (#2981)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

4 files changed, 183 insertions(+), 8 deletions(-)

apps/web/src/components/chat/composerProviderState.test.tsx+98-2
...@@ -5,11 +5,14 @@ import {...@@ -5,11 +5,14 @@ import {
5 type ProviderOptionSelection,5 type ProviderOptionSelection,
6 type ServerProviderModel,6 type ServerProviderModel,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { getProviderOptionDescriptors } from "@t3tools/shared/model";
9import { getProviderModelCapabilities } from "../../providerModels";
8import {10import {
9 getComposerPromptInjectionState,11 getComposerPromptInjectionState,
10 getComposerProviderState,12 getComposerProviderState,
11 renderProviderTraitsMenuContent,13 renderProviderTraitsMenuContent,
12 renderProviderTraitsPicker,14 renderProviderTraitsPicker,
15 withImplicitFastModeDefault,
13} from "./composerProviderState";16} from "./composerProviderState";
1417
15// Everything in composerProviderState is now data-driven by the model's18// Everything in composerProviderState is now data-driven by the model's
...@@ -37,8 +40,16 @@ function selectDescriptor(...@@ -37,8 +40,16 @@ function selectDescriptor(
37 };40 };
38}41}
3942
40function booleanDescriptor(id: string): Extract<ProviderOptionDescriptor, { type: "boolean" }> {43function booleanDescriptor(
41 return { id, label: id, type: "boolean" };44 id: string,
45 currentValue?: boolean,
46): Extract<ProviderOptionDescriptor, { type: "boolean" }> {
47 return {
48 id,
49 label: id,
50 type: "boolean",
51 ...(typeof currentValue === "boolean" ? { currentValue } : {}),
52 };
42}53}
4354
44function modelWith(55function modelWith(
...@@ -373,6 +384,91 @@ describe("getComposerProviderState", () => {...@@ -373,6 +384,91 @@ describe("getComposerProviderState", () => {
373 expect(state).not.toHaveProperty("composerSurfaceClassName");384 expect(state).not.toHaveProperty("composerSurfaceClassName");
374 expect(state).not.toHaveProperty("modelPickerIconClassName");385 expect(state).not.toHaveProperty("modelPickerIconClassName");
375 });386 });
387
388 it("defaults fastMode to false when the provider reports true but the user has not selected it", () => {
389 const state = getComposerProviderState({
390 provider: ProviderDriverKind.make("cursor"),
391 model: MODEL,
392 models: modelWith([booleanDescriptor("fastMode", true)]),
393 modelOptions: undefined,
394 planModeEnabled: true,
395 });
396
397 expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false]));
398 });
399
400 it("keeps explicit fastMode true when the user selected Fast", () => {
401 const state = getComposerProviderState({
402 provider: ProviderDriverKind.make("cursor"),
403 model: MODEL,
404 models: modelWith([booleanDescriptor("fastMode", true)]),
405 modelOptions: selections(["fastMode", true]),
406 planModeEnabled: true,
407 });
408
409 expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", true]));
410 });
411
412 it("keeps explicit fastMode false when the user selected Normal", () => {
413 const state = getComposerProviderState({
414 provider: ProviderDriverKind.make("cursor"),
415 model: MODEL,
416 models: modelWith([booleanDescriptor("fastMode", true)]),
417 modelOptions: selections(["fastMode", false]),
418 planModeEnabled: true,
419 });
420
421 expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false]));
422 });
423});
424
425describe("withImplicitFastModeDefault", () => {
426 it("injects fastMode false only when the model exposes fastMode and no selection exists", () => {
427 expect(
428 withImplicitFastModeDefault(
429 {
430 optionDescriptors: [booleanDescriptor("fastMode", true)],
431 },
432 undefined,
433 ),
434 ).toEqual(selections(["fastMode", false]));
435
436 expect(
437 withImplicitFastModeDefault(
438 {
439 optionDescriptors: [booleanDescriptor("fastMode", true)],
440 },
441 selections(["fastMode", true]),
442 ),
443 ).toEqual(selections(["fastMode", true]));
444 });
445
446 it("does not add fastMode when the model does not expose it", () => {
447 expect(
448 withImplicitFastModeDefault(
449 {
450 optionDescriptors: [booleanDescriptor("thinking", true)],
451 },
452 undefined,
453 ),
454 ).toBeUndefined();
455 });
456});
457
458describe("trait controls fastMode display", () => {
459 it("resolves traits fastMode to Normal when the provider defaults to true without a user selection", () => {
460 const models = modelWith([booleanDescriptor("fastMode", true)]);
461 const provider = ProviderDriverKind.make("cursor");
462 const caps = getProviderModelCapabilities(models, MODEL, provider);
463 const resolved = withImplicitFastModeDefault(caps, undefined);
464 const descriptors = getProviderOptionDescriptors({ caps, selections: resolved });
465 const fastMode = descriptors.find((descriptor) => descriptor.id === "fastMode");
466
467 expect(fastMode?.type).toBe("boolean");
468 if (fastMode?.type === "boolean") {
469 expect(fastMode.currentValue).toBe(false);
470 }
471 });
376});472});
377473
378describe("provider traits render guards", () => {474describe("provider traits render guards", () => {
apps/web/src/components/chat/composerProviderState.tsx+57-5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type ModelCapabilities,
2 type ProviderDriverKind,3 type ProviderDriverKind,
3 type ProviderInstanceId,4 type ProviderInstanceId,
4 type ProviderOptionSelection,5 type ProviderOptionSelection,
...@@ -63,6 +64,44 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI...@@ -63,6 +64,44 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI
63 return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none";64 return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none";
64}65}
6566
67/**
68 * Cursor ACP can report `fastMode: true` as the provider default. T3 only
69 * treats Fast as selected when the user chose it (draft/sticky/settings).
70 * Otherwise inject an explicit `false` so new chats stay Normal and the
71 * send path can overwrite a prior Fast session — descriptor defaults are
72 * otherwise omitted by `buildExplicitProviderOptionSelectionsFromDescriptors`.
73 */
74export function withImplicitFastModeDefault(
75 caps: ModelCapabilities,
76 modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
77): ReadonlyArray<ProviderOptionSelection> | undefined {
78 const hasExplicitFastMode = modelOptions?.some((selection) => selection.id === "fastMode");
79 if (hasExplicitFastMode) {
80 return modelOptions ?? undefined;
81 }
82 const hasFastModeDescriptor = caps.optionDescriptors?.some(
83 (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode",
84 );
85 if (!hasFastModeDescriptor) {
86 return modelOptions ?? undefined;
87 }
88 return [...(modelOptions ?? []), { id: "fastMode", value: false }];
89}
90
91function resolveComposerOptionSelections(
92 models: ReadonlyArray<ServerProviderModel>,
93 model: string,
94 provider: ProviderDriverKind,
95 modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
96 planModeEnabled: boolean,
97): {
98 caps: ModelCapabilities;
99 selections: ReadonlyArray<ProviderOptionSelection> | undefined;
100} {
101 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
102 return { caps, selections: withImplicitFastModeDefault(caps, modelOptions) };
103}
104
66export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {105export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
67 const {106 const {
68 provider,107 provider,
...@@ -87,8 +126,14 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com...@@ -87,8 +126,14 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
87 };126 };
88 }127 }
89 }128 }
90 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);129 const { caps, selections } = resolveComposerOptionSelections(
91 const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions });130 models,
131 model,
132 provider,
133 modelOptions,
134 planModeEnabled,
135 );
136 const descriptors = getProviderOptionDescriptors({ caps, selections });
92 const primarySelectDescriptor = descriptors.find(137 const primarySelectDescriptor = descriptors.find(
93 (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> =>138 (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> =>
94 descriptor.type === "select",139 descriptor.type === "select",
...@@ -104,7 +149,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com...@@ -104,7 +149,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
104 promptEffort,149 promptEffort,
105 modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors(150 modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors(
106 descriptors,151 descriptors,
107 modelOptions,152 selections,
108 ),153 ),
109 ...(ultrathinkActive154 ...(ultrathinkActive
110 ? {155 ? {
...@@ -138,13 +183,20 @@ function renderTraitsControl(...@@ -138,13 +183,20 @@ function renderTraitsControl(
138 isComposerOwned,183 isComposerOwned,
139 } = input;184 } = input;
140 const hasTarget = threadRef !== undefined || draftId !== undefined;185 const hasTarget = threadRef !== undefined || draftId !== undefined;
186 const { selections: resolvedModelOptions } = resolveComposerOptionSelections(
187 models,
188 model,
189 provider,
190 modelOptions,
191 planModeEnabled,
192 );
141 if (193 if (
142 !hasTarget ||194 !hasTarget ||
143 !shouldRenderTraitsControls({195 !shouldRenderTraitsControls({
144 provider,196 provider,
145 models,197 models,
146 model,198 model,
147 modelOptions,199 modelOptions: resolvedModelOptions,
148 prompt,200 prompt,
149 planModeEnabled,201 planModeEnabled,
150 })202 })
...@@ -159,7 +211,7 @@ function renderTraitsControl(...@@ -159,7 +211,7 @@ function renderTraitsControl(
159 {...(threadRef ? { threadRef } : {})}211 {...(threadRef ? { threadRef } : {})}
160 {...(draftId ? { draftId } : {})}212 {...(draftId ? { draftId } : {})}
161 model={model}213 model={model}
162 modelOptions={modelOptions}214 modelOptions={resolvedModelOptions}
163 prompt={prompt}215 prompt={prompt}
164 onPromptChange={onPromptChange}216 onPromptChange={onPromptChange}
165 planModeEnabled={planModeEnabled}217 planModeEnabled={planModeEnabled}
apps/web/src/composerDraftStore.test.ts+19
...@@ -2177,6 +2177,25 @@ describe("composerDraftStore sticky composer settings", () => {...@@ -2177,6 +2177,25 @@ describe("composerDraftStore sticky composer settings", () => {
2177 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor");2177 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor");
2178 });2178 });
21792179
2180 it("preserves sticky provider options when model selection omits options", () => {
2181 const store = useComposerDraftStore.getState();
2182
2183 store.setStickyModelSelection(
2184 modelSelection(CURSOR_DRIVER, "composer-2", {
2185 fastMode: false,
2186 }),
2187 );
2188 store.setStickyModelSelection(modelSelection(CURSOR_DRIVER, "composer-2.5"));
2189
2190 expect(
2191 useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE],
2192 ).toEqual(
2193 modelSelection(CURSOR_DRIVER, "composer-2.5", {
2194 fastMode: false,
2195 }),
2196 );
2197 });
2198
2180 it("applies sticky activeProvider to new drafts", () => {2199 it("applies sticky activeProvider to new drafts", () => {
2181 const store = useComposerDraftStore.getState();2200 const store = useComposerDraftStore.getState();
2182 const threadId = ThreadId.make("thread-sticky-active-provider");2201 const threadId = ThreadId.make("thread-sticky-active-provider");
apps/web/src/composerDraftStore.ts+9-1
...@@ -2914,9 +2914,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2914,9 +2914,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2914 if (!normalized) {2914 if (!normalized) {
2915 return state;2915 return state;
2916 }2916 }
2917 const current = state.stickyModelSelectionByProvider[normalized.instanceId];
2918 // Model-only picker updates omit options (same contract as
2919 // setModelSelection). Keep the last sticky traits so Fast/Normal
2920 // survives Composer 2 → 2.5 and new chats.
2921 const nextSelection =
2922 normalized.options !== undefined
2923 ? normalized
2924 : createModelSelection(normalized.instanceId, normalized.model, current?.options);
2917 const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = {2925 const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = {
2918 ...state.stickyModelSelectionByProvider,2926 ...state.stickyModelSelectionByProvider,
2919 [normalized.instanceId]: normalized,2927 [normalized.instanceId]: nextSelection,
2920 };2928 };
2921 if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {2929 if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
2922 return state.stickyActiveProvider === normalized.instanceId2930 return state.stickyActiveProvider === normalized.instanceId