diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index e8497070ef..3791a71175 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/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f440c9f037..ecf0aa5ea5 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -466,6 +466,7 @@ export interface WebviewMessage { | "currentApiConfigName" | "saveApiConfiguration" | "upsertApiConfiguration" + | "updateProfileModel" | "deleteApiConfiguration" | "loadApiConfiguration" | "loadApiConfigurationById" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 07772816b4..f576f1d6c0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -55,9 +55,17 @@ import { DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, + modelIdKeysByProvider, isRetiredProvider, providerIdentifiers, } from "@roo-code/types" + +const RESET_ONLY_KEYS: readonly string[] = [ + "awsCustomArn", + "reasoningEffort", + "modelMaxTokens", + "modelMaxThinkingTokens", +] import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" import { TaskScheduler } from "../task/TaskScheduler" @@ -251,6 +259,7 @@ export class ClineProvider private taskHistoryStoreInitialized = false public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds private providerProfileMutationQueue = Promise.resolve() + private providerProfileMutationAbortController = new AbortController() private historyTaskCreationQueue = Promise.resolve() private runDelegationTransition(parentTaskId: string, fn: () => Promise): Promise { @@ -258,12 +267,23 @@ export class ClineProvider } private enqueueProviderProfileMutation(fn: (signal: AbortSignal) => Promise): Promise { + if (this._disposed) { + return Promise.reject(new Error("ClineProvider is disposed")) + } + const controller = new AbortController() + const onProviderDispose = () => controller.abort() + this.providerProfileMutationAbortController.signal.addEventListener("abort", onProviderDispose, { once: true }) + // Run fn after either outcome so a rejected mutation never poisons the queue. - const run = this.providerProfileMutationQueue.then( - () => fn(controller.signal), - () => fn(controller.signal), - ) + const run = this.providerProfileMutationQueue + .then( + () => fn(controller.signal), + () => fn(controller.signal), + ) + .finally(() => { + this.providerProfileMutationAbortController.signal.removeEventListener("abort", onProviderDispose) + }) const callerResult = this.withProviderProfileMutationTimeout(run, () => { controller.abort() this.log("Provider profile mutation timed out; aborting in-flight mutation") @@ -286,9 +306,8 @@ export class ClineProvider }, ) - // Advance from the timeout-bounded result. Each fn checks its AbortSignal before - // writing state, so advancing the queue on timeout cannot produce stale overwrites. - this.providerProfileMutationQueue = callerResult.then( + // Keep serialization in place until the timed-out mutation and its rollback settle. + this.providerProfileMutationQueue = run.then( () => undefined, () => undefined, ) @@ -840,6 +859,7 @@ export class ClineProvider this._disposed = true this._postStateToWebviewThrottled.cancel() + this.providerProfileMutationAbortController.abort() this.log("Disposing ClineProvider...") // Reject any tasks still waiting for a scheduler permit so they don't @@ -1930,6 +1950,175 @@ export class ClineProvider } } + private getOrganizationAllowListForProfileMutation() { + if (!CloudService.hasInstance()) { + return ORGANIZATION_ALLOW_ALL + } + + try { + const cloudService = CloudService.instance + if (!cloudService.isAuthenticated()) { + return ORGANIZATION_ALLOW_ALL + } + return cloudService.getOrganizationSettings()?.allowList + } catch (error) { + this.log( + `Unable to read organization allow-list for model update: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } + } + + /** + * Applies a model selection to a stored profile. Everything runs inside one queued + * mutation so a selection made against a profile that has since been switched away + * from is dropped instead of saved and reactivated. + */ + async updateProfileModel(name: string, expectedProvider: string, patch: Record): Promise { + if (this._disposed) { + return + } + + try { + await this.enqueueProviderProfileMutation(async (signal) => { + if (signal.aborted || this._disposed) return + + // Mirrors the profile name the webview is shown (see getStateToPostToWebview). + const task = this.getCurrentTask() + const { currentApiConfigName, organizationAllowList: stateOrganizationAllowList } = + await this.getState() + const visibleProfileName = task ? task.taskApiConfigName : currentApiConfigName + + if (visibleProfileName !== name) { + this.log(`Ignoring model update for profile '${name}': active profile is '${visibleProfileName}'`) + return + } + + const { name: _name, id, ...stored } = await this.providerSettingsManager.getProfile({ name }) + + if (signal.aborted || this._disposed) return + + // A profile without an explicit provider is treated as OpenRouter, matching the chat ModelSelector. + const storedProvider = stored.apiProvider ?? providerIdentifiers.openrouter + + if (storedProvider !== expectedProvider) { + this.log( + `Ignoring model update for profile '${name}': provider is '${storedProvider}', expected '${expectedProvider}'`, + ) + return + } + + // Only model selection and its side-effect resets may be patched (see handleModelChangeSideEffects). + const providerModelKey: string | undefined = + storedProvider === providerIdentifiers.openai + ? "openAiModelId" + : modelIdKeysByProvider[storedProvider as keyof typeof modelIdKeysByProvider] + const allowedKeys: ReadonlySet = new Set( + providerModelKey ? [providerModelKey, ...RESET_ONLY_KEYS] : RESET_ONLY_KEYS, + ) + const merged: Record = { ...stored, id, apiProvider: storedProvider } + for (const [key, value] of Object.entries(patch)) { + // The provider is never patchable. + if (key === "apiProvider" || !allowedKeys.has(key)) { + continue + } + if (value !== null && typeof value !== "string" && typeof value !== "number") { + continue + } + if (RESET_ONLY_KEYS.includes(key) && value !== null && !(key === "awsCustomArn" && value === "")) { + continue + } + merged[key] = value === null ? undefined : value + } + + const authoritativeOrganizationAllowList = this.getOrganizationAllowListForProfileMutation() + if (!authoritativeOrganizationAllowList) { + this.log(`Ignoring model update for profile '${name}': organization allow-list is unavailable`) + vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist")) + return + } + if ( + !ProfileValidator.isProfileAllowed(merged as ProviderSettings, authoritativeOrganizationAllowList) + ) { + this.log( + `Ignoring model update for profile '${name}': violates authoritative organization allow-list`, + ) + vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist")) + return + } + if ( + !stateOrganizationAllowList.allowAll && + !ProfileValidator.isProfileAllowed(merged as ProviderSettings, stateOrganizationAllowList) + ) { + this.log(`Ignoring model update for profile '${name}': violates state organization allow-list`) + vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist")) + return + } + + let savedConfig = false + let shouldRollbackContext = false + const originalContextSettings: ProviderSettings = { ...stored, id } as ProviderSettings + try { + await this.providerSettingsManager.saveConfig(name, merged as ProviderSettings) + savedConfig = true + + if (signal.aborted || this._disposed) { + throw new Error("Provider profile mutation aborted") + } + + await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + if (name === currentApiConfigName) { + shouldRollbackContext = true + await this.contextProxy.setProviderSettings(merged as ProviderSettings) + } + + if (signal.aborted || this._disposed) { + throw new Error("Provider profile mutation aborted") + } + } catch (updateError) { + if (savedConfig) { + try { + await this.providerSettingsManager.saveConfig(name, originalContextSettings) + await this.updateGlobalState( + "listApiConfigMeta", + await this.providerSettingsManager.listConfig(), + ) + } catch (rollbackError) { + this.log( + `Failed to rollback profile '${name}' after update failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + if (shouldRollbackContext) { + try { + await this.contextProxy.setProviderSettings(originalContextSettings) + } catch (rollbackError) { + this.log( + `Failed to rollback context settings for '${name}' after update failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + throw updateError + } + + if (signal.aborted || this._disposed) { + return + } + + this.updateTaskApiHandlerIfNeeded(merged as ProviderSettings, { forceRebuild: true }) + await this.persistStickyProviderProfileToCurrentTask(name) + await this.postStateToWebview() + }) + } catch (error) { + this.log(`Error updating profile model: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + vscode.window.showErrorMessage(t("common:errors.save_api_config")) + } + } + async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { const globalSettings = this.contextProxy.getValues() let profileToActivate: string | undefined = globalSettings.currentApiConfigName diff --git a/src/core/webview/__tests__/ClineProvider.updateProfileModel.spec.ts b/src/core/webview/__tests__/ClineProvider.updateProfileModel.spec.ts new file mode 100644 index 0000000000..a772995063 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.updateProfileModel.spec.ts @@ -0,0 +1,684 @@ +// npx vitest run core/webview/__tests__/ClineProvider.updateProfileModel.spec.ts + +import * as vscode from "vscode" +import { makeCompositeDisposable } from "../../../test-utils/vscode" +import { TelemetryService } from "@roo-code/telemetry" +import { CloudService } from "@roo-code/cloud" +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import { ContextProxy } from "../../config/ContextProxy" +import { ClineProvider } from "../ClineProvider" +import { WebviewFocusTracker } from "../WebviewFocusTracker" +import { Task } from "../../task/Task" + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + Disposable: { from: (...subscriptions: vscode.Disposable[]) => makeCompositeDisposable(...subscriptions) }, + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + onDidChangeConfiguration: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +vi.mock("../../../utils/tts", () => ({ + setTtsEnabled: vi.fn(), + setTtsSpeed: vi.fn(), +})) + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn(), +})) + +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(function () { + return { + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + } + }), +})) + +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../../../utils/storage", () => ({ + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"), +})) + +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation(function ( + this: Record, + options: { historyItem?: { id: string }; apiConfiguration?: ProviderSettings } | undefined, + ) { + this.historyItem = options?.historyItem + this.abortTask = vi.fn() + this.handleWebviewAskResponse = vi.fn() + this.clineMessages = [] + this.apiConversationHistory = [] + this.overwriteClineMessages = vi.fn() + this.overwriteApiConversationHistory = vi.fn() + this.taskId = options?.historyItem?.id || "test-task-id" + this.emit = vi.fn() + this.setTaskApiConfigName = vi.fn() + this.updateApiConfiguration = vi.fn().mockImplementation((newConfig: ProviderSettings) => { + this.apiConfiguration = newConfig + }) + this.apiConfiguration = options?.apiConfiguration || { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + } + }), +})) + +vi.mock("../../../i18n", async (importOriginal) => ({ + ...(await importOriginal()), + t: (key: string) => key, +})) + +const { mockCloudInstance } = vi.hoisted(() => ({ + mockCloudInstance: { + isCloudAgent: false, + isAuthenticated: vi.fn().mockReturnValue(false), + getAllowList: vi.fn().mockReturnValue({ allowAll: true }), + getOrganizationSettings: vi.fn().mockReturnValue(undefined), + getUserInfo: vi.fn().mockReturnValue(null), + on: vi.fn(), + off: vi.fn(), + }, +})) + +vi.mock("@roo-code/cloud", () => ({ + getRooCodeApiUrl: vi.fn().mockReturnValue("https://api.roocode.com"), + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return mockCloudInstance + }, + }, +})) + +describe("ClineProvider - updateProfileModel", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + + const manager = () => { + const settingsManager = provider["providerSettingsManager"] + return { + getProfile: vi.mocked(settingsManager.getProfile), + saveConfig: vi.mocked(settingsManager.saveConfig), + activateProfile: vi.mocked(settingsManager.activateProfile), + setModeConfig: vi.mocked(settingsManager.setModeConfig), + listConfig: vi.mocked(settingsManager.listConfig), + } + } + + const mockStoredProfile = (profile: ProviderSettings & { name?: string }) => + manager().getProfile.mockResolvedValue({ name: "test-config", id: "test-id", ...profile }) + + beforeEach(async () => { + vi.clearAllMocks() + mockCloudInstance.isAuthenticated.mockReturnValue(false) + mockCloudInstance.getOrganizationSettings.mockReturnValue(undefined) + mockCloudInstance.getAllowList.mockReturnValue({ allowAll: true }) + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const secrets: Record = {} + mockContext = { + globalState: { + get: vi.fn().mockImplementation((key: string) => { + if (key === "currentApiConfigName") return "test-config" + if (key === "listApiConfigMeta") { + return [ + { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + }, + ] + } + return undefined + }), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + setKeysForSync: vi.fn(), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => secrets[key]), + store: vi.fn().mockImplementation((key: string, value: string) => { + return (secrets[key] = value) + }), + delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), + }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + subscriptions: [], + extension: { packageJSON: { version: "1.0.0" } }, + extensionUri: { fsPath: "/test/extension/path" }, + globalStorageUri: { fsPath: "/test/storage/path" }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + mockWebviewView = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + }, + visible: true, + onDidDispose: vi.fn().mockImplementation((cb) => { + cb() + return { dispose: vi.fn() } + }), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + + provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Test double for providerSettingsManager + Object.defineProperty(provider, "providerSettingsManager", { + value: { + saveConfig: vi.fn().mockResolvedValue("test-id"), + listConfig: vi.fn().mockResolvedValue([ + { + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + modelId: "openai/gpt-4", + }, + ]), + setModeConfig: vi.fn(), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + activateProfile: vi.fn().mockResolvedValue({ + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }), + getProfile: vi.fn().mockResolvedValue({ + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }), + }, + configurable: true, + writable: true, + }) + + await provider.contextProxy.setValue("currentApiConfigName", "test-config") + }) + + it("merges the patch onto the stored profile, clearing null fields", async () => { + mockStoredProfile({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "stored-key", + reasoningEffort: "high", + }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "x/y", + reasoningEffort: null, + }) + + expect(manager().getProfile).toHaveBeenCalledWith({ name: "test-config" }) + expect(manager().saveConfig).toHaveBeenCalledTimes(1) + expect(manager().saveConfig).toHaveBeenCalledWith("test-config", { + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "x/y", + openRouterApiKey: "stored-key", + reasoningEffort: undefined, + }) + }) + + it("rejects the update when the stored provider differs from the expected provider", async () => { + await provider.updateProfileModel("test-config", providerIdentifiers.anthropic, { apiModelId: "x" }) + + expect(manager().saveConfig).not.toHaveBeenCalled() + }) + + it("treats a profile without apiProvider as OpenRouter and normalizes apiProvider when saving", async () => { + mockStoredProfile({}) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "x/y", + }) + + expect(manager().saveConfig).toHaveBeenCalledWith("test-config", { + id: "test-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "x/y", + }) + expect(provider.contextProxy.getValues().apiProvider).toBe(providerIdentifiers.openrouter) + }) + + it("normalizes apiProvider to OpenRouter when rebuilding task handler for provider-less profile", async () => { + const mockTask = new Task({} as unknown as ConstructorParameters[0]) + Object.defineProperty(mockTask, "taskApiConfigName", { value: "test-config" }) + await provider.addClineToStack(mockTask) + mockStoredProfile({}) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "x/y", + }) + + expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "x/y", + }), + ) + }) + + it("ignores non-null values for reset-only keys while updating model selection", async () => { + mockStoredProfile({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + reasoningEffort: "low", + modelMaxTokens: 4096, + modelMaxThinkingTokens: 2048, + }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "x/y", + reasoningEffort: "high", + modelMaxTokens: 8192, + modelMaxThinkingTokens: 4096, + }) + + const saved = manager().saveConfig.mock.calls[0][1] + expect(saved.openRouterModelId).toBe("x/y") + expect(saved.reasoningEffort).toBe("low") + expect(saved.modelMaxTokens).toBe(4096) + expect(saved.modelMaxThinkingTokens).toBe(2048) + }) + + it("ignores non-setting keys, apiProvider, own __proto__ keys and non-primitive values", async () => { + mockStoredProfile({ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "stored-key" }) + const patch = JSON.parse( + '{"openRouterModelId":"x/y","notASetting":"evil","apiProvider":"anthropic","__proto__":{"polluted":true},"openRouterApiKey":{"nested":1}}', + ) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, patch) + + const saved = manager().saveConfig.mock.calls[0][1] + expect(saved).not.toHaveProperty("notASetting") + expect(saved).not.toHaveProperty("polluted") + expect(Object.getPrototypeOf(saved)).toBe(Object.prototype) + expect(saved.apiProvider).toBe(providerIdentifiers.openrouter) + expect(saved.openRouterApiKey).toBe("stored-key") + expect(saved.openRouterModelId).toBe("x/y") + }) + + it("ignores patch keys other than model ids and model-selection resets", async () => { + mockStoredProfile({ apiProvider: providerIdentifiers.openrouter, openRouterBaseUrl: "https://stored" }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "x/y", + openRouterBaseUrl: "https://evil", + reasoningEffort: null, + }) + + const saved = manager().saveConfig.mock.calls[0][1] + expect(saved.openRouterBaseUrl).toBe("https://stored") + expect(saved.openRouterModelId).toBe("x/y") + }) + + it("rejects a model outside the organization allow-list without saving", async () => { + mockStoredProfile({ apiProvider: providerIdentifiers.openrouter, openRouterModelId: "allowed/model" }) + vi.spyOn(provider, "getState").mockResolvedValue({ + ...(await provider.getState()), + organizationAllowList: { + allowAll: false, + providers: { [providerIdentifiers.openrouter]: { allowAll: false, models: ["allowed/model"] } }, + }, + }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "blocked/model", + }) + expect(manager().saveConfig).not.toHaveBeenCalled() + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "allowed/model", + }) + expect(manager().saveConfig).toHaveBeenCalledTimes(1) + }) + + it("rejects a model update when the authenticated organization policy is unavailable or throws", async () => { + mockStoredProfile({ apiProvider: providerIdentifiers.openrouter, openRouterModelId: "allowed/model" }) + mockCloudInstance.isAuthenticated.mockReturnValue(true) + mockCloudInstance.getOrganizationSettings.mockImplementation(() => { + throw new Error("Failed to fetch settings") + }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "blocked/model", + }) + + expect(manager().saveConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.violated_organization_allowlist") + }) + + it("drops an update for a profile that is not the visible profile without reading or saving it", async () => { + await provider.updateProfileModel("other-config", providerIdentifiers.openrouter, { + openRouterModelId: "x/y", + }) + + expect(manager().getProfile).not.toHaveBeenCalled() + expect(manager().saveConfig).not.toHaveBeenCalled() + }) + + it("drops a stale update that was queued behind a profile switch", async () => { + manager().activateProfile.mockResolvedValue({ + name: "other-config", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "other/model", + }) + + // The webview still shows "test-config" when the model is picked, before the switch lands. + const switching = provider.activateProviderProfile({ name: "other-config" }) + const updating = provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "x/y", + }) + await Promise.all([switching, updating]) + + expect(manager().saveConfig).not.toHaveBeenCalled() + expect(provider.contextProxy.getValues().currentApiConfigName).toBe("other-config") + }) + + it("uses the task's sticky profile name as the visible profile when a task is active without modifying global profile or mode config", async () => { + // Test double with minimal options + const mockTask = new Task({} as unknown as ConstructorParameters[0]) + Object.defineProperty(mockTask, "taskApiConfigName", { value: "sticky-config" }) + await provider.addClineToStack(mockTask) + mockStoredProfile({ name: "sticky-config", apiProvider: providerIdentifiers.openrouter }) + + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + + await provider.updateProfileModel("sticky-config", providerIdentifiers.openrouter, { + openRouterModelId: "x/y", + }) + expect(manager().saveConfig).toHaveBeenCalledWith( + "sticky-config", + expect.objectContaining({ openRouterModelId: "x/y" }), + ) + expect(manager().setModeConfig).not.toHaveBeenCalled() + expect(provider.contextProxy.getValues().currentApiConfigName).toBe("test-config") + expect(setProviderSettingsSpy).not.toHaveBeenCalled() + expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ openRouterModelId: "x/y" }), + ) + + manager().saveConfig.mockClear() + // The global profile name is not what the webview is shown while a task is active. + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "x/z", + }) + expect(manager().saveConfig).not.toHaveBeenCalled() + }) + + it("updates contextProxy provider settings when updating the current global profile", async () => { + mockStoredProfile({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }) + + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "openai/gpt-4.5", + }) + + expect(setProviderSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ openRouterModelId: "openai/gpt-4.5" }), + ) + expect(manager().setModeConfig).not.toHaveBeenCalled() + }) + + it("rolls back saved profile if post-save state update fails", async () => { + mockStoredProfile({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "my-key", + }) + + let listConfigCalls = 0 + manager().listConfig.mockImplementation(async () => { + listConfigCalls++ + if (listConfigCalls === 1) { + throw new Error("Global state sync failed") + } + return [] + }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "openai/gpt-4.5", + }) + + // First call saves the patched model, second call rolls back to original stored config + expect(manager().saveConfig).toHaveBeenCalledTimes(2) + expect(manager().saveConfig).toHaveBeenNthCalledWith( + 1, + "test-config", + expect.objectContaining({ openRouterModelId: "openai/gpt-4.5" }), + ) + expect(manager().saveConfig).toHaveBeenNthCalledWith( + 2, + "test-config", + expect.objectContaining({ openRouterModelId: "openai/gpt-4", openRouterApiKey: "my-key" }), + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.save_api_config") + }) + + it("rolls back saved profile if mutation signal is aborted after saveConfig", async () => { + mockStoredProfile({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "my-key", + }) + + const controller = new AbortController() + provider["enqueueProviderProfileMutation"] = vi.fn().mockImplementation(async (fn) => { + return fn(controller.signal) + }) + + manager().saveConfig.mockImplementationOnce(async () => { + controller.abort() + return "test-id" + }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "openai/gpt-4.5", + }) + + expect(manager().saveConfig).toHaveBeenCalledTimes(2) + expect(manager().saveConfig).toHaveBeenNthCalledWith( + 1, + "test-config", + expect.objectContaining({ openRouterModelId: "openai/gpt-4.5" }), + ) + expect(manager().saveConfig).toHaveBeenNthCalledWith( + 2, + "test-config", + expect.objectContaining({ openRouterModelId: "openai/gpt-4", openRouterApiKey: "my-key" }), + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.save_api_config") + }) + + it("enforces organization allow-list when authenticated through CloudService", async () => { + mockStoredProfile({ apiProvider: providerIdentifiers.openrouter, openRouterModelId: "allowed/model" }) + mockCloudInstance.isAuthenticated.mockReturnValue(true) + mockCloudInstance.getOrganizationSettings.mockReturnValue({ + allowList: { + allowAll: false, + providers: { [providerIdentifiers.openrouter]: { allowAll: false, models: ["allowed/model"] } }, + }, + } as unknown as ReturnType) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "blocked/model", + }) + expect(manager().saveConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.violated_organization_allowlist") + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "allowed/model", + }) + expect(manager().saveConfig).toHaveBeenCalledTimes(1) + expect(manager().saveConfig).toHaveBeenCalledWith( + "test-config", + expect.objectContaining({ openRouterModelId: "allowed/model" }), + ) + }) + + it("rolls back saved profile and prevents handler rebuild when mutation signal aborts during context update", async () => { + const mockTask = new Task({} as unknown as ConstructorParameters[0]) + Object.defineProperty(mockTask, "taskApiConfigName", { value: "test-config" }) + await provider.addClineToStack(mockTask) + + mockStoredProfile({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "my-key", + }) + + const controller = new AbortController() + provider["enqueueProviderProfileMutation"] = vi.fn().mockImplementation(async (fn) => { + return fn(controller.signal) + }) + + const originalSetProviderSettings = provider.contextProxy.setProviderSettings.bind(provider.contextProxy) + vi.spyOn(provider.contextProxy, "setProviderSettings").mockImplementationOnce(async (settings) => { + await originalSetProviderSettings(settings) + controller.abort() + }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "openai/gpt-4.5", + }) + + expect(manager().saveConfig).toHaveBeenCalledTimes(2) + expect(manager().saveConfig).toHaveBeenNthCalledWith( + 2, + "test-config", + expect.objectContaining({ openRouterModelId: "openai/gpt-4", openRouterApiKey: "my-key" }), + ) + expect(provider.contextProxy.getValues().openRouterModelId).toBe("openai/gpt-4") + expect(mockTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.save_api_config") + }) + + it("rolls back saved profile and restores context when contextProxy.setProviderSettings fails", async () => { + mockStoredProfile({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "my-key", + }) + + let setSettingsCalls = 0 + const originalSetProviderSettings = provider.contextProxy.setProviderSettings.bind(provider.contextProxy) + vi.spyOn(provider.contextProxy, "setProviderSettings").mockImplementation(async (settings) => { + setSettingsCalls++ + if (setSettingsCalls === 1) { + throw new Error("Context secret storage write failed") + } + return originalSetProviderSettings(settings) + }) + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "openai/gpt-4.5", + }) + + expect(manager().saveConfig).toHaveBeenCalledTimes(2) + expect(manager().saveConfig).toHaveBeenNthCalledWith( + 2, + "test-config", + expect.objectContaining({ openRouterModelId: "openai/gpt-4", openRouterApiKey: "my-key" }), + ) + expect(provider.contextProxy.setProviderSettings).toHaveBeenCalledTimes(2) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.save_api_config") + }) + + it("aborts active and queued profile mutations upon provider disposal", async () => { + mockStoredProfile({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }) + + await provider.dispose() + + await provider.updateProfileModel("test-config", providerIdentifiers.openrouter, { + openRouterModelId: "openai/gpt-4.5", + }) + + expect(manager().saveConfig).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.updateProfileModel.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.updateProfileModel.spec.ts new file mode 100644 index 0000000000..86d5ce34e2 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.updateProfileModel.spec.ts @@ -0,0 +1,41 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.updateProfileModel.spec.ts + +import { providerIdentifiers } from "@roo-code/types" + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +describe("webviewMessageHandler - updateProfileModel", () => { + let mockProvider: { updateProfileModel: ReturnType } + + const send = (values: Record | undefined, text: string = "default") => + webviewMessageHandler(mockProvider as unknown as ClineProvider, { type: "updateProfileModel", text, values }) + + beforeEach(() => { + vi.clearAllMocks() + mockProvider = { updateProfileModel: vi.fn().mockResolvedValue(undefined) } + }) + + it("delegates the profile name, expected provider and patch to the provider", async () => { + const patch = { apiModelId: "claude-3-5-haiku", reasoningEffort: null } + + await send({ expectedProvider: providerIdentifiers.anthropic, patch }) + + expect(mockProvider.updateProfileModel).toHaveBeenCalledTimes(1) + expect(mockProvider.updateProfileModel).toHaveBeenCalledWith("default", providerIdentifiers.anthropic, patch) + }) + + it.each([ + ["missing profile name", { expectedProvider: providerIdentifiers.anthropic, patch: {} }, ""], + ["missing values", undefined, "default"], + ["missing expectedProvider", { patch: { apiModelId: "x" } }, "default"], + ["non-string expectedProvider", { expectedProvider: 1, patch: { apiModelId: "x" } }, "default"], + ["missing patch", { expectedProvider: providerIdentifiers.anthropic }, "default"], + ["null patch", { expectedProvider: providerIdentifiers.anthropic, patch: null }, "default"], + ["non-object patch", { expectedProvider: providerIdentifiers.anthropic, patch: "x" }, "default"], + ])("does nothing for %s", async (_label, values, text) => { + await send(values, text) + + expect(mockProvider.updateProfileModel).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4ba94d454c..5816a3c398 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2283,6 +2283,16 @@ export const webviewMessageHandler = async ( await provider.upsertProviderProfile(message.text, message.apiConfiguration) } break + case "updateProfileModel": { + const expectedProvider: unknown = message.values?.expectedProvider + const patch: unknown = message.values?.patch + if (!message.text || typeof expectedProvider !== "string" || typeof patch !== "object" || patch === null) { + break + } + + await provider.updateProfileModel(message.text, expectedProvider, patch as Record) + break + } case "renameApiConfiguration": if (message.values && message.apiConfiguration) { try { diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index a78f3f0f8e..0d901e43b1 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, type ModelSelection } from "./ModelSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" @@ -92,6 +93,7 @@ export const ChatTextArea = forwardRef( const { filePaths, openedTabs, + apiConfiguration, currentApiConfigName, listApiConfigMeta, customModes, @@ -104,6 +106,7 @@ export const ChatTextArea = forwardRef( commands, enterBehavior, lockApiConfigAcrossModes, + organizationAllowList, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -114,6 +117,7 @@ export const ChatTextArea = forwardRef( displayName: currentApiConfigName || "", // Use the name directly for display. } }, [listApiConfigMeta, currentApiConfigName]) + const hasPersistedApiConfiguration = !!currentApiConfigName const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) @@ -944,6 +948,18 @@ export const ChatTextArea = forwardRef( [setMode], ) + // Common model selector handler + const handleModelChange = useCallback( + (selection: ModelSelection) => { + vscode.postMessage({ + type: "updateProfileModel", + text: currentApiConfigName, + values: { expectedProvider: selection.expectedProvider, patch: selection.patch }, + }) + }, + [currentApiConfigName], + ) + // Helper function to handle API config change const handleApiConfigChange = useCallback((value: string) => { vscode.postMessage({ type: "loadApiConfigurationById", text: value }) @@ -1319,6 +1335,14 @@ 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..b96a10cdf9 --- /dev/null +++ b/webview-ui/src/components/chat/ModelSelector.tsx @@ -0,0 +1,289 @@ +import { useState, useMemo, useCallback } from "react" +import { Fzf } from "fzf" + +import { + type ModelInfo, + type ModelRecord, + type OrganizationAllowList, + type ProviderName, + 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, + handleModelChangeSideEffects, + getStaticModelsForProvider, + isStaticModelProvider, +} from "../settings/utils/providerModelConfig" +import { filterModels } from "../settings/utils/organizationFilters" +import { SEARCH_THRESHOLD } from "./selectorConstants" + +export type ModelSelectionPatch = Record + +export interface ModelSelection { + expectedProvider: ProviderName + patch: ModelSelectionPatch +} + +interface ModelSelectorProps { + apiConfiguration: ProviderSettings + onChange: (selection: ModelSelection) => void + disabled?: boolean + title: string + triggerClassName?: string + organizationAllowList?: OrganizationAllowList +} + +export const ModelSelector = ({ + apiConfiguration, + onChange, + disabled = false, + title, + triggerClassName = "", + organizationAllowList, +}: ModelSelectorProps) => { + const { t } = useAppTranslation() + const [open, setOpen] = useState(false) + const [searchValue, setSearchValue] = useState("") + const portalContainer = useRooPortal("roo-portal") + + const rawProvider = apiConfiguration?.apiProvider || providerIdentifiers.openrouter + const retired = isRetiredProvider(rawProvider) + const provider = retired ? providerIdentifiers.openrouter : rawProvider + const dynamicProvider = !retired && isDynamicProvider(provider) ? provider : undefined + const modelConfig = retired ? undefined : getProviderModelConfig(provider, apiConfiguration) + + const routerModels = useRouterModels({ provider: dynamicProvider, enabled: !!dynamicProvider }) + const { id: selectedModelId, info: selectedModelInfo, isLoading } = useSelectedModel(apiConfiguration) + + const models: ModelRecord = useMemo(() => { + if (!modelConfig) { + return {} + } + + const staticModels = isStaticModelProvider(provider) + ? getStaticModelsForProvider(provider, undefined, apiConfiguration) + : {} + const { "custom-arn": _customArn, ...modelsWithoutCustomArn } = staticModels + + const allowedModels = dynamicProvider + ? filterModels( + { ...modelsWithoutCustomArn, ...(routerModels.data?.[dynamicProvider] ?? {}) }, + provider, + organizationAllowList, + ) + : filterModels(modelsWithoutCustomArn, provider, organizationAllowList) + + // Exclude deprecated models from being newly selectable, but keep the currently + // selected model visible even if it's deprecated (mirrors ModelPicker.tsx). + return Object.fromEntries( + Object.entries(allowedModels ?? {}).filter( + ([modelId, modelInfo]) => modelId === selectedModelId || !modelInfo.deprecated, + ), + ) + }, [ + modelConfig, + dynamicProvider, + routerModels.data, + provider, + apiConfiguration, + organizationAllowList, + selectedModelId, + ]) + + const modelIds = useMemo(() => Object.keys(models), [models]) + + const isModelListLoading = !!dynamicProvider && routerModels.isLoading + const isSupported = !!modelConfig && (isModelListLoading || modelIds.length > 0) + const isDisabled = disabled || modelIds.length === 0 + + // Label shown for a model — prefers `ModelInfo.displayName` when present, falling back to + // the raw model id (mirrors ModelPicker.tsx's trigger/list label logic). + const getModelLabel = useCallback((modelId: string, info?: ModelInfo) => info?.displayName ?? modelId, []) + + const selectedModelLabel = getModelLabel(selectedModelId, selectedModelInfo) + + // Create searchable items for fuzzy search. + const searchableItems = useMemo( + () => + modelIds.map((id) => { + const label = getModelLabel(id, models[id]) + return { original: id, searchStr: label === id ? id : `${label} ${id}` } + }), + [modelIds, models, getModelLabel], + ) + + const fzfInstance = useMemo( + () => new Fzf(searchableItems, { selector: (item) => item.searchStr }), + [searchableItems], + ) + + const filteredModelIds = useMemo(() => { + if (!searchValue) { + return modelIds + } + + return fzfInstance.find(searchValue).map((result) => result.item.original) + }, [modelIds, searchValue, fzfInstance]) + + const handleOpenChange = useCallback((next: boolean) => { + setOpen(next) + if (!next) { + setSearchValue("") + } + }, []) + + const handleEditClick = useCallback(() => { + vscode.postMessage({ type: "switchTab", tab: "settings" }) + setOpen(false) + }, []) + + const handleSelect = useCallback( + (modelId: string) => { + if (!modelConfig) { + return + } + + const patch: ModelSelectionPatch = {} + const setField = (field: K, value: ProviderSettings[K]) => { + patch[field] = typeof value === "string" || typeof value === "number" ? value : null + } + handleModelChangeSideEffects(provider, modelId, setField) + patch[modelConfig.field] = modelId + + onChange({ expectedProvider: provider, patch }) + + setOpen(false) + setSearchValue("") + }, + [modelConfig, provider, onChange], + ) + + const renderModelItem = useCallback( + (modelId: string) => { + const isCurrentModel = modelId === selectedModelId + const label = getModelLabel(modelId, models[modelId]) + + return ( + + ) + }, + [selectedModelId, models, getModelLabel, handleSelect, isDisabled], + ) + + if (!isSupported) { + return ( + + + + ) + } + + return ( + + + + + {isLoading || isModelListLoading ? 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" + autoFocus + /> + {searchValue.length > 0 && ( +
+
+ )} +
+ )} + + {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 5cced6a92f..5ce0df4bf5 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", { @@ -71,6 +83,7 @@ describe("ChatTextArea", () => { apiConfiguration: { apiProvider: providerIdentifiers.anthropic, }, + currentApiConfigName: "default", taskHistory: [], cwd: "/test/workspace", }) @@ -1239,6 +1252,113 @@ describe("ChatTextArea", () => { }) }) + 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", + ) + }) + + it("disables the model selector trigger when selectApiConfigDisabled is true", () => { + render() + + expect(screen.getByTestId("model-selector-trigger")).toBeDisabled() + }) + + it("enables the model selector trigger when selectApiConfigDisabled is false", () => { + render() + + expect(screen.getByTestId("model-selector-trigger")).not.toBeDisabled() + }) + + it("disables model selection without a persisted API configuration", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + currentTaskId: "task-local-context", + currentApiConfigName: undefined, + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + const trigger = screen.getByTestId("model-selector-trigger") + expect(trigger).toBeDisabled() + expect(trigger.closest("[data-tooltip-content]")).toHaveAttribute( + "data-tooltip-content", + "chat:selectApiConfig", + ) + + mockPostMessage.mockClear() + fireEvent.click(trigger) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + it("posts only the model patch for the current profile and never a full apiConfiguration", () => { + render() + + mockPostMessage.mockClear() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + fireEvent.click(screen.getAllByText(/claude-3-5-haiku/i)[0]) + + expect(mockPostMessage).toHaveBeenCalledTimes(1) + const message = mockPostMessage.mock.calls[0][0] + expect(message).toEqual({ + type: "updateProfileModel", + text: "default", + values: { + expectedProvider: providerIdentifiers.anthropic, + patch: { + apiModelId: expect.stringContaining("claude-3-5-haiku"), + reasoningEffort: null, + modelMaxTokens: null, + modelMaxThinkingTokens: null, + }, + }, + }) + expect(message).not.toHaveProperty("apiConfiguration") + }) + + it("updates the target profile when currentApiConfigName changes", () => { + const { rerender } = render() + + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + currentTaskId: "task-local-context", + currentApiConfigName: "updated-profile", + taskHistory: [], + cwd: "/test/workspace", + }) + + rerender() + + mockPostMessage.mockClear() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + fireEvent.click(screen.getAllByText(/claude-3-5-haiku/i)[0]) + + expect(mockPostMessage).toHaveBeenCalledTimes(1) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateProfileModel", + text: "updated-profile", + }), + ) + }) + }) + describe("blank suggestion copy crash (issue #1226)", () => { const getSendButton = (container: HTMLElement) => { const buttons = container.querySelectorAll("button") 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..5abdff19d8 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ModelSelector.spec.tsx @@ -0,0 +1,1305 @@ +import type { ComponentProps } from "react" +import userEvent from "@testing-library/user-event" +import type { Popover, PopoverTrigger, PopoverContent, StandardTooltip } from "@/components/ui" +import { + providerIdentifiers, + retiredProviderIdentifiers, + type ModelInfo, + type RouterModels, + type ProviderSettings, + type OrganizationAllowList, +} 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, popoverContentContainer } = vi.hoisted(() => ({ + useRooPortalMock: vi.fn(() => document.body), + popoverContentContainer: { current: undefined as Element | DocumentFragment | null | undefined }, +})) + +vi.mock("@/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: useRooPortalMock, +})) + +const { useRouterModelsMock, useSelectedModelMock } = vi.hoisted(() => ({ + useRouterModelsMock: vi.fn((): { data: Partial | undefined; isLoading: boolean } => ({ + data: {}, + 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", async () => { + const { createContext, useContext } = await import("react") + const PopoverContext = createContext, "open" | "onOpenChange">>({}) + return { + Popover: ({ children, open, onOpenChange }: ComponentProps) => ( + +
+ {children} +
+
+ ), + PopoverTrigger: ({ children, ...props }: ComponentProps) => { + const { open, onOpenChange } = useContext(PopoverContext) + return ( + + ) + }, + PopoverContent: ({ children, container }: ComponentProps) => { + const { open } = useContext(PopoverContext) + popoverContentContainer.current = container + return open ?
{children}
: null + }, + StandardTooltip: ({ children, content }: ComponentProps) => ( +
{children}
+ ), + } +}) + +const makeModelInfo = (overrides: Partial = {}): ModelInfo => ({ + contextWindow: 128000, + supportsPromptCache: false, + ...overrides, +}) + +const manyDynamicModels = Object.fromEntries( + Array.from({ length: 8 }, (_, index) => [`openrouter/model-${index}`, makeModelInfo()]), +) + +describe("ModelSelector", () => { + const onChangeMock = vi.fn() + const focusDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "focus") + beforeAll(() => { + // Global setup replaces focus with a no-op for FAST; keyboard tests need native DOM focus. + const frame = document.createElement("iframe") + document.body.appendChild(frame) + const nativeFocus = frame.contentDocument?.createElement("button").focus + if (!nativeFocus) throw new Error("Could not initialize native DOM focus") + Object.defineProperty(HTMLElement.prototype, "focus", { + configurable: true, + writable: true, + value: nativeFocus, + }) + frame.remove() + }) + afterAll(() => { + if (focusDescriptor) Object.defineProperty(HTMLElement.prototype, "focus", focusDescriptor) + }) + + beforeEach(() => { + vi.clearAllMocks() + useRouterModelsMock.mockReturnValue({ data: {}, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "claude-sonnet-4-5", isLoading: false }) + }) + + it.each([true, false])("distinguishes a pending dynamic list from a settled list (has models: %s)", (hasModels) => { + useRouterModelsMock.mockReturnValue({ data: undefined, isLoading: true }) + const props = { + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + title: "Select model", + onChange: onChangeMock, + } + const { rerender } = render() + expect(screen.getByTestId("model-selector-trigger")).toHaveTextContent("common:ui.loading") + expect(screen.getByTestId("model-selector-trigger")).toBeDisabled() + expect(screen.queryByTestId("model-selector-disabled")).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + expect(onChangeMock).not.toHaveBeenCalled() + + useRouterModelsMock.mockReturnValue({ + data: { openrouter: hasModels ? { "model-a": makeModelInfo() } : {} }, + isLoading: false, + }) + rerender() + if (hasModels) { + expect(screen.getByTestId("model-selector-trigger")).not.toBeDisabled() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + expect(screen.getByRole("button", { name: "model-a" })).toBeInTheDocument() + } else { + expect(screen.getByTestId("model-selector-disabled")).toBeInTheDocument() + } + }) + + it("supports keyboard search clearing and model selection", async () => { + const user = userEvent.setup() + useRouterModelsMock.mockReturnValue({ data: { openrouter: manyDynamicModels }, isLoading: false }) + render( + , + ) + await user.click(screen.getByTestId("model-selector-trigger")) + const search = screen.getByRole("textbox") + await user.type(search, "model-3") + await user.tab() + expect(screen.getByRole("button", { name: "common:ui.clear_search" })).toHaveFocus() + await user.keyboard("{Enter}") + expect(search).toHaveValue("") + expect(screen.queryByRole("button", { name: "common:ui.clear_search" })).not.toBeInTheDocument() + search.focus() + await user.tab() + expect(screen.getByRole("button", { name: "openrouter/model-0" })).toHaveFocus() + await user.keyboard(" ") + expect(screen.queryByTestId("popover-content")).not.toBeInTheDocument() + expect(onChangeMock).toHaveBeenCalledWith({ + expectedProvider: providerIdentifiers.openrouter, + patch: { + openRouterModelId: "openrouter/model-0", + reasoningEffort: null, + modelMaxTokens: null, + modelMaxThinkingTokens: null, + }, + }) + }) + + it("blocks model updates when disabled after the model list is rendered", async () => { + const user = userEvent.setup() + useRouterModelsMock.mockReturnValue({ data: { openrouter: { "model-a": makeModelInfo() } }, isLoading: false }) + const props = { + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + title: "Select model", + onChange: onChangeMock, + } + const { rerender } = render() + await user.click(screen.getByTestId("model-selector-trigger")) + const model = screen.getByRole("button", { name: "model-a" }) + model.focus() + rerender() + expect(model).toBeDisabled() + await user.keyboard("{Enter}") + await user.click(model) + expect(onChangeMock).not.toHaveBeenCalled() + }) + + it("renders the static model list for a static provider and calls onChange on select", () => { + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(screen.getByTestId("model-selector-trigger")).not.toBeDisabled() + + const anotherModel = screen.getAllByText(/claude-3-5-haiku/i)[0] + fireEvent.click(anotherModel) + + expect(onChangeMock).toHaveBeenCalledTimes(1) + const [selection] = onChangeMock.mock.calls[0] + expect(selection).toEqual({ + expectedProvider: providerIdentifiers.anthropic, + patch: { + apiModelId: expect.stringContaining("claude-3-5-haiku"), + reasoningEffort: null, + modelMaxTokens: null, + modelMaxThinkingTokens: null, + }, + }) + expect(selection.patch).not.toHaveProperty("apiProvider") + }) + + it("resets reasoning/thinking-token overrides and closes the popover after selecting a model", () => { + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + fireEvent.click(screen.getAllByText(/claude-3-5-haiku/i)[0]) + + expect(onChangeMock).toHaveBeenCalledWith({ + expectedProvider: providerIdentifiers.anthropic, + patch: { + apiModelId: expect.stringContaining("claude-3-5-haiku"), + reasoningEffort: null, + modelMaxTokens: null, + modelMaxThinkingTokens: null, + }, + }) + expect(screen.getByTestId("popover-root")).toHaveAttribute("data-open", "false") + expect(screen.queryByTestId("popover-content")).not.toBeInTheDocument() + }) + + it("prefers a model's displayName over its raw id when present", () => { + useRouterModelsMock.mockReturnValue({ + data: { + openrouter: { + "openrouter/model-a": makeModelInfo({ displayName: "Model A (friendly)" }), + "openrouter/model-b": makeModelInfo(), + }, + }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ + id: "openrouter/model-a", + info: { displayName: "Model A (friendly)" }, + isLoading: false, + }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + // 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": makeModelInfo(), "openrouter/model-b": makeModelInfo() } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(screen.getByText("openrouter/model-b")).toBeInTheDocument() + const list = within(screen.getByTestId("popover-content")) + expect(list.getByRole("button", { name: "openrouter/model-a" })).toHaveAttribute("aria-pressed", "true") + expect(list.getByRole("button", { name: "openrouter/model-b" })).toHaveAttribute("aria-pressed", "false") + + fireEvent.click(screen.getByText("openrouter/model-b")) + + expect(onChangeMock).toHaveBeenCalledWith({ + expectedProvider: providerIdentifiers.openrouter, + patch: { + openRouterModelId: "openrouter/model-b", + reasoningEffort: null, + modelMaxTokens: null, + modelMaxThinkingTokens: null, + }, + }) + }) + + it.each([ + { provider: providerIdentifiers.deepseek, staticModel: "deepseek-v4-flash" }, + { provider: providerIdentifiers.moonshot, staticModel: "kimi-k3" }, + ])("merges the static catalog with router models for $provider", ({ provider, staticModel }) => { + useRouterModelsMock.mockReturnValue({ + data: { [provider]: { "router-only-model": makeModelInfo() } }, + isLoading: false, + }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const list = within(screen.getByTestId("popover-content")) + expect(list.getByText(staticModel)).toBeInTheDocument() + expect(list.getByText("router-only-model")).toBeInTheDocument() + }) + + it.each([ + { provider: providerIdentifiers.deepseek, staticModel: "deepseek-v4-flash" }, + { provider: providerIdentifiers.moonshot, staticModel: "kimi-k3" }, + ])( + "falls back to the static catalog when router models are unavailable for $provider", + ({ provider, staticModel }) => { + useRouterModelsMock.mockReturnValue({ data: undefined, isLoading: false }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(within(screen.getByTestId("popover-content")).getByText(staticModel)).toBeInTheDocument() + }, + ) + + 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("falls back to the openrouter dynamic provider when apiProvider is unset", () => { + render( + , + ) + + expect(useRouterModelsMock).toHaveBeenCalledWith({ + provider: providerIdentifiers.openrouter, + enabled: true, + }) + // No router models are loaded/mocked here, so the empty dynamic model list renders the + // unsupported/disabled fallback rather than an interactive trigger. + expect(screen.getByTestId("model-selector-disabled")).toBeInTheDocument() + }) + + it("mounts the popover content into the roo portal container", () => { + render( + , + ) + + expect(useRooPortalMock).toHaveBeenCalledWith("roo-portal") + expect(popoverContentContainer.current).toBe(useRooPortalMock.mock.results[0]?.value) + expect(popoverContentContainer.current).toBe(document.body) + }) + + it("shows the disabled view with a raw provider 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 no selected model label, + // it falls back to the actual configured (retired) provider rather than openrouter. + expect(useRouterModelsMock).toHaveBeenCalledWith({ provider: undefined, enabled: false }) + expect(screen.getByTestId("model-selector-disabled")).toHaveTextContent(retiredProviderIdentifiers.groq) + }) + + it("preserves the selected model label for a retired provider when one is available", () => { + useSelectedModelMock.mockReturnValue({ + id: "llama-3-groq", + info: { displayName: "Llama 3 (Groq)" }, + isLoading: false, + }) + + render( + , + ) + + expect(screen.getByTestId("model-selector-disabled")).toHaveTextContent("Llama 3 (Groq)") + expect(screen.getByTestId("model-selector-disabled")).not.toHaveTextContent(retiredProviderIdentifiers.groq) + }) + + 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") + expect(screen.queryByTestId("popover-content")).not.toBeInTheDocument() + }) + + 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": makeModelInfo(), "openrouter/model-b": makeModelInfo() } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + 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": makeModelInfo(), "openrouter/model-b": makeModelInfo() } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + 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}`, makeModelInfo()]), + ) + useRouterModelsMock.mockReturnValue({ data: { openrouter: atThreshold }, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-0", isLoading: false }) + + const { rerender } = render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + expect(screen.queryByLabelText("common:ui.search_placeholder")).not.toBeInTheDocument() + + const aboveThreshold = { ...atThreshold, "openrouter/model-6": makeModelInfo() } + 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( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + 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( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + 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(screen.queryByTestId("popover-content")).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId("model-selector-trigger")) + expect(screen.getByLabelText("common:ui.search_placeholder")).toHaveValue("") + }) + + it("clears the search value when the popover closes without a selection", () => { + useRouterModelsMock.mockReturnValue({ data: { openrouter: manyDynamicModels }, isLoading: false }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-0", isLoading: false }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const searchInput = screen.getByLabelText("common:ui.search_placeholder") + fireEvent.change(searchInput, { target: { value: "model-3" } }) + expect(searchInput).toHaveValue("model-3") + + // Close the popover via the trigger without selecting a model. + fireEvent.click(screen.getByTestId("model-selector-trigger")) + expect(screen.queryByTestId("popover-content")).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId("model-selector-trigger")) + expect(screen.getByLabelText("common:ui.search_placeholder")).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", makeModelInfo({ displayName: "Zebra Special" })], + ...Array.from({ length: 7 }, (_, index) => [`openrouter/model-${index}`, makeModelInfo()]), + ]), + }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + 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( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + // 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.click(screen.getByTestId("model-selector-trigger")) + + 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( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + 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( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + 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": makeModelInfo(), ...manyDynamicModels } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + const { rerender } = render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + 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": makeModelInfo(), ...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": makeModelInfo(), "openrouter/model-b": makeModelInfo() } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + const { rerender } = render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + 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(onChangeMock).toHaveBeenCalledWith({ + expectedProvider: providerIdentifiers.openrouter, + patch: { + openRouterModelId: "openrouter/model-a", + reasoningEffort: null, + modelMaxTokens: null, + modelMaxThinkingTokens: null, + }, + }) + }) + + it("hides the custom-arn pseudo-model for a Bedrock provider and allows selecting a normal model", () => { + useSelectedModelMock.mockReturnValue({ id: "anthropic.claude-sonnet-4-5-20250929-v1:0", isLoading: false }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const list = within(screen.getByTestId("popover-content")) + + // A normal Bedrock model is present in the list. + expect(list.getByText("anthropic.claude-3-5-haiku-20241022-v1:0")).toBeInTheDocument() + + // The custom-arn pseudo-model is hidden from the chat selector. + expect(list.queryByText("Use Custom ARN")).not.toBeInTheDocument() + expect(list.queryByText("custom-arn")).not.toBeInTheDocument() + + // Selecting a normal Bedrock model sends the expected apiModelId update and clears any + // stale awsCustomArn left over from a previous "Use Custom ARN" selection in Settings, + // so the extension doesn't keep reading the old ARN instead of the newly chosen model. + fireEvent.click(list.getByText("anthropic.claude-3-5-haiku-20241022-v1:0")) + + expect(onChangeMock).toHaveBeenCalledWith({ + expectedProvider: providerIdentifiers.bedrock, + patch: { + apiModelId: "anthropic.claude-3-5-haiku-20241022-v1:0", + awsCustomArn: "", + reasoningEffort: null, + modelMaxTokens: null, + modelMaxThinkingTokens: null, + }, + }) + }) + + it("filters static models by the organization allow list when not all models are allowed", () => { + useSelectedModelMock.mockReturnValue({ id: "claude-3-5-haiku-20241022", isLoading: false }) + + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { + anthropic: { + allowAll: false, + models: ["claude-3-5-haiku-20241022"], + }, + }, + } + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const list = within(screen.getByTestId("popover-content")) + + // Only the allowed model is present. + expect(list.getByText("claude-3-5-haiku-20241022")).toBeInTheDocument() + + // Other models are filtered out by the allow list. + expect(list.queryByText("claude-sonnet-4-5")).not.toBeInTheDocument() + }) + + it("shows the disabled view when the provider is entirely absent from the organization allow list", () => { + useSelectedModelMock.mockReturnValue({ id: "claude-3-5-haiku-20241022", isLoading: false }) + + // allowAll is false and `providers` has no entry at all for the current provider, so + // filterModels returns {} for it rather than an allowed subset. + const allowList: OrganizationAllowList = { + allowAll: false, + providers: {}, + } + + render( + , + ) + + expect(screen.queryByTestId("model-selector-trigger")).not.toBeInTheDocument() + expect(screen.getByTestId("model-selector-disabled")).toBeInTheDocument() + }) + + it("filters dynamic router models by the organization allow list", () => { + useRouterModelsMock.mockReturnValue({ + data: { openrouter: { "openrouter/model-a": makeModelInfo(), "openrouter/model-b": makeModelInfo() } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { + openrouter: { + allowAll: false, + models: ["openrouter/model-a"], + }, + }, + } + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const list = within(screen.getByTestId("popover-content")) + + // Only the allowed model is present. + expect(list.getByText("openrouter/model-a")).toBeInTheDocument() + + // The blocked model is filtered out. + expect(list.queryByText("openrouter/model-b")).not.toBeInTheDocument() + }) + + it("hides deprecated models but keeps a deprecated selected model visible", () => { + useRouterModelsMock.mockReturnValue({ + data: { + openrouter: { + "openrouter/model-a": makeModelInfo({ deprecated: true }), + "openrouter/model-b": makeModelInfo(), + }, + }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const list = within(screen.getByTestId("popover-content")) + + // The currently selected model stays visible even though it's deprecated. + expect(list.getByText("openrouter/model-a")).toBeInTheDocument() + expect(list.getByText("openrouter/model-b")).toBeInTheDocument() + }) + + it("excludes a non-selected deprecated model from the list entirely", () => { + useRouterModelsMock.mockReturnValue({ + data: { + openrouter: { + "openrouter/model-a": makeModelInfo(), + "openrouter/model-deprecated": makeModelInfo({ deprecated: true }), + }, + }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const list = within(screen.getByTestId("popover-content")) + + expect(list.getByText("openrouter/model-a")).toBeInTheDocument() + expect(list.queryByText("openrouter/model-deprecated")).not.toBeInTheDocument() + }) + + it("does not filter models when the organization allow list allows all", () => { + useRouterModelsMock.mockReturnValue({ + data: { openrouter: { "openrouter/model-a": makeModelInfo(), "openrouter/model-b": makeModelInfo() } }, + isLoading: false, + }) + useSelectedModelMock.mockReturnValue({ id: "openrouter/model-a", isLoading: false }) + + const allowList: OrganizationAllowList = { + allowAll: true, + providers: {}, + } + + render( + , + ) + fireEvent.click(screen.getByTestId("model-selector-trigger")) + + const list = within(screen.getByTestId("popover-content")) + + // All models are present when allowAll is true. + expect(list.getByText("openrouter/model-a")).toBeInTheDocument() + expect(list.getByText("openrouter/model-b")).toBeInTheDocument() + }) +}) 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/components/chat/selectorConstants.ts b/webview-ui/src/components/chat/selectorConstants.ts new file mode 100644 index 0000000000..af82a9426c --- /dev/null +++ b/webview-ui/src/components/chat/selectorConstants.ts @@ -0,0 +1 @@ +export const SEARCH_THRESHOLD = 6 diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts new file mode 100644 index 0000000000..d2a4d0d5bf --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useRouterModels.spec.ts @@ -0,0 +1,311 @@ +import React from "react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderHook, waitFor } from "@testing-library/react" +import { + allRouterModelsProvider, + providerIdentifiers, + RouterModelsMessageType, + type RouterModels, +} from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" +import { fetchRouterModels, useRouterModels } from "../useRouterModels" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +const mockRouterModels = { + [providerIdentifiers.openrouter]: { + "anthropic/claude-3.5-sonnet": { + maxTokens: 8192, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + }, + }, +} as unknown as RouterModels + +describe("fetchRouterModels", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + it("posts requestRouterModels with provider when specified", async () => { + const promise = fetchRouterModels(providerIdentifiers.openrouter) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.openrouter }, + }) + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.openrouter }, + routerModels: mockRouterModels, + }, + }), + ) + + const result = await promise + expect(result).toEqual(mockRouterModels) + }) + + it("posts requestRouterModels without provider when omitted", async () => { + const promise = fetchRouterModels() + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + }) + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: undefined }, + routerModels: mockRouterModels, + }, + }), + ) + + const result = await promise + expect(result).toEqual(mockRouterModels) + }) + + it("ignores mismatched provider messages and resolves when matching message arrives", async () => { + const promise = fetchRouterModels(providerIdentifiers.openrouter) + + // Send message with different provider - should be ignored + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.ollama }, + routerModels: {}, + }, + }), + ) + + // Send matching message + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.openrouter }, + routerModels: mockRouterModels, + }, + }), + ) + + const result = await promise + expect(result).toEqual(mockRouterModels) + }) + + it("ignores unrelated messages", async () => { + const promise = fetchRouterModels(providerIdentifiers.openrouter) + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "unrelatedMessageType", + }, + }), + ) + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.openrouter }, + routerModels: mockRouterModels, + }, + }), + ) + + const result = await promise + expect(result).toEqual(mockRouterModels) + }) + + it("rejects when routerModels field is missing in matching response", async () => { + const promise = fetchRouterModels(providerIdentifiers.openrouter) + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.openrouter }, + }, + }), + ) + + await expect(promise).rejects.toThrow("No router models in response") + }) + + it("rejects immediately if signal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + + await expect(fetchRouterModels(providerIdentifiers.openrouter, controller.signal)).rejects.toThrow( + "Router models request aborted", + ) + + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + it("rejects and cleans up when signal aborts while waiting", async () => { + const controller = new AbortController() + const addWindowListenerSpy = vi.spyOn(window, "addEventListener") + const removeWindowListenerSpy = vi.spyOn(window, "removeEventListener") + const addSignalListenerSpy = vi.spyOn(controller.signal, "addEventListener") + const removeSignalListenerSpy = vi.spyOn(controller.signal, "removeEventListener") + + const promise = fetchRouterModels(providerIdentifiers.openrouter, controller.signal) + const addedMessageHandler = addWindowListenerSpy.mock.calls.find(([type]) => type === "message")?.[1] + const addedAbortHandler = addSignalListenerSpy.mock.calls.find(([type]) => type === "abort")?.[1] + + controller.abort() + + await expect(promise).rejects.toThrow("Router models request aborted") + expect(typeof addedMessageHandler).toBe("function") + expect(removeWindowListenerSpy).toHaveBeenCalledWith("message", addedMessageHandler) + expect(typeof addedAbortHandler).toBe("function") + expect(removeSignalListenerSpy).toHaveBeenCalledWith("abort", addedAbortHandler) + }) + + it("rejects and cleans up abort listener when timeout expires", async () => { + vi.useFakeTimers() + const controller = new AbortController() + const addSignalListenerSpy = vi.spyOn(controller.signal, "addEventListener") + const removeSignalListenerSpy = vi.spyOn(controller.signal, "removeEventListener") + const addWindowListenerSpy = vi.spyOn(window, "addEventListener") + const removeWindowListenerSpy = vi.spyOn(window, "removeEventListener") + + const promise = fetchRouterModels(providerIdentifiers.openrouter, controller.signal) + const addedAbortHandler = addSignalListenerSpy.mock.calls.find(([type]) => type === "abort")?.[1] + const addedMessageHandler = addWindowListenerSpy.mock.calls.find(([type]) => type === "message")?.[1] + + vi.advanceTimersByTime(10000) + + await expect(promise).rejects.toThrow("Router models request timed out") + expect(typeof addedAbortHandler).toBe("function") + expect(removeSignalListenerSpy).toHaveBeenCalledWith("abort", addedAbortHandler) + expect(typeof addedMessageHandler).toBe("function") + expect(removeWindowListenerSpy).toHaveBeenCalledWith("message", addedMessageHandler) + }) + + it("removes abort listener and cleans up timer on successful response", async () => { + vi.useFakeTimers() + const controller = new AbortController() + const addSignalListenerSpy = vi.spyOn(controller.signal, "addEventListener") + const removeSignalListenerSpy = vi.spyOn(controller.signal, "removeEventListener") + const addWindowListenerSpy = vi.spyOn(window, "addEventListener") + const removeWindowListenerSpy = vi.spyOn(window, "removeEventListener") + + const promise = fetchRouterModels(providerIdentifiers.openrouter, controller.signal) + const addedAbortHandler = addSignalListenerSpy.mock.calls.find(([type]) => type === "abort")?.[1] + const addedMessageHandler = addWindowListenerSpy.mock.calls.find(([type]) => type === "message")?.[1] + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.openrouter }, + routerModels: mockRouterModels, + }, + }), + ) + + const result = await promise + expect(result).toEqual(mockRouterModels) + expect(typeof addedAbortHandler).toBe("function") + expect(removeSignalListenerSpy).toHaveBeenCalledWith("abort", addedAbortHandler) + expect(typeof addedMessageHandler).toBe("function") + expect(removeWindowListenerSpy).toHaveBeenCalledWith("message", addedMessageHandler) + + // Advancing timers should not cause rejection or error + vi.advanceTimersByTime(15000) + // Aborting afterwards should not trigger rejection + controller.abort() + }) +}) + +describe("useRouterModels", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + const makeQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + it("fetches router models with the specified provider", async () => { + const queryClient = makeQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => useRouterModels({ provider: providerIdentifiers.openrouter }), { wrapper }) + + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.openrouter }, + }), + ) + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: providerIdentifiers.openrouter }, + routerModels: mockRouterModels, + }, + }), + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(mockRouterModels) + }) + + it("uses allRouterModelsProvider in query key when provider is not specified", async () => { + const queryClient = makeQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => useRouterModels(), { wrapper }) + + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + }), + ) + + const queryState = queryClient.getQueryCache().findAll() + expect(queryState[0].queryKey).toEqual([RouterModelsMessageType.routerModels, allRouterModelsProvider]) + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: RouterModelsMessageType.routerModels, + values: { provider: undefined }, + routerModels: mockRouterModels, + }, + }), + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + }) + + it("does not fetch when enabled is false", async () => { + const queryClient = makeQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + + renderHook(() => useRouterModels({ provider: providerIdentifiers.openrouter, enabled: false }), { wrapper }) + + expect(vscode.postMessage).not.toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index a7d6b36725..66f6a4ff08 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -14,19 +14,38 @@ type UseRouterModelsOptions = { enabled?: boolean // gate fetching entirely } -export const fetchRouterModels = async (provider?: string) => +export const fetchRouterModels = async (provider?: string, signal?: AbortSignal) => new Promise((resolve, reject) => { + let timeout: ReturnType | undefined + const cleanup = () => { + if (timeout !== undefined) { + clearTimeout(timeout) + timeout = undefined + } + signal?.removeEventListener("abort", abortHandler) if (typeof window !== "undefined") { window.removeEventListener("message", handler) } } - const timeout = setTimeout(() => { + if (signal?.aborted) { + reject(new Error("Router models request aborted")) + return + } + + const abortHandler = () => { + cleanup() + reject(new Error("Router models request aborted")) + } + + timeout = setTimeout(() => { cleanup() reject(new Error("Router models request timed out")) }, 10000) + signal?.addEventListener("abort", abortHandler, { once: true }) + const handler = (event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -39,7 +58,6 @@ export const fetchRouterModels = async (provider?: string) => return } - clearTimeout(timeout) cleanup() if (message.routerModels) { @@ -62,7 +80,7 @@ export const useRouterModels = (opts: UseRouterModelsOptions = {}) => { const provider = opts.provider || undefined return useQuery({ queryKey: [RouterModelsMessageType.routerModels, provider || allRouterModelsProvider], - queryFn: () => fetchRouterModels(provider), + queryFn: ({ signal }) => fetchRouterModels(provider, signal), enabled: opts.enabled !== false, }) } diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 76ec98e8f2..9dc312ac08 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/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index 56e9a3745e..64e4dad906 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "S’està carregant...", + "clear_search": "Esborra la cerca", "search_placeholder": "Cerca...", "no_results": "No s'han trobat resultats" }, diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index eb0903d0ba..75a8d36483 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/de/common.json b/webview-ui/src/i18n/locales/de/common.json index ab8bd6d240..a1b382c1d2 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "Wird geladen...", + "clear_search": "Suche löschen", "search_placeholder": "Suchen...", "no_results": "Keine Ergebnisse gefunden" }, diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index d3d219a54c..50bfeb1f0d 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/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 981eaeec75..f3179c47b9 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "Loading...", + "clear_search": "Clear search", "search_placeholder": "Search...", "no_results": "No results found" }, diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 602a109faa..020fc62551 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/es/common.json b/webview-ui/src/i18n/locales/es/common.json index 03455b7cad..ea27b02733 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "Cargando...", + "clear_search": "Borrar búsqueda", "search_placeholder": "Buscar...", "no_results": "No se encontraron resultados" }, diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index ffff4caada..08cf54aa58 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/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index def93ad6c5..5df1530142 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "Chargement...", + "clear_search": "Effacer la recherche", "search_placeholder": "Rechercher...", "no_results": "Aucun résultat trouvé" }, diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 3ac03b47c1..9e43868835 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/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 076530e6b0..c531a42d25 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "लोड हो रहा है...", + "clear_search": "खोज साफ़ करें", "search_placeholder": "खोजें...", "no_results": "कोई परिणाम नहीं मिला" }, diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 2833e146b9..b808f06f15 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/id/common.json b/webview-ui/src/i18n/locales/id/common.json index a65295f28d..cb5db3153b 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -20,6 +20,8 @@ "billion_suffix": "m" }, "ui": { + "loading": "Memuat...", + "clear_search": "Hapus pencarian", "search_placeholder": "Cari...", "no_results": "Tidak ada hasil yang ditemukan" }, diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 5f4daf25e3..1dc25f9875 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/it/common.json b/webview-ui/src/i18n/locales/it/common.json index 9b801628f4..f6768c9835 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "Caricamento...", + "clear_search": "Cancella ricerca", "search_placeholder": "Cerca...", "no_results": "Nessun risultato trovato" }, diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 868232b977..50d1c03b50 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/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index b3b9d462e0..35b1837e75 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "読み込み中...", + "clear_search": "検索をクリア", "search_placeholder": "検索...", "no_results": "結果が見つかりません" }, diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 623a391b56..7a4f6899e8 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/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index d7120e2520..1e05c74c76 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "불러오는 중...", + "clear_search": "검색어 지우기", "search_placeholder": "검색...", "no_results": "결과를 찾을 수 없습니다" }, diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 9bb3d0e3dc..74ed3fc28e 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/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index ec6cf89ccb..3e814618b1 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -20,6 +20,8 @@ "billion_suffix": "mrd" }, "ui": { + "loading": "Laden...", + "clear_search": "Zoekopdracht wissen", "search_placeholder": "Zoeken...", "no_results": "Geen resultaten gevonden" }, diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 21f903ced2..a2718babfe 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/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index 419aa83af1..4d0e56ed78 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -20,6 +20,8 @@ "keep": "Zachowaj" }, "ui": { + "loading": "Ładowanie...", + "clear_search": "Wyczyść wyszukiwanie", "search_placeholder": "Szukaj...", "no_results": "Nie znaleziono wyników" }, diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 1de53f8d2f..2fb3b3f347 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/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index 4990796976..ca5dd63dd3 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -20,6 +20,8 @@ "keep": "Manter" }, "ui": { + "loading": "Carregando...", + "clear_search": "Limpar pesquisa", "search_placeholder": "Pesquisar...", "no_results": "Nenhum resultado encontrado" }, diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 77f561b679..bc55f0dc79 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/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index f66384a693..8869b03f7b 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -20,6 +20,8 @@ "keep": "Оставить" }, "ui": { + "loading": "Загрузка...", + "clear_search": "Очистить поиск", "search_placeholder": "Поиск...", "no_results": "Результатов не найдено" }, diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 83c82c9aa2..5c75cd72bf 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/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index db9e991cd5..9db0b37727 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "Yükleniyor...", + "clear_search": "Aramayı temizle", "search_placeholder": "Ara...", "no_results": "Sonuç bulunamadı" }, diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 5ca5b9eb46..bb65fb75c2 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/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index 57eb31fafa..f9155d48ab 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "Đang tải...", + "clear_search": "Xóa tìm kiếm", "search_placeholder": "Tìm kiếm...", "no_results": "Không tìm thấy kết quả nào" }, diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index bb7caf68c2..5606f2696c 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-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index 10df089333..8087de8eeb 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "加载中...", + "clear_search": "清除搜索", "search_placeholder": "搜索...", "no_results": "未找到结果" }, diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 4675e39848..0c947c709a 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": "使用額外內容強化提示詞", diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index 558609c157..469d119b64 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -20,6 +20,8 @@ "billion_suffix": "b" }, "ui": { + "loading": "載入中...", + "clear_search": "清除搜尋", "search_placeholder": "搜尋...", "no_results": "找不到結果" },