From ca34f5f47ce38a11757941ab15ac37ea189eef53 Mon Sep 17 00:00:00 2001 From: Timmske Date: Tue, 24 Mar 2026 21:26:38 +0100 Subject: [PATCH] fix(web): filter model picker by server provider health status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model picker used hardcoded availability flags, ignoring server health checks. When codex was missing, it still appeared selectable, blocking users who only have Claude Code installed. Thread providerStatuses from the server config query into ProviderModelPicker. Providers that fail health checks (CLI not installed or not on PATH) now appear as disabled with a "Not installed" label instead of being selectable. Static unavailable providers (Cursor) retain their "Coming soon" label. The providerStatuses prop is optional—when omitted the component falls back to the existing static lists, preserving backward compatibility. Fixes #1332 --- apps/web/src/components/ChatView.tsx | 3 +- .../components/chat/ProviderModelPicker.tsx | 45 ++++++++++++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 59ff4f73c8..9bedc3e7b9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1018,6 +1018,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, @@ -1107,7 +1108,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], @@ -3780,6 +3780,7 @@ export default function ChatView({ threadId }: ChatViewProps) { model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} modelOptionsByProvider={modelOptionsByProvider} + providerStatuses={providerStatuses} {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 95f27f39cd..47f0675100 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,6 +1,6 @@ -import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { type ModelSlug, type ProviderKind, type ServerProviderStatus } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; -import { memo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; import { Button } from "../ui/button"; @@ -54,11 +54,37 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; activeProviderIconClassName?: string; + providerStatuses?: ReadonlyArray; compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); + + // Derive which providers are unavailable based on server health checks. + // When providerStatuses is not provided, all statically-available providers remain selectable. + const unavailableProviders = useMemo(() => { + if (!props.providerStatuses || props.providerStatuses.length === 0) { + return null; + } + return new Set( + props.providerStatuses.filter((status) => !status.available).map((status) => status.provider), + ); + }, [props.providerStatuses]); + + const effectiveAvailableOptions = useMemo(() => { + if (!unavailableProviders) return AVAILABLE_PROVIDER_OPTIONS; + return AVAILABLE_PROVIDER_OPTIONS.filter((option) => !unavailableProviders.has(option.value)); + }, [unavailableProviders]); + + const effectiveUnavailableOptions = useMemo(() => { + if (!unavailableProviders) return UNAVAILABLE_PROVIDER_OPTIONS; + const healthUnavailable = AVAILABLE_PROVIDER_OPTIONS.filter((option) => + unavailableProviders.has(option.value), + ); + return [...healthUnavailable, ...UNAVAILABLE_PROVIDER_OPTIONS]; + }, [unavailableProviders]); + const activeProvider = props.lockedProvider ?? props.provider; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = @@ -139,7 +165,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : ( <> - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + {effectiveAvailableOptions.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; return ( @@ -174,9 +200,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { + {effectiveUnavailableOptions.length > 0 && } + {effectiveUnavailableOptions.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + // Providers that are statically available but failed server health checks + // show "Not installed"; providers not yet supported show "Coming soon". + const isHealthUnavailable = AVAILABLE_PROVIDER_OPTIONS.some( + (available) => available.value === option.value, + ); return ( {option.label} - Coming soon + {isHealthUnavailable ? "Not installed" : "Coming soon"} ); })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } + {effectiveUnavailableOptions.length === 0 && } {COMING_SOON_PROVIDER_OPTIONS.map((option) => { const OptionIcon = option.icon; return (