246 lines · 8.4 KB · TypeScript
RawDownload
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;} 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,  claudeAgent: 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; function providerIconClassName(  provider: ProviderKind | ProviderPickerKind,  fallbackClassName: string,): string {  return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName;} export const ProviderModelPicker = memo(function ProviderModelPicker(props: {  provider: ProviderKind;  model: ModelSlug;  lockedProvider: ProviderKind | null;  modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;  ultrathinkActive?: boolean;  compact?: boolean;  disabled?: boolean;  onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;}) {  const [isMenuOpen, setIsMenuOpen] = useState(false);  const activeProvider = props.lockedProvider ?? props.provider;  const selectedProviderOptions = props.modelOptionsByProvider[activeProvider];  const selectedModelLabel =    selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;  const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider];  const handleModelChange = (provider: ProviderKind, value: string) => {    if (props.disabled) return;    if (!value) return;    const resolvedModel = resolveModelForProviderPicker(      provider,      value,      props.modelOptionsByProvider[provider],    );    if (!resolvedModel) return;    props.onProviderModelChange(provider, resolvedModel);    setIsMenuOpen(false);  };   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 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0",              props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3",            )}            disabled={props.disabled}          />        }      >        <span          className={cn(            "flex min-w-0 w-full items-center gap-2 overflow-hidden",            props.compact ? "max-w-36" : undefined,          )}        >          <ProviderIcon            aria-hidden="true"            className={cn(              "size-4 shrink-0",              providerIconClassName(activeProvider, "text-muted-foreground/70"),              activeProvider === "claudeAgent" && props.ultrathinkActive                ? "ultrathink-chroma"                : undefined,            )}          />          <span className="min-w-0 flex-1 truncate">{selectedModelLabel}</span>          <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />        </span>      </MenuTrigger>      <MenuPopup align="start">        {props.lockedProvider !== null ? (          <MenuGroup>            <MenuRadioGroup              value={props.model}              onValueChange={(value) => handleModelChange(props.lockedProvider!, value)}            >              {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => (                <MenuRadioItem                  key={`${props.lockedProvider}:${modelOption.slug}`}                  value={modelOption.slug}                  onClick={() => setIsMenuOpen(false)}                >                  {modelOption.name}                </MenuRadioItem>              ))}            </MenuRadioGroup>          </MenuGroup>        ) : (          <>            {AVAILABLE_PROVIDER_OPTIONS.map((option) => {              const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];              return (                <MenuSub key={option.value}>                  <MenuSubTrigger>                    <OptionIcon                      aria-hidden="true"                      className={cn(                        "size-4 shrink-0",                        providerIconClassName(option.value, "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) => handleModelChange(option.value, value)}                      >                        {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="size-4 shrink-0 text-muted-foreground/85 opacity-80"                  />                  <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>  );});