Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -3780,6 +3780,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
model={selectedModelForPickerWithCustomFallback}
lockedProvider={lockedProvider}
modelOptionsByProvider={modelOptionsByProvider}
providerStatuses={providerStatuses}
{...(composerProviderState.modelPickerIconClassName
? {
activeProviderIconClassName:
Expand Down
45 changes: 38 additions & 7 deletions apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -54,11 +54,37 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
lockedProvider: ProviderKind | null;
modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
activeProviderIconClassName?: string;
providerStatuses?: ReadonlyArray<ServerProviderStatus>;
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 =
Expand Down Expand Up @@ -139,7 +165,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
</MenuGroup>
) : (
<>
{AVAILABLE_PROVIDER_OPTIONS.map((option) => {
{effectiveAvailableOptions.map((option) => {
const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
return (
<MenuSub key={option.value}>
Expand Down Expand Up @@ -174,9 +200,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
</MenuSub>
);
})}
{UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />}
{UNAVAILABLE_PROVIDER_OPTIONS.map((option) => {
{effectiveUnavailableOptions.length > 0 && <MenuDivider />}
{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 (
<MenuItem key={option.value} disabled>
<OptionIcon
Expand All @@ -185,12 +216,12 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
/>
<span>{option.label}</span>
<span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]">
Coming soon
{isHealthUnavailable ? "Not installed" : "Coming soon"}
</span>
</MenuItem>
);
})}
{UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && <MenuDivider />}
{effectiveUnavailableOptions.length === 0 && <MenuDivider />}
{COMING_SOON_PROVIDER_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
Expand Down