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..6dc0a749b5 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..a74ecab246 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, @@ -1311,6 +1313,12 @@ 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..8521d077b1 --- /dev/null +++ b/webview-ui/src/components/chat/ModelSelector.tsx @@ -0,0 +1,266 @@ +import { useState, useMemo, useCallback } from "react" +import { Fzf } from "fzf" + +import { + type ModelInfo, + type ModelRecord, + 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 { + getProviderModelConfig, + getStaticModelsForProvider, + isStaticModelProvider, +} from "../settings/utils/providerModelConfig" + +const SEARCH_THRESHOLD = 6 + +interface ModelSelectorProps { + apiConfiguration: ProviderSettings + currentApiConfigName?: string + disabled?: boolean + title: string + triggerClassName?: string +} + +export const ModelSelector = ({ + apiConfiguration, + currentApiConfigName, + disabled = false, + title, + triggerClassName = "", +}: 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(() => { + // Stryker disable next-line ConditionalExpression,BlockStatement: every provider that is + // dynamic or has static models also has an entry in PROVIDER_MODEL_CONFIG, so `modelConfig` + // is only ever undefined for providers that would fall through to `{}` below anyway. + if (!modelConfig) { + return {} + } + + if (dynamicProvider) { + return routerModels.data?.[dynamicProvider] ?? {} + } + + // Stryker disable next-line ConditionalExpression: getStaticModelsForProvider already + // falls back to `{}` for a provider missing from MODELS_BY_PROVIDER, so forcing this + // branch to run unconditionally yields the same result as the `false` case below. + if (isStaticModelProvider(provider)) { + const staticModels = getStaticModelsForProvider(provider, undefined, apiConfiguration) + const { "custom-arn": _customArn, ...rest } = staticModels + return rest + } + + return {} + }, [modelConfig, dynamicProvider, routerModels.data, provider, apiConfiguration]) + + 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). + // Stryker disable next-line ArrayDeclaration: this callback closes over no props or state, so + // its identity across renders isn't observable — only its (unmutated) body behavior is. + 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(() => { + // Stryker disable next-line ConditionalExpression,BlockStatement: fzf's `find("")` already + // returns every item in its original order, so skipping this shortcut is unobservable. + 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" }) + // Stryker disable next-line BooleanLiteral,CallExpression: this button only renders + // while the popover (and its `open` state) doesn't exist, so this call has no + // observable effect either way. + setOpen(false) + }, + // Stryker disable next-line ArrayDeclaration: this callback closes over no props or state. + [], + ) + + const handleSelect = useCallback( + (modelId: string) => { + // Stryker disable next-line ConditionalExpression,BlockStatement: handleSelect is only + // ever invoked from a rendered model-list item, which requires a non-empty `models` + // map, which in turn requires `modelConfig` to be defined — this guard can't be hit. + 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 ( +
handleSelect(modelId)} + className={cn( + "px-3 py-1.5 text-sm cursor-pointer flex items-center group", + "hover:bg-vscode-list-hoverBackground", + isCurrentModel && + "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground", + )}> +
{label}
+ {isCurrentModel && ( +
+ +
+ )} +
+ ) + }, + [selectedModelId, models, getModelLabel, handleSelect], + ) + + if (!isSupported) { + return ( + + + + ) + } + + return ( + + + + {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.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index a3a5558748..8bea0f340d 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from "react" + import { providerIdentifiers } from "@roo-code/types" import { defaultModeSlug } from "@roo/modes" @@ -34,6 +36,16 @@ const mockConvertToMentionPath = pathMentions.convertToMentionPath as ReturnType // Mock ExtensionStateContext vi.mock("@src/context/ExtensionStateContext") +vi.mock("@src/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + StandardTooltip: ({ children, content }: { children: ReactNode; content?: ReactNode }) => ( +
{children}
+ ), + } +}) + // Custom query function to get the enhance prompt button const getEnhancePromptButton = () => { return screen.getByRole("button", { @@ -1206,4 +1218,21 @@ describe("ChatTextArea", () => { expect(sendButton).toHaveClass("pointer-events-auto") }) }) + + describe("model selector", () => { + it("passes the responsive trigger class name to the model selector", () => { + render() + + expect(screen.getByTestId("model-selector-trigger")).toHaveClass("min-w-[28px]") + }) + + it("passes the selectModel translation as the trigger tooltip", () => { + render() + + expect(screen.getByTestId("model-selector-trigger").closest("[data-tooltip-content]")).toHaveAttribute( + "data-tooltip-content", + "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..6d10825f42 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ModelSelector.spec.tsx @@ -0,0 +1,755 @@ +import { providerIdentifiers, retiredProviderIdentifiers } 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, + }), +})) + +const { useRooPortalMock } = vi.hoisted(() => ({ + useRooPortalMock: vi.fn(() => document.body), +})) + +vi.mock("@/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: useRooPortalMock, +})) + +const { useRouterModelsMock, useSelectedModelMock } = vi.hoisted(() => ({ + useRouterModelsMock: vi.fn(() => ({ data: {} as Record, isLoading: false })), + useSelectedModelMock: vi.fn((): { id: string; info?: { displayName?: string }; 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", () => ({ + Popover: ({ children, open }: any) => ( +
+ {children} +
+ ), + PopoverTrigger: ({ children, disabled, ...props }: any) => ( + + ), + PopoverContent: ({ children }: any) =>
{children}
, + StandardTooltip: ({ children, content }: any) =>
{children}
, +})) + +const manyDynamicModels = Object.fromEntries(Array.from({ length: 8 }, (_, index) => [`openrouter/model-${index}`, {}])) + +describe("ModelSelector", () => { + beforeEach(() => { + vi.clearAllMocks() + useRouterModelsMock.mockReturnValue({ data: {}, 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", () => { + render( + , + ) + + expect(screen.getByTestId("model-selector-trigger")).not.toBeDisabled() + + const anotherModel = screen.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") }), + }), + ) + }) + + it("resets reasoning/thinking-token overrides and closes the popover after selecting a model", () => { + render( + , + ) + + fireEvent.click(screen.getAllByText(/claude-3-5-haiku/i)[0]) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + apiConfiguration: expect.objectContaining({ + reasoningEffort: undefined, + modelMaxTokens: undefined, + modelMaxThinkingTokens: undefined, + }), + }), + ) + expect(screen.getByTestId("popover-root")).toHaveAttribute("data-open", "false") + }) + + it("prefers a model's displayName over its raw id when present", () => { + useRouterModelsMock.mockReturnValue({ + data: { + openrouter: { + "openrouter/model-a": { displayName: "Model A (friendly)" }, + "openrouter/model-b": {}, + }, + }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ + id: "openrouter/model-a", + info: { 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. + expect(screen.getByText("openrouter/model-b")).toBeInTheDocument() + }) + + it("renders the dynamic router model list for a dynamic provider", () => { + useRouterModelsMock.mockReturnValue({ + data: { openrouter: { "openrouter/model-a": {}, "openrouter/model-b": {} } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + + expect(screen.getByText("openrouter/model-b")).toBeInTheDocument() + + fireEvent.click(screen.getByText("openrouter/model-b")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "upsertApiConfiguration", + apiConfiguration: expect.objectContaining({ openRouterModelId: "openrouter/model-b" }), + }), + ) + }) + + it("requests router models for a dynamic provider with fetching enabled", () => { + render( + , + ) + + expect(useRouterModelsMock).toHaveBeenCalledWith({ + provider: providerIdentifiers.openrouter, + enabled: true, + }) + }) + + it("requests router models with fetching disabled for a static provider", () => { + render( + , + ) + + expect(useRouterModelsMock).toHaveBeenCalledWith({ + provider: undefined, + enabled: false, + }) + }) + + it("mounts the popover content into the roo portal container", () => { + render( + , + ) + + expect(useRooPortalMock).toHaveBeenCalledWith("roo-portal") + }) + + it("shows the disabled view with an openrouter fallback label for a retired provider", () => { + useSelectedModelMock.mockReturnValue({ id: "", isLoading: false }) + + render( + , + ) + + // Retired providers have no model config of their own, so they never fetch router + // models and always render the unsupported/disabled view with an openrouter fallback. + expect(useRouterModelsMock).toHaveBeenCalledWith({ provider: undefined, enabled: false }) + expect(screen.getByTestId("model-selector-disabled")).toHaveTextContent(providerIdentifiers.openrouter) + }) + + 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("falls back to the provider name in the unsupported trigger when there is no selected model label", () => { + useSelectedModelMock.mockReturnValue({ id: "", isLoading: false }) + + render( + , + ) + + expect(screen.getByTestId("model-selector-disabled")).toHaveTextContent(providerIdentifiers.ollama) + }) + + it("shows the unsupported tooltip content and base classes on the disabled view", () => { + useSelectedModelMock.mockReturnValue({ id: "", isLoading: false }) + + render( + , + ) + + const disabledButton = screen.getByTestId("model-selector-disabled") + expect(disabledButton.closest("[data-tooltip-content]")).toHaveAttribute( + "data-tooltip-content", + "chat:selectModelUnsupported", + ) + expect(disabledButton).toHaveClass("min-w-0") + expect(disabledButton).toHaveClass("opacity-50") + }) + + it("disables the enabled selector's trigger when the disabled prop is set", () => { + render( + , + ) + + const trigger = screen.getByTestId("model-selector-trigger") + expect(trigger).toBeDisabled() + expect(trigger).toHaveClass("cursor-not-allowed") + expect(trigger).not.toHaveClass("opacity-100") + }) + + it("shows the loading label instead of the selected model while the selection is loading", () => { + useSelectedModelMock.mockReturnValue({ id: "claude-sonnet-4-5", isLoading: true }) + + render( + , + ) + + const trigger = screen.getByTestId("model-selector-trigger") + expect(trigger).toHaveTextContent("common:ui.loading") + expect(trigger).not.toHaveTextContent("claude-sonnet-4-5") + }) + + it("shows the title as the enabled trigger's tooltip content and applies the base trigger classes", () => { + render( + , + ) + + const trigger = screen.getByTestId("model-selector-trigger") + expect(trigger.closest("[data-tooltip-content]")).toHaveAttribute("data-tooltip-content", "Select model") + expect(trigger).toHaveClass("min-w-0") + }) + + it("does not open the popover until the user interacts with the trigger", () => { + render( + , + ) + + expect(screen.getByTestId("popover-root")).toHaveAttribute("data-open", "false") + }) + + it("does not append anything to the trigger class name by default", () => { + render( + , + ) + + expect(screen.getByTestId("model-selector-trigger")).not.toHaveClass("Stryker") + }) + + it("applies a custom trigger class name when provided", () => { + render( + , + ) + + expect(screen.getByTestId("model-selector-trigger")).toHaveClass("my-custom-trigger") + }) + + it("highlights the currently selected model with a check mark and not other models", () => { + useRouterModelsMock.mockReturnValue({ + data: { openrouter: { "openrouter/model-a": {}, "openrouter/model-b": {} } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + + const list = within(screen.getByTestId("popover-content")) + const currentItem = list.getByText("openrouter/model-a").parentElement + const otherItem = list.getByText("openrouter/model-b").parentElement + + expect(currentItem).toHaveClass("bg-vscode-list-activeSelectionBackground") + expect(currentItem?.querySelector(".codicon-check")).toBeInTheDocument() + + expect(otherItem).not.toHaveClass("bg-vscode-list-activeSelectionBackground") + expect(otherItem?.querySelector(".codicon-check")).not.toBeInTheDocument() + expect(otherItem).toHaveClass("px-3") + expect(otherItem).toHaveClass("hover:bg-vscode-list-hoverBackground") + }) + + it("does not show a search box when there are few models", () => { + useRouterModelsMock.mockReturnValue({ + data: { openrouter: { "openrouter/model-a": {}, "openrouter/model-b": {} } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + + expect(screen.queryByLabelText("common:ui.search_placeholder")).not.toBeInTheDocument() + }) + + it("does not show a search box at exactly the search threshold, but does show it just above it", () => { + const atThreshold = Object.fromEntries( + Array.from({ length: 6 }, (_, index) => [`openrouter/model-${index}`, {}]), + ) + useRouterModelsMock.mockReturnValue({ data: { openrouter: atThreshold }, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-0", isLoading: false }) + + const { rerender } = render( + , + ) + + expect(screen.queryByLabelText("common:ui.search_placeholder")).not.toBeInTheDocument() + + const aboveThreshold = { ...atThreshold, "openrouter/model-6": {} } + useRouterModelsMock.mockReturnValue({ data: { openrouter: aboveThreshold }, isLoading: false }) + + rerender( + , + ) + + expect(screen.getByLabelText("common:ui.search_placeholder")).toBeInTheDocument() + }) + + it("shows an empty search box above the search threshold and filters the model list as the user types", () => { + useRouterModelsMock.mockReturnValue({ data: { openrouter: manyDynamicModels }, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-0", isLoading: false }) + + render( + , + ) + + const searchInput = screen.getByLabelText("common:ui.search_placeholder") + expect(searchInput).toHaveValue("") + expect(searchInput).toHaveAttribute("placeholder", "common:ui.search_placeholder") + const list = within(screen.getByTestId("popover-content")) + expect(Object.keys(manyDynamicModels).every((id) => list.getByText(id) !== null)).toBe(true) + + fireEvent.change(searchInput, { target: { value: "model-3" } }) + + expect(list.getByText("openrouter/model-3")).toBeInTheDocument() + expect(list.queryByText("openrouter/model-0")).not.toBeInTheDocument() + }) + + it("clears the search value after selecting a filtered model", () => { + useRouterModelsMock.mockReturnValue({ data: { openrouter: manyDynamicModels }, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-0", isLoading: false }) + + render( + , + ) + + const searchInput = screen.getByLabelText("common:ui.search_placeholder") + fireEvent.change(searchInput, { target: { value: "model-3" } }) + fireEvent.click(within(screen.getByTestId("popover-content")).getByText("openrouter/model-3")) + + expect(searchInput).toHaveValue("") + }) + + it("matches a model by its displayName, and by its raw id when it has no displayName", () => { + useRouterModelsMock.mockReturnValue({ + data: { + openrouter: Object.fromEntries([ + ["openrouter/model-a", { displayName: "Zebra Special" }], + ...Array.from({ length: 7 }, (_, index) => [`openrouter/model-${index}`, {}]), + ]), + }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + + const list = within(screen.getByTestId("popover-content")) + const searchInput = screen.getByLabelText("common:ui.search_placeholder") + + fireEvent.change(searchInput, { target: { value: "Zebra" } }) + expect(list.getByText("Zebra Special")).toBeInTheDocument() + + fireEvent.change(searchInput, { target: { value: "model-3" } }) + expect(list.getByText("openrouter/model-3")).toBeInTheDocument() + expect(list.queryByText("Zebra Special")).not.toBeInTheDocument() + }) + + it("does not search a model's id against itself twice when it has no displayName", () => { + useRouterModelsMock.mockReturnValue({ data: { openrouter: manyDynamicModels }, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-0", isLoading: false }) + + render( + , + ) + + // A search term that only fuzzy-matches if the id were searched as "id id" (i.e. searched + // against itself twice) must not match, since a model without a displayName is only + // searched against its raw id once. + fireEvent.change(screen.getByLabelText("common:ui.search_placeholder"), { + target: { value: "3 openrouter" }, + }) + + expect(screen.getByText("common:ui.no_results")).toBeInTheDocument() + }) + + it("shows a no-results message when the search does not match any model", () => { + useRouterModelsMock.mockReturnValue({ data: { openrouter: manyDynamicModels }, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-0", isLoading: false }) + + render( + , + ) + + fireEvent.change(screen.getByLabelText("common:ui.search_placeholder"), { + target: { value: "no-such-model" }, + }) + + expect(screen.getByText("common:ui.no_results")).toBeInTheDocument() + }) + + it("shows a clear icon only once the user has typed a search value, and clears the search when clicked", () => { + useRouterModelsMock.mockReturnValue({ data: { openrouter: manyDynamicModels }, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-0", isLoading: false }) + + const { container } = render( + , + ) + + expect(container.querySelector(".codicon-close")).not.toBeInTheDocument() + + const searchInput = screen.getByLabelText("common:ui.search_placeholder") + fireEvent.change(searchInput, { target: { value: "model-3" } }) + + const clearIcon = container.querySelector(".codicon-close") + expect(clearIcon).toBeInTheDocument() + + fireEvent.click(clearIcon as Element) + + expect(searchInput).toHaveValue("") + expect(container.querySelector(".codicon-close")).not.toBeInTheDocument() + expect(within(screen.getByTestId("popover-content")).getByText("openrouter/model-0")).toBeInTheDocument() + }) + + it("shows the selectModel footer heading in the popover", () => { + render( + , + ) + + expect(within(screen.getByTestId("popover-content")).getByText("chat:selectModel")).toBeInTheDocument() + }) + + it("re-derives the search index when the model list changes on rerender", () => { + useRouterModelsMock.mockReturnValue({ + data: { openrouter: { "openrouter/model-a": {}, ...manyDynamicModels } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + const { rerender } = render( + , + ) + + const searchInput = screen.getByLabelText("common:ui.search_placeholder") + fireEvent.change(searchInput, { target: { value: "brand-new-model" } }) + expect(screen.getByText("common:ui.no_results")).toBeInTheDocument() + + useRouterModelsMock.mockReturnValue({ + data: { openrouter: { "openrouter/brand-new-model": {}, ...manyDynamicModels } }, + isLoading: false, + }) + + rerender( + , + ) + + // The search index must be rebuilt from the new model list, not reused from the first + // render, for the newly-added model to be findable and the removed one to disappear. + expect( + within(screen.getByTestId("popover-content")).getByText("openrouter/brand-new-model"), + ).toBeInTheDocument() + }) + + it("re-renders the model list with fresh click handlers and highlighting when the selection changes", () => { + useRouterModelsMock.mockReturnValue({ + data: { openrouter: { "openrouter/model-a": {}, "openrouter/model-b": {} } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + const { rerender } = render( + , + ) + + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-b", isLoading: false }) + rerender( + , + ) + + const list = within(screen.getByTestId("popover-content")) + expect(list.getByText("openrouter/model-a").parentElement).not.toHaveClass( + "bg-vscode-list-activeSelectionBackground", + ) + expect(list.getByText("openrouter/model-b").parentElement).toHaveClass( + "bg-vscode-list-activeSelectionBackground", + ) + + fireEvent.click(list.getByText("openrouter/model-a")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + apiConfiguration: expect.objectContaining({ openRouterModelId: "openrouter/model-a" }), + }), + ) + }) + + it("sends the current config name on select even after it changes on rerender", () => { + useRouterModelsMock.mockReturnValue({ + data: { openrouter: { "openrouter/model-a": {}, "openrouter/model-b": {} } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + const { rerender } = render( + , + ) + + rerender( + , + ) + + fireEvent.click(within(screen.getByTestId("popover-content")).getByText("openrouter/model-b")) + + expect(vscode.postMessage).toHaveBeenCalledWith(expect.objectContaining({ text: "config-two" })) + }) +}) 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..b829f28078 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..4d815fe6eb 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..dbe2d65f2d 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..8155d69338 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..b829f28078 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..4d815fe6eb 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..dbe2d65f2d 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..1cb9115d04 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/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 87cec9f61f..e5447e1d21 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Selecciona el mode d'interacció", "selectApiConfig": "Seleccioneu la configuració de l'API", + "selectModel": "Seleccioneu el model", + "selectModelUnsupported": "La selecció de model no està disponible per a aquest proveïdor aquí. Feu clic per obrir la configuració.", "lockApiConfigAcrossModes": "Bloqueja la configuració de l'API a tots els modes en aquest espai de treball", "unlockApiConfigAcrossModes": "La configuració de l'API està bloquejada a tots els modes en aquest espai de treball (fes clic per desbloquejar)", "enhancePrompt": "Millora la sol·licitud amb context addicional", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index b9591fe3aa..2e5705be01 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Interaktionsmodus auswählen", "selectApiConfig": "API-Konfiguration auswählen", + "selectModel": "Modell auswählen", + "selectModelUnsupported": "Die Modellauswahl ist für diesen Anbieter hier nicht verfügbar. Klicken Sie, um die Einstellungen zu öffnen.", "lockApiConfigAcrossModes": "API-Konfiguration für alle Modi in diesem Arbeitsbereich sperren", "unlockApiConfigAcrossModes": "API-Konfiguration ist für alle Modi in diesem Arbeitsbereich gesperrt (klicke zum Entsperren)", "enhancePrompt": "Prompt mit zusätzlichem Kontext verbessern", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index fe241f6145..cb9de2f009 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -140,6 +140,8 @@ }, "selectMode": "Select mode for interaction", "selectApiConfig": "Select API configuration", + "selectModel": "Select model", + "selectModelUnsupported": "Model selection isn't available for this provider here. Click to open settings.", "lockApiConfigAcrossModes": "Lock API configuration across all modes in this workspace", "unlockApiConfigAcrossModes": "API configuration is locked across all modes in this workspace (click to unlock)", "enhancePrompt": "Enhance prompt with additional context", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 1cbeab08e4..cafbf7732d 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Seleccionar modo de interacción", "selectApiConfig": "Seleccionar configuración de API", + "selectModel": "Seleccionar modelo", + "selectModelUnsupported": "La selección de modelo no está disponible para este proveedor aquí. Haga clic para abrir la configuración.", "lockApiConfigAcrossModes": "Bloquear la configuración de API en todos los modos de este espacio de trabajo", "unlockApiConfigAcrossModes": "La configuración de API está bloqueada en todos los modos de este espacio de trabajo (clic para desbloquear)", "enhancePrompt": "Mejorar el mensaje con contexto adicional", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 7ab8213cd5..c70b2a1fc4 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Sélectionner le mode d'interaction", "selectApiConfig": "Sélectionner la configuration de l'API", + "selectModel": "Sélectionner le modèle", + "selectModelUnsupported": "La sélection du modèle n'est pas disponible pour ce fournisseur ici. Cliquez pour ouvrir les paramètres.", "lockApiConfigAcrossModes": "Verrouiller la configuration API pour tous les modes dans cet espace de travail", "unlockApiConfigAcrossModes": "La configuration API est verrouillée pour tous les modes dans cet espace de travail (cliquer pour déverrouiller)", "enhancePrompt": "Améliorer la requête avec un contexte supplémentaire", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 8b57cefc89..c4c2bfd8cd 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "इंटरैक्शन मोड चुनें", "selectApiConfig": "एपीआई कॉन्फ़िगरेशन का चयन करें", + "selectModel": "मॉडल चुनें", + "selectModelUnsupported": "इस प्रदाता के लिए यहां मॉडल चयन उपलब्ध नहीं है। सेटिंग्स खोलने के लिए क्लिक करें।", "lockApiConfigAcrossModes": "इस कार्यक्षेत्र में सभी मोड के लिए API कॉन्फ़िगरेशन लॉक करें", "unlockApiConfigAcrossModes": "इस कार्यक्षेत्र में सभी मोड के लिए API कॉन्फ़िगरेशन लॉक है (अनलॉक करने के लिए क्लिक करें)", "enhancePrompt": "अतिरिक्त संदर्भ के साथ प्रॉम्प्ट बढ़ाएँ", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index fe3d6808d8..5d8222ad7f 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -143,6 +143,8 @@ }, "selectMode": "Pilih mode untuk interaksi", "selectApiConfig": "Pilih konfigurasi API", + "selectModel": "Pilih model", + "selectModelUnsupported": "Pemilihan model tidak tersedia untuk penyedia ini di sini. Klik untuk membuka pengaturan.", "lockApiConfigAcrossModes": "Kunci konfigurasi API di semua mode dalam workspace ini", "unlockApiConfigAcrossModes": "Konfigurasi API terkunci di semua mode dalam workspace ini (klik untuk membuka kunci)", "enhancePrompt": "Tingkatkan prompt dengan konteks tambahan", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 9dcfd68997..fcbc121ec5 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Seleziona modalità di interazione", "selectApiConfig": "Seleziona la configurazione API", + "selectModel": "Seleziona modello", + "selectModelUnsupported": "La selezione del modello non è disponibile per questo provider qui. Fai clic per aprire le impostazioni.", "lockApiConfigAcrossModes": "Blocca la configurazione API per tutte le modalità in questo workspace", "unlockApiConfigAcrossModes": "La configurazione API è bloccata per tutte le modalità in questo workspace (clicca per sbloccare)", "enhancePrompt": "Migliora prompt con contesto aggiuntivo", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index f098c68879..2ede184d60 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "対話モードを選択", "selectApiConfig": "API構成を選択", + "selectModel": "モデルを選択", + "selectModelUnsupported": "このプロバイダーではここでモデルを選択できません。クリックして設定を開きます。", "lockApiConfigAcrossModes": "このワークスペースのすべてのモードでAPI構成をロック", "unlockApiConfigAcrossModes": "このワークスペースのすべてのモードでAPI構成がロックされています(クリックで解除)", "enhancePrompt": "追加コンテキストでプロンプトを強化", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 7c1b51f934..3beed9fa9d 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "상호작용 모드 선택", "selectApiConfig": "API 구성 선택", + "selectModel": "모델 선택", + "selectModelUnsupported": "이 공급자는 여기서 모델을 선택할 수 없습니다. 클릭하면 설정으로 이동합니다.", "lockApiConfigAcrossModes": "이 워크스페이스의 모든 모드에서 API 구성 잠금", "unlockApiConfigAcrossModes": "이 워크스페이스의 모든 모드에서 API 구성이 잠겨 있습니다 (클릭하여 해제)", "enhancePrompt": "추가 컨텍스트로 프롬프트 향상", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index d75e451b81..0e1f0a0d4c 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Selecteer modus voor interactie", "selectApiConfig": "Selecteer API-configuratie", + "selectModel": "Selecteer model", + "selectModelUnsupported": "Modelselectie is hier niet beschikbaar voor deze provider. Klik om instellingen te openen.", "lockApiConfigAcrossModes": "API-configuratie vergrendelen voor alle modi in deze werkruimte", "unlockApiConfigAcrossModes": "API-configuratie is vergrendeld voor alle modi in deze werkruimte (klik om te ontgrendelen)", "enhancePrompt": "Prompt verbeteren met extra context", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 9769c48201..49aa35ec05 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Wybierz tryb interakcji", "selectApiConfig": "Wybierz konfigurację API", + "selectModel": "Wybierz model", + "selectModelUnsupported": "Wybór modelu nie jest tutaj dostępny dla tego dostawcy. Kliknij, aby otworzyć ustawienia.", "lockApiConfigAcrossModes": "Zablokuj konfigurację API dla wszystkich trybów w tym obszarze roboczym", "unlockApiConfigAcrossModes": "Konfiguracja API jest zablokowana dla wszystkich trybów w tym obszarze roboczym (kliknij, aby odblokować)", "enhancePrompt": "Ulepsz podpowiedź dodatkowym kontekstem", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 1ce9610adc..0c9af12bb4 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Selecionar modo de interação", "selectApiConfig": "Selecionar configuração da API", + "selectModel": "Selecionar modelo", + "selectModelUnsupported": "A seleção de modelo não está disponível para este provedor aqui. Clique para abrir as configurações.", "lockApiConfigAcrossModes": "Bloquear configuração da API em todos os modos neste workspace", "unlockApiConfigAcrossModes": "A configuração da API está bloqueada em todos os modos neste workspace (clique para desbloquear)", "enhancePrompt": "Aprimorar prompt com contexto adicional", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 4195d4d705..450e7b2663 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Выберите режим взаимодействия", "selectApiConfig": "Выберите конфигурацию API", + "selectModel": "Выберите модель", + "selectModelUnsupported": "Выбор модели недоступен для этого провайдера здесь. Нажмите, чтобы открыть настройки.", "lockApiConfigAcrossModes": "Заблокировать конфигурацию API для всех режимов в этом рабочем пространстве", "unlockApiConfigAcrossModes": "Конфигурация API заблокирована для всех режимов в этом рабочем пространстве (нажми, чтобы разблокировать)", "enhancePrompt": "Улучшить запрос с дополнительным контекстом", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 9fdffcd14b..0d0f1b1bf9 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Etkileşim modunu seçin", "selectApiConfig": "API yapılandırmasını seçin", + "selectModel": "Model seçin", + "selectModelUnsupported": "Bu sağlayıcı için model seçimi burada kullanılamıyor. Ayarları açmak için tıklayın.", "lockApiConfigAcrossModes": "Bu çalışma alanındaki tüm modlarda API yapılandırmasını kilitle", "unlockApiConfigAcrossModes": "Bu çalışma alanındaki tüm modlarda API yapılandırması kilitli (kilidi açmak için tıkla)", "enhancePrompt": "Ek bağlamla istemi geliştir", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 2812fbdccf..9443fa3702 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "Chọn chế độ tương tác", "selectApiConfig": "Chọn cấu hình API", + "selectModel": "Chọn mô hình", + "selectModelUnsupported": "Không thể chọn mô hình cho nhà cung cấp này ở đây. Nhấn để mở cài đặt.", "lockApiConfigAcrossModes": "Khóa cấu hình API cho tất cả chế độ trong workspace này", "unlockApiConfigAcrossModes": "Cấu hình API đã bị khóa cho tất cả chế độ trong workspace này (nhấn để mở khóa)", "enhancePrompt": "Nâng cao yêu cầu với ngữ cảnh bổ sung", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 5575dad5df..ed6fdce1db 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -113,6 +113,8 @@ }, "selectMode": "选择交互模式", "selectApiConfig": "选择 API 配置", + "selectModel": "选择模型", + "selectModelUnsupported": "此提供商在此处不支持模型选择。点击以打开设置。", "lockApiConfigAcrossModes": "锁定此工作区所有模式的 API 配置", "unlockApiConfigAcrossModes": "此工作区所有模式的 API 配置已锁定(点击解锁)", "enhancePrompt": "增强提示词", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 184728d6a5..901ac7b30f 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -140,6 +140,8 @@ }, "selectMode": "選擇互動模式", "selectApiConfig": "選取 API 設定", + "selectModel": "選擇模型", + "selectModelUnsupported": "此提供者在此處無法選取模型。按一下以開啟設定。", "lockApiConfigAcrossModes": "鎖定此工作區所有模式的 API 設定", "unlockApiConfigAcrossModes": "此工作區所有模式的 API 設定已鎖定(點擊解鎖)", "enhancePrompt": "使用額外內容強化提示詞",