|
| 1 | +import { useMemo, useState, useCallback, useRef, type KeyboardEvent } from "react" |
| 2 | + |
| 3 | +import { cn } from "@/lib/utils" |
| 4 | +import { useRooPortal } from "@/components/ui/hooks/useRooPortal" |
| 5 | +import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" |
| 6 | +import { useAppTranslation } from "@/i18n/TranslationContext" |
| 7 | +import { vscode } from "@/utils/vscode" |
| 8 | +import { useExtensionState } from "@/context/ExtensionStateContext" |
| 9 | +import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" |
| 10 | +import { filterModels } from "@/components/settings/utils/organizationFilters" |
| 11 | +import { useChatModelSelector } from "./hooks/useChatModelSelector" |
| 12 | + |
| 13 | +interface ChatModelSelectorProps { |
| 14 | + disabled?: boolean |
| 15 | + title: string |
| 16 | + triggerClassName?: string |
| 17 | +} |
| 18 | + |
| 19 | +export const ChatModelSelector = ({ disabled = false, title, triggerClassName = "" }: ChatModelSelectorProps) => { |
| 20 | + const { t } = useAppTranslation() |
| 21 | + const { apiConfiguration, organizationAllowList, currentApiConfigName } = useExtensionState() |
| 22 | + const { id: selectedModelId } = useSelectedModel(apiConfiguration) |
| 23 | + const { provider, models, modelIdKey, defaultModelId, isLoading, valueTransform, displayTransform } = |
| 24 | + useChatModelSelector() |
| 25 | + |
| 26 | + const [open, setOpen] = useState(false) |
| 27 | + const [searchValue, setSearchValue] = useState("") |
| 28 | + const searchInputRef = useRef<HTMLInputElement>(null) |
| 29 | + const portalContainer = useRooPortal("roo-portal") |
| 30 | + |
| 31 | + // Filter deprecated models but always keep the currently selected one visible. |
| 32 | + const modelIds = useMemo(() => { |
| 33 | + const filteredModels = filterModels(models, provider, organizationAllowList) |
| 34 | + const available = Object.entries(filteredModels ?? {}) |
| 35 | + .filter(([modelId, modelInfo]) => { |
| 36 | + if (modelId === selectedModelId) return true |
| 37 | + return !modelInfo.deprecated |
| 38 | + }) |
| 39 | + .map(([modelId]) => modelId) |
| 40 | + .sort((a, b) => a.localeCompare(b)) |
| 41 | + return available |
| 42 | + }, [models, provider, organizationAllowList, selectedModelId]) |
| 43 | + |
| 44 | + // Resolve the display value (custom transform for compound config values like VSCode LM). |
| 45 | + const displayValue = useMemo(() => { |
| 46 | + if (displayTransform && modelIdKey) { |
| 47 | + const storedValue = apiConfiguration?.[modelIdKey] |
| 48 | + return storedValue ? displayTransform(storedValue) : undefined |
| 49 | + } |
| 50 | + return selectedModelId || (searchValue ? searchValue : undefined) |
| 51 | + }, [displayTransform, modelIdKey, apiConfiguration, selectedModelId, searchValue]) |
| 52 | + |
| 53 | + const filteredModelIds = useMemo(() => { |
| 54 | + if (!searchValue) return modelIds |
| 55 | + const q = searchValue.toLowerCase() |
| 56 | + return modelIds.filter((id) => id.toLowerCase().includes(q)) |
| 57 | + }, [modelIds, searchValue]) |
| 58 | + |
| 59 | + const onSelect = useCallback( |
| 60 | + (modelId: string) => { |
| 61 | + if (!modelId || !modelIdKey || !apiConfiguration) { |
| 62 | + return |
| 63 | + } |
| 64 | + |
| 65 | + setOpen(false) |
| 66 | + setSearchValue("") |
| 67 | + |
| 68 | + // Transform the model id for storage if needed (e.g. VSCode LM selector object). |
| 69 | + const valueToStore = valueTransform ? valueTransform(modelId) : modelId |
| 70 | + |
| 71 | + // Persist the change to the current API configuration profile. The |
| 72 | + // backend will save the profile, activate it and broadcast the |
| 73 | + // updated apiConfiguration back to the webview. |
| 74 | + vscode.postMessage({ |
| 75 | + type: "upsertApiConfiguration", |
| 76 | + text: currentApiConfigName, |
| 77 | + apiConfiguration: { |
| 78 | + ...apiConfiguration, |
| 79 | + [modelIdKey]: valueToStore, |
| 80 | + }, |
| 81 | + }) |
| 82 | + }, |
| 83 | + [modelIdKey, apiConfiguration, valueTransform, currentApiConfigName], |
| 84 | + ) |
| 85 | + |
| 86 | + const onClearSearch = useCallback(() => { |
| 87 | + setSearchValue("") |
| 88 | + searchInputRef.current?.focus() |
| 89 | + }, []) |
| 90 | + |
| 91 | + const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { |
| 92 | + if (event.nativeEvent.isComposing) return |
| 93 | + |
| 94 | + const options = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("[data-model-option]")) |
| 95 | + const currentIndex = options.findIndex((option) => option === event.target) |
| 96 | + const isSearchInput = event.target === searchInputRef.current |
| 97 | + if (!isSearchInput && currentIndex === -1) return |
| 98 | + |
| 99 | + if (isSearchInput && event.key === "Enter") { |
| 100 | + event.preventDefault() |
| 101 | + options[0]?.click() |
| 102 | + return |
| 103 | + } |
| 104 | + |
| 105 | + let nextIndex: number |
| 106 | + switch (event.key) { |
| 107 | + case "ArrowDown": |
| 108 | + nextIndex = currentIndex + 1 |
| 109 | + break |
| 110 | + case "ArrowUp": |
| 111 | + nextIndex = currentIndex === -1 ? options.length - 1 : currentIndex - 1 |
| 112 | + break |
| 113 | + case "Home": |
| 114 | + case "End": |
| 115 | + if (isSearchInput) return |
| 116 | + nextIndex = event.key === "Home" ? 0 : options.length - 1 |
| 117 | + break |
| 118 | + default: |
| 119 | + return |
| 120 | + } |
| 121 | + event.preventDefault() |
| 122 | + if (nextIndex < 0 || nextIndex >= options.length) { |
| 123 | + searchInputRef.current?.focus() |
| 124 | + } else { |
| 125 | + options[nextIndex]?.focus() |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + return ( |
| 130 | + <Popover open={open} onOpenChange={setOpen} data-testid="chat-model-selector-root"> |
| 131 | + <StandardTooltip content={title}> |
| 132 | + <PopoverTrigger |
| 133 | + disabled={disabled || !modelIdKey} |
| 134 | + data-testid="chat-model-selector-trigger" |
| 135 | + className={cn( |
| 136 | + "min-w-0 inline-flex items-center relative whitespace-nowrap px-1.5 py-1 text-xs", |
| 137 | + "bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground", |
| 138 | + "transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset", |
| 139 | + disabled || !modelIdKey |
| 140 | + ? "opacity-50 cursor-not-allowed" |
| 141 | + : "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer", |
| 142 | + triggerClassName, |
| 143 | + )}> |
| 144 | + <span className="truncate max-w-[140px]"> |
| 145 | + {isLoading ? "…" : displayValue || defaultModelId || t("chat:selectModel")} |
| 146 | + </span> |
| 147 | + </PopoverTrigger> |
| 148 | + </StandardTooltip> |
| 149 | + <PopoverContent |
| 150 | + align="start" |
| 151 | + sideOffset={4} |
| 152 | + container={portalContainer} |
| 153 | + className="p-0 overflow-hidden w-[300px]"> |
| 154 | + <div className="flex flex-col w-full" onKeyDown={onKeyDown}> |
| 155 | + <div className="relative p-2 border-b border-vscode-dropdown-border"> |
| 156 | + <input |
| 157 | + ref={searchInputRef} |
| 158 | + aria-label={t("chat:searchModel")} |
| 159 | + value={searchValue} |
| 160 | + onChange={(e) => setSearchValue(e.target.value)} |
| 161 | + placeholder={t("chat:searchModel")} |
| 162 | + className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0" |
| 163 | + autoFocus |
| 164 | + /> |
| 165 | + {searchValue.length > 0 && ( |
| 166 | + <div className="absolute right-4 top-0 bottom-0 flex items-center justify-center"> |
| 167 | + <button |
| 168 | + type="button" |
| 169 | + aria-label={t("chat:clearSearch")} |
| 170 | + className="codicon codicon-close text-vscode-input-foreground opacity-50 hover:opacity-100 text-xs cursor-pointer" |
| 171 | + onClick={onClearSearch} |
| 172 | + /> |
| 173 | + </div> |
| 174 | + )} |
| 175 | + </div> |
| 176 | + |
| 177 | + {modelIds.length === 0 && !isLoading && ( |
| 178 | + <div className="py-2 px-3 text-sm text-vscode-foreground/70">{t("chat:modelListEmpty")}</div> |
| 179 | + )} |
| 180 | + |
| 181 | + {modelIds.length > 0 && ( |
| 182 | + <div className="max-h-[300px] overflow-y-auto"> |
| 183 | + {filteredModelIds.map((modelId) => ( |
| 184 | + <button |
| 185 | + type="button" |
| 186 | + data-model-option |
| 187 | + key={modelId} |
| 188 | + onClick={() => onSelect(modelId)} |
| 189 | + data-testid={`chat-model-option-${modelId}`} |
| 190 | + className={cn( |
| 191 | + "w-full text-left px-3 py-1.5 text-sm cursor-pointer flex items-center gap-2", |
| 192 | + "hover:bg-vscode-list-hoverBackground focus-visible:outline focus-visible:outline-1 focus-visible:outline-vscode-focusBorder focus-visible:-outline-offset-1", |
| 193 | + modelId === displayValue && |
| 194 | + "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground", |
| 195 | + )}> |
| 196 | + <span className="flex-1 min-w-0 truncate" title={modelId}> |
| 197 | + {modelId} |
| 198 | + </span> |
| 199 | + {modelId === displayValue && <span className="codicon codicon-check text-xs" />} |
| 200 | + </button> |
| 201 | + ))} |
| 202 | + </div> |
| 203 | + )} |
| 204 | + |
| 205 | + {searchValue && !modelIds.includes(searchValue) && ( |
| 206 | + <button |
| 207 | + type="button" |
| 208 | + data-model-option |
| 209 | + data-testid="chat-model-use-custom" |
| 210 | + onClick={() => onSelect(searchValue)} |
| 211 | + className="w-full text-left px-3 py-1.5 text-sm cursor-pointer hover:bg-vscode-list-hoverBackground border-t border-vscode-input-border focus-visible:outline focus-visible:outline-1 focus-visible:outline-vscode-focusBorder focus-visible:-outline-offset-1"> |
| 212 | + {t("chat:useCustomModel", { modelId: searchValue })} |
| 213 | + </button> |
| 214 | + )} |
| 215 | + </div> |
| 216 | + </PopoverContent> |
| 217 | + </Popover> |
| 218 | + ) |
| 219 | +} |
0 commit comments