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 e8497070ef..3c03cbac03 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/ChatModelSelector.tsx b/webview-ui/src/components/chat/ChatModelSelector.tsx new file mode 100644 index 0000000000..f3d95aed95 --- /dev/null +++ b/webview-ui/src/components/chat/ChatModelSelector.tsx @@ -0,0 +1,103 @@ +import { useCallback, useMemo } from "react" + +import { cn } from "@/lib/utils" +import { useRooPortal } from "@/components/ui/hooks/useRooPortal" +import { vscode } from "@/utils/vscode" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { ModelSelect } from "@/components/ui/model-select/ModelSelect" + +import { useChatModelSelector } from "./hooks/useChatModelSelector" + +interface ChatModelSelectorProps { + disabled?: boolean + title: string + triggerClassName?: string +} + +/** + * Compact model picker for the chat input bar. + * + * Wraps the shared {@link ModelSelect} with a chat-specific trigger and a + * persistence adapter: selections are written to the active API configuration + * profile via `upsertApiConfiguration` rather than the settings buffer. + */ +export const ChatModelSelector = ({ disabled = false, title, triggerClassName = "" }: ChatModelSelectorProps) => { + const { apiConfiguration, organizationAllowList, currentApiConfigName } = useExtensionState() + const { provider, models, modelIdKey, defaultModelId, isLoading, valueTransform, displayTransform } = + useChatModelSelector() + + const portalContainer = useRooPortal("roo-portal") + + // Compute the display string for the currently stored selection. + const displayValue = useMemo(() => { + if (!modelIdKey) return "" + const stored = apiConfiguration?.[modelIdKey] + if (!stored) return "" + return displayTransform ? displayTransform(stored) : String(stored) + }, [apiConfiguration, modelIdKey, displayTransform]) + + // Persist the selection to the active profile. ModelSelect delivers the + // raw model id, so apply valueTransform before writing. + const persistSelection = useCallback( + (modelId: string) => { + // Refuse to write until the user has actually picked a provider — + // the profile schema strips provider-specific fields from + // provider-less profiles, so the selection would be dropped on reload. + if (!modelIdKey || !apiConfiguration || !currentApiConfigName || !apiConfiguration.apiProvider) { + return + } + + const value = valueTransform ? valueTransform(modelId) : modelId + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { ...apiConfiguration, [modelIdKey]: value }, + }) + }, + [modelIdKey, apiConfiguration, currentApiConfigName, valueTransform], + ) + + // Free-form model ids are only offered when the provider is unrestricted. + const hideCustomModel = useMemo(() => { + if (!organizationAllowList || organizationAllowList.allowAll) return false + const providerConfig = provider ? organizationAllowList.providers?.[provider] : undefined + return !providerConfig?.allowAll + }, [organizationAllowList, provider]) + + const isDisabled = disabled || !modelIdKey || !currentApiConfigName || !apiConfiguration?.apiProvider + + return ( + ( + + )} + /> + ) +} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index a78f3f0f8e..a6f47af6d7 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 { ChatModelSelector } from "./ChatModelSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" @@ -1319,6 +1320,11 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> +
diff --git a/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx new file mode 100644 index 0000000000..d273bb1e0b --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatModelSelector.spec.tsx @@ -0,0 +1,236 @@ +// npx vitest src/components/chat/__tests__/ChatModelSelector.spec.tsx + +import { screen, fireEvent, renderWithExtensionState } from "@/utils/test-utils" +import { act, type ReactNode } from "react" +import { QueryClient } from "@tanstack/react-query" +import { type Mock } from "vitest" + +import { type ModelInfo, type ProviderSettings, getProviderDefaultModelId, providerIdentifiers } from "@roo-code/types" + +import { useRouterModels } from "@/components/ui/hooks/useRouterModels" +import { useOllamaModels } from "@/components/ui/hooks/useOllamaModels" +import { useLmStudioModels } from "@/components/ui/hooks/useLmStudioModels" +import { useVsCodeLmModels } from "@/components/ui/hooks/useVsCodeLmModels" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@/utils/vscode" + +import { ChatModelSelector } from "../ChatModelSelector" + +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: { children: ReactNode }) => children, + useExtensionState: vi.fn(), +})) + +vi.mock("@/components/ui/hooks/useRouterModels") +vi.mock("@/components/ui/hooks/useOllamaModels") +vi.mock("@/components/ui/hooks/useLmStudioModels") +vi.mock("@/components/ui/hooks/useVsCodeLmModels") +vi.mock("@/components/ui/hooks/useRooPortal", () => ({ useRooPortal: () => document.body })) + +const mockUseRouterModels = useRouterModels as Mock +const mockUseOllamaModels = useOllamaModels as Mock +const mockUseLmStudioModels = useLmStudioModels as Mock +const mockUseVsCodeLmModels = useVsCodeLmModels as Mock +const mockUseExtensionState = useExtensionState as Mock +const mockPostMessage = vscode.postMessage as Mock + +Element.prototype.scrollIntoView = vi.fn() + +type Story = Pick< + ReturnType, + "apiConfiguration" | "organizationAllowList" | "currentApiConfigName" | "routerModels" +> + +describe("ChatModelSelector", () => { + const modelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + } + + const routerModels = { + openrouter: { + "model-a": { name: "Model A", ...modelInfo }, + "model-b": { name: "Model B", ...modelInfo }, + }, + } + + const queryClient = new QueryClient() + + const setState = (story: Partial) => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: { apiProvider: providerIdentifiers.openrouter } as ProviderSettings, + organizationAllowList: { allowAll: true, providers: {} }, + currentApiConfigName: "Default", + ...story, + } as unknown as ReturnType) + } + + const renderSelector = () => renderWithExtensionState(, { queryClient }) + + beforeEach(() => { + vi.clearAllMocks() + mockUseRouterModels.mockReturnValue({ + data: routerModels, + isLoading: false, + isError: false, + } as unknown as ReturnType) + mockUseOllamaModels.mockReturnValue({ + data: undefined, + isLoading: false, + } as unknown as ReturnType) + mockUseLmStudioModels.mockReturnValue({ + data: undefined, + isLoading: false, + } as unknown as ReturnType) + mockUseVsCodeLmModels.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType) + setState({}) + }) + + it("renders the default model id when no model is selected", () => { + renderSelector() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toBeInTheDocument() + expect(trigger).toHaveTextContent(getProviderDefaultModelId(providerIdentifiers.openrouter)) + }) + + it("renders the selected model id from the active configuration", () => { + setState({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-opus-20240229" }, + }) + + renderSelector() + + expect(screen.getByTestId("chat-model-selector-trigger")).toHaveTextContent("claude-3-opus-20240229") + }) + + it("persists the selection to the active profile via upsertApiConfiguration", async () => { + renderSelector() + + await act(async () => { + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + }) + + await act(async () => { + fireEvent.click(screen.getByTestId("model-option-model-a")) + }) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "Default", + apiConfiguration: expect.objectContaining({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-a", + }), + }) + }) + + it("disables the trigger when no apiProvider is stored on the active profile", async () => { + // Profiles without apiProvider get their provider-specific fields stripped + // by the schema validator, so the adapter must disable the trigger rather + // than let the user pick a model that would be silently dropped on reload. + setState({ apiConfiguration: {} }) + + renderSelector() + + const trigger = screen.getByTestId("chat-model-selector-trigger") + expect(trigger).toBeDisabled() + + // Clicking a disabled trigger must not open the popover, so no model + // option should be reachable. + await act(async () => { + fireEvent.click(trigger) + }) + + expect(screen.queryByTestId("model-option-model-a")).not.toBeInTheDocument() + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + }) + + it("preserves the stored provider without injecting a provider override", async () => { + setState({ + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-a", + }, + }) + + renderSelector() + + await act(async () => { + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + }) + + await act(async () => { + fireEvent.click(screen.getByTestId("model-option-model-b")) + }) + + const payload = mockPostMessage.mock.calls.find(([msg]) => msg.type === "upsertApiConfiguration")?.[0] + expect(payload?.apiConfiguration).toBeDefined() + // The adapter must not rewrite apiProvider even though the hook defaults + // to OpenRouter when no provider is set. + expect(payload?.apiConfiguration).toHaveProperty("apiProvider", providerIdentifiers.openrouter) + }) + + it("lists VS Code LM models keyed as vendor/family even when custom models are restricted", async () => { + setState({ + apiConfiguration: { apiProvider: providerIdentifiers.vscodeLm }, + organizationAllowList: { + allowAll: false, + providers: { [providerIdentifiers.vscodeLm]: { allowAll: false, models: ["copilot/gpt-4o"] } }, + }, + }) + mockUseVsCodeLmModels.mockReturnValue({ + data: [{ vendor: "copilot", family: "gpt-4o" }], + isLoading: false, + } as unknown as ReturnType) + + renderSelector() + + await act(async () => { + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + }) + + expect(screen.getByTestId("model-option-copilot/gpt-4o")).toBeInTheDocument() + }) + + it("is disabled and does not persist when there is no active profile", async () => { + setState({ currentApiConfigName: undefined }) + + renderSelector() + + expect(screen.getByTestId("chat-model-selector-trigger")).toBeDisabled() + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + it("hides the custom-model escape hatch for a restricted provider", async () => { + setState({ + organizationAllowList: { + allowAll: false, + providers: { [providerIdentifiers.openrouter]: { allowAll: false } }, + }, + }) + + renderSelector() + + await act(async () => { + fireEvent.click(screen.getByTestId("chat-model-selector-trigger")) + }) + + await act(async () => { + fireEvent.change(screen.getByTestId("model-input"), { target: { value: "my-custom-model" } }) + }) + + expect(screen.queryByTestId("use-custom-model")).not.toBeInTheDocument() + }) +}) 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__/__screenshots__/chat-composer-focus-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png index 4de566f736..9bab7a3a7e 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png index f5c759fad8..f1641a0f3a 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png index 465ffc0263..8d232cb2ab 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png index 1f238a4619..0ad2884894 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-focus-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png index fad9aa30b9..6c90b6f610 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png index f5c759fad8..8e9c01c7fe 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png index 465ffc0263..8d232cb2ab 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png index 1f238a4619..b6976d6cff 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png differ diff --git a/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx new file mode 100644 index 0000000000..32023d4876 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx @@ -0,0 +1,183 @@ +// npx vitest src/components/chat/hooks/__tests__/useChatModelSelector.spec.tsx + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderHook } from "@testing-library/react" +import React, { type ReactNode } from "react" +import { type Mock } from "vitest" + +import { type ProviderSettings, providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types" + +import { useRouterModels } from "@/components/ui/hooks/useRouterModels" +import { useOllamaModels } from "@/components/ui/hooks/useOllamaModels" +import { useLmStudioModels } from "@/components/ui/hooks/useLmStudioModels" +import { useVsCodeLmModels } from "@/components/ui/hooks/useVsCodeLmModels" +import { useExtensionState } from "@src/context/ExtensionStateContext" + +import { useChatModelSelector } from "../useChatModelSelector" + +vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: { children: ReactNode }) => children, + useExtensionState: vi.fn(), +})) + +vi.mock("@/components/ui/hooks/useRouterModels") +vi.mock("@/components/ui/hooks/useOllamaModels") +vi.mock("@/components/ui/hooks/useLmStudioModels") +vi.mock("@/components/ui/hooks/useVsCodeLmModels") + +const mockUseRouterModels = useRouterModels as Mock +const mockUseOllamaModels = useOllamaModels as Mock +const mockUseLmStudioModels = useLmStudioModels as Mock +const mockUseVsCodeLmModels = useVsCodeLmModels as Mock +const mockUseExtensionState = useExtensionState as Mock + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +) + +describe("useChatModelSelector", () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseRouterModels.mockReturnValue({ + data: {}, + isLoading: false, + isError: false, + } as unknown as ReturnType) + mockUseOllamaModels.mockReturnValue({ + data: undefined, + isLoading: false, + } as unknown as ReturnType) + mockUseLmStudioModels.mockReturnValue({ + data: undefined, + isLoading: false, + } as unknown as ReturnType) + mockUseVsCodeLmModels.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType) + }) + + const setApiConfig = (apiConfiguration: Partial | undefined) => { + mockUseExtensionState.mockReturnValue({ + apiConfiguration: apiConfiguration as ProviderSettings, + routerModels: {}, + } as unknown as ReturnType) + } + + it("resolves router models for openrouter provider", () => { + mockUseRouterModels.mockReturnValue({ + data: { + [providerIdentifiers.openrouter]: { + "model-a": { name: "Model A", maxTokens: 8192, contextWindow: 200000, supportsPromptCache: true }, + }, + }, + isLoading: false, + } as unknown as ReturnType) + + setApiConfig({ apiProvider: providerIdentifiers.openrouter }) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.openrouter) + expect(result.current.modelIdKey).toBe("openRouterModelId") + expect(result.current.models).toHaveProperty("model-a") + expect(result.current.isLoading).toBe(false) + expect(mockUseVsCodeLmModels).toHaveBeenCalledWith(false) + }) + + it("resolves ollama models for ollama provider", () => { + mockUseOllamaModels.mockReturnValue({ + data: { + "llama3:latest": { name: "Llama 3", maxTokens: 4096, contextWindow: 8192, supportsPromptCache: false }, + }, + isLoading: false, + } as unknown as ReturnType) + + setApiConfig({ apiProvider: providerIdentifiers.ollama }) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.ollama) + expect(result.current.modelIdKey).toBe("ollamaModelId") + expect(result.current.models).toHaveProperty("llama3:latest") + expect(result.current.isLoading).toBe(false) + expect(mockUseVsCodeLmModels).toHaveBeenCalledWith(false) + }) + + it("resolves lmstudio models for lmstudio provider", () => { + mockUseLmStudioModels.mockReturnValue({ + data: { + "qwen2.5-coder": { + name: "Qwen2.5-Coder", + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false, + }, + }, + isLoading: true, + } as unknown as ReturnType) + + setApiConfig({ apiProvider: providerIdentifiers.lmstudio }) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.lmstudio) + expect(result.current.modelIdKey).toBe("lmStudioModelId") + expect(result.current.models).toHaveProperty("qwen2.5-coder") + expect(result.current.isLoading).toBe(true) + expect(mockUseVsCodeLmModels).toHaveBeenCalledWith(false) + }) + + it("composes vsCodeLm models with vendor/family transforms", () => { + mockUseVsCodeLmModels.mockReturnValue({ + data: [{ vendor: "copilot", family: "gpt-4o" }], + isLoading: false, + } as unknown as ReturnType) + + setApiConfig({ apiProvider: providerIdentifiers.vscodeLm }) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.vscodeLm) + expect(result.current.modelIdKey).toBe("vsCodeLmModelSelector") + expect(result.current.models).toHaveProperty("copilot/gpt-4o") + expect(result.current.valueTransform).toBeDefined() + expect(result.current.displayTransform).toBeDefined() + + // valueTransform: "copilot/gpt-4o" -> { vendor, family } + expect(result.current.valueTransform?.("copilot/gpt-4o")).toEqual({ vendor: "copilot", family: "gpt-4o" }) + // displayTransform: { vendor, family } -> "copilot/gpt-4o" + expect(result.current.displayTransform?.({ vendor: "copilot", family: "gpt-4o" })).toBe("copilot/gpt-4o") + // displayTransform: empty value -> "" + expect(result.current.displayTransform?.(null)).toBe("") + expect(mockUseVsCodeLmModels).toHaveBeenCalledWith(true) + }) + + it("resolves static models for anthropic provider", () => { + setApiConfig({ apiProvider: providerIdentifiers.anthropic }) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.anthropic) + expect(result.current.modelIdKey).toBe("apiModelId") + expect(result.current.models).not.toBeNull() + expect(Object.keys(result.current.models as object).length).toBeGreaterThan(0) + expect(mockUseVsCodeLmModels).toHaveBeenCalledWith(false) + }) + + it("defaults to openrouter when apiConfiguration is empty", () => { + setApiConfig({}) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.openrouter) + expect(result.current.modelIdKey).toBe("openRouterModelId") + expect(mockUseVsCodeLmModels).toHaveBeenCalledWith(false) + }) + + it("returns undefined for retired providers", () => { + setApiConfig({ apiProvider: retiredProviderIdentifiers.groq as unknown as ProviderSettings["apiProvider"] }) + const { result } = renderHook(() => useChatModelSelector(), { wrapper }) + + expect(result.current.provider).toBeUndefined() + expect(result.current.models).toBeNull() + expect(result.current.modelIdKey).toBeUndefined() + expect(result.current.isLoading).toBe(false) + expect(mockUseVsCodeLmModels).toHaveBeenCalledWith(false) + }) +}) diff --git a/webview-ui/src/components/chat/hooks/useChatModelSelector.ts b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts new file mode 100644 index 0000000000..1f314dae6c --- /dev/null +++ b/webview-ui/src/components/chat/hooks/useChatModelSelector.ts @@ -0,0 +1,182 @@ +import { useMemo } from "react" + +import { + type ModelInfo, + type ModelRecord, + type ProviderName, + type ProviderSettings, + getProviderDefaultModelId, + isRetiredProvider, + providerIdentifiers, +} from "@roo-code/types" + +import { useRouterModels } from "@/components/ui/hooks/useRouterModels" +import { useOllamaModels } from "@/components/ui/hooks/useOllamaModels" +import { useLmStudioModels } from "@/components/ui/hooks/useLmStudioModels" +import { useVsCodeLmModels } from "@/components/ui/hooks/useVsCodeLmModels" +import { getStaticModelsForProvider } from "@/components/settings/utils/providerModelConfig" +import { MODELS_BY_PROVIDER } from "@/components/settings/constants" +import { useExtensionState } from "@/context/ExtensionStateContext" + +type ModelIdKey = keyof Pick< + ProviderSettings, + | "openRouterModelId" + | "requestyModelId" + | "unboundModelId" + | "openAiModelId" + | "litellmModelId" + | "vercelAiGatewayModelId" + | "zooGatewayModelId" + | "opencodeGoModelId" + | "kenariModelId" + | "nanoGptModelId" + | "apiModelId" + | "ollamaModelId" + | "lmStudioModelId" + | "vsCodeLmModelSelector" +> + +// Router providers resolve their list through `useRouterModels` (or the +// backend-broadcast cache for litellm/poe). The map holds the config field each +// provider stores its selection under. +const ROUTER_PROVIDERS: Partial> = { + [providerIdentifiers.openrouter]: { modelIdKey: "openRouterModelId" }, + [providerIdentifiers.requesty]: { modelIdKey: "requestyModelId" }, + [providerIdentifiers.unbound]: { modelIdKey: "unboundModelId" }, + [providerIdentifiers.litellm]: { modelIdKey: "litellmModelId", fromState: true }, + [providerIdentifiers.vercelAiGateway]: { modelIdKey: "vercelAiGatewayModelId" }, + [providerIdentifiers.zooGateway]: { modelIdKey: "zooGatewayModelId" }, + [providerIdentifiers.opencodeGo]: { modelIdKey: "opencodeGoModelId" }, + [providerIdentifiers.kenari]: { modelIdKey: "kenariModelId" }, + [providerIdentifiers.nanogpt]: { modelIdKey: "nanoGptModelId" }, + [providerIdentifiers.kimiCode]: { modelIdKey: "apiModelId" }, + [providerIdentifiers.poe]: { modelIdKey: "apiModelId", fromState: true }, +} + +export interface ChatModelSelectorData { + provider: ProviderName | undefined + models: Record | null + modelIdKey: ModelIdKey | undefined + defaultModelId: string + isLoading: boolean + valueTransform?: (modelId: string) => unknown + displayTransform?: (value: unknown) => string +} + +/** + * Resolves the model list, storage key and transforms for the active provider + * so the chat input bar can render a compact model picker that mirrors the + * settings page sources. + * + * It composes the same webview data hooks the settings providers use (router + * models, Ollama, LM Studio) and the shared static provider tables, so it needs + * no chat-specific backend protocol. + */ +export const useChatModelSelector = (): ChatModelSelectorData => { + const { apiConfiguration, routerModels: stateRouterModels } = useExtensionState() + + const provider = (apiConfiguration?.apiProvider || providerIdentifiers.openrouter) as ProviderName + const activeProvider = isRetiredProvider(provider) ? undefined : provider + const routerConfig = activeProvider ? ROUTER_PROVIDERS[activeProvider] : undefined + + const routerModels = useRouterModels({ + provider: routerConfig && !routerConfig.fromState ? activeProvider : undefined, + enabled: !!routerConfig && !routerConfig.fromState, + }) + + // The list hooks only fire when a model id is provided; pass a sentinel so + // the picker can populate its options before a model is configured. + const ollamaModels = useOllamaModels( + activeProvider === providerIdentifiers.ollama ? apiConfiguration?.ollamaModelId || "ollama" : undefined, + ) + const lmStudioModels = useLmStudioModels( + activeProvider === providerIdentifiers.lmstudio ? apiConfiguration?.lmStudioModelId || "lmstudio" : undefined, + ) + const vsCodeLmModels = useVsCodeLmModels(activeProvider === providerIdentifiers.vscodeLm) + + return useMemo(() => { + let models: Record | null = null + let modelIdKey: ModelIdKey | undefined = undefined + let valueTransform: ((modelId: string) => unknown) | undefined + let displayTransform: ((value: unknown) => string) | undefined + let isLoading = false + + const defaultModelId = activeProvider + ? getProviderDefaultModelId(activeProvider, { + isChina: + activeProvider === providerIdentifiers.zai && apiConfiguration?.zaiApiLine === "china_coding", + }) + : "" + + if (activeProvider) { + if (routerConfig) { + const source = (routerConfig.fromState ? stateRouterModels : routerModels.data) as + | Partial> + | undefined + models = source?.[activeProvider] ?? null + modelIdKey = routerConfig.modelIdKey + isLoading = !routerConfig.fromState && routerModels.isLoading + } else if (activeProvider === providerIdentifiers.ollama) { + models = ollamaModels.data ?? null + modelIdKey = "ollamaModelId" + isLoading = ollamaModels.isLoading + } else if (activeProvider === providerIdentifiers.lmstudio) { + models = lmStudioModels.data ?? null + modelIdKey = "lmStudioModelId" + isLoading = lmStudioModels.isLoading + } else if (activeProvider === providerIdentifiers.vscodeLm) { + // VSCode LM exposes compound `{ vendor, family }` selections. Key the + // options as `vendor/family` (mirrors the settings provider) so they + // stay selectable even when free-form model ids are restricted. + modelIdKey = "vsCodeLmModelSelector" + models = (vsCodeLmModels.data ?? []).reduce( + (acc, model) => { + const modelId = `${model.vendor}/${model.family}` + acc[modelId] = { + maxTokens: 0, + contextWindow: 0, + supportsPromptCache: false, + description: `${model.vendor} - ${model.family}`, + } + return acc + }, + {} as Record, + ) + isLoading = vsCodeLmModels.isLoading + valueTransform = (modelId) => { + const [vendor, family] = modelId.split("/") + return { vendor, family } + } + displayTransform = (value) => { + if (!value) return "" + const selector = value as { vendor?: string; family?: string } + return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" + } + } else { + models = MODELS_BY_PROVIDER[activeProvider] + ? getStaticModelsForProvider(activeProvider, undefined, apiConfiguration) + : null + modelIdKey = "apiModelId" + } + } + + return { + provider: activeProvider, + models, + modelIdKey, + defaultModelId, + isLoading, + valueTransform, + displayTransform, + } + }, [ + activeProvider, + routerConfig, + apiConfiguration, + routerModels, + stateRouterModels, + ollamaModels, + lmStudioModels, + vsCodeLmModels, + ]) +} diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index aa49528f5e..726f278b59 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -1,7 +1,7 @@ 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, Info } from "lucide-react" +import { ChevronsUpDown, Info } from "lucide-react" import { type ProviderSettings, @@ -13,21 +13,9 @@ import { 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 { Button } from "@src/components/ui" +import { ModelSelect } from "@/components/ui/model-select/ModelSelect" import { ModelInfoView } from "./ModelInfoView" import { ApiErrorMessage } from "./ApiErrorMessage" @@ -96,23 +84,20 @@ export const ModelPicker = ({ }: ModelPickerProps) => { const { t } = useAppTranslation() - const [open, setOpen] = useState(false) const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) const isInitialized = useRef(false) - const searchInputRef = useRef(null) - const selectTimeoutRef = useRef(null) - const closeTimeoutRef = useRef(null) const { id: selectedModelId, info: selectedModelInfo } = useSelectedModel(apiConfiguration) - // Get the display value for the current selection - // If displayTransform is provided, use it to convert the stored value to a display string + // Derive the display value for the current selection. If displayTransform + // is provided, convert the stored value to a display string; otherwise + // use the resolved selected model id from useSelectedModel. const displayValue = useMemo(() => { if (displayTransform) { const storedValue = apiConfiguration[modelIdKey] - return storedValue ? displayTransform(storedValue) : undefined + return storedValue ? displayTransform(storedValue) : "" } - return selectedModelId + return selectedModelId ?? "" }, [displayTransform, apiConfiguration, modelIdKey, selectedModelId]) const activeProvider = @@ -120,175 +105,50 @@ export const ModelPicker = ({ ? undefined : apiConfiguration.apiProvider - const modelIds = useMemo(() => { - const filteredModels = filterModels(models, activeProvider, organizationAllowList) - - // Include the currently selected model even if deprecated (so users can see what they have selected) - // But filter out other deprecated models from being newly selectable - const availableModels = Object.entries(filteredModels ?? {}) - .filter(([modelId, modelInfo]) => { - // Always include the currently selected model - if (modelId === selectedModelId) return true - // Filter out deprecated models that aren't currently selected - return !modelInfo.deprecated - }) - .reduce( - (acc, [modelId, modelInfo]) => { - acc[modelId] = modelInfo - return acc - }, - {} as Record, - ) - - return Object.keys(availableModels).sort((a, b) => a.localeCompare(b)) - }, [models, activeProvider, organizationAllowList, selectedModelId]) - - const [searchValue, setSearchValue] = useState("") - const onSelect = useCallback( (modelId: string) => { - if (!modelId) { - return - } - - setOpen(false) - - // Apply value transform if provided (e.g., for VSCodeLM selector) + if (!modelId) return const valueToStore = valueTransform ? valueTransform(modelId) : modelId setApiConfigurationField(modelIdKey, valueToStore as ProviderSettings[ModelIdKey]) - - // Call the optional change callback onModelChange?.(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, valueTransform, onModelChange], ) - 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() - }, []) - + // Auto-initialize a default model when none is selected yet. This runs + // only once and only for the settings picker (the chat selector never + // auto-writes configuration). useEffect(() => { - if (!selectedModelId && !isInitialized.current) { - const initialValue = modelIds.includes(selectedModelId) ? selectedModelId : defaultModelId + if (isInitialized.current) return + if (!selectedModelId) { + const initialValue = selectedModelId === defaultModelId ? 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)) + }, [selectedModelId, defaultModelId, modelIdKey, setApiConfigurationField]) return ( <>
- - + ( - - - -
- - {searchValue.length > 0 && ( -
- -
- )} -
- - - {searchValue && ( -
- {t("settings:modelPicker.noMatchFound")} -
- )} -
- - {modelIds.map((model) => ( - - - {model} - - - - ))} - -
- {searchValue && !modelIds.includes(searchValue) && ( -
- - {t("settings:modelPicker.useCustomModel", { modelId: searchValue })} - -
- )} -
-
-
+ )} + />
{errorMessage && } {selectedModelInfo?.deprecated && ( diff --git a/webview-ui/src/components/ui/hooks/__tests__/useVsCodeLmModels.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useVsCodeLmModels.spec.tsx new file mode 100644 index 0000000000..20c9c2c28f --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useVsCodeLmModels.spec.tsx @@ -0,0 +1,214 @@ +// npx vitest src/components/ui/hooks/__tests__/useVsCodeLmModels.spec.ts +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderHook, waitFor } from "@testing-library/react" +import React from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { VsCodeLmModelsMessageType } from "@roo-code/types" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +import { vscode } from "@src/utils/vscode" + +import { getVsCodeLmModels, useVsCodeLmModels } from "../useVsCodeLmModels" + +const wrapper = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return { queryClient, Wrapper } +} + +const emitMessage = (data: unknown) => { + window.dispatchEvent(new MessageEvent("message", { data })) +} + +describe("useVsCodeLmModels", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + it("does not post a request when disabled", async () => { + const addSpy = vi.spyOn(window, "addEventListener") + const { Wrapper } = wrapper() + const { result } = renderHook(() => useVsCodeLmModels(false), { wrapper: Wrapper }) + + // Give React Query a tick to settle; if the queryFn had been invoked + // it would have synchronously posted a message and registered a listener. + await waitFor(() => expect(result.current.fetchStatus).toBe("idle")) + + expect(vscode.postMessage).not.toHaveBeenCalled() + expect(addSpy).not.toHaveBeenCalledWith("message", expect.any(Function)) + + addSpy.mockRestore() + }) + + it("posts a request when enabled and resolves with the matching response", async () => { + const { Wrapper } = wrapper() + const { result } = renderHook(() => useVsCodeLmModels(true), { wrapper: Wrapper }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: VsCodeLmModelsMessageType.requestVsCodeLmModels, + }) + + emitMessage({ + type: VsCodeLmModelsMessageType.vsCodeLmModels, + vsCodeLmModels: [{ vendor: "copilot", family: "gpt-4o" }], + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual([{ vendor: "copilot", family: "gpt-4o" }]) + }) + + it("ignores unrelated messages and keeps waiting for the matching response", async () => { + const { Wrapper } = wrapper() + const { result } = renderHook(() => useVsCodeLmModels(true), { wrapper: Wrapper }) + + emitMessage({ type: "someOtherMessage", payload: "nope" }) + + await waitFor(() => expect(vscode.postMessage).toHaveBeenCalled()) + expect(result.current.isSuccess).toBe(false) + + emitMessage({ + type: VsCodeLmModelsMessageType.vsCodeLmModels, + vsCodeLmModels: [{ vendor: "copilot", family: "gpt-4o-mini" }], + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual([{ vendor: "copilot", family: "gpt-4o-mini" }]) + }) + + it("defaults to an empty array when the response omits the model list", async () => { + const { Wrapper } = wrapper() + const { result } = renderHook(() => useVsCodeLmModels(true), { wrapper: Wrapper }) + + emitMessage({ type: VsCodeLmModelsMessageType.vsCodeLmModels }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual([]) + }) + + it("removes the same window message listener it registered once the request resolves", async () => { + const { Wrapper } = wrapper() + const addSpy = vi.spyOn(window, "addEventListener") + const removeSpy = vi.spyOn(window, "removeEventListener") + + renderHook(() => useVsCodeLmModels(true), { wrapper: Wrapper }) + + const registeredHandler = addSpy.mock.calls.find(([eventName]) => eventName === "message")?.[1] + expect(registeredHandler).toBeDefined() + + emitMessage({ + type: VsCodeLmModelsMessageType.vsCodeLmModels, + vsCodeLmModels: [], + }) + + await waitFor(() => { + expect(removeSpy).toHaveBeenCalledWith("message", registeredHandler) + }) + + addSpy.mockRestore() + removeSpy.mockRestore() + }) + + it("rejects and removes the message listener when the query is aborted", async () => { + const { queryClient, Wrapper } = wrapper() + const addSpy = vi.spyOn(window, "addEventListener") + const removeSpy = vi.spyOn(window, "removeEventListener") + + const { result } = renderHook(() => useVsCodeLmModels(true), { wrapper: Wrapper }) + + const registeredHandler = addSpy.mock.calls.find(([eventName]) => eventName === "message")?.[1] + expect(registeredHandler).toBeDefined() + + await queryClient.cancelQueries({ queryKey: ["vsCodeLmModels"] }) + + await waitFor(() => { + expect(removeSpy).toHaveBeenCalledWith("message", registeredHandler) + }) + expect(result.current.isLoading).toBe(false) + expect(result.current.data).toBeUndefined() + + // A later response must not populate the aborted query. + emitMessage({ + type: VsCodeLmModelsMessageType.vsCodeLmModels, + vsCodeLmModels: [{ vendor: "copilot", family: "gpt-4o" }], + }) + expect(result.current.data).toBeUndefined() + + addSpy.mockRestore() + removeSpy.mockRestore() + }) + + it("rejects with a timeout error if no response arrives within 10 seconds", async () => { + const { Wrapper } = wrapper() + const addSpy = vi.spyOn(window, "addEventListener") + const removeSpy = vi.spyOn(window, "removeEventListener") + + vi.useFakeTimers() + try { + const { result } = renderHook(() => useVsCodeLmModels(true), { wrapper: Wrapper }) + + const registeredHandler = addSpy.mock.calls.find(([eventName]) => eventName === "message")?.[1] + expect(registeredHandler).toBeDefined() + + // Advance the fake clock that the hook's internal setTimeout depends + // on. React Query schedules its own timers on the same clock, so we let + // them fire as well; after 10s the request promise must be rejected with + // the timeout error and the message listener removed. + await vi.advanceTimersByTimeAsync(10_000) + + // Listener is cleaned up as part of the timeout path. + expect(removeSpy).toHaveBeenCalledWith("message", registeredHandler) + // React Query exposes the rejection as `error` on the result. + await vi.waitFor(() => { + expect(result.current.isError).toBe(true) + }) + expect(result.current.error).toBeInstanceOf(Error) + expect((result.current.error as Error).message).toBe("VS Code LM models request timed out") + } finally { + vi.useRealTimers() + addSpy.mockRestore() + removeSpy.mockRestore() + } + }) + + it("rejects immediately when the signal is already aborted on entry", async () => { + // The already-aborted branch must never register a window listener, never + // start a timer and never post a request, otherwise we leak listeners when + // React Query hands us a cancelled signal. + const addSpy = vi.spyOn(window, "addEventListener") + const removeSpy = vi.spyOn(window, "removeEventListener") + const timeoutSpy = vi.spyOn(globalThis, "setTimeout") + const addEventListenerSpy = vi.spyOn(AbortSignal.prototype, "addEventListener") + + try { + const controller = new AbortController() + controller.abort() + + const aborted = await getVsCodeLmModels(controller.signal).then( + () => undefined, + (err: unknown) => err, + ) + expect(aborted).toBeInstanceOf(DOMException) + expect((aborted as DOMException).name).toBe("AbortError") + + expect(addSpy).not.toHaveBeenCalledWith("message", expect.any(Function)) + expect(removeSpy).not.toHaveBeenCalledWith("message", expect.any(Function)) + expect(vscode.postMessage).not.toHaveBeenCalled() + expect(timeoutSpy).not.toHaveBeenCalled() + expect(addEventListenerSpy).not.toHaveBeenCalled() + } finally { + addSpy.mockRestore() + removeSpy.mockRestore() + timeoutSpy.mockRestore() + addEventListenerSpy.mockRestore() + } + }) +}) diff --git a/webview-ui/src/components/ui/hooks/useVsCodeLmModels.ts b/webview-ui/src/components/ui/hooks/useVsCodeLmModels.ts new file mode 100644 index 0000000000..6e7d9166a7 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/useVsCodeLmModels.ts @@ -0,0 +1,67 @@ +import { useQuery } from "@tanstack/react-query" +import { type LanguageModelChatSelector } from "vscode" + +import { type ExtensionMessage, VsCodeLmModelsMessageType } from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" + +// The query key is a constant ("vsCodeLmModels"), so at most one in-flight +// request can exist at any moment. No requestId correlation is needed: if +// React Query retries, it aborts the previous signal before firing the next +// request, so responses are always matched to the current queryFn invocation. +// Exported for tests so we can exercise the already-aborted signal branch +// directly without going through React Query. +export const getVsCodeLmModels = async (signal?: AbortSignal) => + new Promise((resolve, reject) => { + let settled = false + + const finish = (fn: () => void) => { + if (settled) return + settled = true + cleanup() + fn() + } + + const handleAbort = () => { + if (settled) return + settled = true + cleanup() + reject(new DOMException("Aborted", "AbortError")) + } + + const cleanup = () => { + window.removeEventListener("message", handler) + signal?.removeEventListener("abort", handleAbort) + clearTimeout(timeout) + } + + // If the signal is already aborted, reject immediately without + // registering listeners or starting a timer. + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")) + return + } + + const timeout = setTimeout(() => { + finish(() => reject(new Error("VS Code LM models request timed out"))) + }, 10000) + + const handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data + + if (message.type === VsCodeLmModelsMessageType.vsCodeLmModels) { + finish(() => resolve(message.vsCodeLmModels ?? [])) + } + } + + signal?.addEventListener("abort", handleAbort, { once: true }) + window.addEventListener("message", handler) + vscode.postMessage({ type: VsCodeLmModelsMessageType.requestVsCodeLmModels }) + }) + +export const useVsCodeLmModels = (enabled: boolean) => + useQuery({ + queryKey: ["vsCodeLmModels"], + queryFn: ({ signal }) => getVsCodeLmModels(signal), + enabled, + }) diff --git a/webview-ui/src/components/ui/model-select/ModelSelect.tsx b/webview-ui/src/components/ui/model-select/ModelSelect.tsx new file mode 100644 index 0000000000..d2673d1132 --- /dev/null +++ b/webview-ui/src/components/ui/model-select/ModelSelect.tsx @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react" +import { Check, X } from "lucide-react" + +import { type ModelInfo, type OrganizationAllowList, type ProviderName } from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { cn } from "@src/lib/utils" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, + StandardTooltip, +} from "@src/components/ui" +import { useEscapeKey } from "@src/hooks/useEscapeKey" + +import { filterModels } from "@/components/settings/utils/organizationFilters" + +export interface ModelSelectProps { + models: Record | null + value: string + onChange: (modelId: string) => void + placeholder?: string + disabled?: boolean + isLoading?: boolean + hideCustomModel?: boolean + /** Provider used to apply organization allow-list filtering. */ + activeProvider?: ProviderName + organizationAllowList?: OrganizationAllowList + /** Extra classes for the popover content. */ + contentClassName?: string + /** Portal container for the popover content. */ + portalContainer?: HTMLElement + /** Optional tooltip shown above the trigger (wraps PopoverTrigger). */ + tooltip?: string + /** Custom trigger renderer. */ + renderTrigger?: (args: { + displayValue: string + isLoading: boolean + placeholder: string + disabled: boolean + }) => ReactNode +} + +/** + * Core model selection logic shared by the settings page and the chat input + * bar: a Popover with a searchable Command list, ESC to close, search-state + * cleanup, and an optional custom-model escape hatch. + * + * This component intentionally does **not** auto-initialize a default model, + * nor does it write to configuration directly — callers own persistence. + */ +export const ModelSelect = ({ + models, + value, + onChange, + placeholder, + disabled = false, + isLoading = false, + hideCustomModel = false, + activeProvider, + organizationAllowList, + contentClassName, + portalContainer, + tooltip, + renderTrigger, +}: ModelSelectProps) => { + const { t } = useAppTranslation() + + const [open, setOpen] = useState(false) + const searchInputRef = useRef(null) + const selectTimeoutRef = useRef(null) + const closeTimeoutRef = useRef(null) + const [searchValue, setSearchValue] = useState("") + + const displayValue = value ?? "" + + const modelIds = useMemo(() => { + const filtered = filterModels(models, activeProvider, organizationAllowList) + + // Always include the currently selected model even if deprecated, but + // filter out other deprecated models from being newly selectable. + const available = Object.entries(filtered ?? {}).filter(([modelId, info]) => + modelId === displayValue ? true : !info.deprecated, + ) + + return available.map(([id]) => id).sort((a, b) => a.localeCompare(b)) + }, [models, activeProvider, organizationAllowList, displayValue]) + + const onSelect = useCallback( + (modelId: string) => { + if (!modelId) return + + setOpen(false) + onChange(modelId) + + if (selectTimeoutRef.current) clearTimeout(selectTimeoutRef.current) + // Delay to ensure the popover is closed before clearing search. + selectTimeoutRef.current = setTimeout(() => setSearchValue(""), 100) + }, + [onChange], + ) + + const onOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + // Abandon the current search when the popover closes. + if (!nextOpen) { + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = setTimeout(() => setSearchValue(""), 100) + } + }, []) + + const onClearSearch = useCallback(() => { + setSearchValue("") + searchInputRef.current?.focus() + }, []) + + // Cleanup pending timeouts on unmount to prevent test flakiness. + useEffect(() => { + return () => { + if (selectTimeoutRef.current) clearTimeout(selectTimeoutRef.current) + if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current) + } + }, []) + + useEscapeKey(open, () => setOpen(false)) + + const resolvedPlaceholder = placeholder ?? t("settings:common.select") + + const triggerNode = renderTrigger?.({ + displayValue, + isLoading, + placeholder: resolvedPlaceholder, + disabled, + }) + + return ( + + {tooltip ? ( + + + {triggerNode} + + + ) : ( + + {triggerNode} + + )} + + +
+ + {searchValue.length > 0 && ( +
+ +
+ )} +
+ + + {searchValue && ( +
{t("settings:modelPicker.noMatchFound")}
+ )} +
+ + {modelIds.map((model) => ( + + + {model} + + + + ))} + +
+ {!hideCustomModel && searchValue && !modelIds.includes(searchValue) && ( +
+ + {t("settings:modelPicker.useCustomModel", { modelId: searchValue })} + +
+ )} +
+
+
+ ) +}