| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- import { useMemo, useState, useCallback, useEffect, useRef } from "react"
- import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
- import { Trans } from "react-i18next"
- import { ChevronsUpDown, Check, X } from "lucide-react"
- import type { ProviderSettings, ModelInfo } from "@roo-code/types"
- import type { OrganizationAllowList } from "@roo/cloud"
- import { useAppTranslation } from "@src/i18n/TranslationContext"
- import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
- import { filterModels } from "./utils/organizationFilters"
- import { cn } from "@src/lib/utils"
- import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- Popover,
- PopoverContent,
- PopoverTrigger,
- Button,
- } from "@src/components/ui"
- import { useEscapeKey } from "@src/hooks/useEscapeKey"
- import { ModelInfoView } from "./ModelInfoView"
- import { ApiErrorMessage } from "./ApiErrorMessage"
- type ModelIdKey = keyof Pick<
- ProviderSettings,
- | "glamaModelId"
- | "openRouterModelId"
- | "unboundModelId"
- | "requestyModelId"
- | "openAiModelId"
- | "litellmModelId"
- | "ioIntelligenceModelId"
- >
- interface ModelPickerProps {
- defaultModelId: string
- models: Record<string, ModelInfo> | null
- modelIdKey: ModelIdKey
- serviceName: string
- serviceUrl: string
- apiConfiguration: ProviderSettings
- setApiConfigurationField: <K extends keyof ProviderSettings>(
- field: K,
- value: ProviderSettings[K],
- isUserAction?: boolean,
- ) => void
- organizationAllowList: OrganizationAllowList
- errorMessage?: string
- }
- export const ModelPicker = ({
- defaultModelId,
- models,
- modelIdKey,
- serviceName,
- serviceUrl,
- apiConfiguration,
- setApiConfigurationField,
- organizationAllowList,
- errorMessage,
- }: ModelPickerProps) => {
- const { t } = useAppTranslation()
- const [open, setOpen] = useState(false)
- const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
- const isInitialized = useRef(false)
- const searchInputRef = useRef<HTMLInputElement>(null)
- const selectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
- const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null)
- const modelIds = useMemo(() => {
- const filteredModels = filterModels(models, apiConfiguration.apiProvider, organizationAllowList)
- return Object.keys(filteredModels ?? {}).sort((a, b) => a.localeCompare(b))
- }, [models, apiConfiguration.apiProvider, organizationAllowList])
- const { id: selectedModelId, info: selectedModelInfo } = useSelectedModel(apiConfiguration)
- const [searchValue, setSearchValue] = useState("")
- const onSelect = useCallback(
- (modelId: string) => {
- if (!modelId) {
- return
- }
- setOpen(false)
- setApiConfigurationField(modelIdKey, modelId)
- // Clear any existing timeout
- if (selectTimeoutRef.current) {
- clearTimeout(selectTimeoutRef.current)
- }
- // Delay to ensure the popover is closed before setting the search value.
- selectTimeoutRef.current = setTimeout(() => setSearchValue(""), 100)
- },
- [modelIdKey, setApiConfigurationField],
- )
- const onOpenChange = useCallback((open: boolean) => {
- setOpen(open)
- // Abandon the current search if the popover is closed.
- if (!open) {
- // Clear any existing timeout
- if (closeTimeoutRef.current) {
- clearTimeout(closeTimeoutRef.current)
- }
- // Clear the search value when closing instead of prefilling it
- closeTimeoutRef.current = setTimeout(() => setSearchValue(""), 100)
- }
- }, [])
- const onClearSearch = useCallback(() => {
- setSearchValue("")
- searchInputRef.current?.focus()
- }, [])
- useEffect(() => {
- if (!selectedModelId && !isInitialized.current) {
- const initialValue = modelIds.includes(selectedModelId) ? selectedModelId : defaultModelId
- setApiConfigurationField(modelIdKey, initialValue, false) // false = automatic initialization
- }
- isInitialized.current = true
- }, [modelIds, setApiConfigurationField, modelIdKey, selectedModelId, defaultModelId])
- // Cleanup timeouts on unmount to prevent test flakiness
- useEffect(() => {
- return () => {
- if (selectTimeoutRef.current) {
- clearTimeout(selectTimeoutRef.current)
- }
- if (closeTimeoutRef.current) {
- clearTimeout(closeTimeoutRef.current)
- }
- }
- }, [])
- // Use the shared ESC key handler hook
- useEscapeKey(open, () => setOpen(false))
- return (
- <>
- <div>
- <label className="block font-medium mb-1">{t("settings:modelPicker.label")}</label>
- <Popover open={open} onOpenChange={onOpenChange}>
- <PopoverTrigger asChild>
- <Button
- variant="combobox"
- role="combobox"
- aria-expanded={open}
- className="w-full justify-between"
- data-testid="model-picker-button">
- <div className="truncate">{selectedModelId ?? t("settings:common.select")}</div>
- <ChevronsUpDown className="opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
- <Command>
- <div className="relative">
- <CommandInput
- ref={searchInputRef}
- value={searchValue}
- onValueChange={setSearchValue}
- placeholder={t("settings:modelPicker.searchPlaceholder")}
- className="h-9 mr-4"
- data-testid="model-input"
- />
- {searchValue.length > 0 && (
- <div className="absolute right-2 top-0 bottom-0 flex items-center justify-center">
- <X
- className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
- onClick={onClearSearch}
- />
- </div>
- )}
- </div>
- <CommandList>
- <CommandEmpty>
- {searchValue && (
- <div className="py-2 px-1 text-sm">
- {t("settings:modelPicker.noMatchFound")}
- </div>
- )}
- </CommandEmpty>
- <CommandGroup>
- {modelIds.map((model) => (
- <CommandItem
- key={model}
- value={model}
- onSelect={onSelect}
- data-testid={`model-option-${model}`}>
- <span className="truncate" title={model}>
- {model}
- </span>
- <Check
- className={cn(
- "size-4 p-0.5 ml-auto",
- model === selectedModelId ? "opacity-100" : "opacity-0",
- )}
- />
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- {searchValue && !modelIds.includes(searchValue) && (
- <div className="p-1 border-t border-vscode-input-border">
- <CommandItem data-testid="use-custom-model" value={searchValue} onSelect={onSelect}>
- {t("settings:modelPicker.useCustomModel", { modelId: searchValue })}
- </CommandItem>
- </div>
- )}
- </Command>
- </PopoverContent>
- </Popover>
- </div>
- {errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
- {selectedModelId && selectedModelInfo && (
- <ModelInfoView
- apiProvider={apiConfiguration.apiProvider}
- selectedModelId={selectedModelId}
- modelInfo={selectedModelInfo}
- isDescriptionExpanded={isDescriptionExpanded}
- setIsDescriptionExpanded={setIsDescriptionExpanded}
- />
- )}
- <div className="text-sm text-vscode-descriptionForeground">
- <Trans
- i18nKey="settings:modelPicker.automaticFetch"
- components={{
- serviceLink: <VSCodeLink href={serviceUrl} className="text-sm" />,
- defaultModelLink: <VSCodeLink onClick={() => onSelect(defaultModelId)} className="text-sm" />,
- }}
- values={{ serviceName, defaultModelId }}
- />
- </div>
- </>
- )
- }
|