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 {
55 type ProviderOptionSelection,
66 type ServerProviderModel,
77} from "@t3tools/contracts";
8import { getProviderOptionDescriptors } from "@t3tools/shared/model";
9import { getProviderModelCapabilities } from "../../providerModels";
810import {
911 getComposerPromptInjectionState,
1012 getComposerProviderState,
1113 renderProviderTraitsMenuContent,
1214 renderProviderTraitsPicker,
15 withImplicitFastModeDefault,
1316} from "./composerProviderState";
1417
1518// Everything in composerProviderState is now data-driven by the model's
......@@ -37,8 +40,16 @@ function selectDescriptor(
3740 };
3841}
3942
40function booleanDescriptor(id: string): Extract<ProviderOptionDescriptor, { type: "boolean" }> {
41 return { id, label: id, type: "boolean" };
43function booleanDescriptor(
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 };
4253}
4354
4455function modelWith(
......@@ -373,6 +384,91 @@ describe("getComposerProviderState", () => {
373384 expect(state).not.toHaveProperty("composerSurfaceClassName");
374385 expect(state).not.toHaveProperty("modelPickerIconClassName");
375386 });
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 });
376472});
377473
378474describe("provider traits render guards", () => {
apps/web/src/components/chat/composerProviderState.tsx+57-5
......@@ -1,4 +1,5 @@
11import {
2 type ModelCapabilities,
23 type ProviderDriverKind,
34 type ProviderInstanceId,
45 type ProviderOptionSelection,
......@@ -63,6 +64,44 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI
6364 return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none";
6465}
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
66105export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
67106 const {
68107 provider,
......@@ -87,8 +126,14 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
87126 };
88127 }
89128 }
90 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
91 const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions });
129 const { caps, selections } = resolveComposerOptionSelections(
130 models,
131 model,
132 provider,
133 modelOptions,
134 planModeEnabled,
135 );
136 const descriptors = getProviderOptionDescriptors({ caps, selections });
92137 const primarySelectDescriptor = descriptors.find(
93138 (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> =>
94139 descriptor.type === "select",
......@@ -104,7 +149,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
104149 promptEffort,
105150 modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors(
106151 descriptors,
107 modelOptions,
152 selections,
108153 ),
109154 ...(ultrathinkActive
110155 ? {
......@@ -138,13 +183,20 @@ function renderTraitsControl(
138183 isComposerOwned,
139184 } = input;
140185 const hasTarget = threadRef !== undefined || draftId !== undefined;
186 const { selections: resolvedModelOptions } = resolveComposerOptionSelections(
187 models,
188 model,
189 provider,
190 modelOptions,
191 planModeEnabled,
192 );
141193 if (
142194 !hasTarget ||
143195 !shouldRenderTraitsControls({
144196 provider,
145197 models,
146198 model,
147 modelOptions,
199 modelOptions: resolvedModelOptions,
148200 prompt,
149201 planModeEnabled,
150202 })
......@@ -159,7 +211,7 @@ function renderTraitsControl(
159211 {...(threadRef ? { threadRef } : {})}
160212 {...(draftId ? { draftId } : {})}
161213 model={model}
162 modelOptions={modelOptions}
214 modelOptions={resolvedModelOptions}
163215 prompt={prompt}
164216 onPromptChange={onPromptChange}
165217 planModeEnabled={planModeEnabled}
apps/web/src/composerDraftStore.test.ts+19
......@@ -2177,6 +2177,25 @@ describe("composerDraftStore sticky composer settings", () => {
21772177 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor");
21782178 });
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
21802199 it("applies sticky activeProvider to new drafts", () => {
21812200 const store = useComposerDraftStore.getState();
21822201 const threadId = ThreadId.make("thread-sticky-active-provider");
apps/web/src/composerDraftStore.ts+9-1
......@@ -2914,9 +2914,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
29142914 if (!normalized) {
29152915 return state;
29162916 }
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);
29172925 const nextMap: Partial<Record<ProviderInstanceId, ModelSelection>> = {
29182926 ...state.stickyModelSelectionByProvider,
2919 [normalized.instanceId]: normalized,
2927 [normalized.instanceId]: nextSelection,
29202928 };
29212929 if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
29222930 return state.stickyActiveProvider === normalized.instanceId