Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/TraitsPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/composerProviderState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
177 changes: 177 additions & 0 deletions apps/web/src/components/chat/useComposerSelection.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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<AtomCommandResult<void, unknown>>) | 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<unknown>(null).pipe(Atom.keepAlive));

function setup() {
const setModelSelection = vi.fn<(value: unknown) => Promise<AtomCommandResult<void, unknown>>>();
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<ServerProvider> = [
{
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<typeof useComposerSelection>[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 (
<button
type="button"
onClick={() => onProviderModelSelectStable(cursor, props.model ?? stored.model)}
>
Pick the model
</button>
);
}

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(<Picker thread={thread} runtime={null} />);
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(<Picker thread={thread} runtime={runtime} />);
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(
<AppAtomRegistryProvider>
<Picker thread={thread} runtime={null} model="auto-smart" />
</AppAtomRegistryProvider>,
);
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]]));
});
20 changes: 18 additions & 2 deletions apps/web/src/components/chat/useComposerSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
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;
}
Expand All @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/modelSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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");
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/modelSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ function appendUnavailableDynamicModelSelection(
selectedModel: string | null | undefined,
hiddenModels: ReadonlyArray<string>,
): 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;
Expand Down Expand Up @@ -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;
Expand Down
Loading