-
Notifications
You must be signed in to change notification settings - Fork 276
feat: add model selector UI to chat #1633
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
910812c
b758132
cdd30a7
c754d5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,267 @@ | ||||||||||||||||||||||
| import { useState, useMemo, useCallback } from "react" | ||||||||||||||||||||||
| import { Fzf } from "fzf" | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import { | ||||||||||||||||||||||
| type ModelInfo, | ||||||||||||||||||||||
| type ModelRecord, | ||||||||||||||||||||||
| type OrganizationAllowList, | ||||||||||||||||||||||
| type ProviderSettings, | ||||||||||||||||||||||
| isDynamicProvider, | ||||||||||||||||||||||
| isRetiredProvider, | ||||||||||||||||||||||
| providerIdentifiers, | ||||||||||||||||||||||
| } from "@roo-code/types" | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import { cn } from "@/lib/utils" | ||||||||||||||||||||||
| import { enabledSelectorTriggerClassName, selectorTriggerClassName } from "@/components/ui/selectorTriggerStyles" | ||||||||||||||||||||||
| import { useRooPortal } from "@/components/ui/hooks/useRooPortal" | ||||||||||||||||||||||
| import { useRouterModels } from "@/components/ui/hooks/useRouterModels" | ||||||||||||||||||||||
| import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" | ||||||||||||||||||||||
| import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" | ||||||||||||||||||||||
| import { useAppTranslation } from "@/i18n/TranslationContext" | ||||||||||||||||||||||
| import { vscode } from "@/utils/vscode" | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import { filterModels } from "../settings/utils/organizationFilters" | ||||||||||||||||||||||
| import { | ||||||||||||||||||||||
| getProviderModelConfig, | ||||||||||||||||||||||
| getStaticModelsForProvider, | ||||||||||||||||||||||
| isStaticModelProvider, | ||||||||||||||||||||||
| } from "../settings/utils/providerModelConfig" | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const SEARCH_THRESHOLD = 6 | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| interface ModelSelectorProps { | ||||||||||||||||||||||
| apiConfiguration: ProviderSettings | ||||||||||||||||||||||
| currentApiConfigName?: string | ||||||||||||||||||||||
| disabled?: boolean | ||||||||||||||||||||||
| title: string | ||||||||||||||||||||||
| triggerClassName?: string | ||||||||||||||||||||||
| organizationAllowList?: OrganizationAllowList | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export const ModelSelector = ({ | ||||||||||||||||||||||
| apiConfiguration, | ||||||||||||||||||||||
| currentApiConfigName, | ||||||||||||||||||||||
| disabled = false, | ||||||||||||||||||||||
| title, | ||||||||||||||||||||||
| triggerClassName = "", | ||||||||||||||||||||||
|
Check failure on line 46 in webview-ui/src/components/chat/ModelSelector.tsx
|
||||||||||||||||||||||
| organizationAllowList, | ||||||||||||||||||||||
| }: ModelSelectorProps) => { | ||||||||||||||||||||||
| const { t } = useAppTranslation() | ||||||||||||||||||||||
| const [open, setOpen] = useState(false) | ||||||||||||||||||||||
| const [searchValue, setSearchValue] = useState("") | ||||||||||||||||||||||
| const portalContainer = useRooPortal("roo-portal") | ||||||||||||||||||||||
|
Check failure on line 52 in webview-ui/src/components/chat/ModelSelector.tsx
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const rawProvider = apiConfiguration?.apiProvider || providerIdentifiers.openrouter | ||||||||||||||||||||||
| const retired = isRetiredProvider(rawProvider) | ||||||||||||||||||||||
| const provider = retired ? providerIdentifiers.openrouter : rawProvider | ||||||||||||||||||||||
| const dynamicProvider = !retired && isDynamicProvider(provider) ? provider : undefined | ||||||||||||||||||||||
| const modelConfig = retired ? undefined : getProviderModelConfig(provider, apiConfiguration) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const routerModels = useRouterModels({ provider: dynamicProvider, enabled: !!dynamicProvider }) | ||||||||||||||||||||||
|
Check failure on line 60 in webview-ui/src/components/chat/ModelSelector.tsx
|
||||||||||||||||||||||
| const { id: selectedModelId, info: selectedModelInfo, isLoading } = useSelectedModel(apiConfiguration) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const models: ModelRecord = useMemo(() => { | ||||||||||||||||||||||
| if (!modelConfig) { | ||||||||||||||||||||||
|
Check failure on line 64 in webview-ui/src/components/chat/ModelSelector.tsx
|
||||||||||||||||||||||
| return {} | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| let resolved: ModelRecord | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (dynamicProvider) { | ||||||||||||||||||||||
| resolved = routerModels.data?.[dynamicProvider] ?? {} | ||||||||||||||||||||||
| } else if (isStaticModelProvider(provider)) { | ||||||||||||||||||||||
|
Check failure on line 72 in webview-ui/src/components/chat/ModelSelector.tsx
|
||||||||||||||||||||||
| const staticModels = getStaticModelsForProvider(provider, undefined, apiConfiguration) | ||||||||||||||||||||||
| const { "custom-arn": _customArn, ...rest } = staticModels | ||||||||||||||||||||||
| resolved = rest | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
|
Check failure on line 76 in webview-ui/src/components/chat/ModelSelector.tsx
|
||||||||||||||||||||||
| resolved = {} | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Apply the organization allowlist so the inline selector never exposes | ||||||||||||||||||||||
| // or activates models the organization has not approved. Mirrors the | ||||||||||||||||||||||
| // filtering performed by ModelPicker in the settings view. | ||||||||||||||||||||||
| return filterModels(resolved, provider, organizationAllowList) ?? {} | ||||||||||||||||||||||
| }, [modelConfig, dynamicProvider, routerModels.data, provider, apiConfiguration, organizationAllowList]) | ||||||||||||||||||||||
|
Check failure on line 84 in webview-ui/src/components/chat/ModelSelector.tsx
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const modelIds = useMemo(() => Object.keys(models), [models]) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const isSupported = !!modelConfig && modelIds.length > 0 | ||||||||||||||||||||||
| const isDisabled = disabled || !isSupported | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Label shown for a model — prefers `ModelInfo.displayName` when present, falling back to | ||||||||||||||||||||||
| // the raw model id (mirrors ModelPicker.tsx's trigger/list label logic). | ||||||||||||||||||||||
| const getModelLabel = useCallback((modelId: string, info?: ModelInfo) => info?.displayName ?? modelId, []) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const selectedModelLabel = getModelLabel(selectedModelId, selectedModelInfo) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Create searchable items for fuzzy search. | ||||||||||||||||||||||
| const searchableItems = useMemo( | ||||||||||||||||||||||
| () => | ||||||||||||||||||||||
| modelIds.map((id) => { | ||||||||||||||||||||||
| const label = getModelLabel(id, models[id]) | ||||||||||||||||||||||
| return { original: id, searchStr: label === id ? id : `${label} ${id}` } | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| [modelIds, models, getModelLabel], | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const fzfInstance = useMemo( | ||||||||||||||||||||||
| () => new Fzf(searchableItems, { selector: (item) => item.searchStr }), | ||||||||||||||||||||||
| [searchableItems], | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const filteredModelIds = useMemo(() => { | ||||||||||||||||||||||
| if (!searchValue) { | ||||||||||||||||||||||
| return modelIds | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return fzfInstance.find(searchValue).map((result) => result.item.original) | ||||||||||||||||||||||
| }, [modelIds, searchValue, fzfInstance]) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleEditClick = useCallback(() => { | ||||||||||||||||||||||
| vscode.postMessage({ type: "switchTab", tab: "settings" }) | ||||||||||||||||||||||
| setOpen(false) | ||||||||||||||||||||||
| }, []) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleSelect = useCallback( | ||||||||||||||||||||||
| (modelId: string) => { | ||||||||||||||||||||||
| if (!modelConfig) { | ||||||||||||||||||||||
| return | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const updated: ProviderSettings = { | ||||||||||||||||||||||
| ...apiConfiguration, | ||||||||||||||||||||||
| reasoningEffort: undefined, | ||||||||||||||||||||||
| modelMaxTokens: undefined, | ||||||||||||||||||||||
| modelMaxThinkingTokens: undefined, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| ;(updated as Record<string, unknown>)[modelConfig.field] = modelId | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| vscode.postMessage({ | ||||||||||||||||||||||
| type: "upsertApiConfiguration", | ||||||||||||||||||||||
| text: currentApiConfigName, | ||||||||||||||||||||||
| apiConfiguration: updated, | ||||||||||||||||||||||
| }) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| setOpen(false) | ||||||||||||||||||||||
| setSearchValue("") | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| [apiConfiguration, modelConfig, currentApiConfigName], | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const renderModelItem = useCallback( | ||||||||||||||||||||||
| (modelId: string) => { | ||||||||||||||||||||||
| const isCurrentModel = modelId === selectedModelId | ||||||||||||||||||||||
| const label = getModelLabel(modelId, models[modelId]) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| key={modelId} | ||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||
| role="option" | ||||||||||||||||||||||
| aria-selected={isCurrentModel} | ||||||||||||||||||||||
| onClick={() => handleSelect(modelId)} | ||||||||||||||||||||||
| className={cn( | ||||||||||||||||||||||
| "w-full text-left px-3 py-1.5 text-sm cursor-pointer flex items-center group", | ||||||||||||||||||||||
| "hover:bg-vscode-list-hoverBackground focus-visible:outline-0 focus-visible:bg-vscode-list-hoverBackground", | ||||||||||||||||||||||
| isCurrentModel && | ||||||||||||||||||||||
| "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground", | ||||||||||||||||||||||
| )}> | ||||||||||||||||||||||
| <span className="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{label}</span> | ||||||||||||||||||||||
| {isCurrentModel && ( | ||||||||||||||||||||||
| <span className="size-5 p-1 flex items-center justify-center"> | ||||||||||||||||||||||
| <span className="codicon codicon-check text-xs" /> | ||||||||||||||||||||||
| </span> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| </button> | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| [selectedModelId, models, getModelLabel, handleSelect], | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // While a dynamic provider's model list is still loading, keep the trigger | ||||||||||||||||||||||
| // visible (showing the loading label) instead of falling back to the | ||||||||||||||||||||||
| // unsupported-provider shortcut. Only show the unsupported fallback once we | ||||||||||||||||||||||
| // know the provider genuinely has no selectable models. The loading gate | ||||||||||||||||||||||
| // checks both the selected-model resolution (useSelectedModel) and the | ||||||||||||||||||||||
| // router-models query (useRouterModels) so a dynamic provider whose model | ||||||||||||||||||||||
| // list hasn't resolved yet never flashes the unsupported shortcut. | ||||||||||||||||||||||
| if (!isSupported && !isLoading && !routerModels.isLoading) { | ||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <StandardTooltip content={t("chat:selectModelUnsupported")}> | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| data-testid="model-selector-disabled" | ||||||||||||||||||||||
| className={cn( | ||||||||||||||||||||||
| "min-w-0 inline-flex items-center relative whitespace-nowrap px-1.5 py-1 text-xs", | ||||||||||||||||||||||
| selectorTriggerClassName, | ||||||||||||||||||||||
| "opacity-50", | ||||||||||||||||||||||
| triggerClassName, | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| onClick={handleEditClick}> | ||||||||||||||||||||||
| <span className="truncate">{selectedModelLabel || provider}</span> | ||||||||||||||||||||||
| </button> | ||||||||||||||||||||||
| </StandardTooltip> | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <Popover open={open} onOpenChange={setOpen} data-testid="model-selector-root"> | ||||||||||||||||||||||
| <StandardTooltip content={title}> | ||||||||||||||||||||||
| <PopoverTrigger | ||||||||||||||||||||||
| disabled={isDisabled} | ||||||||||||||||||||||
| data-testid="model-selector-trigger" | ||||||||||||||||||||||
| className={cn( | ||||||||||||||||||||||
| "min-w-0 inline-flex items-center relative whitespace-nowrap px-1.5 py-1 text-xs", | ||||||||||||||||||||||
| selectorTriggerClassName, | ||||||||||||||||||||||
| isDisabled ? "opacity-50 cursor-not-allowed" : enabledSelectorTriggerClassName, | ||||||||||||||||||||||
| triggerClassName, | ||||||||||||||||||||||
| )}> | ||||||||||||||||||||||
| <span className="truncate"> | ||||||||||||||||||||||
| {isLoading || routerModels.isLoading ? t("common:ui.loading") : selectedModelLabel} | ||||||||||||||||||||||
| </span> | ||||||||||||||||||||||
| </PopoverTrigger> | ||||||||||||||||||||||
| </StandardTooltip> | ||||||||||||||||||||||
| <PopoverContent | ||||||||||||||||||||||
| align="start" | ||||||||||||||||||||||
| sideOffset={4} | ||||||||||||||||||||||
| container={portalContainer} | ||||||||||||||||||||||
| className="p-0 overflow-hidden w-[300px]"> | ||||||||||||||||||||||
| <div className="flex flex-col w-full"> | ||||||||||||||||||||||
| {modelIds.length > SEARCH_THRESHOLD && ( | ||||||||||||||||||||||
| <div className="relative p-2 border-b border-vscode-dropdown-border"> | ||||||||||||||||||||||
| <input | ||||||||||||||||||||||
| aria-label={t("common:ui.search_placeholder")} | ||||||||||||||||||||||
| value={searchValue} | ||||||||||||||||||||||
| onChange={(e) => setSearchValue(e.target.value)} | ||||||||||||||||||||||
| placeholder={t("common:ui.search_placeholder")} | ||||||||||||||||||||||
| 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" | ||||||||||||||||||||||
| autoFocus | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| {searchValue.length > 0 && ( | ||||||||||||||||||||||
| <div className="absolute right-4 top-0 bottom-0 flex items-center justify-center"> | ||||||||||||||||||||||
| <span | ||||||||||||||||||||||
| className="codicon codicon-close text-vscode-input-foreground opacity-50 hover:opacity-100 text-xs cursor-pointer" | ||||||||||||||||||||||
| onClick={() => setSearchValue("")} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
|
Comment on lines
+241
to
+244
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use a native, named button for the search clear control. When 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| {filteredModelIds.length === 0 ? ( | ||||||||||||||||||||||
| <div className="py-2 px-3 text-sm text-vscode-foreground/70">{t("common:ui.no_results")}</div> | ||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||
| <div className="max-h-[300px] overflow-y-auto py-1"> | ||||||||||||||||||||||
| {filteredModelIds.map(renderModelItem)} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border"> | ||||||||||||||||||||||
| <h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground"> | ||||||||||||||||||||||
| {t("chat:selectModel")} | ||||||||||||||||||||||
| </h4> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
| </PopoverContent> | ||||||||||||||||||||||
| </Popover> | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Separate provider support from model availability.
isSupportedbecomes false when the organization allowlist removes every model. Line 188 then labels the supported provider as unsupported and directs the user to Settings, where the same allowlist still applies.A router-model failure can enter the same branch after loading stops. Track provider capability, query failure, and an empty filtered list as separate states. Use the unsupported fallback only when the provider has no model-selection capability.
🤖 Prompt for AI Agents