authorgravatar for mfazekas@szemafor.comMiklós Fazekas <mfazekas@szemafor.com> 2026-07-22 15:58:27+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 15:58:27+02:00
logb6e1b39335ffd34583c94f5b1e5e2c80520729fe
tree1ccbee9c94905fc17de762a7edb7427d38f22a92
parentc7b21ff172f8e64318ec681e8839ee60ca660fa4
signature Signed by PGP key B5690EEEBB952194

fix(web): default provider selection for users without Codex (#4117)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

7 files changed, 458 insertions(+), 97 deletions(-)

apps/web/src/components/ChatView.tsx+6-8
......@@ -152,6 +152,7 @@ import {
152152} from "~/projectScripts";
153153import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
154154import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
155import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
155156import { useEnvironmentSettings } from "../hooks/useSettings";
156157import { resolveAppModelSelectionForInstance } from "../modelSelection";
157158import { getTerminalFocusOwner } from "../lib/terminalFocus";
......@@ -1361,10 +1362,7 @@ function ChatViewContent(props: ChatViewProps) {
13611362 ? buildLocalDraftThread(
13621363 threadId,
13631364 draftThread,
1364 fallbackDraftProject?.defaultModelSelection ?? {
1365 instanceId: ProviderInstanceId.make("codex"),
1366 model: DEFAULT_MODEL,
1367 },
1365 fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION,
13681366 )
13691367 : undefined,
13701368 [draftThread, fallbackDraftProject?.defaultModelSelection, threadId],
......@@ -1856,7 +1854,7 @@ function ChatViewContent(props: ChatViewProps) {
18561854 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
18571855 const unlockedSelectedProvider = resolveSelectableProvider(
18581856 providerStatuses,
1859 selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"),
1857 selectedProviderByThreadId ?? threadProvider,
18601858 );
18611859 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
18621860 const phase = derivePhase(activeThread?.session ?? null);
......@@ -4038,7 +4036,7 @@ function ChatViewContent(props: ChatViewProps) {
40384036 return;
40394037 }
40404038 const sendCtx = composerRef.current?.getSendContext();
4041 if (!sendCtx) return;
4039 if (!sendCtx?.providerAvailable) return;
40424040 const {
40434041 images: composerImages,
40444042 terminalContexts: composerTerminalContexts,
......@@ -4614,7 +4612,7 @@ function ChatViewContent(props: ChatViewProps) {
46144612 }
46154613
46164614 const sendCtx = composerRef.current?.getSendContext();
4617 if (!sendCtx) {
4615 if (!sendCtx?.providerAvailable) {
46184616 return;
46194617 }
46204618 const {
......@@ -4773,7 +4771,7 @@ function ChatViewContent(props: ChatViewProps) {
47734771 }
47744772
47754773 const sendCtx = composerRef.current?.getSendContext();
4776 if (!sendCtx) {
4774 if (!sendCtx?.providerAvailable) {
47774775 return;
47784776 }
47794777 const {
apps/web/src/components/CommandPalette.tsx+14-7
......@@ -7,12 +7,10 @@ import {
77 squashAtomCommandFailure,
88} from "@t3tools/client-runtime/state/runtime";
99import {
10 DEFAULT_MODEL,
1110 type DesktopWslState,
1211 type EnvironmentId,
1312 type FilesystemBrowseResult,
1413 type ProjectId,
15 ProviderInstanceId,
1614 type SourceControlDiscoveryResult,
1715 type SourceControlProviderKind,
1816 type SourceControlRepositoryInfo,
......@@ -111,7 +109,8 @@ import { CommandPaletteResults } from "./CommandPaletteResults";
111109import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
112110import { ProjectFavicon } from "./ProjectFavicon";
113111import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
114import { primaryServerKeybindingsAtom } from "../state/server";
112import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
113import { resolveDefaultProviderModelSelection } from "../providerInstances";
115114import { resolveShortcutCommand } from "../keybindings";
116115import {
117116 Command,
......@@ -476,6 +475,7 @@ function OpenCommandPaletteDialog(props: {
476475 const projects = useProjects();
477476 const threads = useThreadShells();
478477 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
478 const providers = useAtomValue(primaryServerProvidersAtom);
479479 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
480480 const currentView = viewStack.at(-1) ?? null;
481481 const [browseGeneration, setBrowseGeneration] = useState(0);
......@@ -1149,6 +1149,10 @@ function OpenCommandPaletteDialog(props: {
11491149 }
11501150
11511151 const projectId = newProjectId();
1152 const targetEnvironmentProviders =
1153 environments.find((environment) => environment.environmentId === input.environmentId)
1154 ?.serverConfig?.providers ??
1155 (input.environmentId === primaryEnvironmentId ? providers : []);
11521156 const createResult = await createProject({
11531157 environmentId: input.environmentId,
11541158 input: {
......@@ -1156,10 +1160,10 @@ function OpenCommandPaletteDialog(props: {
11561160 title: inferProjectTitleFromPath(cwd),
11571161 workspaceRoot: cwd,
11581162 createWorkspaceRootIfMissing: true,
1159 defaultModelSelection: {
1160 instanceId: ProviderInstanceId.make("codex"),
1161 model: DEFAULT_MODEL,
1162 },
1163 defaultModelSelection: resolveDefaultProviderModelSelection(
1164 targetEnvironmentProviders,
1165 null,
1166 ),
11631167 },
11641168 });
11651169 if (createResult._tag === "Failure") {
......@@ -1195,8 +1199,11 @@ function OpenCommandPaletteDialog(props: {
11951199 [
11961200 handleNewThread,
11971201 createProject,
1202 environments,
11981203 navigate,
1204 primaryEnvironmentId,
11991205 projects,
1206 providers,
12001207 setOpen,
12011208 clientSettings.sidebarThreadSortOrder,
12021209 threads,
apps/web/src/components/chat/ChatComposer.tsx+100-57
......@@ -114,7 +114,9 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../
114114import {
115115 applyProviderInstanceSettings,
116116 deriveProviderInstanceEntries,
117 NO_PROVIDER_MODEL_SELECTION,
117118 resolveProviderDriverKindForInstanceSelection,
119 resolveSelectableProviderInstanceEntry,
118120 sortProviderInstanceEntries,
119121 type ProviderInstanceEntry,
120122} from "../../providerInstances";
......@@ -433,6 +435,7 @@ export interface ChatComposerHandle {
433435 selectedPromptEffort: string | null;
434436 selectedModelOptionsForDispatch: unknown;
435437 selectedModelSelection: ModelSelection;
438 providerAvailable: boolean;
436439 selectedProvider: ProviderDriverKind;
437440 selectedModel: string;
438441 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;
......@@ -696,8 +699,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
696699 providerInstanceEntries,
697700 providerStatuses,
698701 explicitSelectedInstanceId,
699 ) ?? ProviderDriverKind.make("codex");
700 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
702 ) ??
703 providerInstanceEntries[0]?.driverKind ??
704 ProviderDriverKind.make("unconfigured");
705 const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
701706 const lockedContinuationGroupKey = useMemo((): string | null => {
702707 if (!lockedProvider || !activeThread) return null;
703708 const lockedInstanceId =
......@@ -734,7 +739,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
734739 for (const candidate of candidates) {
735740 if (!candidate) continue;
736741 const match = providerInstanceEntries.find(
737 (entry) => entry.instanceId === candidate && entry.enabled,
742 (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable,
738743 );
739744 if (match) {
740745 // When locked to a specific driver kind, ignore persisted instance
......@@ -749,36 +754,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
749754 return match.instanceId;
750755 }
751756 }
752 if (explicitSelectedInstanceId) {
753 return ProviderInstanceId.make(explicitSelectedInstanceId);
754 }
755 const byKind = providerInstanceEntries.find(
757 const compatibleEntries = providerInstanceEntries.filter(
756758 (entry) =>
757 entry.enabled &&
758 entry.driverKind === selectedProvider &&
759 (!lockedProvider || entry.driverKind === lockedProvider) &&
759760 (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey),
760761 );
761 if (byKind) return byKind.instanceId;
762 const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled);
762 const requestedDriverEntries = compatibleEntries.filter(
763 (entry) => entry.driverKind === requestedDriverKind,
764 );
763765 return (
764 anyEnabled?.instanceId ??
765 providerInstanceEntries[0]?.instanceId ??
766 activeThreadModelSelection?.instanceId ??
767 activeProjectDefaultModelSelection?.instanceId ??
768 ProviderInstanceId.make("codex")
766 resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ??
767 resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ??
768 NO_PROVIDER_MODEL_SELECTION.instanceId
769769 );
770770 }, [
771771 activeProjectDefaultModelSelection?.instanceId,
772772 activeThread?.session?.providerInstanceId,
773773 activeThreadModelSelection?.instanceId,
774774 composerDraft.activeProvider,
775 explicitSelectedInstanceId,
776775 lockedContinuationGroupKey,
777776 lockedProvider,
778777 providerInstanceEntries,
779 selectedProvider,
778 requestedDriverKind,
780779 ]);
781780
781 // Resolve the active instance's snapshot by `instanceId` so a custom
782 // instance gets its own slash commands, skills, and model list — not
783 // the first snapshot for the same driver kind.
784 const selectedProviderEntry = useMemo(
785 () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
786 [providerInstanceEntries, selectedInstanceId],
787 );
788 const noProviderAvailable = selectedProviderEntry === undefined;
789 // The driver kind follows the instance that will actually run the turn,
790 // which can differ from the persisted selection when that selection is
791 // disabled.
792 const selectedProvider: ProviderDriverKind =
793 selectedProviderEntry?.driverKind ?? requestedDriverKind;
794
782795 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
783796 threadRef: composerDraftTarget,
784797 providers: providerStatuses,
......@@ -788,14 +801,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
788801 projectModelSelection: activeProjectDefaultModelSelection,
789802 settings,
790803 });
791
792 // Resolve the active instance's snapshot by `instanceId` so a custom
793 // instance gets its own slash commands, skills, and model list — not
794 // the first snapshot for the same driver kind.
795 const selectedProviderEntry = useMemo(
796 () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
797 [providerInstanceEntries, selectedInstanceId],
798 );
799804 const selectedProviderStatus = useMemo(
800805 () => selectedProviderEntry?.snapshot ?? null,
801806 [selectedProviderEntry],
......@@ -1158,6 +1163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11581163 phase === "running" ||
11591164 isSendBusy ||
11601165 isConnecting ||
1166 noProviderAvailable ||
11611167 projectSelectionRequired ||
11621168 environmentUnavailable !== null ||
11631169 !composerSendState.hasSendableContent;
......@@ -1698,7 +1704,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
16981704
16991705 const shouldBlurMobileComposerOnSubmit = useCallback(() => {
17001706 if (!isMobileViewport) return false;
1701 if (isSendBusy || isConnecting || environmentUnavailable !== null || phase === "running") {
1707 if (
1708 isSendBusy ||
1709 isConnecting ||
1710 noProviderAvailable ||
1711 environmentUnavailable !== null ||
1712 phase === "running"
1713 ) {
17021714 return false;
17031715 }
17041716 if (activePendingProgress) {
......@@ -1713,18 +1725,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17131725 isConnecting,
17141726 isMobileViewport,
17151727 isSendBusy,
1728 noProviderAvailable,
17161729 phase,
17171730 showPlanFollowUpPrompt,
17181731 ]);
17191732
17201733 const submitComposer = useCallback(
17211734 (event?: { preventDefault: () => void }) => {
1735 if (noProviderAvailable) {
1736 event?.preventDefault();
1737 return;
1738 }
17221739 onSend(event);
17231740 if (shouldBlurMobileComposerOnSubmit()) {
17241741 blurMobileComposerAfterSend();
17251742 }
17261743 },
1727 [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],
1744 [blurMobileComposerAfterSend, noProviderAvailable, onSend, shouldBlurMobileComposerOnSubmit],
17281745 );
17291746 const expandMobileComposer = useCallback(() => {
17301747 if (composerBlurFrameRef.current !== null) {
......@@ -2083,6 +2100,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20832100 selectedPromptEffort,
20842101 selectedModelOptionsForDispatch,
20852102 selectedModelSelection,
2103 providerAvailable: !noProviderAvailable,
20862104 selectedProvider,
20872105 selectedModel,
20882106 selectedProviderModels,
......@@ -2110,6 +2128,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21102128 selectedModel,
21112129 selectedModelOptionsForDispatch,
21122130 selectedModelSelection,
2131 noProviderAvailable,
21132132 selectedPromptEffort,
21142133 selectedProvider,
21152134 selectedProviderModels,
......@@ -2260,7 +2279,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22602279 isSendBusy={isSendBusy}
22612280 isConnecting={isConnecting}
22622281 isEnvironmentUnavailable={
2263 environmentUnavailable !== null || projectSelectionRequired
2282 environmentUnavailable !== null ||
2283 noProviderAvailable ||
2284 projectSelectionRequired
22642285 }
22652286 isPreparingWorktree={false}
22662287 hasSendableContent={false}
......@@ -2292,7 +2313,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22922313 {activePendingProgress
22932314 ? activePendingProgress.customAnswer ||
22942315 "Type your own answer, or leave this blank to use the selected option"
2295 : prompt.trim() || "Ask anything..."}
2316 : prompt.trim() ||
2317 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
22962318 </button>
22972319 <button
22982320 type="button"
......@@ -2501,9 +2523,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25012523 ? `${environmentUnavailable.label}: ${connectionStatusText(
25022524 environmentUnavailable.connection,
25032525 )}`
2504 : phase === "disconnected"
2505 ? "Ask for follow-up changes or attach images"
2506 : "Ask anything, @tag files/folders, $use skills, or / for commands"
2526 : noProviderAvailable
2527 ? "Enable a provider in Settings to send a message"
2528 : phase === "disconnected"
2529 ? "Ask for follow-up changes or attach images"
2530 : "Ask anything, @tag files/folders, $use skills, or / for commands"
25072531 }
25082532 disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}
25092533 />
......@@ -2521,7 +2545,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25212545 isSendBusy={isSendBusy}
25222546 isConnecting={isConnecting}
25232547 isEnvironmentUnavailable={
2524 environmentUnavailable !== null || projectSelectionRequired
2548 environmentUnavailable !== null ||
2549 noProviderAvailable ||
2550 projectSelectionRequired
25252551 }
25262552 isPreparingWorktree={false}
25272553 hasSendableContent={false}
......@@ -2556,28 +2582,43 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25562582 )}
25572583 >
25582584 <div className="-m-1 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
2559 <ProviderModelPicker
2560 compact={isComposerFooterCompact}
2561 activeInstanceId={selectedInstanceId}
2562 model={selectedModelForPickerWithCustomFallback}
2563 lockedProvider={lockedProvider}
2564 lockedContinuationGroupKey={lockedContinuationGroupKey}
2565 instanceEntries={providerInstanceEntries}
2566 keybindings={keybindings}
2567 modelOptionsByInstance={modelOptionsByInstance}
2568 terminalOpen={terminalOpen}
2569 open={isComposerModelPickerOpen}
2570 {...(composerProviderState.modelPickerIconClassName
2571 ? {
2572 activeProviderIconClassName: composerProviderState.modelPickerIconClassName,
2573 }
2574 : {})}
2575 onOpenChange={(open) => {
2576 setIsComposerModelPickerOpen(open);
2577 }}
2578 getModelDisabledReason={getModelDisabledReason}
2579 onInstanceModelChange={onProviderModelSelect}
2580 />
2585 {noProviderAvailable ? (
2586 <Button
2587 type="button"
2588 size="sm"
2589 variant="ghost"
2590 disabled
2591 data-chat-provider-unavailable="true"
2592 className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3"
2593 >
2594 <CircleAlertIcon className="size-4" />
2595 No provider available
2596 </Button>
2597 ) : (
2598 <ProviderModelPicker
2599 compact={isComposerFooterCompact}
2600 activeInstanceId={selectedInstanceId}
2601 model={selectedModelForPickerWithCustomFallback}
2602 lockedProvider={lockedProvider}
2603 lockedContinuationGroupKey={lockedContinuationGroupKey}
2604 instanceEntries={providerInstanceEntries}
2605 keybindings={keybindings}
2606 modelOptionsByInstance={modelOptionsByInstance}
2607 terminalOpen={terminalOpen}
2608 open={isComposerModelPickerOpen}
2609 {...(composerProviderState.modelPickerIconClassName
2610 ? {
2611 activeProviderIconClassName:
2612 composerProviderState.modelPickerIconClassName,
2613 }
2614 : {})}
2615 onOpenChange={(open) => {
2616 setIsComposerModelPickerOpen(open);
2617 }}
2618 getModelDisabledReason={getModelDisabledReason}
2619 onInstanceModelChange={onProviderModelSelect}
2620 />
2621 )}
25812622
25822623 {isComposerFooterCompact ? (
25832624 <CompactComposerControlsMenu
......@@ -2634,7 +2675,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26342675 isSendBusy={isSendBusy}
26352676 isConnecting={isConnecting}
26362677 isEnvironmentUnavailable={
2637 environmentUnavailable !== null || projectSelectionRequired
2678 environmentUnavailable !== null ||
2679 noProviderAvailable ||
2680 projectSelectionRequired
26382681 }
26392682 isPreparingWorktree={isPreparingWorktree}
26402683 hasSendableContent={composerSendState.hasSendableContent}
apps/web/src/providerInstances.test.ts+256-2
......@@ -3,8 +3,10 @@ import { describe, expect, it } from "vite-plus/test";
33import {
44 applyProviderInstanceSettings,
55 deriveProviderInstanceEntries,
6 getDefaultProviderInstanceModel,
67 isProviderInstancePickerReady,
78 isProviderInstancePickerVisible,
9 resolveDefaultProviderModelSelection,
810 resolveSelectableProviderInstance,
911 resolveProviderDriverKindForInstanceSelection,
1012} from "./providerInstances";
......@@ -15,6 +17,8 @@ function provider(input: {
1517 enabled?: boolean;
1618 availability?: ServerProvider["availability"];
1719 displayName?: string;
20 status?: ServerProvider["status"];
21 models?: ServerProvider["models"];
1822}): ServerProvider {
1923 return {
2024 instanceId: ProviderInstanceId.make(input.instanceId),
......@@ -23,16 +27,24 @@ function provider(input: {
2327 enabled: input.enabled ?? true,
2428 installed: true,
2529 version: null,
26 status: "ready",
30 status: input.status ?? "ready",
2731 ...(input.availability ? { availability: input.availability } : {}),
2832 auth: { status: "authenticated" },
2933 checkedAt: "2026-01-01T00:00:00.000Z",
30 models: [],
34 models: input.models ?? [],
3135 slashCommands: [],
3236 skills: [],
3337 };
3438}
3539
40const model = (slug: string, isCustom = false, isDefault = false) => ({
41 slug,
42 name: slug,
43 isCustom,
44 ...(isDefault ? { isDefault: true } : {}),
45 capabilities: {},
46});
47
3648describe("isProviderInstancePickerReady", () => {
3749 it("rejects a disabled instance even while its last probe status is ready", () => {
3850 const [entry] = deriveProviderInstanceEntries([
......@@ -146,6 +158,67 @@ describe("resolveSelectableProviderInstance", () => {
146158 expect(resolveSelectableProviderInstance(providers, disabled)).toBe(fallback);
147159 });
148160
161 it("prefers a ready instance over an enabled one whose driver cannot start", () => {
162 const notInstalled = ProviderInstanceId.make("codex");
163 const ready = ProviderInstanceId.make("claudeAgent");
164 const providers = [
165 provider({
166 provider: ProviderDriverKind.make("codex"),
167 instanceId: notInstalled,
168 status: "error",
169 }),
170 provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: ready }),
171 ];
172
173 expect(resolveSelectableProviderInstance(providers, undefined)).toBe(ready);
174 });
175
176 it("prefers an unprobed (warning) instance over one whose probe errored", () => {
177 const notInstalled = ProviderInstanceId.make("codex");
178 const unprobed = ProviderInstanceId.make("claudeAgent");
179 const providers = [
180 provider({
181 provider: ProviderDriverKind.make("codex"),
182 instanceId: notInstalled,
183 status: "error",
184 }),
185 provider({
186 provider: ProviderDriverKind.make("claudeAgent"),
187 instanceId: unprobed,
188 status: "warning",
189 }),
190 ];
191
192 expect(resolveSelectableProviderInstance(providers, undefined)).toBe(unprobed);
193 });
194
195 it("keeps a requested instance even when its probe errored", () => {
196 const requested = ProviderInstanceId.make("codex");
197 const providers = [
198 provider({
199 provider: ProviderDriverKind.make("codex"),
200 instanceId: requested,
201 status: "error",
202 }),
203 provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }),
204 ];
205
206 expect(resolveSelectableProviderInstance(providers, requested)).toBe(requested);
207 });
208
209 it("does not invent an errored instance as a new-user default", () => {
210 const notInstalled = ProviderInstanceId.make("codex");
211 const providers = [
212 provider({
213 provider: ProviderDriverKind.make("codex"),
214 instanceId: notInstalled,
215 status: "error",
216 }),
217 ];
218
219 expect(resolveSelectableProviderInstance(providers, undefined)).toBeUndefined();
220 });
221
149222 it("does not return disabled, unavailable, or unknown instances when none are sendable", () => {
150223 const disabled = ProviderInstanceId.make("codex");
151224 const unavailable = ProviderInstanceId.make("claudeAgent");
......@@ -206,3 +279,184 @@ describe("resolveProviderDriverKindForInstanceSelection", () => {
206279 ).toBeUndefined();
207280 });
208281});
282
283describe("getDefaultProviderInstanceModel", () => {
284 it("uses the instance's own models, not the default instance of the kind", () => {
285 const providers = [
286 provider({
287 provider: ProviderDriverKind.make("claudeAgent"),
288 instanceId: "claude_openrouter",
289 models: [model("openai/gpt-5.5", true), model("claude-opus-4-8")],
290 }),
291 provider({
292 provider: ProviderDriverKind.make("claudeAgent"),
293 instanceId: "claudeAgent",
294 models: [model("claude-sonnet-5")],
295 }),
296 ];
297
298 expect(
299 getDefaultProviderInstanceModel(providers, ProviderInstanceId.make("claude_openrouter")),
300 ).toBe("claude-opus-4-8");
301 });
302
303 it("falls back to the driver default when the instance reports no models", () => {
304 const providers = [
305 provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }),
306 ];
307
308 const resolved = getDefaultProviderInstanceModel(
309 providers,
310 ProviderInstanceId.make("claudeAgent"),
311 );
312 expect(typeof resolved).toBe("string");
313 expect(resolved?.length).toBeGreaterThan(0);
314 });
315
316 it("honors the instance's declared default before model-list order", () => {
317 const providers = [
318 provider({
319 provider: ProviderDriverKind.make("claudeAgent"),
320 instanceId: "claudeAgent",
321 models: [model("claude-sonnet-5"), model("claude-opus-4-8", false, true)],
322 }),
323 ];
324
325 expect(getDefaultProviderInstanceModel(providers, ProviderInstanceId.make("claudeAgent"))).toBe(
326 "claude-opus-4-8",
327 );
328 });
329
330 it("returns undefined for an unknown instance", () => {
331 expect(
332 getDefaultProviderInstanceModel([], ProviderInstanceId.make("removed_instance")),
333 ).toBeUndefined();
334 });
335});
336
337describe("resolveDefaultProviderModelSelection", () => {
338 it.each([
339 ["codex", "codex", "gpt-5.6"],
340 ["claudeAgent", "claudeAgent", "claude-fable-5"],
341 ["cursor", "cursor", "composer-2"],
342 ])("uses the only available %s instance", (driver, instanceId, modelSlug) => {
343 const providers = [
344 provider({
345 provider: ProviderDriverKind.make(driver),
346 instanceId,
347 models: [model(modelSlug, false, true)],
348 }),
349 ];
350
351 expect(resolveDefaultProviderModelSelection(providers, null)).toEqual({
352 instanceId,
353 model: modelSlug,
354 });
355 });
356
357 it("preserves a valid stored selection including its options", () => {
358 const providers = [
359 provider({
360 provider: ProviderDriverKind.make("claudeAgent"),
361 instanceId: "claudeAgent",
362 models: [model("claude-opus-4-8")],
363 }),
364 ];
365 const stored = {
366 instanceId: ProviderInstanceId.make("claudeAgent"),
367 model: "custom-model",
368 options: [{ id: "effort", value: "high" }],
369 };
370
371 expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored);
372 });
373
374 it("replaces a stale stored instance with the first ready instance and its model", () => {
375 const providers = [
376 provider({
377 provider: ProviderDriverKind.make("codex"),
378 instanceId: "codex",
379 status: "warning",
380 models: [model("gpt-5.6")],
381 }),
382 provider({
383 provider: ProviderDriverKind.make("claudeAgent"),
384 instanceId: "claudeAgent",
385 models: [model("claude-opus-4-8", false, true)],
386 }),
387 ];
388
389 expect(
390 resolveDefaultProviderModelSelection(providers, {
391 instanceId: ProviderInstanceId.make("removed-provider"),
392 model: "stale-model",
393 }),
394 ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" });
395 });
396
397 it.each([{ enabled: false }, { availability: "unavailable" as const }])(
398 "replaces an unavailable stored instance deterministically",
399 (requestedState) => {
400 const providers = [
401 provider({
402 provider: ProviderDriverKind.make("codex"),
403 instanceId: "codex",
404 models: [model("gpt-5.6")],
405 ...requestedState,
406 }),
407 provider({
408 provider: ProviderDriverKind.make("claudeAgent"),
409 instanceId: "claudeAgent",
410 models: [model("claude-opus-4-8", false, true)],
411 }),
412 ];
413
414 expect(
415 resolveDefaultProviderModelSelection(providers, {
416 instanceId: ProviderInstanceId.make("codex"),
417 model: "gpt-5.6",
418 }),
419 ).toEqual({ instanceId: "claudeAgent", model: "claude-opus-4-8" });
420 },
421 );
422
423 it("returns no selection for empty, disabled, unavailable, or error-only profiles", () => {
424 expect(resolveDefaultProviderModelSelection([], null)).toBeNull();
425 expect(
426 resolveDefaultProviderModelSelection(
427 [
428 provider({
429 provider: ProviderDriverKind.make("codex"),
430 instanceId: "codex",
431 enabled: false,
432 }),
433 ],
434 null,
435 ),
436 ).toBeNull();
437 expect(
438 resolveDefaultProviderModelSelection(
439 [
440 provider({
441 provider: ProviderDriverKind.make("codex"),
442 instanceId: "codex",
443 availability: "unavailable",
444 }),
445 ],
446 null,
447 ),
448 ).toBeNull();
449 expect(
450 resolveDefaultProviderModelSelection(
451 [
452 provider({
453 provider: ProviderDriverKind.make("codex"),
454 instanceId: "codex",
455 status: "error",
456 }),
457 ],
458 null,
459 ),
460 ).toBeNull();
461 });
462});
apps/web/src/providerInstances.ts+80-13
......@@ -13,10 +13,12 @@
1313 * @module providerInstances
1414 */
1515import {
16 DEFAULT_MODEL_BY_PROVIDER,
1617 defaultInstanceIdForDriver,
1718 PROVIDER_DISPLAY_NAMES,
19 type ModelSelection,
1820 type ProviderDriverKind,
19 type ProviderInstanceId,
21 ProviderInstanceId,
2022 type ServerProvider,
2123 type ServerProviderModel,
2224 type ServerSettings,
......@@ -25,6 +27,16 @@ import {
2527
2628import { formatProviderDriverKindLabel } from "./providerModels";
2729
30/**
31 * Local-only placeholder used while a draft has no provider it can safely
32 * target. It must never be persisted or dispatched; the composer disables
33 * send until a live provider replaces it.
34 */
35export const NO_PROVIDER_MODEL_SELECTION: ModelSelection = {
36 instanceId: ProviderInstanceId.make("t3code_no_provider"),
37 model: "",
38};
39
2840/**
2941 * UI-facing projection of one configured provider instance. Carries the
3042 * snapshot verbatim for callers that need server-side fields we don't
......@@ -253,27 +265,82 @@ export function getProviderInstanceModels(
253265 return getProviderInstanceEntry(providers, instanceId)?.models ?? [];
254266}
255267
268/**
269 * Default model slug for a specific instance: its declared built-in default,
270 * then its first built-in model, then any model it reports, then the driver-level default. Custom
271 * instances can serve a different model list than the default instance of
272 * the same driver kind, so the lookup must be instance-scoped rather than
273 * kind-scoped.
274 */
275export function getDefaultProviderInstanceModel(
276 providers: ReadonlyArray<ServerProvider>,
277 instanceId: ProviderInstanceId,
278): string | undefined {
279 const entry = getProviderInstanceEntry(providers, instanceId);
280 if (!entry) return undefined;
281 return (
282 entry.models.find((model) => model.isDefault && !model.isCustom)?.slug ??
283 entry.models.find((model) => !model.isCustom)?.slug ??
284 entry.models[0]?.slug ??
285 DEFAULT_MODEL_BY_PROVIDER[entry.driverKind]
286 );
287}
288
289const isSelectableProviderInstanceEntry = (entry: ProviderInstanceEntry): boolean =>
290 entry.enabled && entry.isAvailable;
291
292/**
293 * Resolve an exact stored instance when it remains enabled and available.
294 * Otherwise choose a deterministic fallback that can plausibly start now:
295 * ready first, then a non-error probe result. An errored provider is retained
296 * only when it was explicitly requested; it is never invented as a new-user
297 * default.
298 */
299export function resolveSelectableProviderInstanceEntry(
300 entries: ReadonlyArray<ProviderInstanceEntry>,
301 instanceId: ProviderInstanceId | undefined,
302): ProviderInstanceEntry | undefined {
303 if (instanceId !== undefined) {
304 const requested = entries.find((entry) => entry.instanceId === instanceId);
305 if (requested && isSelectableProviderInstanceEntry(requested)) {
306 return requested;
307 }
308 }
309 return (
310 entries.find(isProviderInstancePickerReady) ??
311 entries.find((entry) => isSelectableProviderInstanceEntry(entry) && entry.status !== "error")
312 );
313}
314
256315/**
257316 * Resolve the routing key for a selection that may reference an instance
258317 * id that no longer exists (e.g. a persisted thread selection after the
259 * user deleted the custom instance). Returns the first enabled instance
260 * as a fallback so downstream code can still send a turn.
318 * user deleted the custom instance). Returns a ready or non-error fallback,
319 * or `undefined` when no provider can safely become a new selection.
261320 */
262321export function resolveSelectableProviderInstance(
263322 providers: ReadonlyArray<ServerProvider>,
264323 instanceId: ProviderInstanceId | undefined,
265324): ProviderInstanceId | undefined {
266 if (instanceId === undefined) {
267 return deriveProviderInstanceEntries(providers).find(
268 (entry) => entry.enabled && entry.isAvailable,
269 )?.instanceId;
270 }
271325 const entries = deriveProviderInstanceEntries(providers);
272 const requested = entries.find((entry) => entry.instanceId === instanceId);
273 if (requested && requested.enabled && requested.isAvailable) {
274 return instanceId;
275 }
276 return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId;
326 return resolveSelectableProviderInstanceEntry(entries, instanceId)?.instanceId;
327}
328
329/**
330 * Resolve the model selection persisted for a project or new thread. A valid
331 * stored selection is preserved byte-for-byte. Falling back to another
332 * instance also resets the model to that instance's own default, avoiding
333 * cross-provider instance/model pairs.
334 */
335export function resolveDefaultProviderModelSelection(
336 providers: ReadonlyArray<ServerProvider>,
337 selection: ModelSelection | null | undefined,
338): ModelSelection | null {
339 const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId);
340 if (instanceId === undefined) return null;
341 if (selection?.instanceId === instanceId) return selection;
342 const model = getDefaultProviderInstanceModel(providers, instanceId);
343 return model ? { instanceId, model } : null;
277344}
278345
279346/**
packages/client-runtime/src/operations/projects.test.ts+1-5
......@@ -1,6 +1,5 @@
11import { describe, expect, it } from "vite-plus/test";
22import {
3 DEFAULT_MODEL,
43 EnvironmentId,
54 ProjectId,
65 CommandId,
......@@ -138,10 +137,7 @@ describe("add project shared logic", () => {
138137 title: "repo",
139138 workspaceRoot: "/work/repo",
140139 createWorkspaceRootIfMissing: true,
141 defaultModelSelection: {
142 instanceId: "codex",
143 model: DEFAULT_MODEL,
144 },
140 defaultModelSelection: null,
145141 });
146142 });
147143});
packages/client-runtime/src/operations/projects.ts+1-5
......@@ -7,7 +7,6 @@ import type {
77 SourceControlProviderKind,
88 SourceControlRepositoryInfo,
99} from "@t3tools/contracts";
10import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts";
1110import * as Arr from "effect/Array";
1211import * as Option from "effect/Option";
1312import * as Order from "effect/Order";
......@@ -215,10 +214,7 @@ export function buildProjectCreateCommand(input: {
215214 title: inferProjectTitleFromPath(input.workspaceRoot),
216215 workspaceRoot: input.workspaceRoot,
217216 createWorkspaceRootIfMissing: true,
218 defaultModelSelection: {
219 instanceId: ProviderInstanceId.make("codex"),
220 model: DEFAULT_MODEL,
221 },
217 defaultModelSelection: null,
222218 createdAt: input.createdAt,
223219 };
224220}