Skip to content
Open
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
14 changes: 11 additions & 3 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ import { CommandPaletteResults } from "./CommandPaletteResults";
import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
import { ProjectFavicon } from "./ProjectFavicon";
import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
import { primaryServerKeybindingsAtom } from "../state/server";
import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
import {
getDefaultProviderInstanceModel,
resolveSelectableProviderInstance,
} from "../providerInstances";
import { resolveShortcutCommand } from "../keybindings";
import {
Command,
Expand Down Expand Up @@ -476,6 +480,7 @@ function OpenCommandPaletteDialog(props: {
const projects = useProjects();
const threads = useThreadShells();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const providers = useAtomValue(primaryServerProvidersAtom);
const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
const currentView = viewStack.at(-1) ?? null;
const [browseGeneration, setBrowseGeneration] = useState(0);
Expand Down Expand Up @@ -1149,6 +1154,8 @@ function OpenCommandPaletteDialog(props: {
}

const projectId = newProjectId();
const defaultInstanceId =
resolveSelectableProviderInstance(providers, undefined) ?? ProviderInstanceId.make("codex");
const createResult = await createProject({
environmentId: input.environmentId,
input: {
Expand All @@ -1157,8 +1164,8 @@ function OpenCommandPaletteDialog(props: {
workspaceRoot: cwd,
createWorkspaceRootIfMissing: true,
defaultModelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: DEFAULT_MODEL,
instanceId: defaultInstanceId,
model: getDefaultProviderInstanceModel(providers, defaultInstanceId) ?? DEFAULT_MODEL,
},
},
});
Expand Down Expand Up @@ -1197,6 +1204,7 @@ function OpenCommandPaletteDialog(props: {
createProject,
navigate,
projects,
providers,
setOpen,
clientSettings.sidebarThreadSortOrder,
threads,
Expand Down
49 changes: 37 additions & 12 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
providerStatuses,
explicitSelectedInstanceId,
) ?? ProviderDriverKind.make("codex");
const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
const lockedContinuationGroupKey = useMemo((): string | null => {
if (!lockedProvider || !activeThread) return null;
const lockedInstanceId =
Expand Down Expand Up @@ -732,12 +732,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
}
if (explicitSelectedInstanceId) {
return ProviderInstanceId.make(explicitSelectedInstanceId);
// An id missing from the entries list may be a custom instance whose
// snapshot has not arrived yet, so it is trusted as-is. A known-but-
// disabled id is abandoned only when an enabled instance satisfies
// the thread's driver/continuation lock; otherwise it is kept so the
// send fails with the server error naming the disabled provider
// rather than a driver switch the thread cannot make.
const isKnownDisabled = providerInstanceEntries.some(
(entry) => entry.instanceId === explicitSelectedInstanceId && !entry.enabled,
);
const replacement = isKnownDisabled
? providerInstanceEntries.find(
(entry) =>
entry.enabled &&
(!lockedProvider || entry.driverKind === lockedProvider) &&
(!lockedContinuationGroupKey ||
entry.continuationGroupKey === lockedContinuationGroupKey),
)
: undefined;
if (!isKnownDisabled || replacement === undefined) {
return ProviderInstanceId.make(explicitSelectedInstanceId);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
const byKind = providerInstanceEntries.find(
(entry) =>
entry.enabled &&
entry.driverKind === selectedProvider &&
entry.driverKind === requestedDriverKind &&
(!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey),
);
if (byKind) return byKind.instanceId;
Expand All @@ -758,9 +778,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
lockedContinuationGroupKey,
lockedProvider,
providerInstanceEntries,
selectedProvider,
requestedDriverKind,
]);

// Resolve the active instance's snapshot by `instanceId` so a custom
// instance gets its own slash commands, skills, and model list — not
// the first snapshot for the same driver kind.
const selectedProviderEntry = useMemo(
() => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
[providerInstanceEntries, selectedInstanceId],
);
// The driver kind follows the instance that will actually run the turn,
// which can differ from the persisted selection when that selection is
// disabled.
const selectedProvider: ProviderDriverKind =
selectedProviderEntry?.driverKind ?? requestedDriverKind;

const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
threadRef: composerDraftTarget,
providers: providerStatuses,
Expand All @@ -770,14 +803,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
projectModelSelection: activeProjectDefaultModelSelection,
settings,
});

// Resolve the active instance's snapshot by `instanceId` so a custom
// instance gets its own slash commands, skills, and model list — not
// the first snapshot for the same driver kind.
const selectedProviderEntry = useMemo(
() => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
[providerInstanceEntries, selectedInstanceId],
);
const selectedProviderStatus = useMemo(
() => selectedProviderEntry?.snapshot ?? null,
[selectedProviderEntry],
Expand Down
115 changes: 113 additions & 2 deletions apps/web/src/providerInstances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
applyProviderInstanceSettings,
deriveProviderInstanceEntries,
getDefaultProviderInstanceModel,
isProviderInstancePickerReady,
isProviderInstancePickerVisible,
resolveSelectableProviderInstance,
Expand All @@ -15,6 +16,8 @@ function provider(input: {
enabled?: boolean;
availability?: ServerProvider["availability"];
displayName?: string;
status?: ServerProvider["status"];
models?: ServerProvider["models"];
}): ServerProvider {
return {
instanceId: ProviderInstanceId.make(input.instanceId),
Expand All @@ -23,11 +26,11 @@ function provider(input: {
enabled: input.enabled ?? true,
installed: true,
version: null,
status: "ready",
status: input.status ?? "ready",
...(input.availability ? { availability: input.availability } : {}),
auth: { status: "authenticated" },
checkedAt: "2026-01-01T00:00:00.000Z",
models: [],
models: input.models ?? [],
slashCommands: [],
skills: [],
};
Expand Down Expand Up @@ -146,6 +149,67 @@ describe("resolveSelectableProviderInstance", () => {
expect(resolveSelectableProviderInstance(providers, disabled)).toBe(fallback);
});

it("prefers a ready instance over an enabled one whose driver cannot start", () => {
const notInstalled = ProviderInstanceId.make("codex");
const ready = ProviderInstanceId.make("claudeAgent");
const providers = [
provider({
provider: ProviderDriverKind.make("codex"),
instanceId: notInstalled,
status: "error",
}),
provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: ready }),
];

expect(resolveSelectableProviderInstance(providers, undefined)).toBe(ready);
});

it("prefers an unprobed (warning) instance over one whose probe errored", () => {
const notInstalled = ProviderInstanceId.make("codex");
const unprobed = ProviderInstanceId.make("claudeAgent");
const providers = [
provider({
provider: ProviderDriverKind.make("codex"),
instanceId: notInstalled,
status: "error",
}),
provider({
provider: ProviderDriverKind.make("claudeAgent"),
instanceId: unprobed,
status: "warning",
}),
];

expect(resolveSelectableProviderInstance(providers, undefined)).toBe(unprobed);
});

it("keeps a requested instance even when its probe errored", () => {
const requested = ProviderInstanceId.make("codex");
const providers = [
provider({
provider: ProviderDriverKind.make("codex"),
instanceId: requested,
status: "error",
}),
provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }),
];

expect(resolveSelectableProviderInstance(providers, requested)).toBe(requested);
});

it("still falls back to an enabled instance when nothing is ready", () => {
const notInstalled = ProviderInstanceId.make("codex");
const providers = [
provider({
provider: ProviderDriverKind.make("codex"),
instanceId: notInstalled,
status: "error",
}),
];

expect(resolveSelectableProviderInstance(providers, undefined)).toBe(notInstalled);
});

it("does not return disabled, unavailable, or unknown instances when none are sendable", () => {
const disabled = ProviderInstanceId.make("codex");
const unavailable = ProviderInstanceId.make("claudeAgent");
Expand Down Expand Up @@ -206,3 +270,50 @@ describe("resolveProviderDriverKindForInstanceSelection", () => {
).toBeUndefined();
});
});

describe("getDefaultProviderInstanceModel", () => {
const model = (slug: string, isCustom = false) => ({
slug,
name: slug,
isCustom,
capabilities: {},
});

it("uses the instance's own models, not the default instance of the kind", () => {
const providers = [
provider({
provider: ProviderDriverKind.make("claudeAgent"),
instanceId: "claude_openrouter",
models: [model("openai/gpt-5.5", true), model("claude-opus-4-8")],
}),
provider({
provider: ProviderDriverKind.make("claudeAgent"),
instanceId: "claudeAgent",
models: [model("claude-sonnet-5")],
}),
];

expect(
getDefaultProviderInstanceModel(providers, ProviderInstanceId.make("claude_openrouter")),
).toBe("claude-opus-4-8");
});

it("falls back to the driver default when the instance reports no models", () => {
const providers = [
provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }),
];

const resolved = getDefaultProviderInstanceModel(
providers,
ProviderInstanceId.make("claudeAgent"),
);
expect(typeof resolved).toBe("string");
expect(resolved?.length).toBeGreaterThan(0);
});

it("returns undefined for an unknown instance", () => {
expect(
getDefaultProviderInstanceModel([], ProviderInstanceId.make("removed_instance")),
).toBeUndefined();
});
});
46 changes: 37 additions & 9 deletions apps/web/src/providerInstances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* @module providerInstances
*/
import {
DEFAULT_MODEL_BY_PROVIDER,
defaultInstanceIdForDriver,
PROVIDER_DISPLAY_NAMES,
type ProviderDriverKind,
Expand Down Expand Up @@ -253,6 +254,26 @@ export function getProviderInstanceModels(
return getProviderInstanceEntry(providers, instanceId)?.models ?? [];
}

/**
* Default model slug for a specific instance: its first built-in model,
* then any model it reports, then the driver-level default. Custom
* instances can serve a different model list than the default instance of
* the same driver kind, so the lookup must be instance-scoped rather than
* kind-scoped.
*/
export function getDefaultProviderInstanceModel(
providers: ReadonlyArray<ServerProvider>,
instanceId: ProviderInstanceId,
): string | undefined {
const entry = getProviderInstanceEntry(providers, instanceId);
if (!entry) return undefined;
return (
entry.models.find((model) => !model.isCustom)?.slug ??
entry.models[0]?.slug ??
DEFAULT_MODEL_BY_PROVIDER[entry.driverKind]
);
}

/**
* Resolve the routing key for a selection that may reference an instance
* id that no longer exists (e.g. a persisted thread selection after the
Expand All @@ -263,17 +284,24 @@ export function resolveSelectableProviderInstance(
providers: ReadonlyArray<ServerProvider>,
instanceId: ProviderInstanceId | undefined,
): ProviderInstanceId | undefined {
if (instanceId === undefined) {
return deriveProviderInstanceEntries(providers).find(
(entry) => entry.enabled && entry.isAvailable,
)?.instanceId;
}
const entries = deriveProviderInstanceEntries(providers);
const requested = entries.find((entry) => entry.instanceId === instanceId);
if (requested && requested.enabled && requested.isAvailable) {
return instanceId;
if (instanceId !== undefined) {
const requested = entries.find((entry) => entry.instanceId === instanceId);
if (requested && requested.enabled && requested.isAvailable) {
return instanceId;
}
}
return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId;
// "ready" instances can start a session right now. "warning" instances
// (not probed yet this session, or probed with a degraded result) may
// still work. "error" (e.g. the driver CLI is not installed) almost
// never starts, so a broken instance is chosen only when nothing else
// is configured.
const selectable = (entry: ProviderInstanceEntry) => entry.enabled && entry.isAvailable;
return (
entries.find(isProviderInstancePickerReady)?.instanceId ??
entries.find((entry) => selectable(entry) && entry.status !== "error")?.instanceId ??
entries.find(selectable)?.instanceId
);
}

/**
Expand Down
Loading