From a39a262624bdcd34971f821de6967f359399bf93 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 08:29:31 -0500 Subject: [PATCH 1/8] fix(web): Cursor threads keep a stored model the catalog does not list The composer replaced a Cursor thread's stored model with the first catalog row when the instance's discovered catalog did not list it, and the next send switched the thread to that model and dropped its options. Cursor's catalog comes from the account at runtime, like OpenCode's, so keep the stored model as unavailable and send its saved options unchanged. Fixes #16526 Co-Authored-By: Claude Opus 5.5 --- .../components/chat/ModelPickerContent.tsx | 4 ++- apps/web/src/components/chat/TraitsPicker.tsx | 2 +- .../components/chat/composerProviderState.tsx | 2 +- apps/web/src/modelSelection.test.ts | 34 +++++++++++++++++++ apps/web/src/modelSelection.ts | 8 +++-- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 3b9e7b9cf22c..6ccda8ba04ec 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -110,7 +110,9 @@ function shouldIncludeModelPickerOption(input: { if (isProviderInstancePickerReady(input.entry)) return true; return ( input.entry.enabled && - (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") && + (input.entry.driverKind === "opencode" || + input.entry.driverKind === "antigravity" || + input.entry.driverKind === "cursor") && input.entry.instanceId === input.activeInstanceId && input.option.slug === input.activeModel && input.option.isUnavailable === true diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 00566955e5d4..5fdca6c861cb 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -126,7 +126,7 @@ function getSelectedTraits( ) { const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); const modelIsUnavailable = - provider === "opencode" && + (provider === "opencode" || provider === "cursor") && !models.some((candidate) => candidate.slug === normalizeModelSlug(model, provider)); const descriptors = modelIsUnavailable ? buildUnavailableModelOptionDescriptors( diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 85f725fce2b2..9f70fca0f3c6 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -113,7 +113,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com promptInjectionState = "none", planModeEnabled, } = input; - if (provider === "opencode") { + if (provider === "opencode" || provider === "cursor") { const normalizedModel = normalizeModelSlug(model, provider); const modelIsInCatalog = models.some((candidate) => candidate.slug === normalizedModel); if (!modelIsInCatalog) { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 05a7e76b6b25..38c9645f1bc0 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -254,6 +254,11 @@ describe("instance-scoped model selection", () => { availableModel: "gemini-3.1-pro", missingModel: "gemini-3.1-pro-high", }, + { + driverName: "cursor", + availableModel: "auto-smart", + missingModel: "claude-opus-5-5", + }, ])("$driverName catalog gaps", ({ driverName, availableModel, missingModel }) => { it("preserves a selected model when a catalog refresh no longer contains it", () => { const providers = [ @@ -361,6 +366,35 @@ describe("instance-scoped model selection", () => { expect(dispatch.modelOptionsForDispatch).toBeUndefined(); }); + it("sends a Cursor thread's stored model and options when the catalog lacks them", () => { + const instanceId = ProviderInstanceId.make("cursor"); + const driver = ProviderDriverKind.make("cursor"); + const stored = createModelSelection(instanceId, "claude-opus-5-5", [ + { id: "effort", value: "medium" }, + ]); + const providers = [provider({ provider: driver, instanceId, models: ["auto-smart"] })]; + const state = deriveEffectiveComposerModelState({ + draft: null, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: stored, + projectModelSelection: null, + settings: settingsWithProviderInstances(), + }); + const dispatch = getComposerProviderState({ + provider: driver, + model: state.selectedModel, + models: providers[0]!.models, + modelOptions: state.modelOptions?.[instanceId], + planModeEnabled: false, + }); + + expect( + createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), + ).toEqual(stored); + }); + it("preserves an explicit draft OpenCode selection while the catalog is empty", () => { const instanceId = ProviderInstanceId.make("opencode_work"); const driver = ProviderDriverKind.make("opencode"); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 0b57a6cbb46f..06b3ae9ecb4a 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -77,7 +77,9 @@ function appendUnavailableDynamicModelSelection( selectedModel: string | null | undefined, hiddenModels: ReadonlyArray, ): AppModelOption[] { - if (provider !== "opencode" && provider !== "antigravity") return options; + if (provider !== "opencode" && provider !== "antigravity" && provider !== "cursor") { + return options; + } const slug = normalizeCustomModelSlug(selectedModel); if (!slug) return options; if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options; @@ -265,7 +267,9 @@ export function resolveAppModelSelectionForInstance( } if ( resolutionOptions?.preserveUnavailableSelection && - (entry.driverKind === "opencode" || entry.driverKind === "antigravity") + (entry.driverKind === "opencode" || + entry.driverKind === "antigravity" || + entry.driverKind === "cursor") ) { const unavailableSelection = normalizeCustomModelSlug(selectedModel); const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; From d76690b5d1c772d62083f127e25690db286cb3d4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 20:18:12 -0500 Subject: [PATCH 2/8] fix(web): re-picking a Cursor thread's unavailable model keeps it Clicking the selected Unavailable row in the composer picker resolved the model without preserving it, so the draft fell back to the first catalog model, the same switch #16526 reports. Co-Authored-By: Claude Opus 5.5 --- .../useComposerSelection.browser.test.tsx | 84 +++++++++++++++++++ .../components/chat/useComposerSelection.ts | 2 + 2 files changed, 86 insertions(+) create mode 100644 apps/web/src/components/chat/useComposerSelection.browser.test.tsx diff --git a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx new file mode 100644 index 000000000000..e7ea63409b6e --- /dev/null +++ b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx @@ -0,0 +1,84 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ServerProvider, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { createModelSelection } from "@t3tools/shared/model"; +import { expect, it } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { useComposerDraftStore } from "../../composerDraftStore"; +import { useComposerSelection } from "./useComposerSelection"; + +const environmentId = EnvironmentId.make("environment-local"); +const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-cursor")); +const cursor = ProviderInstanceId.make("cursor"); +const stored = createModelSelection(cursor, "claude-opus-5-5", [{ id: "effort", value: "medium" }]); + +const providers: ReadonlyArray = [ + { + instanceId: cursor, + driver: ProviderDriverKind.make("cursor"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [{ slug: "auto-smart", name: "Auto", isCustom: false, capabilities: {} }], + slashCommands: [], + skills: [], + }, +]; + +// The hook reads only the thread's identity and stored selection. +const activeThread = { + environmentId, + id: threadRef.threadId, + modelSelection: stored, +} as EnvironmentThreadShell; + +function Picker() { + const { onProviderModelSelectStable } = useComposerSelection({ + environmentId, + composerDraftTarget: threadRef, + draftThread: null, + activeThread, + activeRuntime: null, + activeProjectSettings: { settings: { newWorktreesStartFromOrigin: false } }, + settings: DEFAULT_UNIFIED_SETTINGS, + providerStatuses: providers, + lockedProvider: null, + supportsProviderSwitchingViaHandoff: false, + multipleModelSelections: null, + runtimeMode: "full-access", + interactionMode: "default", + interactionModeEnabled: true, + isLocalDraftThread: false, + canOverrideServerThreadEnvMode: false, + setPendingServerThreadEnvModeEntry: () => {}, + setPendingServerThreadStartFromOriginByThreadId: () => {}, + scheduleComposerFocus: () => {}, + }); + return ( + + ); +} + +it("keeps a Cursor thread's stored model when the user re-picks its unavailable row", async () => { + await using _screen = await render(); + await page.getByRole("button", { name: "Pick the stored model" }).click(); + + expect( + useComposerDraftStore.getState().getComposerDraft(threadRef)?.modelSelectionByProvider[cursor] + ?.model, + ).toBe("claude-opus-5-5"); +}); diff --git a/apps/web/src/components/chat/useComposerSelection.ts b/apps/web/src/components/chat/useComposerSelection.ts index 61992327d361..0142d2c5353a 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -174,11 +174,13 @@ export function useComposerSelection(input: ComposerSelectionInput) { return; } } + // The picker lists an unavailable model only as the active selection, so re-picking keeps it. const resolvedModel = resolveAppModelSelectionForInstance( instanceId, settings, providerStatuses, model, + { preserveUnavailableSelection: true }, ); if (!resolvedModel) { if (options?.focusComposer !== false) scheduleComposerFocus(); From d6b446eabc44186888adc5a9d0497b9aaae4245a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 17:46:26 +0000 Subject: [PATCH 3/8] fix(web): re-picking a Cursor thread's unavailable model keeps its options Co-Authored-By: Claude Opus 5.5 --- .../useComposerSelection.browser.test.tsx | 49 ++++++++++++------- .../components/chat/useComposerSelection.ts | 24 ++++++--- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx index e7ea63409b6e..647546d63ced 100644 --- a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx @@ -17,7 +17,6 @@ import { useComposerDraftStore } from "../../composerDraftStore"; import { useComposerSelection } from "./useComposerSelection"; const environmentId = EnvironmentId.make("environment-local"); -const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-cursor")); const cursor = ProviderInstanceId.make("cursor"); const stored = createModelSelection(cursor, "claude-opus-5-5", [{ id: "effort", value: "medium" }]); @@ -38,24 +37,25 @@ const providers: ReadonlyArray = [ ]; // The hook reads only the thread's identity and stored selection. -const activeThread = { - environmentId, - id: threadRef.threadId, - modelSelection: stored, -} as EnvironmentThreadShell; +function cursorThread(id: string) { + return { environmentId, id: ThreadId.make(id), modelSelection: stored } as EnvironmentThreadShell; +} -function Picker() { +function Picker(props: { + thread: EnvironmentThreadShell; + runtime: Parameters[0]["activeRuntime"]; +}) { const { onProviderModelSelectStable } = useComposerSelection({ environmentId, - composerDraftTarget: threadRef, + composerDraftTarget: scopeThreadRef(environmentId, props.thread.id), draftThread: null, - activeThread, - activeRuntime: null, + activeThread: props.thread, + activeRuntime: props.runtime, activeProjectSettings: { settings: { newWorktreesStartFromOrigin: false } }, settings: DEFAULT_UNIFIED_SETTINGS, providerStatuses: providers, lockedProvider: null, - supportsProviderSwitchingViaHandoff: false, + supportsProviderSwitchingViaHandoff: true, multipleModelSelections: null, runtimeMode: "full-access", interactionMode: "default", @@ -73,12 +73,27 @@ function Picker() { ); } -it("keeps a Cursor thread's stored model when the user re-picks its unavailable row", async () => { - await using _screen = await render(); +it("keeps a Cursor thread's stored model and options when the user re-picks its unavailable row", async () => { + const thread = cursorThread("thread-cursor"); + await using _screen = await render(); + await page.getByRole("button", { name: "Pick the stored model" }).click(); + + const draft = useComposerDraftStore + .getState() + .getComposerDraft(scopeThreadRef(environmentId, thread.id)); + expect(draft?.modelSelectionByProvider[cursor] ?? thread.modelSelection).toEqual(stored); +}); + +it("switches back to the thread's stored model while the session runs on another instance", async () => { + const thread = cursorThread("thread-cursor-handoff"); + const runtime = { providerInstanceId: ProviderInstanceId.make("codex") } as Parameters< + typeof useComposerSelection + >[0]["activeRuntime"]; + await using _screen = await render(); await page.getByRole("button", { name: "Pick the stored model" }).click(); - expect( - useComposerDraftStore.getState().getComposerDraft(threadRef)?.modelSelectionByProvider[cursor] - ?.model, - ).toBe("claude-opus-5-5"); + const draft = useComposerDraftStore + .getState() + .getComposerDraft(scopeThreadRef(environmentId, thread.id)); + expect(draft?.activeProvider).toBe(cursor); }); diff --git a/apps/web/src/components/chat/useComposerSelection.ts b/apps/web/src/components/chat/useComposerSelection.ts index 0142d2c5353a..b8133fc93f0d 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -182,7 +182,20 @@ export function useComposerSelection(input: ComposerSelectionInput) { model, { preserveUnavailableSelection: true }, ); - if (!resolvedModel) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + const draft = useComposerDraftStore.getState().getComposerDraft(threadRef); + const shownInstanceId = + draft?.activeProvider ?? + activeRuntime?.providerInstanceId ?? + activeThread.modelSelection.instanceId; + const shownSelection = + draft?.modelSelectionByProvider[shownInstanceId] ?? activeThread.modelSelection; + if ( + !resolvedModel || + (instanceId === shownInstanceId && + shownSelection.instanceId === instanceId && + shownSelection.model === resolvedModel) + ) { if (options?.focusComposer !== false) scheduleComposerFocus(); return; } @@ -209,11 +222,10 @@ export function useComposerSelection(input: ComposerSelectionInput) { if (options?.focusComposer !== false) scheduleComposerFocus(); return; } - setComposerDraftModelSelection( - scopeThreadRef(activeThread.environmentId, activeThread.id), - nextModelSelection, - { explicit: true, replaceOptions: true }, - ); + setComposerDraftModelSelection(threadRef, nextModelSelection, { + explicit: true, + replaceOptions: true, + }); setStickyComposerModelSelection(nextModelSelection); if (options?.focusComposer !== false) scheduleComposerFocus(); }; From 5273207247e305a4cf7fea23368995276e551e34 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 18:43:39 +0000 Subject: [PATCH 4/8] test(web): wait for the draft store in the composer selection browser tests Co-Authored-By: Claude Opus 5.5 --- .../useComposerSelection.browser.test.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx index 647546d63ced..edd3b6487ef0 100644 --- a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx @@ -9,7 +9,7 @@ import { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; -import { expect, it } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; import { render } from "../../../test/render"; @@ -78,10 +78,12 @@ it("keeps a Cursor thread's stored model and options when the user re-picks its await using _screen = await render(); await page.getByRole("button", { name: "Pick the stored model" }).click(); - const draft = useComposerDraftStore - .getState() - .getComposerDraft(scopeThreadRef(environmentId, thread.id)); - expect(draft?.modelSelectionByProvider[cursor] ?? thread.modelSelection).toEqual(stored); + await vi.waitFor(() => { + const draft = useComposerDraftStore + .getState() + .getComposerDraft(scopeThreadRef(environmentId, thread.id)); + expect(draft?.modelSelectionByProvider[cursor] ?? thread.modelSelection).toEqual(stored); + }); }); it("switches back to the thread's stored model while the session runs on another instance", async () => { @@ -92,8 +94,10 @@ it("switches back to the thread's stored model while the session runs on another await using _screen = await render(); await page.getByRole("button", { name: "Pick the stored model" }).click(); - const draft = useComposerDraftStore - .getState() - .getComposerDraft(scopeThreadRef(environmentId, thread.id)); - expect(draft?.activeProvider).toBe(cursor); + await vi.waitFor(() => { + const draft = useComposerDraftStore + .getState() + .getComposerDraft(scopeThreadRef(environmentId, thread.id)); + expect(draft?.activeProvider).toBe(cursor); + }); }); From db66b196b0dc11f843f1efae237745fac149ab26 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 19:03:38 +0000 Subject: [PATCH 5/8] test(web): assert the composer's effective model and options after the Cursor switch-back Co-Authored-By: Claude Opus 5.5 --- .../chat/useComposerSelection.browser.test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx index edd3b6487ef0..51b809bf7d7f 100644 --- a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx @@ -13,7 +13,7 @@ import { expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; import { render } from "../../../test/render"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { deriveEffectiveComposerModelState, useComposerDraftStore } from "../../composerDraftStore"; import { useComposerSelection } from "./useComposerSelection"; const environmentId = EnvironmentId.make("environment-local"); @@ -99,5 +99,16 @@ it("switches back to the thread's stored model while the session runs on another .getState() .getComposerDraft(scopeThreadRef(environmentId, thread.id)); expect(draft?.activeProvider).toBe(cursor); + expect( + deriveEffectiveComposerModelState({ + draft, + providers, + selectedProvider: ProviderDriverKind.make("cursor"), + selectedInstanceId: cursor, + threadModelSelection: thread.modelSelection, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }), + ).toEqual({ selectedModel: stored.model, modelOptions: { [cursor]: stored.options } }); }); }); From 61fb13080d905bd6dbef5bf02d00d167d4369220 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 10 Nov 2026 13:34:20 +0000 Subject: [PATCH 6/8] fix(web): re-picking a model whose save failed saves it again Main now saves a composer pick on the thread right away. Re-picking the model the composer already shows changes nothing on this branch, so a pick whose save failed stayed unsaved until the next message. That re-pick now runs the save again and still keeps the shown options. Co-Authored-By: Claude Opus 5.5 --- .../useComposerSelection.browser.test.tsx | 71 +++++++++++++++++-- .../components/chat/useComposerSelection.ts | 2 + 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx index 9e2ffa959311..ecf225d25442 100644 --- a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx @@ -1,4 +1,5 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId, @@ -9,13 +10,45 @@ import { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; +const mocks = vi.hoisted(() => ({ + setModelSelection: null as ((value: unknown) => Promise>) | null, +})); + +vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({ + ...(await importOriginal()), + useOrchestrationCommand: (() => (value: unknown) => mocks.setModelSelection?.(value)) as never, +})); + +vi.mock(import("../../state/threads"), async (original) => { + const actual = await original(); + return { + ...actual, + environmentThreadShells: { + ...actual.environmentThreadShells, + threadShellAtom: ((ref: { threadId: string }) => shellAtom(ref.threadId)) as never, + }, + }; +}); + import { render } from "../../../test/render"; import { deriveEffectiveComposerModelState, useComposerDraftStore } from "../../composerDraftStore"; +import { AppAtomRegistryProvider, appAtomRegistry } from "../../rpc/atomRegistry"; import { useComposerSelection } from "./useComposerSelection"; +// Kept alive so a shell set before the hook mounts is still there when it saves a pick. +const shellAtom = Atom.family((_threadId: string) => Atom.make(null).pipe(Atom.keepAlive)); + +function setup() { + const setModelSelection = vi.fn<(value: unknown) => Promise>>(); + mocks.setModelSelection = setModelSelection; + return { setModelSelection }; +} + const environmentId = EnvironmentId.make("environment-local"); const cursor = ProviderInstanceId.make("cursor"); const stored = createModelSelection(cursor, "claude-opus-5-5", [{ id: "effort", value: "medium" }]); @@ -44,6 +77,7 @@ function cursorThread(id: string) { function Picker(props: { thread: EnvironmentThreadShell; runtime: Parameters[0]["activeRuntime"]; + model?: string; }) { const { onProviderModelSelectStable } = useComposerSelection({ environmentId, @@ -65,8 +99,11 @@ function Picker(props: { scheduleComposerFocus: () => {}, }); return ( - ); } @@ -74,7 +111,7 @@ function Picker(props: { it("keeps a Cursor thread's stored model and options when the user re-picks its unavailable row", async () => { const thread = cursorThread("thread-cursor"); await using _screen = await render(); - await page.getByRole("button", { name: "Pick the stored model" }).click(); + await page.getByRole("button", { name: "Pick the model" }).click(); await vi.waitFor(() => { const draft = useComposerDraftStore @@ -90,7 +127,7 @@ it("switches back to the thread's stored model while the session runs on another typeof useComposerSelection >[0]["activeRuntime"]; await using _screen = await render(); - await page.getByRole("button", { name: "Pick the stored model" }).click(); + await page.getByRole("button", { name: "Pick the model" }).click(); await vi.waitFor(() => { const draft = useComposerDraftStore @@ -110,3 +147,29 @@ it("switches back to the thread's stored model while the session runs on another ).toEqual({ selectedModel: stored.model, modelOptions: { [cursor]: stored.options } }); }); }); + +it("saves a re-picked model again when its first save failed", async () => { + const { setModelSelection } = setup(); + setModelSelection + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("offline")))) + .mockResolvedValue(AsyncResult.success(undefined)); + const thread = cursorThread("thread-cursor-retry"); + appAtomRegistry.set(shellAtom(thread.id), thread); + await using _screen = await render( + + + , + ); + const pick = page.getByRole("button", { name: "Pick the model" }); + await pick.click(); + await vi.waitFor(() => expect(setModelSelection).toHaveBeenCalledTimes(1)); + await pick.click(); + + const autoSave = expect.objectContaining({ + input: { + threadId: thread.id, + modelSelection: expect.objectContaining({ instanceId: cursor, model: "auto-smart" }), + }, + }); + await vi.waitFor(() => expect(setModelSelection.mock.calls).toEqual([[autoSave], [autoSave]])); +}); diff --git a/apps/web/src/components/chat/useComposerSelection.ts b/apps/web/src/components/chat/useComposerSelection.ts index b3fe109f18de..c9bba2ae76bc 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -174,6 +174,8 @@ export function useComposerSelection(input: ComposerSelectionInput) { shownSelection.instanceId === instanceId && shownSelection.model === resolvedModel) ) { + // Re-picking the shown model keeps its options and saves it again if that save failed. + if (resolvedModel && !isLocalDraftThread) applyThreadModelPick(threadRef); if (options?.focusComposer !== false) scheduleComposerFocus(); return; } From e24345a15cf64e8a48911d48aa8124b3da26eb70 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 10 Nov 2026 13:36:46 +0000 Subject: [PATCH 7/8] test(web): drop the plan mode input main removed from the Cursor send test Main replaced getComposerProviderState's planModeEnabled with the optional includePlanAgent. The send test's options hold no plan agent, so it needs neither. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/modelSelection.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 98e8bacd0332..dc3222648128 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -534,7 +534,6 @@ describe("instance-scoped model selection", () => { model: state.selectedModel, models: providers[0]!.models, modelOptions: state.modelOptions?.[instanceId], - planModeEnabled: false, }); expect( From dbd16825f817f71b079593899da8411172be3130 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:27:29 +0000 Subject: [PATCH 8/8] test(web): reset the model-save mock in every composer selection test AI-assisted; verified with the tests and tools listed above. --- .../src/components/chat/useComposerSelection.browser.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx index ecf225d25442..d5fb9496b575 100644 --- a/apps/web/src/components/chat/useComposerSelection.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx @@ -109,6 +109,7 @@ function Picker(props: { } it("keeps a Cursor thread's stored model and options when the user re-picks its unavailable row", async () => { + setup(); const thread = cursorThread("thread-cursor"); await using _screen = await render(); await page.getByRole("button", { name: "Pick the model" }).click(); @@ -122,6 +123,7 @@ it("keeps a Cursor thread's stored model and options when the user re-picks its }); it("switches back to the thread's stored model while the session runs on another instance", async () => { + setup(); const thread = cursorThread("thread-cursor-handoff"); const runtime = { providerInstanceId: ProviderInstanceId.make("codex") } as Parameters< typeof useComposerSelection