ModelPicker.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import { useMemo, useState, useCallback, useEffect, useRef } from "react"
  2. import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
  3. import { Trans } from "react-i18next"
  4. import { ChevronsUpDown, Check, X } from "lucide-react"
  5. import type { ProviderSettings, ModelInfo } from "@roo-code/types"
  6. import type { OrganizationAllowList } from "@roo/cloud"
  7. import { useAppTranslation } from "@src/i18n/TranslationContext"
  8. import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
  9. import { filterModels } from "./utils/organizationFilters"
  10. import { cn } from "@src/lib/utils"
  11. import {
  12. Command,
  13. CommandEmpty,
  14. CommandGroup,
  15. CommandInput,
  16. CommandItem,
  17. CommandList,
  18. Popover,
  19. PopoverContent,
  20. PopoverTrigger,
  21. Button,
  22. } from "@src/components/ui"
  23. import { useEscapeKey } from "@src/hooks/useEscapeKey"
  24. import { ModelInfoView } from "./ModelInfoView"
  25. import { ApiErrorMessage } from "./ApiErrorMessage"
  26. type ModelIdKey = keyof Pick<
  27. ProviderSettings,
  28. | "glamaModelId"
  29. | "openRouterModelId"
  30. | "unboundModelId"
  31. | "requestyModelId"
  32. | "openAiModelId"
  33. | "litellmModelId"
  34. | "ioIntelligenceModelId"
  35. >
  36. interface ModelPickerProps {
  37. defaultModelId: string
  38. models: Record<string, ModelInfo> | null
  39. modelIdKey: ModelIdKey
  40. serviceName: string
  41. serviceUrl: string
  42. apiConfiguration: ProviderSettings
  43. setApiConfigurationField: <K extends keyof ProviderSettings>(
  44. field: K,
  45. value: ProviderSettings[K],
  46. isUserAction?: boolean,
  47. ) => void
  48. organizationAllowList: OrganizationAllowList
  49. errorMessage?: string
  50. }
  51. export const ModelPicker = ({
  52. defaultModelId,
  53. models,
  54. modelIdKey,
  55. serviceName,
  56. serviceUrl,
  57. apiConfiguration,
  58. setApiConfigurationField,
  59. organizationAllowList,
  60. errorMessage,
  61. }: ModelPickerProps) => {
  62. const { t } = useAppTranslation()
  63. const [open, setOpen] = useState(false)
  64. const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
  65. const isInitialized = useRef(false)
  66. const searchInputRef = useRef<HTMLInputElement>(null)
  67. const selectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
  68. const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null)
  69. const modelIds = useMemo(() => {
  70. const filteredModels = filterModels(models, apiConfiguration.apiProvider, organizationAllowList)
  71. return Object.keys(filteredModels ?? {}).sort((a, b) => a.localeCompare(b))
  72. }, [models, apiConfiguration.apiProvider, organizationAllowList])
  73. const { id: selectedModelId, info: selectedModelInfo } = useSelectedModel(apiConfiguration)
  74. const [searchValue, setSearchValue] = useState("")
  75. const onSelect = useCallback(
  76. (modelId: string) => {
  77. if (!modelId) {
  78. return
  79. }
  80. setOpen(false)
  81. setApiConfigurationField(modelIdKey, modelId)
  82. // Clear any existing timeout
  83. if (selectTimeoutRef.current) {
  84. clearTimeout(selectTimeoutRef.current)
  85. }
  86. // Delay to ensure the popover is closed before setting the search value.
  87. selectTimeoutRef.current = setTimeout(() => setSearchValue(""), 100)
  88. },
  89. [modelIdKey, setApiConfigurationField],
  90. )
  91. const onOpenChange = useCallback((open: boolean) => {
  92. setOpen(open)
  93. // Abandon the current search if the popover is closed.
  94. if (!open) {
  95. // Clear any existing timeout
  96. if (closeTimeoutRef.current) {
  97. clearTimeout(closeTimeoutRef.current)
  98. }
  99. // Clear the search value when closing instead of prefilling it
  100. closeTimeoutRef.current = setTimeout(() => setSearchValue(""), 100)
  101. }
  102. }, [])
  103. const onClearSearch = useCallback(() => {
  104. setSearchValue("")
  105. searchInputRef.current?.focus()
  106. }, [])
  107. useEffect(() => {
  108. if (!selectedModelId && !isInitialized.current) {
  109. const initialValue = modelIds.includes(selectedModelId) ? selectedModelId : defaultModelId
  110. setApiConfigurationField(modelIdKey, initialValue, false) // false = automatic initialization
  111. }
  112. isInitialized.current = true
  113. }, [modelIds, setApiConfigurationField, modelIdKey, selectedModelId, defaultModelId])
  114. // Cleanup timeouts on unmount to prevent test flakiness
  115. useEffect(() => {
  116. return () => {
  117. if (selectTimeoutRef.current) {
  118. clearTimeout(selectTimeoutRef.current)
  119. }
  120. if (closeTimeoutRef.current) {
  121. clearTimeout(closeTimeoutRef.current)
  122. }
  123. }
  124. }, [])
  125. // Use the shared ESC key handler hook
  126. useEscapeKey(open, () => setOpen(false))
  127. return (
  128. <>
  129. <div>
  130. <label className="block font-medium mb-1">{t("settings:modelPicker.label")}</label>
  131. <Popover open={open} onOpenChange={onOpenChange}>
  132. <PopoverTrigger asChild>
  133. <Button
  134. variant="combobox"
  135. role="combobox"
  136. aria-expanded={open}
  137. className="w-full justify-between"
  138. data-testid="model-picker-button">
  139. <div className="truncate">{selectedModelId ?? t("settings:common.select")}</div>
  140. <ChevronsUpDown className="opacity-50" />
  141. </Button>
  142. </PopoverTrigger>
  143. <PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
  144. <Command>
  145. <div className="relative">
  146. <CommandInput
  147. ref={searchInputRef}
  148. value={searchValue}
  149. onValueChange={setSearchValue}
  150. placeholder={t("settings:modelPicker.searchPlaceholder")}
  151. className="h-9 mr-4"
  152. data-testid="model-input"
  153. />
  154. {searchValue.length > 0 && (
  155. <div className="absolute right-2 top-0 bottom-0 flex items-center justify-center">
  156. <X
  157. className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
  158. onClick={onClearSearch}
  159. />
  160. </div>
  161. )}
  162. </div>
  163. <CommandList>
  164. <CommandEmpty>
  165. {searchValue && (
  166. <div className="py-2 px-1 text-sm">
  167. {t("settings:modelPicker.noMatchFound")}
  168. </div>
  169. )}
  170. </CommandEmpty>
  171. <CommandGroup>
  172. {modelIds.map((model) => (
  173. <CommandItem
  174. key={model}
  175. value={model}
  176. onSelect={onSelect}
  177. data-testid={`model-option-${model}`}>
  178. <span className="truncate" title={model}>
  179. {model}
  180. </span>
  181. <Check
  182. className={cn(
  183. "size-4 p-0.5 ml-auto",
  184. model === selectedModelId ? "opacity-100" : "opacity-0",
  185. )}
  186. />
  187. </CommandItem>
  188. ))}
  189. </CommandGroup>
  190. </CommandList>
  191. {searchValue && !modelIds.includes(searchValue) && (
  192. <div className="p-1 border-t border-vscode-input-border">
  193. <CommandItem data-testid="use-custom-model" value={searchValue} onSelect={onSelect}>
  194. {t("settings:modelPicker.useCustomModel", { modelId: searchValue })}
  195. </CommandItem>
  196. </div>
  197. )}
  198. </Command>
  199. </PopoverContent>
  200. </Popover>
  201. </div>
  202. {errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
  203. {selectedModelId && selectedModelInfo && (
  204. <ModelInfoView
  205. apiProvider={apiConfiguration.apiProvider}
  206. selectedModelId={selectedModelId}
  207. modelInfo={selectedModelInfo}
  208. isDescriptionExpanded={isDescriptionExpanded}
  209. setIsDescriptionExpanded={setIsDescriptionExpanded}
  210. />
  211. )}
  212. <div className="text-sm text-vscode-descriptionForeground">
  213. <Trans
  214. i18nKey="settings:modelPicker.automaticFetch"
  215. components={{
  216. serviceLink: <VSCodeLink href={serviceUrl} className="text-sm" />,
  217. defaultModelLink: <VSCodeLink onClick={() => onSelect(defaultModelId)} className="text-sm" />,
  218. }}
  219. values={{ serviceName, defaultModelId }}
  220. />
  221. </div>
  222. </>
  223. )
  224. }