From fc49ed02e2d1b27d80d9df6e96b830de30951467 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 7 Feb 2026 15:31:50 -0700 Subject: [PATCH 1/2] feat: add lock toggle to pin API config across all modes in workspace Add a lock/unlock toggle inside the API config selector popover (next to the settings gear) that, when enabled, applies the selected API configuration to all modes in the current workspace. - Add lockApiConfigAcrossModes to ExtensionState and WebviewMessage types - Store setting in workspaceState (per-workspace, not global) - When locked, activateProviderProfile sets config for all modes - Lock icon in ApiConfigSelector popover bottom bar next to gear - Full i18n: English + 17 locale translations (all mention workspace scope) - 9 new tests: 2 ClineProvider, 2 handler, 5 UI (77 total pass) --- packages/types/src/vscode-extension-host.ts | 2 + pnpm-lock.yaml | 6 +- src/core/webview/ClineProvider.ts | 17 +- .../ClineProvider.lockApiConfig.spec.ts | 395 ++++++++++++++++++ ...ebviewMessageHandler.lockApiConfig.spec.ts | 76 ++++ src/core/webview/webviewMessageHandler.ts | 24 +- .../src/components/chat/ApiConfigSelector.tsx | 14 + .../src/components/chat/ChatTextArea.tsx | 8 + .../chat/__tests__/ApiConfigSelector.spec.tsx | 2 + .../ChatTextArea.lockApiConfig.spec.tsx | 156 +++++++ .../src/context/ExtensionStateContext.tsx | 1 + webview-ui/src/i18n/locales/ca/chat.json | 2 + webview-ui/src/i18n/locales/de/chat.json | 2 + webview-ui/src/i18n/locales/en/chat.json | 2 + webview-ui/src/i18n/locales/es/chat.json | 2 + webview-ui/src/i18n/locales/fr/chat.json | 2 + webview-ui/src/i18n/locales/hi/chat.json | 2 + webview-ui/src/i18n/locales/id/chat.json | 2 + webview-ui/src/i18n/locales/it/chat.json | 2 + webview-ui/src/i18n/locales/ja/chat.json | 2 + webview-ui/src/i18n/locales/ko/chat.json | 2 + webview-ui/src/i18n/locales/nl/chat.json | 2 + webview-ui/src/i18n/locales/pl/chat.json | 2 + webview-ui/src/i18n/locales/pt-BR/chat.json | 2 + webview-ui/src/i18n/locales/ru/chat.json | 2 + webview-ui/src/i18n/locales/tr/chat.json | 2 + webview-ui/src/i18n/locales/vi/chat.json | 2 + webview-ui/src/i18n/locales/zh-CN/chat.json | 2 + webview-ui/src/i18n/locales/zh-TW/chat.json | 2 + 29 files changed, 732 insertions(+), 5 deletions(-) create mode 100644 src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts create mode 100644 src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts create mode 100644 webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 51c7fa49d5e..49a63c3ae48 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -336,6 +336,7 @@ export type ExtensionState = Pick< | "showWorktreesInHomeScreen" | "disabledTools" > & { + lockApiConfigAcrossModes?: boolean version: string clineMessages: ClineMessage[] currentTaskItem?: HistoryItem @@ -524,6 +525,7 @@ export interface WebviewMessage { | "searchFiles" | "toggleApiConfigPin" | "hasOpenedModeSelector" + | "lockApiConfigAcrossModes" | "clearCloudAuthSkipModel" | "cloudButtonClicked" | "rooCloudSignIn" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff809db9add..7f48e153c9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14958,7 +14958,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -22251,8 +22251,8 @@ snapshots: zhipu-ai-provider@0.2.2(zod@3.25.76): dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) transitivePeerDependencies: - zod diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5ff0f1c3658..ef1f579eb27 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -54,7 +54,7 @@ import { Package } from "../../shared/package" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" -import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { Mode, defaultModeSlug, getAllModes, getModeBySlug } from "../../shared/modes" import { experimentDefault } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" import { WebviewMessage } from "../../shared/WebviewMessage" @@ -1538,6 +1538,18 @@ export class ClineProvider if (id && persistModeConfig) { await this.providerSettingsManager.setModeConfig(mode, id) + + // If lock is enabled, apply this config to ALL modes + const lockApiConfigAcrossModes = (await this.getState()).lockApiConfigAcrossModes + if (lockApiConfigAcrossModes) { + const { customModes } = await this.getState() + const allModes = getAllModes(customModes) + for (const modeConfig of allModes) { + if (modeConfig.slug !== mode) { + await this.providerSettingsManager.setModeConfig(modeConfig.slug, id) + } + } + } } // Change the provider for the current task. @@ -2081,6 +2093,7 @@ export class ClineProvider openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, isBrowserSessionActive, + lockApiConfigAcrossModes, } = await this.getState() let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2229,6 +2242,7 @@ export class ClineProvider profileThresholds: profileThresholds ?? {}, cloudApiUrl: getRooCodeApiUrl(), hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false, + lockApiConfigAcrossModes: lockApiConfigAcrossModes ?? false, alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false, followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000, includeDiagnosticMessages: includeDiagnosticMessages ?? true, @@ -2464,6 +2478,7 @@ export class ClineProvider stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, profileThresholds: stateValues.profileThresholds ?? {}, + lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts new file mode 100644 index 00000000000..5d9a0a942e4 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -0,0 +1,395 @@ +// npx vitest run core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts + +import * as vscode from "vscode" +import { TelemetryService } from "@roo-code/telemetry" +import { ClineProvider } from "../ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + 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("../../task/Task", () => ({ + Task: vi.fn().mockImplementation((options) => ({ + taskId: options.taskId || "test-task-id", + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + emit: vi.fn(), + parentTask: options.parentTask, + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn(), + _taskApiConfigName: options.historyItem?.apiConfigName, + taskApiConfigName: options.historyItem?.apiConfigName, + })), +})) + +vi.mock("../../prompts/sections/custom-instructions") + +vi.mock("../../../utils/safeWriteJson") + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(() => ({ + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + })), +})) + +vi.mock("../../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({ + getName: () => "test-strategy", + applyDiff: vi.fn(), + })), +})) + +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + } + }, + }, + BridgeOrchestrator: { + isEnabled: vi.fn().mockReturnValue(false), + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +vi.mock("../../../shared/modes", () => { + const mockModes = [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit", "browser"], + }, + { + slug: "architect", + name: "Architect Mode", + roleDefinition: "You are an architect", + groups: ["read", "edit"], + }, + { + slug: "ask", + name: "Ask Mode", + roleDefinition: "You are an assistant", + groups: ["read"], + }, + { + slug: "debug", + name: "Debug Mode", + roleDefinition: "You are a debugger", + groups: ["read", "edit"], + }, + { + slug: "orchestrator", + name: "Orchestrator Mode", + roleDefinition: "You are an orchestrator", + groups: [], + }, + ] + + return { + modes: mockModes, + getAllModes: vi.fn((customModes?: Array<{ slug: string }>) => { + if (!customModes?.length) { + return [...mockModes] + } + const allModes = [...mockModes] + customModes.forEach((cm) => { + const idx = allModes.findIndex((m) => m.slug === cm.slug) + if (idx !== -1) { + allModes[idx] = cm as (typeof mockModes)[number] + } else { + allModes.push(cm as (typeof mockModes)[number]) + } + }) + return allModes + }), + getModeBySlug: vi.fn().mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit", "browser"], + }), + defaultModeSlug: "code", + } +}) + +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), + codeMode: "code", +})) + +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +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("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn().mockReturnValue(true), + createInstance: vi.fn(), + get instance() { + return { + trackEvent: vi.fn(), + trackError: vi.fn(), + setProvider: vi.fn(), + captureModeSwitch: vi.fn(), + } + }, + }, +})) + +describe("ClineProvider - Lock API Config Across Modes", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "default-profile", + } + + const workspaceState: Record = {} + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => globalState[key]), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => secrets[key]), + store: vi.fn().mockImplementation((key: string, value: string | undefined) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + }, + workspaceState: { + get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => { + return key in workspaceState ? workspaceState[key] : defaultValue + }), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + workspaceState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(workspaceState)), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + 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 + + const mockPostMessage = vi.fn() + + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation((callback) => { + callback() + 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)) + + // Mock getMcpHub method + provider.getMcpHub = vi.fn().mockReturnValue({ + listTools: vi.fn().mockResolvedValue([]), + callTool: vi.fn().mockResolvedValue({ content: [] }), + listResources: vi.fn().mockResolvedValue([]), + readResource: vi.fn().mockResolvedValue({ contents: [] }), + getAllServers: vi.fn().mockReturnValue([]), + }) + }) + + describe("activateProviderProfile applies config to all modes when lock is enabled", () => { + beforeEach(async () => { + await provider.resolveWebviewView(mockWebviewView) + }) + + it("should apply config to all modes when lockApiConfigAcrossModes is true", async () => { + // Set lockApiConfigAcrossModes via workspaceState + await mockContext.workspaceState.update("lockApiConfigAcrossModes", true) + + // Mock providerSettingsManager.activateProfile + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "new-profile", + id: "new-profile-id", + apiProvider: "anthropic", + }) + + // Mock providerSettingsManager.listConfig + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, + ]) + + // Spy on setModeConfig + const setModeConfigSpy = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockResolvedValue(undefined) + + // Mock updateTaskHistory to avoid side effects + vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => Promise.resolve([])) + + // Call activateProviderProfile with persistModeConfig: true + await provider.activateProviderProfile({ id: "new-profile-id" }, { persistModeConfig: true }) + + // Should call setModeConfig for the current mode (code) first, + // then for all other modes (architect, ask, debug, orchestrator) + expect(setModeConfigSpy).toHaveBeenCalledTimes(5) + expect(setModeConfigSpy).toHaveBeenCalledWith("code", "new-profile-id") + expect(setModeConfigSpy).toHaveBeenCalledWith("architect", "new-profile-id") + expect(setModeConfigSpy).toHaveBeenCalledWith("ask", "new-profile-id") + expect(setModeConfigSpy).toHaveBeenCalledWith("debug", "new-profile-id") + expect(setModeConfigSpy).toHaveBeenCalledWith("orchestrator", "new-profile-id") + }) + + it("should apply config to only current mode when lockApiConfigAcrossModes is false", async () => { + // Set lockApiConfigAcrossModes to false via workspaceState + await mockContext.workspaceState.update("lockApiConfigAcrossModes", false) + + // Mock providerSettingsManager.activateProfile + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "new-profile", + id: "new-profile-id", + apiProvider: "anthropic", + }) + + // Mock providerSettingsManager.listConfig + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, + ]) + + // Spy on setModeConfig + const setModeConfigSpy = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockResolvedValue(undefined) + + // Mock updateTaskHistory to avoid side effects + vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => Promise.resolve([])) + + // Call activateProviderProfile with persistModeConfig: true + await provider.activateProviderProfile({ id: "new-profile-id" }, { persistModeConfig: true }) + + // Should call setModeConfig only for the current mode (code) + expect(setModeConfigSpy).toHaveBeenCalledTimes(1) + expect(setModeConfigSpy).toHaveBeenCalledWith("code", "new-profile-id") + }) + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts new file mode 100644 index 00000000000..1def838679f --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts @@ -0,0 +1,76 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +describe("webviewMessageHandler - lockApiConfigAcrossModes", () => { + let mockProvider: { + context: { + workspaceState: { + get: ReturnType + update: ReturnType + } + } + getState: ReturnType + postStateToWebview: ReturnType + providerSettingsManager: { + setModeConfig: ReturnType + } + postMessageToWebview: ReturnType + getCurrentTask: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + + mockProvider = { + context: { + workspaceState: { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }, + }, + getState: vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + listApiConfigMeta: [{ name: "test-config", id: "config-123" }], + customModes: [], + }), + postStateToWebview: vi.fn(), + providerSettingsManager: { + setModeConfig: vi.fn(), + }, + postMessageToWebview: vi.fn(), + getCurrentTask: vi.fn(), + } + }) + + it("sets lockApiConfigAcrossModes to true and applies config to all modes", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "lockApiConfigAcrossModes", + bool: true, + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("lockApiConfigAcrossModes", true) + + // Should apply config to all 5 default modes + expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledTimes(5) + expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "config-123") + expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("code", "config-123") + expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("ask", "config-123") + expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("debug", "config-123") + expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("orchestrator", "config-123") + + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("sets lockApiConfigAcrossModes to false without applying to all modes", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "lockApiConfigAcrossModes", + bool: false, + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("lockApiConfigAcrossModes", false) + expect(mockProvider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 75f1ce0ff4a..8cf729c64e1 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -64,7 +64,7 @@ import { openMention } from "../mentions" import { resolveImageMentions } from "../mentions/resolveImageMentions" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { getWorkspacePath } from "../../utils/path" -import { Mode, defaultModeSlug } from "../../shared/modes" +import { Mode, defaultModeSlug, getAllModes } from "../../shared/modes" import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" import { generateSystemPrompt } from "./generateSystemPrompt" @@ -1661,6 +1661,28 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() break + case "lockApiConfigAcrossModes": { + const enabled = message.bool ?? false + await provider.context.workspaceState.update("lockApiConfigAcrossModes", enabled) + + // When enabling the lock, apply the current config to all modes immediately + if (enabled) { + const state = await provider.getState() + const currentConfigId = state.listApiConfigMeta?.find( + (c: { name: string }) => c.name === state.currentApiConfigName, + )?.id + if (currentConfigId) { + const allModes = getAllModes(state.customModes) + for (const modeConfig of allModes) { + await provider.providerSettingsManager.setModeConfig(modeConfig.slug, currentConfigId) + } + } + } + + await provider.postStateToWebview() + break + } + case "toggleApiConfigPin": if (message.text) { const currentPinned = getGlobalState("pinnedApiConfigs") ?? {} diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index 4396019a2d2..e370296ec32 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -20,6 +20,8 @@ interface ApiConfigSelectorProps { listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }> pinnedApiConfigs?: Record togglePinnedApiConfig: (id: string) => void + lockApiConfigAcrossModes: boolean + onToggleLockApiConfig: () => void } export const ApiConfigSelector = ({ @@ -32,6 +34,8 @@ export const ApiConfigSelector = ({ listApiConfigMeta, pinnedApiConfigs, togglePinnedApiConfig, + lockApiConfigAcrossModes, + onToggleLockApiConfig, }: ApiConfigSelectorProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(false) @@ -228,6 +232,16 @@ export const ApiConfigSelector = ({ onClick={handleEditClick} tooltip={false} /> + {/* Info icon and title on the right with matching spacing */} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 654f2e1011e..4c0b2bbfd08 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -103,6 +103,7 @@ export const ChatTextArea = forwardRef( commands, cloudUserInfo, enterBehavior, + lockApiConfigAcrossModes, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -945,6 +946,11 @@ export const ChatTextArea = forwardRef( vscode.postMessage({ type: "loadApiConfigurationById", text: value }) }, []) + const handleToggleLockApiConfig = useCallback(() => { + const newValue = !lockApiConfigAcrossModes + vscode.postMessage({ type: "lockApiConfigAcrossModes", bool: newValue }) + }, [lockApiConfigAcrossModes]) + return (
( listApiConfigMeta={listApiConfigMeta || []} pinnedApiConfigs={pinnedApiConfigs} togglePinnedApiConfig={togglePinnedApiConfig} + lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} + onToggleLockApiConfig={handleToggleLockApiConfig} />
diff --git a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx index ff1b95f9499..a71216d96f8 100644 --- a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx @@ -72,6 +72,8 @@ describe("ApiConfigSelector", () => { ], pinnedApiConfigs: { config1: true }, togglePinnedApiConfig: mockTogglePinnedApiConfig, + lockApiConfigAcrossModes: false, + onToggleLockApiConfig: vi.fn(), } beforeEach(() => { diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx new file mode 100644 index 00000000000..d3fb2b6890a --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx @@ -0,0 +1,156 @@ +import { defaultModeSlug } from "@roo/modes" + +import { render, fireEvent, screen } from "@src/utils/test-utils" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +import { ChatTextArea } from "../ChatTextArea" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@src/components/common/CodeBlock") +vi.mock("@src/components/common/MarkdownBlock") +vi.mock("@src/utils/path-mentions", () => ({ + convertToMentionPath: vi.fn((path: string) => path), +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext") + +const mockPostMessage = vscode.postMessage as ReturnType + +describe("ChatTextArea - lockApiConfigAcrossModes toggle", () => { + const defaultProps = { + inputValue: "", + setInputValue: vi.fn(), + onSend: vi.fn(), + sendingDisabled: false, + selectApiConfigDisabled: false, + onSelectImages: vi.fn(), + shouldDisableImages: false, + placeholderText: "Type a message...", + selectedImages: [] as string[], + setSelectedImages: vi.fn(), + onHeightChange: vi.fn(), + mode: defaultModeSlug, + setMode: vi.fn(), + modeShortcutText: "(⌘. for next mode)", + } + + const defaultState = { + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: "anthropic" }, + taskHistory: [], + cwd: "/test/workspace", + listApiConfigMeta: [{ id: "default", name: "Default", modelId: "claude-3" }], + currentApiConfigName: "Default", + pinnedApiConfigs: {}, + togglePinnedApiConfig: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + /** + * Helper: Opens the ApiConfigSelector popover by clicking the trigger, + * then returns the lock toggle button by its aria-label. + */ + const openPopoverAndGetLockToggle = (ariaLabel: string) => { + const trigger = screen.getByTestId("dropdown-trigger") + fireEvent.click(trigger) + return screen.getByRole("button", { name: ariaLabel }) + } + + describe("rendering", () => { + it("renders with muted opacity when lockApiConfigAcrossModes is false", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: false, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Unlocked state has muted opacity + expect(button.className).toContain("opacity-60") + expect(button.className).not.toContain("text-vscode-focusBorder") + }) + + it("renders with highlight color when lockApiConfigAcrossModes is true", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: true, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:unlockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Locked state has the focus border highlight color + expect(button.className).toContain("text-vscode-focusBorder") + expect(button.className).not.toContain("opacity-60") + }) + + it("renders in unlocked state when lockApiConfigAcrossModes is undefined (default)", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Default (undefined/falsy) renders in unlocked style + expect(button.className).toContain("opacity-60") + }) + }) + + describe("interaction", () => { + it("posts lockApiConfigAcrossModes=true message when locking", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: false, + }) + + render() + + // Clear any initialization messages + mockPostMessage.mockClear() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "lockApiConfigAcrossModes", + bool: true, + }) + }) + + it("posts lockApiConfigAcrossModes=false message when unlocking", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: true, + }) + + render() + + // Clear any initialization messages + mockPostMessage.mockClear() + + const button = openPopoverAndGetLockToggle("chat:unlockApiConfigAcrossModes") + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "lockApiConfigAcrossModes", + bool: false, + }) + }) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 47110d08751..2378873f010 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -264,6 +264,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode openRouterImageGenerationSelectedModel: "", includeCurrentTime: true, includeCurrentCost: true, + lockApiConfigAcrossModes: false, }) const [didHydrateState, setDidHydrateState] = useState(false) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 5bba7dc4459..070adc265db 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Selecciona el mode d'interacció", "selectApiConfig": "Seleccioneu la configuració de l'API", + "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", "addImages": "Afegeix imatges al missatge", "sendMessage": "Envia el missatge", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 58bc85b60cf..bc550520d7a 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Interaktionsmodus auswählen", "selectApiConfig": "API-Konfiguration auswählen", + "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", "addImages": "Bilder zur Nachricht hinzufügen", "sendMessage": "Nachricht senden", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index b9652cfce5c..bd97e041f20 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -122,6 +122,8 @@ }, "selectMode": "Select mode for interaction", "selectApiConfig": "Select API configuration", + "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", "modeSelector": { "title": "Modes", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 6c894642c88..0687bc7037b 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Seleccionar modo de interacción", "selectApiConfig": "Seleccionar configuración de API", + "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", "addImages": "Agregar imágenes al mensaje", "sendMessage": "Enviar mensaje", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 598344de1d7..f4887b7e6c7 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Sélectionner le mode d'interaction", "selectApiConfig": "Sélectionner la configuration de l'API", + "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", "addImages": "Ajouter des images au message", "sendMessage": "Envoyer le message", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 74d5e4e3eef..580822b82f5 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "इंटरैक्शन मोड चुनें", "selectApiConfig": "एपीआई कॉन्फ़िगरेशन का चयन करें", + "lockApiConfigAcrossModes": "इस कार्यक्षेत्र में सभी मोड के लिए API कॉन्फ़िगरेशन लॉक करें", + "unlockApiConfigAcrossModes": "इस कार्यक्षेत्र में सभी मोड के लिए API कॉन्फ़िगरेशन लॉक है (अनलॉक करने के लिए क्लिक करें)", "enhancePrompt": "अतिरिक्त संदर्भ के साथ प्रॉम्प्ट बढ़ाएँ", "addImages": "संदेश में चित्र जोड़ें", "sendMessage": "संदेश भेजें", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index f814b9d4a9a..600708e4d9c 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -125,6 +125,8 @@ }, "selectMode": "Pilih mode untuk interaksi", "selectApiConfig": "Pilih konfigurasi API", + "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", "enhancePromptDescription": "Tombol 'Tingkatkan Prompt' membantu memperbaiki prompt kamu dengan memberikan konteks tambahan, klarifikasi, atau penyusunan ulang. Coba ketik prompt di sini dan klik tombol lagi untuk melihat cara kerjanya.", "modeSelector": { diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index eca4264df20..ce8e45bf459 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Seleziona modalità di interazione", "selectApiConfig": "Seleziona la configurazione API", + "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", "addImages": "Aggiungi immagini al messaggio", "sendMessage": "Invia messaggio", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 9a0b1b2a35c..f3c3d86f7a1 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "対話モードを選択", "selectApiConfig": "API構成を選択", + "lockApiConfigAcrossModes": "このワークスペースのすべてのモードでAPI構成をロック", + "unlockApiConfigAcrossModes": "このワークスペースのすべてのモードでAPI構成がロックされています(クリックで解除)", "enhancePrompt": "追加コンテキストでプロンプトを強化", "addImages": "メッセージに画像を追加", "sendMessage": "メッセージを送信", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 3b26c6e6e19..817768c2753 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "상호작용 모드 선택", "selectApiConfig": "API 구성 선택", + "lockApiConfigAcrossModes": "이 워크스페이스의 모든 모드에서 API 구성 잠금", + "unlockApiConfigAcrossModes": "이 워크스페이스의 모든 모드에서 API 구성이 잠겨 있습니다 (클릭하여 해제)", "enhancePrompt": "추가 컨텍스트로 프롬프트 향상", "addImages": "메시지에 이미지 추가", "sendMessage": "메시지 보내기", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 241e9f22166..0a728aa38e8 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Selecteer modus voor interactie", "selectApiConfig": "Selecteer API-configuratie", + "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", "enhancePromptDescription": "De knop 'Prompt verbeteren' helpt je prompt te verbeteren door extra context, verduidelijking of herformulering te bieden. Probeer hier een prompt te typen en klik opnieuw op de knop om te zien hoe het werkt.", "modeSelector": { diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index ae6b5a96ac7..09a1c994927 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Wybierz tryb interakcji", "selectApiConfig": "Wybierz konfigurację API", + "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", "addImages": "Dodaj obrazy do wiadomości", "sendMessage": "Wyślij wiadomość", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 2ca72ebd970..cc4fbbd742e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Selecionar modo de interação", "selectApiConfig": "Selecionar configuração da API", + "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", "addImages": "Adicionar imagens à mensagem", "sendMessage": "Enviar mensagem", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 347bf1be81e..c5e4a3f0e2a 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Выберите режим взаимодействия", "selectApiConfig": "Выберите конфигурацию API", + "lockApiConfigAcrossModes": "Заблокировать конфигурацию API для всех режимов в этом рабочем пространстве", + "unlockApiConfigAcrossModes": "Конфигурация API заблокирована для всех режимов в этом рабочем пространстве (нажми, чтобы разблокировать)", "enhancePrompt": "Улучшить запрос с дополнительным контекстом", "enhancePromptDescription": "Кнопка 'Улучшить запрос' помогает сделать ваш запрос лучше, предоставляя дополнительный контекст, уточнения или переформулировку. Попробуйте ввести запрос и снова нажать кнопку, чтобы увидеть, как это работает.", "modeSelector": { diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 2301541cdff..b913a5afb2b 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Etkileşim modunu seçin", "selectApiConfig": "API yapılandırmasını seçin", + "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", "addImages": "Mesaja resim ekle", "sendMessage": "Mesaj gönder", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 33b13d9b269..9aecd5c3857 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Chọn chế độ tương tác", "selectApiConfig": "Chọn cấu hình API", + "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", "addImages": "Thêm hình ảnh vào tin nhắn", "sendMessage": "Gửi tin nhắn", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 7a94bfb48d7..853279506fc 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "选择交互模式", "selectApiConfig": "选择 API 配置", + "lockApiConfigAcrossModes": "锁定此工作区所有模式的 API 配置", + "unlockApiConfigAcrossModes": "此工作区所有模式的 API 配置已锁定(点击解锁)", "enhancePrompt": "增强提示词", "addImages": "添加图片到消息", "sendMessage": "发送消息", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 926cb105fb4..84c54900b68 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -122,6 +122,8 @@ }, "selectMode": "選擇互動模式", "selectApiConfig": "選取 API 設定", + "lockApiConfigAcrossModes": "鎖定此工作區所有模式的 API 設定", + "unlockApiConfigAcrossModes": "此工作區所有模式的 API 設定已鎖定(點擊解鎖)", "enhancePrompt": "使用額外內容強化提示詞", "modeSelector": { "title": "模式", From 7f6c90a91fa6c93ade07f97f7069cbb52a1ccb00 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 7 Feb 2026 19:47:34 -0700 Subject: [PATCH 2/2] refactor: replace write-fan-out with read-time override for lock API config The original lock implementation used setModeConfig() fan-out to write the locked config to ALL modes globally. Since the lock flag lives in workspace- scoped workspaceState but modeApiConfigs are in global secrets, this caused cross-workspace data destruction. Replaced with read-time guards: - handleModeSwitch: early return when lock is on (skip per-mode config load) - createTaskWithHistoryItem: skip mode-based config restoration under lock - activateProviderProfile: removed fan-out block - lockApiConfigAcrossModes handler: simplified to flag + state post only - Fixed pre-existing workspaceState mock gap in ClineProvider.spec.ts and ClineProvider.sticky-profile.spec.ts --- src/core/webview/ClineProvider.ts | 24 +++--- .../ClineProvider.apiHandlerRebuild.spec.ts | 5 ++ .../ClineProvider.lockApiConfig.spec.ts | 83 +++++++------------ .../webview/__tests__/ClineProvider.spec.ts | 30 +++++++ .../ClineProvider.sticky-mode.spec.ts | 5 ++ .../ClineProvider.sticky-profile.spec.ts | 5 ++ .../ClineProvider.taskHistory.spec.ts | 5 ++ ...ebviewMessageHandler.lockApiConfig.spec.ts | 12 +-- src/core/webview/webviewMessageHandler.ts | 16 +--- 9 files changed, 93 insertions(+), 92 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ef1f579eb27..fc15a8dd5c3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -54,7 +54,7 @@ import { Package } from "../../shared/package" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" -import { Mode, defaultModeSlug, getAllModes, getModeBySlug } from "../../shared/modes" +import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes" import { experimentDefault } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" import { WebviewMessage } from "../../shared/WebviewMessage" @@ -899,7 +899,8 @@ export class ClineProvider // Load the saved API config for the restored mode if it exists. // Skip mode-based profile activation if historyItem.apiConfigName exists, // since the task's specific provider profile will override it anyway. - if (!historyItem.apiConfigName) { + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (!historyItem.apiConfigName && !lockApiConfigAcrossModes) { const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) const listApiConfig = await this.providerSettingsManager.listConfig() @@ -1316,6 +1317,13 @@ export class ClineProvider this.emit(RooCodeEventName.ModeChanged, newMode) + // If workspace lock is on, keep the current API config — don't load mode-specific config + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (lockApiConfigAcrossModes) { + await this.postStateToWebview() + return + } + // Load the saved API config for the new mode if it exists. const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) const listApiConfig = await this.providerSettingsManager.listConfig() @@ -1538,18 +1546,6 @@ export class ClineProvider if (id && persistModeConfig) { await this.providerSettingsManager.setModeConfig(mode, id) - - // If lock is enabled, apply this config to ALL modes - const lockApiConfigAcrossModes = (await this.getState()).lockApiConfigAcrossModes - if (lockApiConfigAcrossModes) { - const { customModes } = await this.getState() - const allModes = getAllModes(customModes) - for (const modeConfig of allModes) { - if (modeConfig.slug !== mode) { - await this.providerSettingsManager.setModeConfig(modeConfig.slug, id) - } - } - } } // Change the provider for the current task. diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 04f5d577929..9e57ae94b81 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -171,6 +171,11 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (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" }, diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index 5d9a0a942e4..9b5e3b16ee6 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -318,78 +318,55 @@ describe("ClineProvider - Lock API Config Across Modes", () => { }) }) - describe("activateProviderProfile applies config to all modes when lock is enabled", () => { + describe("handleModeSwitch honors lockApiConfigAcrossModes as a read-time override", () => { beforeEach(async () => { await provider.resolveWebviewView(mockWebviewView) }) - it("should apply config to all modes when lockApiConfigAcrossModes is true", async () => { - // Set lockApiConfigAcrossModes via workspaceState + it("skips mode-specific config lookup/load when lockApiConfigAcrossModes is true", async () => { await mockContext.workspaceState.update("lockApiConfigAcrossModes", true) - // Mock providerSettingsManager.activateProfile - vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ - name: "new-profile", - id: "new-profile-id", - apiProvider: "anthropic", - }) - - // Mock providerSettingsManager.listConfig - vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, - ]) - - // Spy on setModeConfig - const setModeConfigSpy = vi - .spyOn(provider.providerSettingsManager, "setModeConfig") + const getModeConfigIdSpy = vi + .spyOn(provider.providerSettingsManager, "getModeConfigId") + .mockResolvedValue("architect-profile-id") + const listConfigSpy = vi + .spyOn(provider.providerSettingsManager, "listConfig") + .mockResolvedValue([ + { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, + ]) + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") .mockResolvedValue(undefined) - // Mock updateTaskHistory to avoid side effects - vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => Promise.resolve([])) - - // Call activateProviderProfile with persistModeConfig: true - await provider.activateProviderProfile({ id: "new-profile-id" }, { persistModeConfig: true }) + await provider.handleModeSwitch("architect") - // Should call setModeConfig for the current mode (code) first, - // then for all other modes (architect, ask, debug, orchestrator) - expect(setModeConfigSpy).toHaveBeenCalledTimes(5) - expect(setModeConfigSpy).toHaveBeenCalledWith("code", "new-profile-id") - expect(setModeConfigSpy).toHaveBeenCalledWith("architect", "new-profile-id") - expect(setModeConfigSpy).toHaveBeenCalledWith("ask", "new-profile-id") - expect(setModeConfigSpy).toHaveBeenCalledWith("debug", "new-profile-id") - expect(setModeConfigSpy).toHaveBeenCalledWith("orchestrator", "new-profile-id") + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(listConfigSpy).not.toHaveBeenCalled() + expect(activateProviderProfileSpy).not.toHaveBeenCalled() }) - it("should apply config to only current mode when lockApiConfigAcrossModes is false", async () => { - // Set lockApiConfigAcrossModes to false via workspaceState + it("keeps normal mode-specific lookup/load behavior when lockApiConfigAcrossModes is false", async () => { await mockContext.workspaceState.update("lockApiConfigAcrossModes", false) - // Mock providerSettingsManager.activateProfile - vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ - name: "new-profile", - id: "new-profile-id", - apiProvider: "anthropic", - }) - - // Mock providerSettingsManager.listConfig + const getModeConfigIdSpy = vi + .spyOn(provider.providerSettingsManager, "getModeConfigId") + .mockResolvedValue("architect-profile-id") vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ - { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" }, + { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + apiProvider: "anthropic", + }) - // Spy on setModeConfig - const setModeConfigSpy = vi - .spyOn(provider.providerSettingsManager, "setModeConfig") + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") .mockResolvedValue(undefined) - // Mock updateTaskHistory to avoid side effects - vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => Promise.resolve([])) - - // Call activateProviderProfile with persistModeConfig: true - await provider.activateProviderProfile({ id: "new-profile-id" }, { persistModeConfig: true }) + await provider.handleModeSwitch("architect") - // Should call setModeConfig only for the current mode (code) - expect(setModeConfigSpy).toHaveBeenCalledTimes(1) - expect(setModeConfigSpy).toHaveBeenCalledWith("code", "new-profile-id") + expect(getModeConfigIdSpy).toHaveBeenCalledWith("architect") + expect(activateProviderProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" }) }) }) }) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 26f6fbd8aba..4bad630ed56 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -405,6 +405,11 @@ describe("ClineProvider", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (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" }, @@ -2147,6 +2152,11 @@ describe("Project MCP Settings", () => { store: vi.fn(), delete: vi.fn(), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -2277,6 +2287,11 @@ describe.skip("ContextProxy integration", () => { update: vi.fn(), keys: vi.fn().mockReturnValue([]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, @@ -2342,6 +2357,11 @@ describe("getTelemetryProperties", () => { update: vi.fn(), keys: vi.fn().mockReturnValue([]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, @@ -2504,6 +2524,11 @@ describe("ClineProvider - Router Models", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (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" }, @@ -2857,6 +2882,11 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (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" }, diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 27aab0b7da2..af674d7a5e0 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -227,6 +227,11 @@ describe("ClineProvider - Sticky Mode", () => { return Promise.resolve() }), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 80b14746a76..ee63b45b254 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -229,6 +229,11 @@ describe("ClineProvider - Sticky Provider Profile", () => { return Promise.resolve() }), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index f5e6afa7f06..e0f1d2dc29a 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -287,6 +287,11 @@ describe("ClineProvider Task History Synchronization", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (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" }, diff --git a/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts index 1def838679f..fd9b4a77401 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts @@ -44,22 +44,14 @@ describe("webviewMessageHandler - lockApiConfigAcrossModes", () => { } }) - it("sets lockApiConfigAcrossModes to true and applies config to all modes", async () => { + it("sets lockApiConfigAcrossModes to true and posts state without mode config fan-out", async () => { await webviewMessageHandler(mockProvider as unknown as ClineProvider, { type: "lockApiConfigAcrossModes", bool: true, }) expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("lockApiConfigAcrossModes", true) - - // Should apply config to all 5 default modes - expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledTimes(5) - expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "config-123") - expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("code", "config-123") - expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("ask", "config-123") - expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("debug", "config-123") - expect(mockProvider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("orchestrator", "config-123") - + expect(mockProvider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() expect(mockProvider.postStateToWebview).toHaveBeenCalled() }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 8cf729c64e1..ae0da758412 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -64,7 +64,7 @@ import { openMention } from "../mentions" import { resolveImageMentions } from "../mentions/resolveImageMentions" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { getWorkspacePath } from "../../utils/path" -import { Mode, defaultModeSlug, getAllModes } from "../../shared/modes" +import { Mode, defaultModeSlug } from "../../shared/modes" import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" import { generateSystemPrompt } from "./generateSystemPrompt" @@ -1665,20 +1665,6 @@ export const webviewMessageHandler = async ( const enabled = message.bool ?? false await provider.context.workspaceState.update("lockApiConfigAcrossModes", enabled) - // When enabling the lock, apply the current config to all modes immediately - if (enabled) { - const state = await provider.getState() - const currentConfigId = state.listApiConfigMeta?.find( - (c: { name: string }) => c.name === state.currentApiConfigName, - )?.id - if (currentConfigId) { - const allModes = getAllModes(state.customModes) - for (const modeConfig of allModes) { - await provider.providerSettingsManager.setModeConfig(modeConfig.slug, currentConfigId) - } - } - } - await provider.postStateToWebview() break }