| author | |
| committer | |
| log | b6e1b39335ffd34583c94f5b1e5e2c80520729fe |
| tree | 1ccbee9c94905fc17de762a7edb7427d38f22a92 |
| parent | c7b21ff172f8e64318ec681e8839ee60ca660fa4 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { | ... | @@ -152,6 +152,7 @@ import { |
| 152 | } from "~/projectScripts"; | 152 | } from "~/projectScripts"; |
| 153 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | 153 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 154 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | 154 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 155 | import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; | ||
| 155 | import { useEnvironmentSettings } from "../hooks/useSettings"; | 156 | import { useEnvironmentSettings } from "../hooks/useSettings"; |
| 156 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 157 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 157 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; | 158 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| ... | @@ -1361,10 +1362,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1361,10 +1362,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1361 | ? buildLocalDraftThread( | 1362 | ? buildLocalDraftThread( |
| 1362 | threadId, | 1363 | threadId, |
| 1363 | draftThread, | 1364 | draftThread, |
| 1364 | fallbackDraftProject?.defaultModelSelection ?? { | 1365 | fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, |
| 1365 | instanceId: ProviderInstanceId.make("codex"), | ||
| 1366 | model: DEFAULT_MODEL, | ||
| 1367 | }, | ||
| 1368 | ) | 1366 | ) |
| 1369 | : undefined, | 1367 | : undefined, |
| 1370 | [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], | 1368 | [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], |
| ... | @@ -1856,7 +1854,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1856,7 +1854,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1856 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; | 1854 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; |
| 1857 | const unlockedSelectedProvider = resolveSelectableProvider( | 1855 | const unlockedSelectedProvider = resolveSelectableProvider( |
| 1858 | providerStatuses, | 1856 | providerStatuses, |
| 1859 | selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), | 1857 | selectedProviderByThreadId ?? threadProvider, |
| 1860 | ); | 1858 | ); |
| 1861 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; | 1859 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; |
| 1862 | const phase = derivePhase(activeThread?.session ?? null); | 1860 | const phase = derivePhase(activeThread?.session ?? null); |
| ... | @@ -4038,7 +4036,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4038,7 +4036,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4038 | return; | 4036 | return; |
| 4039 | } | 4037 | } |
| 4040 | const sendCtx = composerRef.current?.getSendContext(); | 4038 | const sendCtx = composerRef.current?.getSendContext(); |
| 4041 | if (!sendCtx) return; | 4039 | if (!sendCtx?.providerAvailable) return; |
| 4042 | const { | 4040 | const { |
| 4043 | images: composerImages, | 4041 | images: composerImages, |
| 4044 | terminalContexts: composerTerminalContexts, | 4042 | terminalContexts: composerTerminalContexts, |
| ... | @@ -4614,7 +4612,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4614,7 +4612,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4614 | } | 4612 | } |
| 4615 | 4613 | ||
| 4616 | const sendCtx = composerRef.current?.getSendContext(); | 4614 | const sendCtx = composerRef.current?.getSendContext(); |
| 4617 | if (!sendCtx) { | 4615 | if (!sendCtx?.providerAvailable) { |
| 4618 | return; | 4616 | return; |
| 4619 | } | 4617 | } |
| 4620 | const { | 4618 | const { |
| ... | @@ -4773,7 +4771,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4773,7 +4771,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4773 | } | 4771 | } |
| 4774 | 4772 | ||
| 4775 | const sendCtx = composerRef.current?.getSendContext(); | 4773 | const sendCtx = composerRef.current?.getSendContext(); |
| 4776 | if (!sendCtx) { | 4774 | if (!sendCtx?.providerAvailable) { |
| 4777 | return; | 4775 | return; |
| 4778 | } | 4776 | } |
| 4779 | const { | 4777 | const { |
apps/web/src/components/CommandPalette.tsx+14-7| ... | @@ -7,12 +7,10 @@ import { | ... | @@ -7,12 +7,10 @@ import { |
| 7 | squashAtomCommandFailure, | 7 | squashAtomCommandFailure, |
| 8 | } from "@t3tools/client-runtime/state/runtime"; | 8 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { | 9 | import { |
| 10 | DEFAULT_MODEL, | ||
| 11 | type DesktopWslState, | 10 | type DesktopWslState, |
| 12 | type EnvironmentId, | 11 | type EnvironmentId, |
| 13 | type FilesystemBrowseResult, | 12 | type FilesystemBrowseResult, |
| 14 | type ProjectId, | 13 | type ProjectId, |
| 15 | ProviderInstanceId, | ||
| 16 | type SourceControlDiscoveryResult, | 14 | type SourceControlDiscoveryResult, |
| 17 | type SourceControlProviderKind, | 15 | type SourceControlProviderKind, |
| 18 | type SourceControlRepositoryInfo, | 16 | type SourceControlRepositoryInfo, |
| ... | @@ -111,7 +109,8 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; | ... | @@ -111,7 +109,8 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; |
| 111 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; | 109 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; |
| 112 | import { ProjectFavicon } from "./ProjectFavicon"; | 110 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 113 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; | 111 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 114 | import { primaryServerKeybindingsAtom } from "../state/server"; | 112 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; |
| 113 | import { resolveDefaultProviderModelSelection } from "../providerInstances"; | ||
| 115 | import { resolveShortcutCommand } from "../keybindings"; | 114 | import { resolveShortcutCommand } from "../keybindings"; |
| 116 | import { | 115 | import { |
| 117 | Command, | 116 | Command, |
| ... | @@ -476,6 +475,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -476,6 +475,7 @@ function OpenCommandPaletteDialog(props: { |
| 476 | const projects = useProjects(); | 475 | const projects = useProjects(); |
| 477 | const threads = useThreadShells(); | 476 | const threads = useThreadShells(); |
| 478 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 477 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 478 | const providers = useAtomValue(primaryServerProvidersAtom); | ||
| 479 | const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]); | 479 | const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]); |
| 480 | const currentView = viewStack.at(-1) ?? null; | 480 | const currentView = viewStack.at(-1) ?? null; |
| 481 | const [browseGeneration, setBrowseGeneration] = useState(0); | 481 | const [browseGeneration, setBrowseGeneration] = useState(0); |
| ... | @@ -1149,6 +1149,10 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1149,6 +1149,10 @@ function OpenCommandPaletteDialog(props: { |
| 1149 | } | 1149 | } |
| 1150 | 1150 | ||
| 1151 | const projectId = newProjectId(); | 1151 | const projectId = newProjectId(); |
| 1152 | const targetEnvironmentProviders = | ||
| 1153 | environments.find((environment) => environment.environmentId === input.environmentId) | ||
| 1154 | ?.serverConfig?.providers ?? | ||
| 1155 | (input.environmentId === primaryEnvironmentId ? providers : []); | ||
| 1152 | const createResult = await createProject({ | 1156 | const createResult = await createProject({ |
| 1153 | environmentId: input.environmentId, | 1157 | environmentId: input.environmentId, |
| 1154 | input: { | 1158 | input: { |
| ... | @@ -1156,10 +1160,10 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1156,10 +1160,10 @@ function OpenCommandPaletteDialog(props: { |
| 1156 | title: inferProjectTitleFromPath(cwd), | 1160 | title: inferProjectTitleFromPath(cwd), |
| 1157 | workspaceRoot: cwd, | 1161 | workspaceRoot: cwd, |
| 1158 | createWorkspaceRootIfMissing: true, | 1162 | createWorkspaceRootIfMissing: true, |
| 1159 | defaultModelSelection: { | 1163 | defaultModelSelection: resolveDefaultProviderModelSelection( |
| 1160 | instanceId: ProviderInstanceId.make("codex"), | 1164 | targetEnvironmentProviders, |
| 1161 | model: DEFAULT_MODEL, | 1165 | null, |
| 1162 | }, | 1166 | ), |
| 1163 | }, | 1167 | }, |
| 1164 | }); | 1168 | }); |
| 1165 | if (createResult._tag === "Failure") { | 1169 | if (createResult._tag === "Failure") { |
| ... | @@ -1195,8 +1199,11 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1195,8 +1199,11 @@ function OpenCommandPaletteDialog(props: { |
| 1195 | [ | 1199 | [ |
| 1196 | handleNewThread, | 1200 | handleNewThread, |
| 1197 | createProject, | 1201 | createProject, |
| 1202 | environments, | ||
| 1198 | navigate, | 1203 | navigate, |
| 1204 | primaryEnvironmentId, | ||
| 1199 | projects, | 1205 | projects, |
| 1206 | providers, | ||
| 1200 | setOpen, | 1207 | setOpen, |
| 1201 | clientSettings.sidebarThreadSortOrder, | 1208 | clientSettings.sidebarThreadSortOrder, |
| 1202 | threads, | 1209 | threads, |
apps/web/src/components/chat/ChatComposer.tsx+100-57| ... | @@ -114,7 +114,9 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ | ... | @@ -114,7 +114,9 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ |
| 114 | import { | 114 | import { |
| 115 | applyProviderInstanceSettings, | 115 | applyProviderInstanceSettings, |
| 116 | deriveProviderInstanceEntries, | 116 | deriveProviderInstanceEntries, |
| 117 | NO_PROVIDER_MODEL_SELECTION, | ||
| 117 | resolveProviderDriverKindForInstanceSelection, | 118 | resolveProviderDriverKindForInstanceSelection, |
| 119 | resolveSelectableProviderInstanceEntry, | ||
| 118 | sortProviderInstanceEntries, | 120 | sortProviderInstanceEntries, |
| 119 | type ProviderInstanceEntry, | 121 | type ProviderInstanceEntry, |
| 120 | } from "../../providerInstances"; | 122 | } from "../../providerInstances"; |
| ... | @@ -433,6 +435,7 @@ export interface ChatComposerHandle { | ... | @@ -433,6 +435,7 @@ export interface ChatComposerHandle { |
| 433 | selectedPromptEffort: string | null; | 435 | selectedPromptEffort: string | null; |
| 434 | selectedModelOptionsForDispatch: unknown; | 436 | selectedModelOptionsForDispatch: unknown; |
| 435 | selectedModelSelection: ModelSelection; | 437 | selectedModelSelection: ModelSelection; |
| 438 | providerAvailable: boolean; | ||
| 436 | selectedProvider: ProviderDriverKind; | 439 | selectedProvider: ProviderDriverKind; |
| 437 | selectedModel: string; | 440 | selectedModel: string; |
| 438 | selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>; | 441 | selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>; |
| ... | @@ -696,8 +699,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -696,8 +699,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 696 | providerInstanceEntries, | 699 | providerInstanceEntries, |
| 697 | providerStatuses, | 700 | providerStatuses, |
| 698 | explicitSelectedInstanceId, | 701 | explicitSelectedInstanceId, |
| 699 | ) ?? ProviderDriverKind.make("codex"); | 702 | ) ?? |
| 700 | const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; | 703 | providerInstanceEntries[0]?.driverKind ?? |
| 704 | ProviderDriverKind.make("unconfigured"); | ||
| 705 | const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; | ||
| 701 | const lockedContinuationGroupKey = useMemo((): string | null => { | 706 | const lockedContinuationGroupKey = useMemo((): string | null => { |
| 702 | if (!lockedProvider || !activeThread) return null; | 707 | if (!lockedProvider || !activeThread) return null; |
| 703 | const lockedInstanceId = | 708 | const lockedInstanceId = |
| ... | @@ -734,7 +739,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -734,7 +739,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 734 | for (const candidate of candidates) { | 739 | for (const candidate of candidates) { |
| 735 | if (!candidate) continue; | 740 | if (!candidate) continue; |
| 736 | const match = providerInstanceEntries.find( | 741 | const match = providerInstanceEntries.find( |
| 737 | (entry) => entry.instanceId === candidate && entry.enabled, | 742 | (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, |
| 738 | ); | 743 | ); |
| 739 | if (match) { | 744 | if (match) { |
| 740 | // When locked to a specific driver kind, ignore persisted instance | 745 | // When locked to a specific driver kind, ignore persisted instance |
| ... | @@ -749,36 +754,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -749,36 +754,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 749 | return match.instanceId; | 754 | return match.instanceId; |
| 750 | } | 755 | } |
| 751 | } | 756 | } |
| 752 | if (explicitSelectedInstanceId) { | 757 | const compatibleEntries = providerInstanceEntries.filter( |
| 753 | return ProviderInstanceId.make(explicitSelectedInstanceId); | ||
| 754 | } | ||
| 755 | const byKind = providerInstanceEntries.find( | ||
| 756 | (entry) => | 758 | (entry) => |
| 757 | entry.enabled && | 759 | (!lockedProvider || entry.driverKind === lockedProvider) && |
| 758 | entry.driverKind === selectedProvider && | ||
| 759 | (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), | 760 | (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), |
| 760 | ); | 761 | ); |
| 761 | if (byKind) return byKind.instanceId; | 762 | const requestedDriverEntries = compatibleEntries.filter( |
| 762 | const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); | 763 | (entry) => entry.driverKind === requestedDriverKind, |
| 764 | ); | ||
| 763 | return ( | 765 | return ( |
| 764 | anyEnabled?.instanceId ?? | 766 | resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? |
| 765 | providerInstanceEntries[0]?.instanceId ?? | 767 | resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? |
| 766 | activeThreadModelSelection?.instanceId ?? | 768 | NO_PROVIDER_MODEL_SELECTION.instanceId |
| 767 | activeProjectDefaultModelSelection?.instanceId ?? | ||
| 768 | ProviderInstanceId.make("codex") | ||
| 769 | ); | 769 | ); |
| 770 | }, [ | 770 | }, [ |
| 771 | activeProjectDefaultModelSelection?.instanceId, | 771 | activeProjectDefaultModelSelection?.instanceId, |
| 772 | activeThread?.session?.providerInstanceId, | 772 | activeThread?.session?.providerInstanceId, |
| 773 | activeThreadModelSelection?.instanceId, | 773 | activeThreadModelSelection?.instanceId, |
| 774 | composerDraft.activeProvider, | 774 | composerDraft.activeProvider, |
| 775 | explicitSelectedInstanceId, | ||
| 776 | lockedContinuationGroupKey, | 775 | lockedContinuationGroupKey, |
| 777 | lockedProvider, | 776 | lockedProvider, |
| 778 | providerInstanceEntries, | 777 | providerInstanceEntries, |
| 779 | selectedProvider, | 778 | requestedDriverKind, |
| 780 | ]); | 779 | ]); |
| 781 | 780 | ||
| 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 | |||
| 782 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ | 795 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 783 | threadRef: composerDraftTarget, | 796 | threadRef: composerDraftTarget, |
| 784 | providers: providerStatuses, | 797 | providers: providerStatuses, |
| ... | @@ -788,14 +801,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -788,14 +801,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 788 | projectModelSelection: activeProjectDefaultModelSelection, | 801 | projectModelSelection: activeProjectDefaultModelSelection, |
| 789 | settings, | 802 | settings, |
| 790 | }); | 803 | }); |
| 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 | ); | ||
| 799 | const selectedProviderStatus = useMemo( | 804 | const selectedProviderStatus = useMemo( |
| 800 | () => selectedProviderEntry?.snapshot ?? null, | 805 | () => selectedProviderEntry?.snapshot ?? null, |
| 801 | [selectedProviderEntry], | 806 | [selectedProviderEntry], |
| ... | @@ -1158,6 +1163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1158,6 +1163,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1158 | phase === "running" || | 1163 | phase === "running" || |
| 1159 | isSendBusy || | 1164 | isSendBusy || |
| 1160 | isConnecting || | 1165 | isConnecting || |
| 1166 | noProviderAvailable || | ||
| 1161 | projectSelectionRequired || | 1167 | projectSelectionRequired || |
| 1162 | environmentUnavailable !== null || | 1168 | environmentUnavailable !== null || |
| 1163 | !composerSendState.hasSendableContent; | 1169 | !composerSendState.hasSendableContent; |
| ... | @@ -1698,7 +1704,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1698,7 +1704,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1698 | 1704 | ||
| 1699 | const shouldBlurMobileComposerOnSubmit = useCallback(() => { | 1705 | const shouldBlurMobileComposerOnSubmit = useCallback(() => { |
| 1700 | if (!isMobileViewport) return false; | 1706 | 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 | ) { | ||
| 1702 | return false; | 1714 | return false; |
| 1703 | } | 1715 | } |
| 1704 | if (activePendingProgress) { | 1716 | if (activePendingProgress) { |
| ... | @@ -1713,18 +1725,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1713,18 +1725,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1713 | isConnecting, | 1725 | isConnecting, |
| 1714 | isMobileViewport, | 1726 | isMobileViewport, |
| 1715 | isSendBusy, | 1727 | isSendBusy, |
| 1728 | noProviderAvailable, | ||
| 1716 | phase, | 1729 | phase, |
| 1717 | showPlanFollowUpPrompt, | 1730 | showPlanFollowUpPrompt, |
| 1718 | ]); | 1731 | ]); |
| 1719 | 1732 | ||
| 1720 | const submitComposer = useCallback( | 1733 | const submitComposer = useCallback( |
| 1721 | (event?: { preventDefault: () => void }) => { | 1734 | (event?: { preventDefault: () => void }) => { |
| 1735 | if (noProviderAvailable) { | ||
| 1736 | event?.preventDefault(); | ||
| 1737 | return; | ||
| 1738 | } | ||
| 1722 | onSend(event); | 1739 | onSend(event); |
| 1723 | if (shouldBlurMobileComposerOnSubmit()) { | 1740 | if (shouldBlurMobileComposerOnSubmit()) { |
| 1724 | blurMobileComposerAfterSend(); | 1741 | blurMobileComposerAfterSend(); |
| 1725 | } | 1742 | } |
| 1726 | }, | 1743 | }, |
| 1727 | [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], | 1744 | [blurMobileComposerAfterSend, noProviderAvailable, onSend, shouldBlurMobileComposerOnSubmit], |
| 1728 | ); | 1745 | ); |
| 1729 | const expandMobileComposer = useCallback(() => { | 1746 | const expandMobileComposer = useCallback(() => { |
| 1730 | if (composerBlurFrameRef.current !== null) { | 1747 | if (composerBlurFrameRef.current !== null) { |
| ... | @@ -2083,6 +2100,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2083,6 +2100,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2083 | selectedPromptEffort, | 2100 | selectedPromptEffort, |
| 2084 | selectedModelOptionsForDispatch, | 2101 | selectedModelOptionsForDispatch, |
| 2085 | selectedModelSelection, | 2102 | selectedModelSelection, |
| 2103 | providerAvailable: !noProviderAvailable, | ||
| 2086 | selectedProvider, | 2104 | selectedProvider, |
| 2087 | selectedModel, | 2105 | selectedModel, |
| 2088 | selectedProviderModels, | 2106 | selectedProviderModels, |
| ... | @@ -2110,6 +2128,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2110,6 +2128,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2110 | selectedModel, | 2128 | selectedModel, |
| 2111 | selectedModelOptionsForDispatch, | 2129 | selectedModelOptionsForDispatch, |
| 2112 | selectedModelSelection, | 2130 | selectedModelSelection, |
| 2131 | noProviderAvailable, | ||
| 2113 | selectedPromptEffort, | 2132 | selectedPromptEffort, |
| 2114 | selectedProvider, | 2133 | selectedProvider, |
| 2115 | selectedProviderModels, | 2134 | selectedProviderModels, |
| ... | @@ -2260,7 +2279,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2260,7 +2279,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2260 | isSendBusy={isSendBusy} | 2279 | isSendBusy={isSendBusy} |
| 2261 | isConnecting={isConnecting} | 2280 | isConnecting={isConnecting} |
| 2262 | isEnvironmentUnavailable={ | 2281 | isEnvironmentUnavailable={ |
| 2263 | environmentUnavailable !== null || projectSelectionRequired | 2282 | environmentUnavailable !== null || |
| 2283 | noProviderAvailable || | ||
| 2284 | projectSelectionRequired | ||
| 2264 | } | 2285 | } |
| 2265 | isPreparingWorktree={false} | 2286 | isPreparingWorktree={false} |
| 2266 | hasSendableContent={false} | 2287 | hasSendableContent={false} |
| ... | @@ -2292,7 +2313,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2292,7 +2313,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2292 | {activePendingProgress | 2313 | {activePendingProgress |
| 2293 | ? activePendingProgress.customAnswer || | 2314 | ? activePendingProgress.customAnswer || |
| 2294 | "Type your own answer, or leave this blank to use the selected option" | 2315 | "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...")} | ||
| 2296 | </button> | 2318 | </button> |
| 2297 | <button | 2319 | <button |
| 2298 | type="button" | 2320 | type="button" |
| ... | @@ -2501,9 +2523,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2501,9 +2523,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2501 | ? `${environmentUnavailable.label}: ${connectionStatusText( | 2523 | ? `${environmentUnavailable.label}: ${connectionStatusText( |
| 2502 | environmentUnavailable.connection, | 2524 | environmentUnavailable.connection, |
| 2503 | )}` | 2525 | )}` |
| 2504 | : phase === "disconnected" | 2526 | : noProviderAvailable |
| 2505 | ? "Ask for follow-up changes or attach images" | 2527 | ? "Enable a provider in Settings to send a message" |
| 2506 | : "Ask anything, @tag files/folders, $use skills, or / for commands" | 2528 | : phase === "disconnected" |
| 2529 | ? "Ask for follow-up changes or attach images" | ||
| 2530 | : "Ask anything, @tag files/folders, $use skills, or / for commands" | ||
| 2507 | } | 2531 | } |
| 2508 | disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} | 2532 | disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} |
| 2509 | /> | 2533 | /> |
| ... | @@ -2521,7 +2545,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2521,7 +2545,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2521 | isSendBusy={isSendBusy} | 2545 | isSendBusy={isSendBusy} |
| 2522 | isConnecting={isConnecting} | 2546 | isConnecting={isConnecting} |
| 2523 | isEnvironmentUnavailable={ | 2547 | isEnvironmentUnavailable={ |
| 2524 | environmentUnavailable !== null || projectSelectionRequired | 2548 | environmentUnavailable !== null || |
| 2549 | noProviderAvailable || | ||
| 2550 | projectSelectionRequired | ||
| 2525 | } | 2551 | } |
| 2526 | isPreparingWorktree={false} | 2552 | isPreparingWorktree={false} |
| 2527 | hasSendableContent={false} | 2553 | hasSendableContent={false} |
| ... | @@ -2556,28 +2582,43 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2556,28 +2582,43 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2556 | )} | 2582 | )} |
| 2557 | > | 2583 | > |
| 2558 | <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"> | 2584 | <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 | 2585 | {noProviderAvailable ? ( |
| 2560 | compact={isComposerFooterCompact} | 2586 | <Button |
| 2561 | activeInstanceId={selectedInstanceId} | 2587 | type="button" |
| 2562 | model={selectedModelForPickerWithCustomFallback} | 2588 | size="sm" |
| 2563 | lockedProvider={lockedProvider} | 2589 | variant="ghost" |
| 2564 | lockedContinuationGroupKey={lockedContinuationGroupKey} | 2590 | disabled |
| 2565 | instanceEntries={providerInstanceEntries} | 2591 | data-chat-provider-unavailable="true" |
| 2566 | keybindings={keybindings} | 2592 | className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3" |
| 2567 | modelOptionsByInstance={modelOptionsByInstance} | 2593 | > |
| 2568 | terminalOpen={terminalOpen} | 2594 | <CircleAlertIcon className="size-4" /> |
| 2569 | open={isComposerModelPickerOpen} | 2595 | No provider available |
| 2570 | {...(composerProviderState.modelPickerIconClassName | 2596 | </Button> |
| 2571 | ? { | 2597 | ) : ( |
| 2572 | activeProviderIconClassName: composerProviderState.modelPickerIconClassName, | 2598 | <ProviderModelPicker |
| 2573 | } | 2599 | compact={isComposerFooterCompact} |
| 2574 | : {})} | 2600 | activeInstanceId={selectedInstanceId} |
| 2575 | onOpenChange={(open) => { | 2601 | model={selectedModelForPickerWithCustomFallback} |
| 2576 | setIsComposerModelPickerOpen(open); | 2602 | lockedProvider={lockedProvider} |
| 2577 | }} | 2603 | lockedContinuationGroupKey={lockedContinuationGroupKey} |
| 2578 | getModelDisabledReason={getModelDisabledReason} | 2604 | instanceEntries={providerInstanceEntries} |
| 2579 | onInstanceModelChange={onProviderModelSelect} | 2605 | keybindings={keybindings} |
| 2580 | /> | 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 | )} | ||
| 2581 | 2622 | ||
| 2582 | {isComposerFooterCompact ? ( | 2623 | {isComposerFooterCompact ? ( |
| 2583 | <CompactComposerControlsMenu | 2624 | <CompactComposerControlsMenu |
| ... | @@ -2634,7 +2675,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2634,7 +2675,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2634 | isSendBusy={isSendBusy} | 2675 | isSendBusy={isSendBusy} |
| 2635 | isConnecting={isConnecting} | 2676 | isConnecting={isConnecting} |
| 2636 | isEnvironmentUnavailable={ | 2677 | isEnvironmentUnavailable={ |
| 2637 | environmentUnavailable !== null || projectSelectionRequired | 2678 | environmentUnavailable !== null || |
| 2679 | noProviderAvailable || | ||
| 2680 | projectSelectionRequired | ||
| 2638 | } | 2681 | } |
| 2639 | isPreparingWorktree={isPreparingWorktree} | 2682 | isPreparingWorktree={isPreparingWorktree} |
| 2640 | hasSendableContent={composerSendState.hasSendableContent} | 2683 | hasSendableContent={composerSendState.hasSendableContent} |
apps/web/src/providerInstances.test.ts+256-2| ... | @@ -3,8 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -3,8 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { | 3 | import { |
| 4 | applyProviderInstanceSettings, | 4 | applyProviderInstanceSettings, |
| 5 | deriveProviderInstanceEntries, | 5 | deriveProviderInstanceEntries, |
| 6 | getDefaultProviderInstanceModel, | ||
| 6 | isProviderInstancePickerReady, | 7 | isProviderInstancePickerReady, |
| 7 | isProviderInstancePickerVisible, | 8 | isProviderInstancePickerVisible, |
| 9 | resolveDefaultProviderModelSelection, | ||
| 8 | resolveSelectableProviderInstance, | 10 | resolveSelectableProviderInstance, |
| 9 | resolveProviderDriverKindForInstanceSelection, | 11 | resolveProviderDriverKindForInstanceSelection, |
| 10 | } from "./providerInstances"; | 12 | } from "./providerInstances"; |
| ... | @@ -15,6 +17,8 @@ function provider(input: { | ... | @@ -15,6 +17,8 @@ function provider(input: { |
| 15 | enabled?: boolean; | 17 | enabled?: boolean; |
| 16 | availability?: ServerProvider["availability"]; | 18 | availability?: ServerProvider["availability"]; |
| 17 | displayName?: string; | 19 | displayName?: string; |
| 20 | status?: ServerProvider["status"]; | ||
| 21 | models?: ServerProvider["models"]; | ||
| 18 | }): ServerProvider { | 22 | }): ServerProvider { |
| 19 | return { | 23 | return { |
| 20 | instanceId: ProviderInstanceId.make(input.instanceId), | 24 | instanceId: ProviderInstanceId.make(input.instanceId), |
| ... | @@ -23,16 +27,24 @@ function provider(input: { | ... | @@ -23,16 +27,24 @@ function provider(input: { |
| 23 | enabled: input.enabled ?? true, | 27 | enabled: input.enabled ?? true, |
| 24 | installed: true, | 28 | installed: true, |
| 25 | version: null, | 29 | version: null, |
| 26 | status: "ready", | 30 | status: input.status ?? "ready", |
| 27 | ...(input.availability ? { availability: input.availability } : {}), | 31 | ...(input.availability ? { availability: input.availability } : {}), |
| 28 | auth: { status: "authenticated" }, | 32 | auth: { status: "authenticated" }, |
| 29 | checkedAt: "2026-01-01T00:00:00.000Z", | 33 | checkedAt: "2026-01-01T00:00:00.000Z", |
| 30 | models: [], | 34 | models: input.models ?? [], |
| 31 | slashCommands: [], | 35 | slashCommands: [], |
| 32 | skills: [], | 36 | skills: [], |
| 33 | }; | 37 | }; |
| 34 | } | 38 | } |
| 35 | 39 | ||
| 40 | const model = (slug: string, isCustom = false, isDefault = false) => ({ | ||
| 41 | slug, | ||
| 42 | name: slug, | ||
| 43 | isCustom, | ||
| 44 | ...(isDefault ? { isDefault: true } : {}), | ||
| 45 | capabilities: {}, | ||
| 46 | }); | ||
| 47 | |||
| 36 | describe("isProviderInstancePickerReady", () => { | 48 | describe("isProviderInstancePickerReady", () => { |
| 37 | it("rejects a disabled instance even while its last probe status is ready", () => { | 49 | it("rejects a disabled instance even while its last probe status is ready", () => { |
| 38 | const [entry] = deriveProviderInstanceEntries([ | 50 | const [entry] = deriveProviderInstanceEntries([ |
| ... | @@ -146,6 +158,67 @@ describe("resolveSelectableProviderInstance", () => { | ... | @@ -146,6 +158,67 @@ describe("resolveSelectableProviderInstance", () => { |
| 146 | expect(resolveSelectableProviderInstance(providers, disabled)).toBe(fallback); | 158 | expect(resolveSelectableProviderInstance(providers, disabled)).toBe(fallback); |
| 147 | }); | 159 | }); |
| 148 | 160 | ||
| 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 | |||
| 149 | it("does not return disabled, unavailable, or unknown instances when none are sendable", () => { | 222 | it("does not return disabled, unavailable, or unknown instances when none are sendable", () => { |
| 150 | const disabled = ProviderInstanceId.make("codex"); | 223 | const disabled = ProviderInstanceId.make("codex"); |
| 151 | const unavailable = ProviderInstanceId.make("claudeAgent"); | 224 | const unavailable = ProviderInstanceId.make("claudeAgent"); |
| ... | @@ -206,3 +279,184 @@ describe("resolveProviderDriverKindForInstanceSelection", () => { | ... | @@ -206,3 +279,184 @@ describe("resolveProviderDriverKindForInstanceSelection", () => { |
| 206 | ).toBeUndefined(); | 279 | ).toBeUndefined(); |
| 207 | }); | 280 | }); |
| 208 | }); | 281 | }); |
| 282 | |||
| 283 | describe("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 | |||
| 337 | describe("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 @@ | ... | @@ -13,10 +13,12 @@ |
| 13 | * @module providerInstances | 13 | * @module providerInstances |
| 14 | */ | 14 | */ |
| 15 | import { | 15 | import { |
| 16 | DEFAULT_MODEL_BY_PROVIDER, | ||
| 16 | defaultInstanceIdForDriver, | 17 | defaultInstanceIdForDriver, |
| 17 | PROVIDER_DISPLAY_NAMES, | 18 | PROVIDER_DISPLAY_NAMES, |
| 19 | type ModelSelection, | ||
| 18 | type ProviderDriverKind, | 20 | type ProviderDriverKind, |
| 19 | type ProviderInstanceId, | 21 | ProviderInstanceId, |
| 20 | type ServerProvider, | 22 | type ServerProvider, |
| 21 | type ServerProviderModel, | 23 | type ServerProviderModel, |
| 22 | type ServerSettings, | 24 | type ServerSettings, |
| ... | @@ -25,6 +27,16 @@ import { | ... | @@ -25,6 +27,16 @@ import { |
| 25 | 27 | ||
| 26 | import { formatProviderDriverKindLabel } from "./providerModels"; | 28 | import { formatProviderDriverKindLabel } from "./providerModels"; |
| 27 | 29 | ||
| 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 | */ | ||
| 35 | export const NO_PROVIDER_MODEL_SELECTION: ModelSelection = { | ||
| 36 | instanceId: ProviderInstanceId.make("t3code_no_provider"), | ||
| 37 | model: "", | ||
| 38 | }; | ||
| 39 | |||
| 28 | /** | 40 | /** |
| 29 | * UI-facing projection of one configured provider instance. Carries the | 41 | * UI-facing projection of one configured provider instance. Carries the |
| 30 | * snapshot verbatim for callers that need server-side fields we don't | 42 | * snapshot verbatim for callers that need server-side fields we don't |
| ... | @@ -253,27 +265,82 @@ export function getProviderInstanceModels( | ... | @@ -253,27 +265,82 @@ export function getProviderInstanceModels( |
| 253 | return getProviderInstanceEntry(providers, instanceId)?.models ?? []; | 265 | return getProviderInstanceEntry(providers, instanceId)?.models ?? []; |
| 254 | } | 266 | } |
| 255 | 267 | ||
| 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 | */ | ||
| 275 | export 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 | |||
| 289 | const 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 | */ | ||
| 299 | export 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 | |||
| 256 | /** | 315 | /** |
| 257 | * Resolve the routing key for a selection that may reference an instance | 316 | * Resolve the routing key for a selection that may reference an instance |
| 258 | * id that no longer exists (e.g. a persisted thread selection after the | 317 | * id that no longer exists (e.g. a persisted thread selection after the |
| 259 | * user deleted the custom instance). Returns the first enabled instance | 318 | * user deleted the custom instance). Returns a ready or non-error fallback, |
| 260 | * as a fallback so downstream code can still send a turn. | 319 | * or `undefined` when no provider can safely become a new selection. |
| 261 | */ | 320 | */ |
| 262 | export function resolveSelectableProviderInstance( | 321 | export function resolveSelectableProviderInstance( |
| 263 | providers: ReadonlyArray<ServerProvider>, | 322 | providers: ReadonlyArray<ServerProvider>, |
| 264 | instanceId: ProviderInstanceId | undefined, | 323 | instanceId: ProviderInstanceId | undefined, |
| 265 | ): ProviderInstanceId | undefined { | 324 | ): ProviderInstanceId | undefined { |
| 266 | if (instanceId === undefined) { | ||
| 267 | return deriveProviderInstanceEntries(providers).find( | ||
| 268 | (entry) => entry.enabled && entry.isAvailable, | ||
| 269 | )?.instanceId; | ||
| 270 | } | ||
| 271 | const entries = deriveProviderInstanceEntries(providers); | 325 | const entries = deriveProviderInstanceEntries(providers); |
| 272 | const requested = entries.find((entry) => entry.instanceId === instanceId); | 326 | return resolveSelectableProviderInstanceEntry(entries, instanceId)?.instanceId; |
| 273 | if (requested && requested.enabled && requested.isAvailable) { | 327 | } |
| 274 | return instanceId; | 328 | |
| 275 | } | 329 | /** |
| 276 | return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; | 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 | */ | ||
| 335 | export 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; | ||
| 277 | } | 344 | } |
| 278 | 345 | ||
| 279 | /** | 346 | /** |
packages/client-runtime/src/operations/projects.test.ts+1-5| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { | 2 | import { |
| 3 | DEFAULT_MODEL, | ||
| 4 | EnvironmentId, | 3 | EnvironmentId, |
| 5 | ProjectId, | 4 | ProjectId, |
| 6 | CommandId, | 5 | CommandId, |
| ... | @@ -138,10 +137,7 @@ describe("add project shared logic", () => { | ... | @@ -138,10 +137,7 @@ describe("add project shared logic", () => { |
| 138 | title: "repo", | 137 | title: "repo", |
| 139 | workspaceRoot: "/work/repo", | 138 | workspaceRoot: "/work/repo", |
| 140 | createWorkspaceRootIfMissing: true, | 139 | createWorkspaceRootIfMissing: true, |
| 141 | defaultModelSelection: { | 140 | defaultModelSelection: null, |
| 142 | instanceId: "codex", | ||
| 143 | model: DEFAULT_MODEL, | ||
| 144 | }, | ||
| 145 | }); | 141 | }); |
| 146 | }); | 142 | }); |
| 147 | }); | 143 | }); |
packages/client-runtime/src/operations/projects.ts+1-5| ... | @@ -7,7 +7,6 @@ import type { | ... | @@ -7,7 +7,6 @@ import type { |
| 7 | SourceControlProviderKind, | 7 | SourceControlProviderKind, |
| 8 | SourceControlRepositoryInfo, | 8 | SourceControlRepositoryInfo, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 11 | import * as Arr from "effect/Array"; | 10 | import * as Arr from "effect/Array"; |
| 12 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 13 | import * as Order from "effect/Order"; | 12 | import * as Order from "effect/Order"; |
| ... | @@ -215,10 +214,7 @@ export function buildProjectCreateCommand(input: { | ... | @@ -215,10 +214,7 @@ export function buildProjectCreateCommand(input: { |
| 215 | title: inferProjectTitleFromPath(input.workspaceRoot), | 214 | title: inferProjectTitleFromPath(input.workspaceRoot), |
| 216 | workspaceRoot: input.workspaceRoot, | 215 | workspaceRoot: input.workspaceRoot, |
| 217 | createWorkspaceRootIfMissing: true, | 216 | createWorkspaceRootIfMissing: true, |
| 218 | defaultModelSelection: { | 217 | defaultModelSelection: null, |
| 219 | instanceId: ProviderInstanceId.make("codex"), | ||
| 220 | model: DEFAULT_MODEL, | ||
| 221 | }, | ||
| 222 | createdAt: input.createdAt, | 218 | createdAt: input.createdAt, |
| 223 | }; | 219 | }; |
| 224 | } | 220 | } |