diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f3145df4500..04bd3a97c05 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -152,6 +152,7 @@ import { } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; +import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -1361,10 +1362,7 @@ function ChatViewContent(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], @@ -1856,7 +1854,7 @@ function ChatViewContent(props: ChatViewProps) { const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), + selectedProviderByThreadId ?? threadProvider, ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); @@ -4038,7 +4036,7 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) return; + if (!sendCtx?.providerAvailable) return; const { images: composerImages, terminalContexts: composerTerminalContexts, @@ -4614,7 +4612,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) { + if (!sendCtx?.providerAvailable) { return; } const { @@ -4773,7 +4771,7 @@ function ChatViewContent(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx) { + if (!sendCtx?.providerAvailable) { return; } const { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..9d824e0f72d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -7,12 +7,10 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { - DEFAULT_MODEL, type DesktopWslState, type EnvironmentId, type FilesystemBrowseResult, type ProjectId, - ProviderInstanceId, type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, @@ -111,7 +109,8 @@ 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 { resolveDefaultProviderModelSelection } from "../providerInstances"; import { resolveShortcutCommand } from "../keybindings"; import { Command, @@ -476,6 +475,7 @@ function OpenCommandPaletteDialog(props: { const projects = useProjects(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -1149,6 +1149,10 @@ function OpenCommandPaletteDialog(props: { } const projectId = newProjectId(); + const targetEnvironmentProviders = + environments.find((environment) => environment.environmentId === input.environmentId) + ?.serverConfig?.providers ?? + (input.environmentId === primaryEnvironmentId ? providers : []); const createResult = await createProject({ environmentId: input.environmentId, input: { @@ -1156,10 +1160,10 @@ function OpenCommandPaletteDialog(props: { title: inferProjectTitleFromPath(cwd), workspaceRoot: cwd, createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + defaultModelSelection: resolveDefaultProviderModelSelection( + targetEnvironmentProviders, + null, + ), }, }); if (createResult._tag === "Failure") { @@ -1195,8 +1199,11 @@ function OpenCommandPaletteDialog(props: { [ handleNewThread, createProject, + environments, navigate, + primaryEnvironmentId, projects, + providers, setOpen, clientSettings.sidebarThreadSortOrder, threads, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 94e4af3bba6..591a07ac4c5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -114,7 +114,9 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, resolveProviderDriverKindForInstanceSelection, + resolveSelectableProviderInstanceEntry, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -433,6 +435,7 @@ export interface ChatComposerHandle { selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; + providerAvailable: boolean; selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray; @@ -696,8 +699,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerInstanceEntries, providerStatuses, explicitSelectedInstanceId, - ) ?? ProviderDriverKind.make("codex"); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + ) ?? + providerInstanceEntries[0]?.driverKind ?? + ProviderDriverKind.make("unconfigured"); + const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const lockedContinuationGroupKey = useMemo((): string | null => { if (!lockedProvider || !activeThread) return null; const lockedInstanceId = @@ -734,7 +739,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) for (const candidate of candidates) { if (!candidate) continue; const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled, + (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, ); if (match) { // When locked to a specific driver kind, ignore persisted instance @@ -749,36 +754,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return match.instanceId; } } - if (explicitSelectedInstanceId) { - return ProviderInstanceId.make(explicitSelectedInstanceId); - } - const byKind = providerInstanceEntries.find( + const compatibleEntries = providerInstanceEntries.filter( (entry) => - entry.enabled && - entry.driverKind === selectedProvider && + (!lockedProvider || entry.driverKind === lockedProvider) && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), ); - if (byKind) return byKind.instanceId; - const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); + const requestedDriverEntries = compatibleEntries.filter( + (entry) => entry.driverKind === requestedDriverKind, + ); return ( - anyEnabled?.instanceId ?? - providerInstanceEntries[0]?.instanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? - ProviderInstanceId.make("codex") + resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? + resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? + NO_PROVIDER_MODEL_SELECTION.instanceId ); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, - explicitSelectedInstanceId, 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], + ); + const noProviderAvailable = selectedProviderEntry === undefined; + // 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, @@ -788,14 +801,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], @@ -1158,6 +1163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) phase === "running" || isSendBusy || isConnecting || + noProviderAvailable || projectSelectionRequired || environmentUnavailable !== null || !composerSendState.hasSendableContent; @@ -1698,7 +1704,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; - if (isSendBusy || isConnecting || environmentUnavailable !== null || phase === "running") { + if ( + isSendBusy || + isConnecting || + noProviderAvailable || + environmentUnavailable !== null || + phase === "running" + ) { return false; } if (activePendingProgress) { @@ -1713,18 +1725,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting, isMobileViewport, isSendBusy, + noProviderAvailable, phase, showPlanFollowUpPrompt, ]); const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { + if (noProviderAvailable) { + event?.preventDefault(); + return; + } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], + [blurMobileComposerAfterSend, noProviderAvailable, onSend, shouldBlurMobileComposerOnSubmit], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -2083,6 +2100,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, + providerAvailable: !noProviderAvailable, selectedProvider, selectedModel, selectedProviderModels, @@ -2110,6 +2128,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, + noProviderAvailable, selectedPromptEffort, selectedProvider, selectedProviderModels, @@ -2260,7 +2279,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy={isSendBusy} isConnecting={isConnecting} isEnvironmentUnavailable={ - environmentUnavailable !== null || projectSelectionRequired + environmentUnavailable !== null || + noProviderAvailable || + projectSelectionRequired } isPreparingWorktree={false} hasSendableContent={false} @@ -2292,7 +2313,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {activePendingProgress ? activePendingProgress.customAnswer || "Type your own answer, or leave this blank to use the selected option" - : prompt.trim() || "Ask anything..."} + : prompt.trim() || + (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} + ) : ( + { + setIsComposerModelPickerOpen(open); + }} + getModelDisabledReason={getModelDisabledReason} + onInstanceModelChange={onProviderModelSelect} + /> + )} {isComposerFooterCompact ? ( ({ + slug, + name: slug, + isCustom, + ...(isDefault ? { isDefault: true } : {}), + capabilities: {}, +}); + describe("isProviderInstancePickerReady", () => { it("rejects a disabled instance even while its last probe status is ready", () => { const [entry] = deriveProviderInstanceEntries([ @@ -146,6 +158,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("does not invent an errored instance as a new-user default", () => { + const notInstalled = ProviderInstanceId.make("codex"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: notInstalled, + status: "error", + }), + ]; + + expect(resolveSelectableProviderInstance(providers, undefined)).toBeUndefined(); + }); + it("does not return disabled, unavailable, or unknown instances when none are sendable", () => { const disabled = ProviderInstanceId.make("codex"); const unavailable = ProviderInstanceId.make("claudeAgent"); @@ -206,3 +279,184 @@ describe("resolveProviderDriverKindForInstanceSelection", () => { ).toBeUndefined(); }); }); + +describe("getDefaultProviderInstanceModel", () => { + 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("honors the instance's declared default before model-list order", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-sonnet-5"), model("claude-opus-4-8", false, true)], + }), + ]; + + expect(getDefaultProviderInstanceModel(providers, ProviderInstanceId.make("claudeAgent"))).toBe( + "claude-opus-4-8", + ); + }); + + it("returns undefined for an unknown instance", () => { + expect( + getDefaultProviderInstanceModel([], ProviderInstanceId.make("removed_instance")), + ).toBeUndefined(); + }); +}); + +describe("resolveDefaultProviderModelSelection", () => { + it.each([ + ["codex", "codex", "gpt-5.6"], + ["claudeAgent", "claudeAgent", "claude-fable-5"], + ["cursor", "cursor", "composer-2"], + ])("uses the only available %s instance", (driver, instanceId, modelSlug) => { + const providers = [ + provider({ + provider: ProviderDriverKind.make(driver), + instanceId, + models: [model(modelSlug, false, true)], + }), + ]; + + expect(resolveDefaultProviderModelSelection(providers, null)).toEqual({ + instanceId, + model: modelSlug, + }); + }); + + it("preserves a valid stored selection including its options", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8")], + }), + ]; + const stored = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "custom-model", + options: [{ id: "effort", value: "high" }], + }; + + expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored); + }); + + it("replaces a stale stored instance with the first ready instance and its model", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + status: "warning", + models: [model("gpt-5.6")], + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8", false, true)], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("removed-provider"), + model: "stale-model", + }), + ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" }); + }); + + it.each([{ enabled: false }, { availability: "unavailable" as const }])( + "replaces an unavailable stored instance deterministically", + (requestedState) => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + models: [model("gpt-5.6")], + ...requestedState, + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [model("claude-opus-4-8", false, true)], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6", + }), + ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" }); + }, + ); + + it("returns no selection for empty, disabled, unavailable, or error-only profiles", () => { + expect(resolveDefaultProviderModelSelection([], null)).toBeNull(); + expect( + resolveDefaultProviderModelSelection( + [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + enabled: false, + }), + ], + null, + ), + ).toBeNull(); + expect( + resolveDefaultProviderModelSelection( + [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + availability: "unavailable", + }), + ], + null, + ), + ).toBeNull(); + expect( + resolveDefaultProviderModelSelection( + [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + status: "error", + }), + ], + null, + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c9ac87ac39f..337e68d44d0 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -13,10 +13,12 @@ * @module providerInstances */ import { + DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, PROVIDER_DISPLAY_NAMES, + type ModelSelection, type ProviderDriverKind, - type ProviderInstanceId, + ProviderInstanceId, type ServerProvider, type ServerProviderModel, type ServerSettings, @@ -25,6 +27,16 @@ import { import { formatProviderDriverKindLabel } from "./providerModels"; +/** + * Local-only placeholder used while a draft has no provider it can safely + * target. It must never be persisted or dispatched; the composer disables + * send until a live provider replaces it. + */ +export const NO_PROVIDER_MODEL_SELECTION: ModelSelection = { + instanceId: ProviderInstanceId.make("t3code_no_provider"), + model: "", +}; + /** * UI-facing projection of one configured provider instance. Carries the * snapshot verbatim for callers that need server-side fields we don't @@ -253,27 +265,82 @@ export function getProviderInstanceModels( return getProviderInstanceEntry(providers, instanceId)?.models ?? []; } +/** + * Default model slug for a specific instance: its declared built-in default, + * then 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, + instanceId: ProviderInstanceId, +): string | undefined { + const entry = getProviderInstanceEntry(providers, instanceId); + if (!entry) return undefined; + return ( + entry.models.find((model) => model.isDefault && !model.isCustom)?.slug ?? + entry.models.find((model) => !model.isCustom)?.slug ?? + entry.models[0]?.slug ?? + DEFAULT_MODEL_BY_PROVIDER[entry.driverKind] + ); +} + +const isSelectableProviderInstanceEntry = (entry: ProviderInstanceEntry): boolean => + entry.enabled && entry.isAvailable; + +/** + * Resolve an exact stored instance when it remains enabled and available. + * Otherwise choose a deterministic fallback that can plausibly start now: + * ready first, then a non-error probe result. An errored provider is retained + * only when it was explicitly requested; it is never invented as a new-user + * default. + */ +export function resolveSelectableProviderInstanceEntry( + entries: ReadonlyArray, + instanceId: ProviderInstanceId | undefined, +): ProviderInstanceEntry | undefined { + if (instanceId !== undefined) { + const requested = entries.find((entry) => entry.instanceId === instanceId); + if (requested && isSelectableProviderInstanceEntry(requested)) { + return requested; + } + } + return ( + entries.find(isProviderInstancePickerReady) ?? + entries.find((entry) => isSelectableProviderInstanceEntry(entry) && entry.status !== "error") + ); +} + /** * 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 - * user deleted the custom instance). Returns the first enabled instance - * as a fallback so downstream code can still send a turn. + * user deleted the custom instance). Returns a ready or non-error fallback, + * or `undefined` when no provider can safely become a new selection. */ export function resolveSelectableProviderInstance( providers: ReadonlyArray, 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; - } - return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; + return resolveSelectableProviderInstanceEntry(entries, instanceId)?.instanceId; +} + +/** + * Resolve the model selection persisted for a project or new thread. A valid + * stored selection is preserved byte-for-byte. Falling back to another + * instance also resets the model to that instance's own default, avoiding + * cross-provider instance/model pairs. + */ +export function resolveDefaultProviderModelSelection( + providers: ReadonlyArray, + selection: ModelSelection | null | undefined, +): ModelSelection | null { + const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId); + if (instanceId === undefined) return null; + if (selection?.instanceId === instanceId) return selection; + const model = getDefaultProviderInstanceModel(providers, instanceId); + return model ? { instanceId, model } : null; } /** diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index f3bc72603ac..11b49742460 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; import { - DEFAULT_MODEL, EnvironmentId, ProjectId, CommandId, @@ -138,10 +137,7 @@ describe("add project shared logic", () => { title: "repo", workspaceRoot: "/work/repo", createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: "codex", - model: DEFAULT_MODEL, - }, + defaultModelSelection: null, }); }); }); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index ec58418a94f..6ae6e18baa2 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -7,7 +7,6 @@ import type { SourceControlProviderKind, SourceControlRepositoryInfo, } from "@t3tools/contracts"; -import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; @@ -215,10 +214,7 @@ export function buildProjectCreateCommand(input: { title: inferProjectTitleFromPath(input.workspaceRoot), workspaceRoot: input.workspaceRoot, createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + defaultModelSelection: null, createdAt: input.createdAt, }; }