import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";import { normalizeModelSlug } from "@t3tools/shared/model";import { memo, useState } from "react";import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";import { ChevronDownIcon } from "lucide-react";import { Button } from "../ui/button";import { Menu, MenuGroup, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator as MenuDivider, MenuSub, MenuSubPopup, MenuSubTrigger, MenuTrigger,} from "../ui/menu";import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons";import { cn } from "~/lib/utils"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { value: ProviderKind; label: string; available: true;} { return option.available && option.value !== "claudeCode";} function resolveModelForProviderPicker( provider: ProviderKind, value: string, options: ReadonlyArray<{ slug: string; name: string }>,): ModelSlug | null { const trimmedValue = value.trim(); if (!trimmedValue) { return null; } const direct = options.find((option) => option.slug === trimmedValue); if (direct) { return direct.slug; } const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); if (byName) { return byName.slug; } const normalized = normalizeModelSlug(trimmedValue, provider); if (!normalized) { return null; } const resolved = options.find((option) => option.slug === normalized); if (resolved) { return resolved.slug; } return null;} const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = { codex: OpenAI, claudeCode: ClaudeAI, cursor: CursorIcon,}; export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);const COMING_SOON_PROVIDER_OPTIONS = [ { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, { id: "gemini", label: "Gemini", icon: Gemini },] as const; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;}) { const [isMenuOpen, setIsMenuOpen] = useState(false); const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; return ( <Menu open={isMenuOpen} onOpenChange={(open) => { if (props.disabled) { setIsMenuOpen(false); return; } setIsMenuOpen(open); }} > <MenuTrigger render={ <Button size="sm" variant="ghost" className={cn( "min-w-0 shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80", props.compact ? "max-w-42" : "sm:px-3", )} disabled={props.disabled} /> } > <span className={cn("flex min-w-0 items-center gap-2", props.compact ? "max-w-36" : undefined)} > <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" /> <span className="truncate">{selectedModelLabel}</span> <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> </span> </MenuTrigger> <MenuPopup align="start"> {AVAILABLE_PROVIDER_OPTIONS.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; const isDisabledByProviderLock = props.lockedProvider !== null && props.lockedProvider !== option.value; return ( <MenuSub key={option.value}> <MenuSubTrigger disabled={isDisabledByProviderLock}> <OptionIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/85" /> {option.label} </MenuSubTrigger> <MenuSubPopup className="[--available-height:min(24rem,70vh)]"> <MenuGroup> <MenuRadioGroup value={props.provider === option.value ? props.model : ""} onValueChange={(value) => { if (props.disabled) return; if (isDisabledByProviderLock) return; if (!value) return; const resolvedModel = resolveModelForProviderPicker( option.value, value, props.modelOptionsByProvider[option.value], ); if (!resolvedModel) return; props.onProviderModelChange(option.value, resolvedModel); setIsMenuOpen(false); }} > {props.modelOptionsByProvider[option.value].map((modelOption) => ( <MenuRadioItem key={`${option.value}:${modelOption.slug}`} value={modelOption.slug} onClick={() => setIsMenuOpen(false)} > {modelOption.name} </MenuRadioItem> ))} </MenuRadioGroup> </MenuGroup> </MenuSubPopup> </MenuSub> ); })} {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />} {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; return ( <MenuItem key={option.value} disabled> <OptionIcon aria-hidden="true" className={cn( "size-4 shrink-0 opacity-80", option.value === "claudeCode" ? "" : "text-muted-foreground/85", )} /> <span>{option.label}</span> <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> Coming soon </span> </MenuItem> ); })} {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && <MenuDivider />} {COMING_SOON_PROVIDER_OPTIONS.map((option) => { const OptionIcon = option.icon; return ( <MenuItem key={option.id} disabled> <OptionIcon aria-hidden="true" className="size-4 shrink-0 opacity-80" /> <span>{option.label}</span> <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> Coming soon </span> </MenuItem> ); })} </MenuPopup> </Menu> );});
import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
import { normalizeModelSlug } from "@t3tools/shared/model";
import { memo, useState } from "react";
import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
import { ChevronDownIcon } from "lucide-react";
import { Button } from "../ui/button";
import {
Menu,
MenuGroup,
MenuItem,
MenuPopup,
MenuRadioGroup,
MenuRadioItem,
MenuSeparator as MenuDivider,
MenuSub,
MenuSubPopup,
MenuSubTrigger,
MenuTrigger,
} from "../ui/menu";
import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons";
import { cn } from "~/lib/utils";
function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
value: ProviderKind;
label: string;
available: true;
} {
return option.available && option.value !== "claudeCode";
}
function resolveModelForProviderPicker(
provider: ProviderKind,
value: string,
options: ReadonlyArray<{ slug: string; name: string }>,
): ModelSlug | null {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
const direct = options.find((option) => option.slug === trimmedValue);
if (direct) {
return direct.slug;
}
const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase());
if (byName) {
return byName.slug;
}
const normalized = normalizeModelSlug(trimmedValue, provider);
if (!normalized) {
return null;
}
const resolved = options.find((option) => option.slug === normalized);
if (resolved) {
return resolved.slug;
}
return null;
}
const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
codex: OpenAI,
claudeCode: ClaudeAI,
cursor: CursorIcon,
};
export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);
const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);
const COMING_SOON_PROVIDER_OPTIONS = [
{ id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
{ id: "gemini", label: "Gemini", icon: Gemini },
] as const;
export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
provider: ProviderKind;
model: ModelSlug;
lockedProvider: ProviderKind | null;
modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
compact?: boolean;
disabled?: boolean;
onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const selectedProviderOptions = props.modelOptionsByProvider[props.provider];
const selectedModelLabel =
selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;
const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];
return (
<Menu
open={isMenuOpen}
onOpenChange={(open) => {
if (props.disabled) {
setIsMenuOpen(false);
return;
}
setIsMenuOpen(open);
}}
>
<MenuTrigger
render={
<Button
size="sm"
variant="ghost"
className={cn(
"min-w-0 shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80",
props.compact ? "max-w-42" : "sm:px-3",
)}
disabled={props.disabled}
/>
}
>
<span
className={cn("flex min-w-0 items-center gap-2", props.compact ? "max-w-36" : undefined)}
>
<ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />
<span className="truncate">{selectedModelLabel}</span>
<ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
</span>
</MenuTrigger>
<MenuPopup align="start">
{AVAILABLE_PROVIDER_OPTIONS.map((option) => {
const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
const isDisabledByProviderLock =
props.lockedProvider !== null && props.lockedProvider !== option.value;
return (
<MenuSub key={option.value}>
<MenuSubTrigger disabled={isDisabledByProviderLock}>
<OptionIcon
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground/85"
/>
{option.label}
</MenuSubTrigger>
<MenuSubPopup className="[--available-height:min(24rem,70vh)]">
<MenuGroup>
<MenuRadioGroup
value={props.provider === option.value ? props.model : ""}
onValueChange={(value) => {
if (props.disabled) return;
if (isDisabledByProviderLock) return;
if (!value) return;
const resolvedModel = resolveModelForProviderPicker(
option.value,
value,
props.modelOptionsByProvider[option.value],
);
if (!resolvedModel) return;
props.onProviderModelChange(option.value, resolvedModel);
setIsMenuOpen(false);
}}
>
{props.modelOptionsByProvider[option.value].map((modelOption) => (
<MenuRadioItem
key={`${option.value}:${modelOption.slug}`}
value={modelOption.slug}
onClick={() => setIsMenuOpen(false)}
>
{modelOption.name}
</MenuRadioItem>
))}
</MenuRadioGroup>
</MenuGroup>
</MenuSubPopup>
</MenuSub>
);
})}
{UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />}
{UNAVAILABLE_PROVIDER_OPTIONS.map((option) => {
const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
return (
<MenuItem key={option.value} disabled>
<OptionIcon
aria-hidden="true"
className={cn(
"size-4 shrink-0 opacity-80",
option.value === "claudeCode" ? "" : "text-muted-foreground/85",
)}
/>
<span>{option.label}</span>
<span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]">
Coming soon
</span>
</MenuItem>
);
})}
{UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && <MenuDivider />}
{COMING_SOON_PROVIDER_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
<MenuItem key={option.id} disabled>
<OptionIcon aria-hidden="true" className="size-4 shrink-0 opacity-80" />
<span>{option.label}</span>
<span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]">
Coming soon
</span>
</MenuItem>
);
})}
</MenuPopup>
</Menu>
);
});