diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8b4bdf0b16e4..f316f60e61f7 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -113,7 +113,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 5154072a8d12..4cff71c95ca5 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -122,7 +122,7 @@ function getSelectedTraits( ) { const caps = getProviderModelCapabilities(models, model, provider, includePlanAgent); 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 44f89b9d4422..06b403b08291 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -86,7 +86,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com promptInjectionState = "none", includePlanAgent, } = 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/components/chat/useComposerSelection.browser.test.tsx b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx new file mode 100644 index 000000000000..d5fb9496b575 --- /dev/null +++ b/apps/web/src/components/chat/useComposerSelection.browser.test.tsx @@ -0,0 +1,177 @@ +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, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ServerProvider, +} 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" }]); + +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. +function cursorThread(id: string) { + return { environmentId, id: ThreadId.make(id), modelSelection: stored } as EnvironmentThreadShell; +} + +function Picker(props: { + thread: EnvironmentThreadShell; + runtime: Parameters[0]["activeRuntime"]; + model?: string; +}) { + const { onProviderModelSelectStable } = useComposerSelection({ + environmentId, + composerDraftTarget: scopeThreadRef(environmentId, props.thread.id), + draftThread: null, + activeThread: props.thread, + activeRuntime: props.runtime, + activeProjectSettings: { settings: { newWorktreesStartFromOrigin: false } }, + settings: DEFAULT_UNIFIED_SETTINGS, + providerStatuses: providers, + lockedProvider: null, + supportsProviderSwitchingViaHandoff: true, + multipleModelSelections: null, + runtimeMode: "full-access", + isLocalDraftThread: false, + canOverrideServerThreadEnvMode: false, + setPendingServerThreadEnvModeEntry: () => {}, + setPendingServerThreadStartFromOriginByThreadId: () => {}, + scheduleComposerFocus: () => {}, + }); + return ( + + ); +} + +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(); + + 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 () => { + setup(); + 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 model" }).click(); + + await vi.waitFor(() => { + const draft = useComposerDraftStore + .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 } }); + }); +}); + +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 1563c8c56b68..c9bba2ae76bc 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -152,13 +152,30 @@ 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) { + 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) + ) { + // 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; } @@ -184,7 +201,6 @@ export function useComposerSelection(input: ComposerSelectionInput) { if (options?.focusComposer !== false) scheduleComposerFocus(); return; } - const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); setComposerDraftModelSelection(threadRef, nextModelSelection, { explicit: true, replaceOptions: true, diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 1f112c48e01d..24b0668e9aa9 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -402,6 +402,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 = [ @@ -508,6 +513,34 @@ 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], + }); + + 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 61be2ece7632..97a9d3aaffc3 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -80,7 +80,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; @@ -282,7 +284,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;