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 (