diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index cb69da51e5..449206deb8 100644 Binary files a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png and b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png differ diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 3721021637..64e998c180 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -27,6 +27,7 @@ import { StandardTooltip } from "@src/components/ui" import Thumbnails from "../common/Thumbnails" import { ModeSelector } from "./ModeSelector" import { ApiConfigSelector } from "./ApiConfigSelector" +import { ModelSelector } from "./ModelSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" @@ -87,6 +88,7 @@ export const ChatTextArea = forwardRef( const { filePaths, openedTabs, + apiConfiguration, currentApiConfigName, listApiConfigMeta, customModes, @@ -99,6 +101,7 @@ export const ChatTextArea = forwardRef( commands, enterBehavior, lockApiConfigAcrossModes, + organizationAllowList, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -1311,6 +1314,13 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> +
diff --git a/webview-ui/src/components/chat/ModelSelector.tsx b/webview-ui/src/components/chat/ModelSelector.tsx new file mode 100644 index 0000000000..e8606340e5 --- /dev/null +++ b/webview-ui/src/components/chat/ModelSelector.tsx @@ -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 = "", + organizationAllowList, +}: ModelSelectorProps) => { + const { t } = useAppTranslation() + const [open, setOpen] = useState(false) + const [searchValue, setSearchValue] = useState("") + const portalContainer = useRooPortal("roo-portal") + + 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 }) + const { id: selectedModelId, info: selectedModelInfo, isLoading } = useSelectedModel(apiConfiguration) + + const models: ModelRecord = useMemo(() => { + if (!modelConfig) { + return {} + } + + let resolved: ModelRecord + + if (dynamicProvider) { + resolved = routerModels.data?.[dynamicProvider] ?? {} + } else if (isStaticModelProvider(provider)) { + const staticModels = getStaticModelsForProvider(provider, undefined, apiConfiguration) + const { "custom-arn": _customArn, ...rest } = staticModels + resolved = rest + } else { + 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]) + + 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)[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 ( + + ) + }, + [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 ( + + + + ) + } + + return ( + + + + + {isLoading || routerModels.isLoading ? t("common:ui.loading") : selectedModelLabel} + + + + +
+ {modelIds.length > SEARCH_THRESHOLD && ( +
+ 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 && ( +
+ setSearchValue("")} + /> +
+ )} +
+ )} + + {filteredModelIds.length === 0 ? ( +
{t("common:ui.no_results")}
+ ) : ( +
+ {filteredModelIds.map(renderModelItem)} +
+ )} + +
+

+ {t("chat:selectModel")} +

+
+
+
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx index 9c447fe011..e1beb9ce9e 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx @@ -17,7 +17,7 @@ for (const theme of visualThemes) { await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) for ( let index = 0; - index < 10 && !(await editor.evaluate((element) => element === document.activeElement)); + index < 15 && !(await editor.evaluate((element) => element === document.activeElement)); index++ ) { await page.keyboard.press("Tab") diff --git a/webview-ui/src/components/chat/__tests__/ModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ModelSelector.spec.tsx new file mode 100644 index 0000000000..5ced8dc018 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ModelSelector.spec.tsx @@ -0,0 +1,575 @@ +import { type ReactNode } from "react" + +import { + providerIdentifiers, + retiredProviderIdentifiers, + type ModelInfo, + type OrganizationAllowList, + type ProviderSettings, + type RouterModels, +} from "@roo-code/types" + +import { render, screen, fireEvent, within } from "@/utils/test-utils" +import { vscode } from "@/utils/vscode" + +import { ModelSelector } from "../ModelSelector" + +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => document.body, +})) + +const { useRouterModelsMock, useSelectedModelMock } = vi.hoisted(() => ({ + useRouterModelsMock: vi.fn((): { data: Partial | undefined; isLoading: boolean } => ({ + data: undefined, + isLoading: false, + })), + useSelectedModelMock: vi.fn((): { id: string; info?: ModelInfo; isLoading: boolean } => ({ + id: "claude-sonnet-4-5", + isLoading: false, + })), +})) + +vi.mock("@/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: useRouterModelsMock, +})) + +vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: useSelectedModelMock, +})) + +vi.mock("@/components/ui", async () => { + const { createContext, useContext } = await import("react") + + type PopoverContextValue = { + open: boolean + onOpenChange: (open: boolean) => void + } + + const PopoverContext = createContext({ + open: false, + onOpenChange: () => {}, + }) + + type PopoverProps = { + children: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + } + + type PopoverTriggerProps = { + children: ReactNode + disabled?: boolean + className?: string + "data-testid"?: string + } + + type PopoverContentProps = { + children: ReactNode + align?: string + sideOffset?: number + container?: HTMLElement | null + className?: string + } + + type StandardTooltipProps = { + children: ReactNode + content?: string + } + + return { + Popover: ({ children, open, onOpenChange }: PopoverProps) => ( + {}) }}> +
+ {children} +
+
+ ), + PopoverTrigger: ({ children, disabled, ...props }: PopoverTriggerProps) => { + const { open, onOpenChange } = useContext(PopoverContext) + return ( + + ) + }, + PopoverContent: ({ children }: PopoverContentProps) => { + const { open } = useContext(PopoverContext) + return open ?
{children}
: null + }, + StandardTooltip: ({ children }: StandardTooltipProps) => <>{children}, + } +}) + +const modelInfo = (overrides: Partial = {}): ModelInfo => ({ + contextWindow: 4096, + supportsPromptCache: false, + ...overrides, +}) + +const allowAllList: OrganizationAllowList = { allowAll: true, providers: {} } + +/** Opens the popover by clicking the trigger and returns the content container. */ +const openPopover = () => { + fireEvent.click(screen.getByTestId("model-selector-trigger")) + return within(screen.getByTestId("popover-content")) +} + +describe("ModelSelector", () => { + beforeEach(() => { + vi.clearAllMocks() + useRouterModelsMock.mockReturnValue({ data: undefined, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "claude-sonnet-4-5", isLoading: false }) + }) + + it("renders the static model list for a static provider and sends upsertApiConfiguration on select", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-5", + reasoningEffort: "high", + modelMaxTokens: 8192, + modelMaxThinkingTokens: 4096, + } + + render( + , + ) + + expect(screen.getByTestId("model-selector-trigger")).not.toBeDisabled() + + const content = openPopover() + const anotherModel = content.getAllByText(/claude-3-5-haiku/i)[0] + fireEvent.click(anotherModel) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: expect.objectContaining({ + apiModelId: expect.stringContaining("claude-3-5-haiku"), + reasoningEffort: undefined, + modelMaxTokens: undefined, + modelMaxThinkingTokens: undefined, + }), + }), + ) + }) + + it("prefers a model's displayName over its raw id when present", () => { + useRouterModelsMock.mockReturnValue({ + data: { + openrouter: { + "openrouter/model-a": modelInfo({ displayName: "Model A (friendly)" }), + "openrouter/model-b": modelInfo(), + }, + }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ + id: "openrouter/model-a", + info: modelInfo({ displayName: "Model A (friendly)" }), + isLoading: false, + }) + + render( + , + ) + + // Trigger shows the displayName, not the raw id. + expect(screen.getByTestId("model-selector-trigger")).toHaveTextContent("Model A (friendly)") + expect(screen.queryByText("openrouter/model-a")).not.toBeInTheDocument() + + // List item for the model without a displayName still falls back to its raw id. + const content = openPopover() + expect(content.getByText("openrouter/model-b")).toBeInTheDocument() + }) + + it("renders the dynamic router model list for a dynamic provider", () => { + useRouterModelsMock.mockReturnValue({ + data: { + openrouter: { + "openrouter/model-a": modelInfo(), + "openrouter/model-b": modelInfo(), + }, + }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/model-a", + reasoningEffort: "medium", + modelMaxTokens: 4096, + modelMaxThinkingTokens: 2048, + } + + render( + , + ) + + const content = openPopover() + expect(content.getByText("openrouter/model-b")).toBeInTheDocument() + + fireEvent.click(content.getByText("openrouter/model-b")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "upsertApiConfiguration", + apiConfiguration: expect.objectContaining({ + openRouterModelId: "openrouter/model-b", + reasoningEffort: undefined, + modelMaxTokens: undefined, + modelMaxThinkingTokens: undefined, + }), + }), + ) + }) + + it("disables the selector for a provider outside the supported scope", () => { + useSelectedModelMock.mockReturnValue({ id: "", isLoading: false }) + + render( + , + ) + + expect(screen.queryByTestId("model-selector-trigger")).not.toBeInTheDocument() + expect(screen.getByTestId("model-selector-disabled")).toBeInTheDocument() + + fireEvent.click(screen.getByTestId("model-selector-disabled")) + + expect(vscode.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "switchTab", tab: "settings" })) + }) + + it("shows the loading trigger instead of the unsupported fallback while a dynamic provider's models are loading", () => { + // Isolate the router-model loading scenario: the router-models query is + // still loading while selected-model resolution has already completed. + // This verifies the component gates the unsupported fallback on + // router-model loading, not just on selected-model loading. + useRouterModelsMock.mockReturnValue({ data: undefined, isLoading: true }) + useSelectedModelMock.mockReturnValue({ id: "", isLoading: false }) + + render( + , + ) + + // The unsupported fallback must not appear while loading. + expect(screen.queryByTestId("model-selector-disabled")).not.toBeInTheDocument() + + // The trigger is visible and shows the loading label. + const trigger = screen.getByTestId("model-selector-trigger") + expect(trigger).toBeInTheDocument() + expect(trigger).toHaveTextContent("common:ui.loading") + }) + + it("does not render model options until the trigger is activated", () => { + render( + , + ) + + // Before opening, model options are not rendered (PopoverContent is hidden). + expect(screen.queryByTestId("popover-content")).not.toBeInTheDocument() + expect(screen.queryAllByRole("option")).toHaveLength(0) + + // After clicking the trigger, the popover content and model options appear. + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(screen.getByTestId("popover-content")).toBeInTheDocument() + expect(screen.queryAllByRole("option")).not.toHaveLength(0) + }) + + it("filters models by search query, shows no-results, and restores the list when cleared", () => { + const models: Record = { + "openrouter/alpha": modelInfo(), + "openrouter/bravo": modelInfo(), + "openrouter/charlie": modelInfo(), + "openrouter/delta": modelInfo(), + "openrouter/echo": modelInfo(), + "openrouter/foxtrot": modelInfo(), + "openrouter/golf": modelInfo(), + "openrouter/hotel": modelInfo(), + } + + useRouterModelsMock.mockReturnValue({ + data: { openrouter: models }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/alpha", isLoading: false }) + + render( + , + ) + + // Open the popover to reveal the search input (> SEARCH_THRESHOLD models). + const content = openPopover() + + const searchInput = screen.getByLabelText("common:ui.search_placeholder") + + // All eight models are visible initially. + expect(content.getByText("openrouter/alpha")).toBeInTheDocument() + expect(content.getByText("openrouter/hotel")).toBeInTheDocument() + + // Type a query that matches only "alpha". + fireEvent.change(searchInput, { target: { value: "alpha" } }) + + expect(content.getByText("openrouter/alpha")).toBeInTheDocument() + expect(content.queryByText("openrouter/bravo")).not.toBeInTheDocument() + expect(content.queryByText("openrouter/hotel")).not.toBeInTheDocument() + + // Type a query that matches nothing. + fireEvent.change(searchInput, { target: { value: "zzz-no-match" } }) + + expect(screen.getByText("common:ui.no_results")).toBeInTheDocument() + expect(content.queryByText("openrouter/alpha")).not.toBeInTheDocument() + + // Clear the query to restore the full list. + fireEvent.change(searchInput, { target: { value: "" } }) + + expect(content.getByText("openrouter/alpha")).toBeInTheDocument() + expect(content.getByText("openrouter/hotel")).toBeInTheDocument() + }) + + it("selects a model via keyboard activation", () => { + render( + , + ) + + // Open the popover. + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + // The model option is a keyboard-accessible native button (role="option"). + // Native