From 14f10d5d382d5a87247309ba5a65650f18acc2ae Mon Sep 17 00:00:00 2001 From: avaritiachaos <1904382932@qq.com> Date: Wed, 29 Apr 2026 13:29:13 +0800 Subject: [PATCH 1/9] Improve API config picker layout --- .changeset/two-column-api-config-selector.md | 5 + .../src/components/chat/ApiConfigSelector.tsx | 216 ++++++++++++++---- .../chat/__tests__/ApiConfigSelector.spec.tsx | 115 +++++----- 3 files changed, 234 insertions(+), 102 deletions(-) create mode 100644 .changeset/two-column-api-config-selector.md diff --git a/.changeset/two-column-api-config-selector.md b/.changeset/two-column-api-config-selector.md new file mode 100644 index 00000000000..0b97304500f --- /dev/null +++ b/.changeset/two-column-api-config-selector.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Improve the chat API configuration picker with a two-column provider and model layout. diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index e370296ec32..5ea5d2a998c 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from "react" +import { useState, useMemo, useCallback, useEffect } from "react" import { Fzf } from "fzf" import { cn } from "@/lib/utils" @@ -10,6 +10,60 @@ import { Button } from "@/components/ui" import { IconButton } from "./IconButton" +type ApiConfigMeta = { + id: string + name: string + apiProvider?: string + modelId?: string +} + +type ProviderGroup = { + key: string + label: string + configs: ApiConfigMeta[] +} + +const PROVIDER_LABELS: Record = { + anthropic: "Anthropic", + bedrock: "Amazon Bedrock", + deepseek: "DeepSeek", + gemini: "Google Gemini", + "gemini-cli": "Gemini CLI", + vertex: "Vertex AI", + openai: "OpenAI Compatible", + "openai-native": "OpenAI", + "openai-codex": "OpenAI Codex", + openrouter: "OpenRouter", + ollama: "Ollama", + lmstudio: "LM Studio", + mistral: "Mistral", + moonshot: "Moonshot", + minimax: "MiniMax", + requesty: "Requesty", + unbound: "Unbound", + poe: "Poe", + xai: "xAI", + baseten: "Baseten", + litellm: "LiteLLM", + sambanova: "SambaNova", + zai: "Z.ai", + fireworks: "Fireworks AI", + "qwen-code": "Qwen Code", + roo: "Roo", + "vscode-lm": "VS Code LM", + "vercel-ai-gateway": "Vercel AI Gateway", +} + +const getProviderKey = (config?: ApiConfigMeta) => config?.apiProvider ?? "unknown" + +const getProviderLabel = (apiProvider?: string) => { + if (!apiProvider) { + return "Other" + } + + return PROVIDER_LABELS[apiProvider] ?? apiProvider +} + interface ApiConfigSelectorProps { value: string displayName: string @@ -17,7 +71,7 @@ interface ApiConfigSelectorProps { title: string onChange: (value: string) => void triggerClassName?: string - listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }> + listApiConfigMeta: ApiConfigMeta[] pinnedApiConfigs?: Record togglePinnedApiConfig: (id: string) => void lockApiConfigAcrossModes: boolean @@ -40,6 +94,7 @@ export const ApiConfigSelector = ({ const { t } = useAppTranslation() const [open, setOpen] = useState(false) const [searchValue, setSearchValue] = useState("") + const [activeProviderKey, setActiveProviderKey] = useState("") const portalContainer = useRooPortal("roo-portal") // Create searchable items for fuzzy search. @@ -47,7 +102,9 @@ export const ApiConfigSelector = ({ () => listApiConfigMeta.map((config) => ({ original: config, - searchStr: config.name, + searchStr: [config.name, config.modelId, config.apiProvider, getProviderLabel(config.apiProvider)] + .filter(Boolean) + .join(" "), })), [listApiConfigMeta], ) @@ -68,20 +125,69 @@ export const ApiConfigSelector = ({ return matchingItems }, [listApiConfigMeta, searchValue, fzfInstance]) - // Separate pinned and unpinned configs. - const { pinnedConfigs, unpinnedConfigs } = useMemo(() => { - const pinned = filteredConfigs.filter((config) => pinnedApiConfigs?.[config.id]) - const unpinned = filteredConfigs.filter((config) => !pinnedApiConfigs?.[config.id]) - return { pinnedConfigs: pinned, unpinnedConfigs: unpinned } + const providerGroups = useMemo(() => { + const groups = new Map() + + for (const config of filteredConfigs) { + const key = getProviderKey(config) + + if (!groups.has(key)) { + groups.set(key, { + key, + label: getProviderLabel(config.apiProvider), + configs: [], + }) + } + + groups.get(key)!.configs.push(config) + } + + return Array.from(groups.values()).map((group) => ({ + ...group, + configs: [...group.configs].sort((a, b) => { + const pinnedDelta = Number(!!pinnedApiConfigs?.[b.id]) - Number(!!pinnedApiConfigs?.[a.id]) + + return pinnedDelta + }), + })) }, [filteredConfigs, pinnedApiConfigs]) + const currentConfig = useMemo( + () => listApiConfigMeta.find((config) => config.id === value), + [listApiConfigMeta, value], + ) + + const currentProviderKey = getProviderKey(currentConfig) + const preferredProviderKey = providerGroups.some((group) => group.key === currentProviderKey) + ? currentProviderKey + : (providerGroups[0]?.key ?? "") + + useEffect(() => { + if (!providerGroups.length) { + setActiveProviderKey("") + return + } + + if (!providerGroups.some((group) => group.key === activeProviderKey)) { + setActiveProviderKey(preferredProviderKey) + } + }, [activeProviderKey, preferredProviderKey, providerGroups]) + + const activeProviderGroup = providerGroups.find((group) => group.key === activeProviderKey) ?? providerGroups[0] + const handleSelect = useCallback( (configId: string) => { + const selectedConfig = listApiConfigMeta.find((config) => config.id === configId) + + if (selectedConfig) { + setActiveProviderKey(getProviderKey(selectedConfig)) + } + onChange(configId) setOpen(false) setSearchValue("") }, - [onChange], + [listApiConfigMeta, onChange], ) const handleEditClick = useCallback(() => { @@ -89,33 +195,59 @@ export const ApiConfigSelector = ({ setOpen(false) }, []) - const renderConfigItem = useCallback( - (config: { id: string; name: string; modelId?: string }, isPinned: boolean) => { + const renderProviderItem = useCallback( + (group: ProviderGroup) => { + const isActive = group.key === activeProviderGroup?.key + const currentCount = group.configs.filter((config) => config.id === value).length + const pinnedCount = group.configs.filter((config) => pinnedApiConfigs?.[config.id]).length + + return ( + + ) + }, + [activeProviderGroup?.key, pinnedApiConfigs, value], + ) + + const renderModelItem = useCallback( + (config: ApiConfigMeta) => { const isCurrentConfig = config.id === value + const isPinned = !!pinnedApiConfigs?.[config.id] return (
handleSelect(config.id)} className={cn( - "px-3 py-1.5 text-sm cursor-pointer flex items-center group", + "px-3 py-1.5 text-sm cursor-pointer flex items-center group gap-2", "hover:bg-vscode-list-hoverBackground", isCurrentConfig && "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground", )}> -
- {config.name} +
+ {config.modelId || config.name} {config.modelId && ( - <> - - {config.modelId} - - + + {config.name} + )}
-
+
{isCurrentConfig && (
@@ -142,7 +274,7 @@ export const ApiConfigSelector = ({
) }, - [value, handleSelect, t, togglePinnedApiConfig], + [value, pinnedApiConfigs, handleSelect, t, togglePinnedApiConfig], ) return ( @@ -167,7 +299,7 @@ export const ApiConfigSelector = ({ align="start" sideOffset={4} container={portalContainer} - className="p-0 overflow-hidden w-[300px]"> + className="p-0 overflow-hidden w-[520px] max-w-[calc(100vw-24px)]">
{/* Search input or info blurb */} {listApiConfigMeta.length > 6 ? ( @@ -197,29 +329,29 @@ export const ApiConfigSelector = ({
)} - {/* Config list - single scroll container */} + {/* Provider/model picker */} {filteredConfigs.length === 0 && searchValue ? (
{t("common:ui.no_results")}
) : ( -
- {/* Pinned configs - sticky header */} - {pinnedConfigs.length > 0 && ( -
0 && "border-b border-vscode-dropdown-foreground/10", - )} - aria-label="Pinned configurations"> - {pinnedConfigs.map((config) => renderConfigItem(config, true))} +
+
+
+ {t("settings:providers.apiProvider")}
- )} - - {/* Unpinned configs */} - {unpinnedConfigs.length > 0 && ( -
- {unpinnedConfigs.map((config) => renderConfigItem(config, false))} +
{providerGroups.map(renderProviderItem)}
+
+
+
+ {activeProviderGroup?.label ?? t("settings:providers.model")}
- )} +
{activeProviderGroup?.configs.map(renderModelItem)}
+
)} diff --git a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx index a71216d96f8..2b4106f0acb 100644 --- a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" +import { render, screen, fireEvent, waitFor, within } from "@/utils/test-utils" import { vscode } from "@/utils/vscode" import { ApiConfigSelector } from "../ApiConfigSelector" @@ -66,9 +66,9 @@ describe("ApiConfigSelector", () => { title: "API Config", onChange: mockOnChange, listApiConfigMeta: [ - { id: "config1", name: "Config 1", modelId: "claude-3-opus-20240229" }, - { id: "config2", name: "Config 2", modelId: "gpt-4" }, - { id: "config3", name: "Config 3", modelId: "claude-3-sonnet-20240229" }, + { id: "config1", name: "Config 1", apiProvider: "anthropic", modelId: "claude-3-opus-20240229" }, + { id: "config2", name: "Config 2", apiProvider: "anthropic", modelId: "gpt-4" }, + { id: "config3", name: "Config 3", apiProvider: "anthropic", modelId: "claude-3-sonnet-20240229" }, ], pinnedApiConfigs: { config1: true }, togglePinnedApiConfig: mockTogglePinnedApiConfig, @@ -157,6 +157,36 @@ describe("ApiConfigSelector", () => { expect(screen.getByText("prompts:apiConfiguration.select")).toBeInTheDocument() }) + test("renders providers and switches the model column by provider", () => { + const props = { + ...defaultProps, + listApiConfigMeta: [ + { id: "anthropic", name: "Claude", apiProvider: "anthropic", modelId: "claude-3-opus" }, + { id: "openai", name: "OpenAI", apiProvider: "openai", modelId: "gpt-4" }, + { id: "deepseek", name: "DeepSeek", apiProvider: "deepseek", modelId: "deepseek-chat" }, + ], + } + + render() + + const trigger = screen.getByTestId("dropdown-trigger") + fireEvent.click(trigger) + + const providerColumn = screen.getByTestId("api-provider-column") + const modelColumn = screen.getByTestId("api-model-column") + + expect(within(providerColumn).getByText("Anthropic")).toBeInTheDocument() + expect(within(providerColumn).getByText("OpenAI Compatible")).toBeInTheDocument() + expect(within(providerColumn).getByText("DeepSeek")).toBeInTheDocument() + expect(within(modelColumn).getByText("claude-3-opus")).toBeInTheDocument() + + fireEvent.click(within(providerColumn).getByText("OpenAI Compatible")) + + expect(within(modelColumn).getByText("gpt-4")).toBeInTheDocument() + expect(within(modelColumn).getByText("OpenAI")).toBeInTheDocument() + expect(within(modelColumn).queryByText("deepseek-chat")).not.toBeInTheDocument() + }) + test("filters configs based on search input", async () => { const props = { ...defaultProps, @@ -176,14 +206,12 @@ describe("ApiConfigSelector", () => { fireEvent.click(trigger) const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder") - fireEvent.change(searchInput, { target: { value: "Config 2" } }) + fireEvent.change(searchInput, { target: { value: "gpt-3.5-turbo" } }) // Wait for the filtering to take effect await waitFor(() => { - // Config 2 should be visible - expect(screen.getByText("Config 2")).toBeInTheDocument() - // Config 3 should not be visible (assuming exact match filtering) - expect(screen.queryByText("Config 3")).not.toBeInTheDocument() + expect(screen.getByText("Config 4")).toBeInTheDocument() + expect(screen.queryByText("Config 2")).not.toBeInTheDocument() }) }) @@ -294,10 +322,9 @@ describe("ApiConfigSelector", () => { // Extract the config names from each row const configNames: string[] = [] configRows.forEach((row) => { - // Find the first span that's flex-shrink-0 (the profile name) - const nameElement = row.querySelector(".flex-1 span.flex-shrink-0") - if (nameElement?.textContent) { - configNames.push(nameElement.textContent) + const match = row.textContent?.match(/Config \d+/) + if (match) { + configNames.push(match[0]) } }) @@ -438,11 +465,12 @@ describe("ApiConfigSelector", () => { expect(searchInput.value).toBe("Config") }) - test("pinned configs remain fixed at top while unpinned configs scroll", () => { + test("pinned configs sort first in the active provider model column", () => { // Create a list with many configs to test scrolling const manyConfigs = Array.from({ length: 15 }, (_, i) => ({ id: `config${i + 1}`, name: `Config ${i + 1}`, + apiProvider: "anthropic", modelId: `model-${i + 1}`, })) @@ -461,46 +489,28 @@ describe("ApiConfigSelector", () => { const trigger = screen.getByTestId("dropdown-trigger") fireEvent.click(trigger) - const popoverContent = screen.getByTestId("popover-content") - - // Should have a single scroll container with max-h-[300px] and overflow-y-auto - const scrollContainer = popoverContent.querySelector(".max-h-\\[300px\\].overflow-y-auto") - expect(scrollContainer).toBeInTheDocument() - - // Check for pinned configs sticky header - const pinnedStickyHeader = scrollContainer?.querySelector(".sticky.top-0.z-10.bg-vscode-dropdown-background") - expect(pinnedStickyHeader).toBeInTheDocument() - expect(pinnedStickyHeader).toHaveAttribute("aria-label", "Pinned configurations") + const providerColumn = screen.getByTestId("api-provider-column") + const modelColumn = screen.getByTestId("api-model-column") + expect(providerColumn).toBeInTheDocument() + expect(modelColumn).toBeInTheDocument() // Check for Config 1, 2, 3 being visible in the sticky header (pinned) expect(screen.getAllByText("Config 1").length).toBeGreaterThan(0) expect(screen.getAllByText("Config 2").length).toBeGreaterThan(0) expect(screen.getAllByText("Config 3").length).toBeGreaterThan(0) - // Verify pinned container contains the pinned configs - if (pinnedStickyHeader) { - const elements = pinnedStickyHeader.querySelectorAll(".flex-shrink-0") - const pinnedConfigTexts = Array.from(elements) - .map((el) => (el as Element).textContent) - .filter((text) => text?.startsWith("Config")) - - expect(pinnedConfigTexts).toContain("Config 1") - expect(pinnedConfigTexts).toContain("Config 2") - expect(pinnedConfigTexts).toContain("Config 3") - } - - // Check for unpinned configs section - const unpinnedSection = scrollContainer?.querySelector('[aria-label="All configurations"]') - expect(unpinnedSection).toBeInTheDocument() + const configNames = Array.from(modelColumn.querySelectorAll(".group")) + .map((row) => row.textContent?.match(/Config \d+/)?.[0]) + .filter(Boolean) - // Verify separator exists as border on pinned section when unpinned configs exist - expect(pinnedStickyHeader).toHaveClass("border-b") + expect(configNames.slice(0, 3)).toEqual(["Config 1", "Config 2", "Config 3"]) }) test("displays all configs in scrollable container when no configs are pinned", () => { const manyConfigs = Array.from({ length: 10 }, (_, i) => ({ id: `config${i + 1}`, name: `Config ${i + 1}`, + apiProvider: "anthropic", modelId: `model-${i + 1}`, })) @@ -515,26 +525,11 @@ describe("ApiConfigSelector", () => { const trigger = screen.getByTestId("dropdown-trigger") fireEvent.click(trigger) - const popoverContent = screen.getByTestId("popover-content") - - // Should have a single scroll container with max-h-[300px] and overflow-y-auto - const scrollContainer = popoverContent.querySelector(".max-h-\\[300px\\].overflow-y-auto") - expect(scrollContainer).toBeInTheDocument() + const modelColumn = screen.getByTestId("api-model-column") + expect(modelColumn).toBeInTheDocument() - // No pinned section should exist when no configs are pinned - const pinnedSection = scrollContainer?.querySelector(".sticky.top-0") - expect(pinnedSection).not.toBeInTheDocument() - - // Should have unpinned configs section with all configs - const unpinnedSection = scrollContainer?.querySelector('[aria-label="All configurations"]') - expect(unpinnedSection).toBeInTheDocument() - - // All configs should be in the unpinned section - const allConfigRows = unpinnedSection?.querySelectorAll(".group") + // All configs should be in the active provider model column. + const allConfigRows = modelColumn.querySelectorAll(".group") expect(allConfigRows?.length).toBe(10) - - // No separator should exist when no pinned configs (no sticky header exists) - const stickyHeader = scrollContainer?.querySelector(".sticky.top-0") - expect(stickyHeader).not.toBeInTheDocument() }) }) From 31be701ebe5e379f88bac840d6acc0cf1427f370 Mon Sep 17 00:00:00 2001 From: avaritiachaos <1904382932@qq.com> Date: Wed, 29 Apr 2026 17:00:15 +0800 Subject: [PATCH 2/9] Revert "Improve API config picker layout" This reverts commit 14f10d5d382d5a87247309ba5a65650f18acc2ae. --- .changeset/two-column-api-config-selector.md | 5 - .../src/components/chat/ApiConfigSelector.tsx | 216 ++++-------------- .../chat/__tests__/ApiConfigSelector.spec.tsx | 115 +++++----- 3 files changed, 102 insertions(+), 234 deletions(-) delete mode 100644 .changeset/two-column-api-config-selector.md diff --git a/.changeset/two-column-api-config-selector.md b/.changeset/two-column-api-config-selector.md deleted file mode 100644 index 0b97304500f..00000000000 --- a/.changeset/two-column-api-config-selector.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Improve the chat API configuration picker with a two-column provider and model layout. diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index 5ea5d2a998c..e370296ec32 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, useEffect } from "react" +import { useState, useMemo, useCallback } from "react" import { Fzf } from "fzf" import { cn } from "@/lib/utils" @@ -10,60 +10,6 @@ import { Button } from "@/components/ui" import { IconButton } from "./IconButton" -type ApiConfigMeta = { - id: string - name: string - apiProvider?: string - modelId?: string -} - -type ProviderGroup = { - key: string - label: string - configs: ApiConfigMeta[] -} - -const PROVIDER_LABELS: Record = { - anthropic: "Anthropic", - bedrock: "Amazon Bedrock", - deepseek: "DeepSeek", - gemini: "Google Gemini", - "gemini-cli": "Gemini CLI", - vertex: "Vertex AI", - openai: "OpenAI Compatible", - "openai-native": "OpenAI", - "openai-codex": "OpenAI Codex", - openrouter: "OpenRouter", - ollama: "Ollama", - lmstudio: "LM Studio", - mistral: "Mistral", - moonshot: "Moonshot", - minimax: "MiniMax", - requesty: "Requesty", - unbound: "Unbound", - poe: "Poe", - xai: "xAI", - baseten: "Baseten", - litellm: "LiteLLM", - sambanova: "SambaNova", - zai: "Z.ai", - fireworks: "Fireworks AI", - "qwen-code": "Qwen Code", - roo: "Roo", - "vscode-lm": "VS Code LM", - "vercel-ai-gateway": "Vercel AI Gateway", -} - -const getProviderKey = (config?: ApiConfigMeta) => config?.apiProvider ?? "unknown" - -const getProviderLabel = (apiProvider?: string) => { - if (!apiProvider) { - return "Other" - } - - return PROVIDER_LABELS[apiProvider] ?? apiProvider -} - interface ApiConfigSelectorProps { value: string displayName: string @@ -71,7 +17,7 @@ interface ApiConfigSelectorProps { title: string onChange: (value: string) => void triggerClassName?: string - listApiConfigMeta: ApiConfigMeta[] + listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }> pinnedApiConfigs?: Record togglePinnedApiConfig: (id: string) => void lockApiConfigAcrossModes: boolean @@ -94,7 +40,6 @@ export const ApiConfigSelector = ({ const { t } = useAppTranslation() const [open, setOpen] = useState(false) const [searchValue, setSearchValue] = useState("") - const [activeProviderKey, setActiveProviderKey] = useState("") const portalContainer = useRooPortal("roo-portal") // Create searchable items for fuzzy search. @@ -102,9 +47,7 @@ export const ApiConfigSelector = ({ () => listApiConfigMeta.map((config) => ({ original: config, - searchStr: [config.name, config.modelId, config.apiProvider, getProviderLabel(config.apiProvider)] - .filter(Boolean) - .join(" "), + searchStr: config.name, })), [listApiConfigMeta], ) @@ -125,69 +68,20 @@ export const ApiConfigSelector = ({ return matchingItems }, [listApiConfigMeta, searchValue, fzfInstance]) - const providerGroups = useMemo(() => { - const groups = new Map() - - for (const config of filteredConfigs) { - const key = getProviderKey(config) - - if (!groups.has(key)) { - groups.set(key, { - key, - label: getProviderLabel(config.apiProvider), - configs: [], - }) - } - - groups.get(key)!.configs.push(config) - } - - return Array.from(groups.values()).map((group) => ({ - ...group, - configs: [...group.configs].sort((a, b) => { - const pinnedDelta = Number(!!pinnedApiConfigs?.[b.id]) - Number(!!pinnedApiConfigs?.[a.id]) - - return pinnedDelta - }), - })) + // Separate pinned and unpinned configs. + const { pinnedConfigs, unpinnedConfigs } = useMemo(() => { + const pinned = filteredConfigs.filter((config) => pinnedApiConfigs?.[config.id]) + const unpinned = filteredConfigs.filter((config) => !pinnedApiConfigs?.[config.id]) + return { pinnedConfigs: pinned, unpinnedConfigs: unpinned } }, [filteredConfigs, pinnedApiConfigs]) - const currentConfig = useMemo( - () => listApiConfigMeta.find((config) => config.id === value), - [listApiConfigMeta, value], - ) - - const currentProviderKey = getProviderKey(currentConfig) - const preferredProviderKey = providerGroups.some((group) => group.key === currentProviderKey) - ? currentProviderKey - : (providerGroups[0]?.key ?? "") - - useEffect(() => { - if (!providerGroups.length) { - setActiveProviderKey("") - return - } - - if (!providerGroups.some((group) => group.key === activeProviderKey)) { - setActiveProviderKey(preferredProviderKey) - } - }, [activeProviderKey, preferredProviderKey, providerGroups]) - - const activeProviderGroup = providerGroups.find((group) => group.key === activeProviderKey) ?? providerGroups[0] - const handleSelect = useCallback( (configId: string) => { - const selectedConfig = listApiConfigMeta.find((config) => config.id === configId) - - if (selectedConfig) { - setActiveProviderKey(getProviderKey(selectedConfig)) - } - onChange(configId) setOpen(false) setSearchValue("") }, - [listApiConfigMeta, onChange], + [onChange], ) const handleEditClick = useCallback(() => { @@ -195,59 +89,33 @@ export const ApiConfigSelector = ({ setOpen(false) }, []) - const renderProviderItem = useCallback( - (group: ProviderGroup) => { - const isActive = group.key === activeProviderGroup?.key - const currentCount = group.configs.filter((config) => config.id === value).length - const pinnedCount = group.configs.filter((config) => pinnedApiConfigs?.[config.id]).length - - return ( - - ) - }, - [activeProviderGroup?.key, pinnedApiConfigs, value], - ) - - const renderModelItem = useCallback( - (config: ApiConfigMeta) => { + const renderConfigItem = useCallback( + (config: { id: string; name: string; modelId?: string }, isPinned: boolean) => { const isCurrentConfig = config.id === value - const isPinned = !!pinnedApiConfigs?.[config.id] return (
handleSelect(config.id)} className={cn( - "px-3 py-1.5 text-sm cursor-pointer flex items-center group gap-2", + "px-3 py-1.5 text-sm cursor-pointer flex items-center group", "hover:bg-vscode-list-hoverBackground", isCurrentConfig && "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground", )}> -
- {config.modelId || config.name} +
+ {config.name} {config.modelId && ( - - {config.name} - + <> + + {config.modelId} + + )}
-
+
{isCurrentConfig && (
@@ -274,7 +142,7 @@ export const ApiConfigSelector = ({
) }, - [value, pinnedApiConfigs, handleSelect, t, togglePinnedApiConfig], + [value, handleSelect, t, togglePinnedApiConfig], ) return ( @@ -299,7 +167,7 @@ export const ApiConfigSelector = ({ align="start" sideOffset={4} container={portalContainer} - className="p-0 overflow-hidden w-[520px] max-w-[calc(100vw-24px)]"> + className="p-0 overflow-hidden w-[300px]">
{/* Search input or info blurb */} {listApiConfigMeta.length > 6 ? ( @@ -329,29 +197,29 @@ export const ApiConfigSelector = ({
)} - {/* Provider/model picker */} + {/* Config list - single scroll container */} {filteredConfigs.length === 0 && searchValue ? (
{t("common:ui.no_results")}
) : ( -
-
-
- {t("settings:providers.apiProvider")} +
+ {/* Pinned configs - sticky header */} + {pinnedConfigs.length > 0 && ( +
0 && "border-b border-vscode-dropdown-foreground/10", + )} + aria-label="Pinned configurations"> + {pinnedConfigs.map((config) => renderConfigItem(config, true))}
-
{providerGroups.map(renderProviderItem)}
-
-
-
- {activeProviderGroup?.label ?? t("settings:providers.model")} + )} + + {/* Unpinned configs */} + {unpinnedConfigs.length > 0 && ( +
+ {unpinnedConfigs.map((config) => renderConfigItem(config, false))}
-
{activeProviderGroup?.configs.map(renderModelItem)}
-
+ )}
)} diff --git a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx index 2b4106f0acb..a71216d96f8 100644 --- a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor, within } from "@/utils/test-utils" +import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" import { vscode } from "@/utils/vscode" import { ApiConfigSelector } from "../ApiConfigSelector" @@ -66,9 +66,9 @@ describe("ApiConfigSelector", () => { title: "API Config", onChange: mockOnChange, listApiConfigMeta: [ - { id: "config1", name: "Config 1", apiProvider: "anthropic", modelId: "claude-3-opus-20240229" }, - { id: "config2", name: "Config 2", apiProvider: "anthropic", modelId: "gpt-4" }, - { id: "config3", name: "Config 3", apiProvider: "anthropic", modelId: "claude-3-sonnet-20240229" }, + { id: "config1", name: "Config 1", modelId: "claude-3-opus-20240229" }, + { id: "config2", name: "Config 2", modelId: "gpt-4" }, + { id: "config3", name: "Config 3", modelId: "claude-3-sonnet-20240229" }, ], pinnedApiConfigs: { config1: true }, togglePinnedApiConfig: mockTogglePinnedApiConfig, @@ -157,36 +157,6 @@ describe("ApiConfigSelector", () => { expect(screen.getByText("prompts:apiConfiguration.select")).toBeInTheDocument() }) - test("renders providers and switches the model column by provider", () => { - const props = { - ...defaultProps, - listApiConfigMeta: [ - { id: "anthropic", name: "Claude", apiProvider: "anthropic", modelId: "claude-3-opus" }, - { id: "openai", name: "OpenAI", apiProvider: "openai", modelId: "gpt-4" }, - { id: "deepseek", name: "DeepSeek", apiProvider: "deepseek", modelId: "deepseek-chat" }, - ], - } - - render() - - const trigger = screen.getByTestId("dropdown-trigger") - fireEvent.click(trigger) - - const providerColumn = screen.getByTestId("api-provider-column") - const modelColumn = screen.getByTestId("api-model-column") - - expect(within(providerColumn).getByText("Anthropic")).toBeInTheDocument() - expect(within(providerColumn).getByText("OpenAI Compatible")).toBeInTheDocument() - expect(within(providerColumn).getByText("DeepSeek")).toBeInTheDocument() - expect(within(modelColumn).getByText("claude-3-opus")).toBeInTheDocument() - - fireEvent.click(within(providerColumn).getByText("OpenAI Compatible")) - - expect(within(modelColumn).getByText("gpt-4")).toBeInTheDocument() - expect(within(modelColumn).getByText("OpenAI")).toBeInTheDocument() - expect(within(modelColumn).queryByText("deepseek-chat")).not.toBeInTheDocument() - }) - test("filters configs based on search input", async () => { const props = { ...defaultProps, @@ -206,12 +176,14 @@ describe("ApiConfigSelector", () => { fireEvent.click(trigger) const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder") - fireEvent.change(searchInput, { target: { value: "gpt-3.5-turbo" } }) + fireEvent.change(searchInput, { target: { value: "Config 2" } }) // Wait for the filtering to take effect await waitFor(() => { - expect(screen.getByText("Config 4")).toBeInTheDocument() - expect(screen.queryByText("Config 2")).not.toBeInTheDocument() + // Config 2 should be visible + expect(screen.getByText("Config 2")).toBeInTheDocument() + // Config 3 should not be visible (assuming exact match filtering) + expect(screen.queryByText("Config 3")).not.toBeInTheDocument() }) }) @@ -322,9 +294,10 @@ describe("ApiConfigSelector", () => { // Extract the config names from each row const configNames: string[] = [] configRows.forEach((row) => { - const match = row.textContent?.match(/Config \d+/) - if (match) { - configNames.push(match[0]) + // Find the first span that's flex-shrink-0 (the profile name) + const nameElement = row.querySelector(".flex-1 span.flex-shrink-0") + if (nameElement?.textContent) { + configNames.push(nameElement.textContent) } }) @@ -465,12 +438,11 @@ describe("ApiConfigSelector", () => { expect(searchInput.value).toBe("Config") }) - test("pinned configs sort first in the active provider model column", () => { + test("pinned configs remain fixed at top while unpinned configs scroll", () => { // Create a list with many configs to test scrolling const manyConfigs = Array.from({ length: 15 }, (_, i) => ({ id: `config${i + 1}`, name: `Config ${i + 1}`, - apiProvider: "anthropic", modelId: `model-${i + 1}`, })) @@ -489,28 +461,46 @@ describe("ApiConfigSelector", () => { const trigger = screen.getByTestId("dropdown-trigger") fireEvent.click(trigger) - const providerColumn = screen.getByTestId("api-provider-column") - const modelColumn = screen.getByTestId("api-model-column") - expect(providerColumn).toBeInTheDocument() - expect(modelColumn).toBeInTheDocument() + const popoverContent = screen.getByTestId("popover-content") + + // Should have a single scroll container with max-h-[300px] and overflow-y-auto + const scrollContainer = popoverContent.querySelector(".max-h-\\[300px\\].overflow-y-auto") + expect(scrollContainer).toBeInTheDocument() + + // Check for pinned configs sticky header + const pinnedStickyHeader = scrollContainer?.querySelector(".sticky.top-0.z-10.bg-vscode-dropdown-background") + expect(pinnedStickyHeader).toBeInTheDocument() + expect(pinnedStickyHeader).toHaveAttribute("aria-label", "Pinned configurations") // Check for Config 1, 2, 3 being visible in the sticky header (pinned) expect(screen.getAllByText("Config 1").length).toBeGreaterThan(0) expect(screen.getAllByText("Config 2").length).toBeGreaterThan(0) expect(screen.getAllByText("Config 3").length).toBeGreaterThan(0) - const configNames = Array.from(modelColumn.querySelectorAll(".group")) - .map((row) => row.textContent?.match(/Config \d+/)?.[0]) - .filter(Boolean) + // Verify pinned container contains the pinned configs + if (pinnedStickyHeader) { + const elements = pinnedStickyHeader.querySelectorAll(".flex-shrink-0") + const pinnedConfigTexts = Array.from(elements) + .map((el) => (el as Element).textContent) + .filter((text) => text?.startsWith("Config")) + + expect(pinnedConfigTexts).toContain("Config 1") + expect(pinnedConfigTexts).toContain("Config 2") + expect(pinnedConfigTexts).toContain("Config 3") + } + + // Check for unpinned configs section + const unpinnedSection = scrollContainer?.querySelector('[aria-label="All configurations"]') + expect(unpinnedSection).toBeInTheDocument() - expect(configNames.slice(0, 3)).toEqual(["Config 1", "Config 2", "Config 3"]) + // Verify separator exists as border on pinned section when unpinned configs exist + expect(pinnedStickyHeader).toHaveClass("border-b") }) test("displays all configs in scrollable container when no configs are pinned", () => { const manyConfigs = Array.from({ length: 10 }, (_, i) => ({ id: `config${i + 1}`, name: `Config ${i + 1}`, - apiProvider: "anthropic", modelId: `model-${i + 1}`, })) @@ -525,11 +515,26 @@ describe("ApiConfigSelector", () => { const trigger = screen.getByTestId("dropdown-trigger") fireEvent.click(trigger) - const modelColumn = screen.getByTestId("api-model-column") - expect(modelColumn).toBeInTheDocument() + const popoverContent = screen.getByTestId("popover-content") + + // Should have a single scroll container with max-h-[300px] and overflow-y-auto + const scrollContainer = popoverContent.querySelector(".max-h-\\[300px\\].overflow-y-auto") + expect(scrollContainer).toBeInTheDocument() - // All configs should be in the active provider model column. - const allConfigRows = modelColumn.querySelectorAll(".group") + // No pinned section should exist when no configs are pinned + const pinnedSection = scrollContainer?.querySelector(".sticky.top-0") + expect(pinnedSection).not.toBeInTheDocument() + + // Should have unpinned configs section with all configs + const unpinnedSection = scrollContainer?.querySelector('[aria-label="All configurations"]') + expect(unpinnedSection).toBeInTheDocument() + + // All configs should be in the unpinned section + const allConfigRows = unpinnedSection?.querySelectorAll(".group") expect(allConfigRows?.length).toBe(10) + + // No separator should exist when no pinned configs (no sticky header exists) + const stickyHeader = scrollContainer?.querySelector(".sticky.top-0") + expect(stickyHeader).not.toBeInTheDocument() }) }) From 98dd3ae7ade0491070a0d9ffc9f983d018e2aa49 Mon Sep 17 00:00:00 2001 From: avaritiachaos <1904382932@qq.com> Date: Wed, 29 Apr 2026 17:07:27 +0800 Subject: [PATCH 3/9] Add status bar model switcher --- .changeset/statusbar-model-quickpick.md | 5 + src/activate/index.ts | 1 + src/activate/providerModelStatusBar.ts | 221 ++++++++++++++++++++++++ src/extension.ts | 2 + 4 files changed, 229 insertions(+) create mode 100644 .changeset/statusbar-model-quickpick.md create mode 100644 src/activate/providerModelStatusBar.ts diff --git a/.changeset/statusbar-model-quickpick.md b/.changeset/statusbar-model-quickpick.md new file mode 100644 index 00000000000..5cc89cff72e --- /dev/null +++ b/.changeset/statusbar-model-quickpick.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add a native VS Code status bar model switcher for the active API provider. diff --git a/src/activate/index.ts b/src/activate/index.ts index bb97206a1b9..2e4af4146f4 100644 --- a/src/activate/index.ts +++ b/src/activate/index.ts @@ -3,3 +3,4 @@ export { registerCommands } from "./registerCommands" export { registerCodeActions } from "./registerCodeActions" export { registerTerminalActions } from "./registerTerminalActions" export { CodeActionProvider } from "./CodeActionProvider" +export { initializeProviderModelStatusBar } from "./providerModelStatusBar" diff --git a/src/activate/providerModelStatusBar.ts b/src/activate/providerModelStatusBar.ts new file mode 100644 index 00000000000..8c362561959 --- /dev/null +++ b/src/activate/providerModelStatusBar.ts @@ -0,0 +1,221 @@ +import * as vscode from "vscode" + +import { + MODELS_BY_PROVIDER, + RooCodeEventName, + getModelId, + isProviderName, + modelIdKeysByProvider, + type ModelIdKey, + type ProviderName, + type ProviderSettings, +} from "@roo-code/types" + +import { getModels } from "../api/providers/fetchers/modelCache" +import { buildApiHandler } from "../api" +import { toRouterName, type GetModelsOptions } from "../shared/api" +import type { ClineProvider } from "../core/webview/ClineProvider" + +const COMMAND_ID = "roo-cline.switchModelFromStatusBar" + +type ModelQuickPickItem = vscode.QuickPickItem & { + modelId: string +} + +const providerLabels: Partial> = { + ...Object.fromEntries(Object.entries(MODELS_BY_PROVIDER).map(([id, meta]) => [id, meta.label])), + openai: "OpenAI Compatible", + "gemini-cli": "Gemini CLI", + "fake-ai": "Fake AI", +} + +export function initializeProviderModelStatusBar({ + context, + provider, + outputChannel, +}: { + context: vscode.ExtensionContext + provider: ClineProvider + outputChannel: vscode.OutputChannel +}) { + const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100) + statusBarItem.command = COMMAND_ID + statusBarItem.name = "Roo Code Model" + context.subscriptions.push(statusBarItem) + + const updateStatusBar = async () => { + try { + const { apiConfiguration } = await provider.getState() + const providerName = getProviderName(apiConfiguration) + const modelId = getCurrentModelId(apiConfiguration) + + statusBarItem.text = `$(symbol-misc) ${getProviderLabel(providerName)} | ${modelId || "Select model"}` + statusBarItem.tooltip = "Switch Roo Code model" + statusBarItem.show() + } catch (error) { + outputChannel.appendLine(`Failed to update Roo Code model status bar: ${String(error)}`) + } + } + + context.subscriptions.push( + vscode.commands.registerCommand(COMMAND_ID, async () => { + await showProviderModelQuickPick({ provider, outputChannel, updateStatusBar }) + }), + ) + + const onProviderProfileChanged = () => { + void updateStatusBar() + } + + provider.on(RooCodeEventName.ProviderProfileChanged, onProviderProfileChanged) + context.subscriptions.push({ + dispose: () => provider.off(RooCodeEventName.ProviderProfileChanged, onProviderProfileChanged), + }) + + void updateStatusBar() +} + +async function showProviderModelQuickPick({ + provider, + outputChannel, + updateStatusBar, +}: { + provider: ClineProvider + outputChannel: vscode.OutputChannel + updateStatusBar: () => Promise +}) { + const { apiConfiguration, currentApiConfigName = "default" } = await provider.getState() + const providerName = getProviderName(apiConfiguration) + const models = await getAvailableModelIds(apiConfiguration, outputChannel) + + if (models.length === 0) { + void vscode.window.showInformationMessage(`No models found for ${getProviderLabel(providerName)}.`) + return + } + + const currentModelId = getCurrentModelId(apiConfiguration) + const selected = await vscode.window.showQuickPick( + models.map((modelId) => ({ + label: modelId, + description: modelId === currentModelId ? "Current" : undefined, + modelId, + })), + { + placeHolder: `Select ${getProviderLabel(providerName)} model`, + matchOnDescription: true, + }, + ) + + if (!selected || selected.modelId === currentModelId) { + return + } + + const modelIdKey = getModelIdKey(providerName) + if (!modelIdKey) { + void vscode.window.showWarningMessage(`Model switching is not supported for ${getProviderLabel(providerName)}.`) + return + } + + await provider.upsertProviderProfile(currentApiConfigName, { + ...apiConfiguration, + apiProvider: providerName, + [modelIdKey]: selected.modelId, + }) + + await updateStatusBar() +} + +async function getAvailableModelIds( + apiConfiguration: ProviderSettings, + outputChannel: vscode.OutputChannel, +): Promise { + const providerName = getProviderName(apiConfiguration) + const staticModels = MODELS_BY_PROVIDER[providerName as keyof typeof MODELS_BY_PROVIDER]?.models + + if (staticModels?.length) { + return staticModels + } + + if (providerName === "openai") { + return getCurrentModelId(apiConfiguration) ? [getCurrentModelId(apiConfiguration)] : [] + } + + try { + const models = await getModels({ + provider: toRouterName(providerName), + apiKey: getProviderApiKey(apiConfiguration), + baseUrl: getProviderBaseUrl(apiConfiguration), + } as GetModelsOptions) + + return Object.keys(models) + } catch (error) { + outputChannel.appendLine( + `Failed to load ${providerName} models for status bar picker: ${error instanceof Error ? error.message : String(error)}`, + ) + void vscode.window.showErrorMessage(`Failed to load ${getProviderLabel(providerName)} models.`) + return [] + } +} + +function getProviderName(apiConfiguration: ProviderSettings): ProviderName { + const providerName = apiConfiguration.apiProvider + return providerName && isProviderName(providerName) ? providerName : "anthropic" +} + +function getProviderLabel(providerName: ProviderName): string { + return providerLabels[providerName] ?? providerName +} + +function getCurrentModelId(apiConfiguration: ProviderSettings): string { + try { + return buildApiHandler(apiConfiguration).getModel().id + } catch { + return getModelId(apiConfiguration) ?? "" + } +} + +function getModelIdKey(providerName: ProviderName): ModelIdKey | undefined { + if (providerName === "openai") { + return "openAiModelId" + } + + return modelIdKeysByProvider[providerName as keyof typeof modelIdKeysByProvider] +} + +function getProviderApiKey(apiConfiguration: ProviderSettings): string | undefined { + switch (apiConfiguration.apiProvider) { + case "litellm": + return apiConfiguration.litellmApiKey + case "requesty": + return apiConfiguration.requestyApiKey + case "unbound": + return apiConfiguration.unboundApiKey + case "roo": + return apiConfiguration.rooApiKey + case "poe": + return apiConfiguration.poeApiKey + case "vercel-ai-gateway": + return apiConfiguration.vercelAiGatewayApiKey + default: + return undefined + } +} + +function getProviderBaseUrl(apiConfiguration: ProviderSettings): string | undefined { + switch (apiConfiguration.apiProvider) { + case "litellm": + return apiConfiguration.litellmBaseUrl + case "requesty": + return apiConfiguration.requestyBaseUrl + case "ollama": + return apiConfiguration.ollamaBaseUrl + case "lmstudio": + return apiConfiguration.lmStudioBaseUrl + case "roo": + return process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy" + case "poe": + return apiConfiguration.poeBaseUrl + default: + return undefined + } +} diff --git a/src/extension.ts b/src/extension.ts index 19c0d70585a..482fac27878 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -46,6 +46,7 @@ import { registerCodeActions, registerTerminalActions, CodeActionProvider, + initializeProviderModelStatusBar, } from "./activate" import { initializeI18n } from "./i18n" import { flushModels, initializeModelCacheRefresh, refreshModels } from "./api/providers/fetchers/modelCache" @@ -316,6 +317,7 @@ export async function activate(context: vscode.ExtensionContext) { } registerCommands({ context, outputChannel, provider }) + initializeProviderModelStatusBar({ context, outputChannel, provider }) /** * We use the text document content provider API to show the left side for diff From 875fa8d1a49aad7b8d563839095a2e3aee6c74fa Mon Sep 17 00:00:00 2001 From: avaritiachaos <1904382932@qq.com> Date: Wed, 29 Apr 2026 17:18:47 +0800 Subject: [PATCH 4/9] Fix extension activation test mock --- src/__tests__/extension.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index a13e78c4bde..7aa0d71468f 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -166,6 +166,7 @@ vi.mock("../activate", () => ({ registerCommands: vi.fn(), registerCodeActions: vi.fn(), registerTerminalActions: vi.fn(), + initializeProviderModelStatusBar: vi.fn(), CodeActionProvider: vi.fn().mockImplementation(() => ({ providedCodeActionKinds: [], })), From d6e482ccf92280ee895434a377c9e3ffdd6f220c Mon Sep 17 00:00:00 2001 From: avaritiachaos <1904382932@qq.com> Date: Wed, 29 Apr 2026 18:09:53 +0800 Subject: [PATCH 5/9] Revert "Fix extension activation test mock" This reverts commit 875fa8d1a49aad7b8d563839095a2e3aee6c74fa. --- src/__tests__/extension.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 7aa0d71468f..a13e78c4bde 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -166,7 +166,6 @@ vi.mock("../activate", () => ({ registerCommands: vi.fn(), registerCodeActions: vi.fn(), registerTerminalActions: vi.fn(), - initializeProviderModelStatusBar: vi.fn(), CodeActionProvider: vi.fn().mockImplementation(() => ({ providedCodeActionKinds: [], })), From 9d76c07ac40971438e1c55059b4e3b8f68efb027 Mon Sep 17 00:00:00 2001 From: avaritiachaos <1904382932@qq.com> Date: Wed, 29 Apr 2026 18:09:54 +0800 Subject: [PATCH 6/9] Revert "Add status bar model switcher" This reverts commit 98dd3ae7ade0491070a0d9ffc9f983d018e2aa49. --- .changeset/statusbar-model-quickpick.md | 5 - src/activate/index.ts | 1 - src/activate/providerModelStatusBar.ts | 221 ------------------------ src/extension.ts | 2 - 4 files changed, 229 deletions(-) delete mode 100644 .changeset/statusbar-model-quickpick.md delete mode 100644 src/activate/providerModelStatusBar.ts diff --git a/.changeset/statusbar-model-quickpick.md b/.changeset/statusbar-model-quickpick.md deleted file mode 100644 index 5cc89cff72e..00000000000 --- a/.changeset/statusbar-model-quickpick.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Add a native VS Code status bar model switcher for the active API provider. diff --git a/src/activate/index.ts b/src/activate/index.ts index 2e4af4146f4..bb97206a1b9 100644 --- a/src/activate/index.ts +++ b/src/activate/index.ts @@ -3,4 +3,3 @@ export { registerCommands } from "./registerCommands" export { registerCodeActions } from "./registerCodeActions" export { registerTerminalActions } from "./registerTerminalActions" export { CodeActionProvider } from "./CodeActionProvider" -export { initializeProviderModelStatusBar } from "./providerModelStatusBar" diff --git a/src/activate/providerModelStatusBar.ts b/src/activate/providerModelStatusBar.ts deleted file mode 100644 index 8c362561959..00000000000 --- a/src/activate/providerModelStatusBar.ts +++ /dev/null @@ -1,221 +0,0 @@ -import * as vscode from "vscode" - -import { - MODELS_BY_PROVIDER, - RooCodeEventName, - getModelId, - isProviderName, - modelIdKeysByProvider, - type ModelIdKey, - type ProviderName, - type ProviderSettings, -} from "@roo-code/types" - -import { getModels } from "../api/providers/fetchers/modelCache" -import { buildApiHandler } from "../api" -import { toRouterName, type GetModelsOptions } from "../shared/api" -import type { ClineProvider } from "../core/webview/ClineProvider" - -const COMMAND_ID = "roo-cline.switchModelFromStatusBar" - -type ModelQuickPickItem = vscode.QuickPickItem & { - modelId: string -} - -const providerLabels: Partial> = { - ...Object.fromEntries(Object.entries(MODELS_BY_PROVIDER).map(([id, meta]) => [id, meta.label])), - openai: "OpenAI Compatible", - "gemini-cli": "Gemini CLI", - "fake-ai": "Fake AI", -} - -export function initializeProviderModelStatusBar({ - context, - provider, - outputChannel, -}: { - context: vscode.ExtensionContext - provider: ClineProvider - outputChannel: vscode.OutputChannel -}) { - const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100) - statusBarItem.command = COMMAND_ID - statusBarItem.name = "Roo Code Model" - context.subscriptions.push(statusBarItem) - - const updateStatusBar = async () => { - try { - const { apiConfiguration } = await provider.getState() - const providerName = getProviderName(apiConfiguration) - const modelId = getCurrentModelId(apiConfiguration) - - statusBarItem.text = `$(symbol-misc) ${getProviderLabel(providerName)} | ${modelId || "Select model"}` - statusBarItem.tooltip = "Switch Roo Code model" - statusBarItem.show() - } catch (error) { - outputChannel.appendLine(`Failed to update Roo Code model status bar: ${String(error)}`) - } - } - - context.subscriptions.push( - vscode.commands.registerCommand(COMMAND_ID, async () => { - await showProviderModelQuickPick({ provider, outputChannel, updateStatusBar }) - }), - ) - - const onProviderProfileChanged = () => { - void updateStatusBar() - } - - provider.on(RooCodeEventName.ProviderProfileChanged, onProviderProfileChanged) - context.subscriptions.push({ - dispose: () => provider.off(RooCodeEventName.ProviderProfileChanged, onProviderProfileChanged), - }) - - void updateStatusBar() -} - -async function showProviderModelQuickPick({ - provider, - outputChannel, - updateStatusBar, -}: { - provider: ClineProvider - outputChannel: vscode.OutputChannel - updateStatusBar: () => Promise -}) { - const { apiConfiguration, currentApiConfigName = "default" } = await provider.getState() - const providerName = getProviderName(apiConfiguration) - const models = await getAvailableModelIds(apiConfiguration, outputChannel) - - if (models.length === 0) { - void vscode.window.showInformationMessage(`No models found for ${getProviderLabel(providerName)}.`) - return - } - - const currentModelId = getCurrentModelId(apiConfiguration) - const selected = await vscode.window.showQuickPick( - models.map((modelId) => ({ - label: modelId, - description: modelId === currentModelId ? "Current" : undefined, - modelId, - })), - { - placeHolder: `Select ${getProviderLabel(providerName)} model`, - matchOnDescription: true, - }, - ) - - if (!selected || selected.modelId === currentModelId) { - return - } - - const modelIdKey = getModelIdKey(providerName) - if (!modelIdKey) { - void vscode.window.showWarningMessage(`Model switching is not supported for ${getProviderLabel(providerName)}.`) - return - } - - await provider.upsertProviderProfile(currentApiConfigName, { - ...apiConfiguration, - apiProvider: providerName, - [modelIdKey]: selected.modelId, - }) - - await updateStatusBar() -} - -async function getAvailableModelIds( - apiConfiguration: ProviderSettings, - outputChannel: vscode.OutputChannel, -): Promise { - const providerName = getProviderName(apiConfiguration) - const staticModels = MODELS_BY_PROVIDER[providerName as keyof typeof MODELS_BY_PROVIDER]?.models - - if (staticModels?.length) { - return staticModels - } - - if (providerName === "openai") { - return getCurrentModelId(apiConfiguration) ? [getCurrentModelId(apiConfiguration)] : [] - } - - try { - const models = await getModels({ - provider: toRouterName(providerName), - apiKey: getProviderApiKey(apiConfiguration), - baseUrl: getProviderBaseUrl(apiConfiguration), - } as GetModelsOptions) - - return Object.keys(models) - } catch (error) { - outputChannel.appendLine( - `Failed to load ${providerName} models for status bar picker: ${error instanceof Error ? error.message : String(error)}`, - ) - void vscode.window.showErrorMessage(`Failed to load ${getProviderLabel(providerName)} models.`) - return [] - } -} - -function getProviderName(apiConfiguration: ProviderSettings): ProviderName { - const providerName = apiConfiguration.apiProvider - return providerName && isProviderName(providerName) ? providerName : "anthropic" -} - -function getProviderLabel(providerName: ProviderName): string { - return providerLabels[providerName] ?? providerName -} - -function getCurrentModelId(apiConfiguration: ProviderSettings): string { - try { - return buildApiHandler(apiConfiguration).getModel().id - } catch { - return getModelId(apiConfiguration) ?? "" - } -} - -function getModelIdKey(providerName: ProviderName): ModelIdKey | undefined { - if (providerName === "openai") { - return "openAiModelId" - } - - return modelIdKeysByProvider[providerName as keyof typeof modelIdKeysByProvider] -} - -function getProviderApiKey(apiConfiguration: ProviderSettings): string | undefined { - switch (apiConfiguration.apiProvider) { - case "litellm": - return apiConfiguration.litellmApiKey - case "requesty": - return apiConfiguration.requestyApiKey - case "unbound": - return apiConfiguration.unboundApiKey - case "roo": - return apiConfiguration.rooApiKey - case "poe": - return apiConfiguration.poeApiKey - case "vercel-ai-gateway": - return apiConfiguration.vercelAiGatewayApiKey - default: - return undefined - } -} - -function getProviderBaseUrl(apiConfiguration: ProviderSettings): string | undefined { - switch (apiConfiguration.apiProvider) { - case "litellm": - return apiConfiguration.litellmBaseUrl - case "requesty": - return apiConfiguration.requestyBaseUrl - case "ollama": - return apiConfiguration.ollamaBaseUrl - case "lmstudio": - return apiConfiguration.lmStudioBaseUrl - case "roo": - return process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy" - case "poe": - return apiConfiguration.poeBaseUrl - default: - return undefined - } -} diff --git a/src/extension.ts b/src/extension.ts index 482fac27878..19c0d70585a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -46,7 +46,6 @@ import { registerCodeActions, registerTerminalActions, CodeActionProvider, - initializeProviderModelStatusBar, } from "./activate" import { initializeI18n } from "./i18n" import { flushModels, initializeModelCacheRefresh, refreshModels } from "./api/providers/fetchers/modelCache" @@ -317,7 +316,6 @@ export async function activate(context: vscode.ExtensionContext) { } registerCommands({ context, outputChannel, provider }) - initializeProviderModelStatusBar({ context, outputChannel, provider }) /** * We use the text document content provider API to show the left side for diff From 6e0af1897eb4069bb418bb0cd11e3082494988bc Mon Sep 17 00:00:00 2001 From: avaritiachaos <1904382932@qq.com> Date: Wed, 29 Apr 2026 18:15:46 +0800 Subject: [PATCH 7/9] Show active model in chat footer --- .changeset/webview-current-model-indicator.md | 5 +++ packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/ClineProvider.ts | 5 +++ .../src/components/chat/ChatTextArea.tsx | 31 +++++++++++++++++++ .../chat/__tests__/ChatTextArea.spec.tsx | 21 +++++++++++++ .../src/context/ExtensionStateContext.tsx | 1 + 6 files changed, 64 insertions(+) create mode 100644 .changeset/webview-current-model-indicator.md diff --git a/.changeset/webview-current-model-indicator.md b/.changeset/webview-current-model-indicator.md new file mode 100644 index 00000000000..a93b79d488f --- /dev/null +++ b/.changeset/webview-current-model-indicator.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Show the current model in the Roo Code chat input footer. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b20539afe49..2a0becac22c 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -314,6 +314,7 @@ export type ExtensionState = Pick< currentTaskItem?: HistoryItem currentTaskTodos?: TodoItem[] // Initial todos for the current task apiConfiguration: ProviderSettings + currentModelId?: string uriScheme?: string shouldShowAnnouncement: boolean diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1106d340050..42289af273e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2129,6 +2129,7 @@ export class ClineProvider const { apiConfiguration, + currentModelId, lastShownAnnouncementId, customInstructions, alwaysAllowReadOnly, @@ -2242,6 +2243,7 @@ export class ClineProvider return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, + currentModelId, customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? false, @@ -2395,6 +2397,8 @@ export class ClineProvider providerSettings.apiProvider = apiProvider } + const currentModelId = getModelId(providerSettings) + let organizationAllowList = ORGANIZATION_ALLOW_ALL try { @@ -2471,6 +2475,7 @@ export class ClineProvider // Return the same structure as before. return { apiConfiguration: providerSettings, + currentModelId, lastShownAnnouncementId: stateValues.lastShownAnnouncementId, customInstructions: stateValues.customInstructions, apiModelId: stateValues.apiModelId, diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index e72c1726f35..c370e0915da 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -99,6 +99,7 @@ export const ChatTextArea = forwardRef( cloudUserInfo, enterBehavior, lockApiConfigAcrossModes, + currentModelId, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -110,6 +111,28 @@ export const ChatTextArea = forwardRef( } }, [listApiConfigMeta, currentApiConfigName]) + const currentModelDisplayName = useMemo(() => { + if (!currentModelId) { + return undefined + } + + return currentModelId + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => { + if (part.toLowerCase() === "deepseek") { + return "DeepSeek" + } + + if (/^v\d+$/i.test(part)) { + return part.toUpperCase() + } + + return part.charAt(0).toUpperCase() + part.slice(1) + }) + .join(" ") + }, [currentModelId]) + const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) const [fileSearchResults, setFileSearchResults] = useState([]) @@ -1320,6 +1343,14 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> + {currentModelDisplayName ? ( +
+ {currentModelDisplayName} +
+ ) : null}
{ }) }) + describe("current model indicator", () => { + it("shows the model id from extension state in the footer", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "deepseek", + }, + currentModelId: "deepseek-v4-pro", + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + const indicator = screen.getByTestId("current-model-indicator") + expect(indicator).toHaveTextContent("DeepSeek V4 Pro") + expect(indicator).toHaveAttribute("title", "deepseek-v4-pro") + }) + }) + describe("handleEnhancePrompt", () => { it("should send message with correct configuration when clicked", () => { const apiConfiguration = { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ce7a607d9a8..6cb0a5d71d4 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -192,6 +192,7 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Partial export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [state, setState] = useState({ apiConfiguration: {}, + currentModelId: undefined, version: "", clineMessages: [], taskHistory: [], From af815892872a8a3a230f24e218301b293bf66fc7 Mon Sep 17 00:00:00 2001 From: avaritiachaos <1904382932@qq.com> Date: Wed, 29 Apr 2026 18:43:27 +0800 Subject: [PATCH 8/9] Make chat footer model selector interactive --- .../src/components/chat/ChatTextArea.tsx | 306 ++++++++++++++++-- .../chat/__tests__/ChatTextArea.spec.tsx | 38 +++ 2 files changed, 318 insertions(+), 26 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index c370e0915da..b7e6869eaf2 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -1,9 +1,20 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { useEvent } from "react-use" import DynamicTextArea from "react-textarea-autosize" -import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square } from "lucide-react" +import { VolumeX, Image, WandSparkles, SendHorizontal, X, ListEnd, Square, Check, ChevronsUpDown } from "lucide-react" -import type { ExtensionMessage } from "@roo-code/types" +import { + isDynamicProvider, + isRetiredProvider, + modelIdKeysByProvider, + openAiModelInfoSaneDefaults, + type ExtensionMessage, + type ModelIdKey, + type ModelRecord, + type OrganizationAllowList, + type ProviderName, + type ProviderSettings, +} from "@roo-code/types" import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions" import { WebviewMessage } from "@roo/WebviewMessage" @@ -22,9 +33,25 @@ import { } from "@src/utils/context-mentions" import { cn } from "@src/lib/utils" import { convertToMentionPath } from "@src/utils/path-mentions" -import { StandardTooltip } from "@src/components/ui" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, + StandardTooltip, +} from "@src/components/ui" +import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" +import { useLmStudioModels } from "@src/components/ui/hooks/useLmStudioModels" +import { useOllamaModels } from "@src/components/ui/hooks/useOllamaModels" import Thumbnails from "../common/Thumbnails" +import { MODELS_BY_PROVIDER as STATIC_MODELS_BY_PROVIDER } from "../settings/constants" +import { filterModels } from "../settings/utils/organizationFilters" import { ModeSelector } from "./ModeSelector" import { ApiConfigSelector } from "./ApiConfigSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" @@ -34,6 +61,243 @@ import { IndexingStatusBadge } from "./IndexingStatusBadge" import { usePromptHistory } from "./hooks/usePromptHistory" import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher" +const QUICK_MODEL_ID_KEYS: Partial> = { + openai: "openAiModelId", + openrouter: "openRouterModelId", + requesty: "requestyModelId", + unbound: "unboundModelId", + litellm: "litellmModelId", + "vercel-ai-gateway": "vercelAiGatewayModelId", + ollama: "ollamaModelId", + lmstudio: "lmStudioModelId", + "openai-native": "apiModelId", +} + +interface CurrentModelSelectorProps { + apiConfiguration?: ProviderSettings + currentApiConfigName?: string + currentModelId?: string + currentModelDisplayName?: string + disabled?: boolean + organizationAllowList?: OrganizationAllowList + setApiConfiguration: (config: ProviderSettings) => void +} + +const CurrentModelSelector = ({ + apiConfiguration, + currentApiConfigName, + currentModelId, + currentModelDisplayName, + disabled, + organizationAllowList, + setApiConfiguration, +}: CurrentModelSelectorProps) => { + const { t } = useAppTranslation() + const [open, setOpen] = useState(false) + const [searchValue, setSearchValue] = useState("") + const [openAiModels, setOpenAiModels] = useState(null) + + const provider = apiConfiguration?.apiProvider + const activeProvider: ProviderName | undefined = + provider && !isRetiredProvider(provider) ? (provider as ProviderName) : undefined + const dynamicProvider = activeProvider && isDynamicProvider(activeProvider) ? activeProvider : undefined + + const routerModels = useRouterModels({ provider: dynamicProvider, enabled: !!dynamicProvider }) + const lmStudioModels = useLmStudioModels( + activeProvider === "lmstudio" ? apiConfiguration?.lmStudioModelId : undefined, + ) + const ollamaModels = useOllamaModels(activeProvider === "ollama" ? apiConfiguration?.ollamaModelId : undefined) + + const onMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + + if (message.type === "openAiModels") { + setOpenAiModels( + Object.fromEntries( + (message.openAiModels ?? []).map((modelId) => [modelId, openAiModelInfoSaneDefaults]), + ), + ) + } + }, []) + + useEvent("message", onMessage) + + useEffect(() => { + if (open && activeProvider === "openai") { + vscode.postMessage({ + type: "requestOpenAiModels", + values: { + baseUrl: apiConfiguration?.openAiBaseUrl, + apiKey: apiConfiguration?.openAiApiKey, + customHeaders: apiConfiguration?.openAiHeaders ?? {}, + }, + }) + } + }, [ + activeProvider, + apiConfiguration?.openAiApiKey, + apiConfiguration?.openAiBaseUrl, + apiConfiguration?.openAiHeaders, + open, + ]) + + const models = useMemo(() => { + if (!activeProvider) { + return null + } + + if (activeProvider === "openai") { + return openAiModels + } + + if (activeProvider === "lmstudio") { + return lmStudioModels.data ?? null + } + + if (activeProvider === "ollama") { + return ollamaModels.data ?? null + } + + if (dynamicProvider) { + return routerModels.data?.[dynamicProvider] ?? null + } + + return STATIC_MODELS_BY_PROVIDER[activeProvider] ?? null + }, [activeProvider, dynamicProvider, lmStudioModels.data, ollamaModels.data, openAiModels, routerModels.data]) + + const modelIds = useMemo(() => { + const filteredModels = filterModels(models, activeProvider, organizationAllowList) + + return Object.entries(filteredModels ?? {}) + .filter(([modelId, modelInfo]) => modelId === currentModelId || !modelInfo.deprecated) + .map(([modelId]) => modelId) + .sort((a, b) => a.localeCompare(b)) + }, [activeProvider, currentModelId, models, organizationAllowList]) + + const modelIdKey = useMemo(() => { + if (!activeProvider) { + return undefined + } + + return ( + QUICK_MODEL_ID_KEYS[activeProvider] ?? + modelIdKeysByProvider[activeProvider as keyof typeof modelIdKeysByProvider] + ) + }, [activeProvider]) + + const handleModelSelect = useCallback( + (modelId: string) => { + if (!apiConfiguration || !currentApiConfigName || !modelIdKey) { + return + } + + const updatedConfiguration = { + ...apiConfiguration, + [modelIdKey]: modelId, + } as ProviderSettings + + setOpen(false) + setSearchValue("") + setApiConfiguration(updatedConfiguration) + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: updatedConfiguration, + }) + }, + [apiConfiguration, currentApiConfigName, modelIdKey, setApiConfiguration], + ) + + if (!currentModelDisplayName || !currentModelId) { + return null + } + + const isLoading = + (activeProvider === "openai" && openAiModels === null) || + (!!dynamicProvider && routerModels.isLoading) || + (activeProvider === "lmstudio" && lmStudioModels.isLoading) || + (activeProvider === "ollama" && ollamaModels.isLoading) + + const canSelectModel = !disabled && !!modelIdKey && (modelIds.length > 0 || activeProvider === "openai") + + return ( + + + + + + + + + +
+ {isLoading ? "Loading..." : t("settings:modelPicker.noMatchFound")} +
+
+ + {modelIds.map((modelId) => ( + + + {formatModelDisplayName(modelId)} + + + + ))} + +
+
+
+
+ ) +} + +const formatModelDisplayName = (modelId: string) => { + return modelId + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => { + if (part.toLowerCase() === "deepseek") { + return "DeepSeek" + } + + if (/^v\d+$/i.test(part)) { + return part.toUpperCase() + } + + return part.charAt(0).toUpperCase() + part.slice(1) + }) + .join(" ") +} + interface ChatTextAreaProps { inputValue: string setInputValue: (value: string) => void @@ -99,7 +363,10 @@ export const ChatTextArea = forwardRef( cloudUserInfo, enterBehavior, lockApiConfigAcrossModes, + apiConfiguration, currentModelId, + organizationAllowList, + setApiConfiguration, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -116,21 +383,7 @@ export const ChatTextArea = forwardRef( return undefined } - return currentModelId - .split(/[-_\s]+/) - .filter(Boolean) - .map((part) => { - if (part.toLowerCase() === "deepseek") { - return "DeepSeek" - } - - if (/^v\d+$/i.test(part)) { - return part.toUpperCase() - } - - return part.charAt(0).toUpperCase() + part.slice(1) - }) - .join(" ") + return formatModelDisplayName(currentModelId) }, [currentModelId]) const [gitCommits, setGitCommits] = useState([]) @@ -1343,14 +1596,15 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> - {currentModelDisplayName ? ( -
- {currentModelDisplayName} -
- ) : null} +
{ }, taskHistory: [], cwd: "/test/workspace", + setApiConfiguration: vi.fn(), }) }) @@ -98,8 +99,10 @@ describe("ChatTextArea", () => { apiProvider: "deepseek", }, currentModelId: "deepseek-v4-pro", + currentApiConfigName: "DeepSeek", taskHistory: [], cwd: "/test/workspace", + setApiConfiguration: vi.fn(), }) render() @@ -108,6 +111,41 @@ describe("ChatTextArea", () => { expect(indicator).toHaveTextContent("DeepSeek V4 Pro") expect(indicator).toHaveAttribute("title", "deepseek-v4-pro") }) + + it("switches the current provider model from the footer picker", async () => { + const setApiConfiguration = vi.fn() + const apiConfiguration = { + apiProvider: "deepseek", + apiModelId: "deepseek-chat", + } + + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration, + currentModelId: "deepseek-chat", + currentApiConfigName: "DeepSeek", + taskHistory: [], + cwd: "/test/workspace", + setApiConfiguration, + }) + + render() + + fireEvent.click(screen.getByTestId("current-model-indicator")) + fireEvent.click(await screen.findByTestId("quick-model-option-deepseek-reasoner")) + + const updatedConfiguration = { + apiProvider: "deepseek", + apiModelId: "deepseek-reasoner", + } + expect(setApiConfiguration).toHaveBeenCalledWith(updatedConfiguration) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "DeepSeek", + apiConfiguration: updatedConfiguration, + }) + }) }) describe("handleEnhancePrompt", () => { From 2b67784fc43af07c25bdf9f400f6b33272b9fc55 Mon Sep 17 00:00:00 2001 From: avaritiachaos <1904382932@qq.com> Date: Wed, 29 Apr 2026 22:32:40 +0800 Subject: [PATCH 9/9] Trigger CI rerun