feat: add workspace-scoped mode-to-profile overrides

Adds the ability to pin a provider profile to a specific mode on a
per-workspace basis. When switching modes, the system checks workspace-
level overrides first and falls back to global mode-to-profile mappings
if no workspace override exists.

Changes:
- Add workspaceModeApiConfigs to ExtensionState type
- Add setWorkspaceModeApiConfig/clearWorkspaceModeApiConfig message types
- Update ClineProvider.handleModeSwitch to check workspace overrides
- Update ClineProvider.getState to include workspace configs
- Add webview message handler for workspace profile operations
- Add workspace profile pin button to ApiConfigSelector UI
- Add translation keys for new UI elements
- Add tests for workspace mode API config message handling

Closes #12227
This commit is contained in:
Roo Code 2026-04-29 20:02:37 +00:00
parent ad25634905
commit b4b2463461
8 changed files with 254 additions and 2 deletions

View file

@ -308,6 +308,7 @@ export type ExtensionState = Pick<
| "disabledTools"
> & {
lockApiConfigAcrossModes?: boolean
workspaceModeApiConfigs?: Record<string, string>
version: string
clineMessages: ClineMessage[]
currentTaskId?: string
@ -499,6 +500,8 @@ export interface WebviewMessage {
| "toggleApiConfigPin"
| "hasOpenedModeSelector"
| "lockApiConfigAcrossModes"
| "setWorkspaceModeApiConfig"
| "clearWorkspaceModeApiConfig"
| "clearCloudAuthSkipModel"
| "cloudButtonClicked"
| "rooCloudSignIn"

View file

@ -999,7 +999,12 @@ export class ClineProvider
const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false)
if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) {
const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
// Check workspace-level override first, then fall back to global mode config.
const workspaceModeApiConfigs =
this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {}
const workspaceConfigId = workspaceModeApiConfigs[historyItem.mode]
const savedConfigId =
workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(historyItem.mode))
const listApiConfig = await this.providerSettingsManager.listConfig()
// Update listApiConfigMeta first to ensure UI has latest data.
@ -1433,8 +1438,13 @@ export class ClineProvider
return
}
// Check for workspace-level mode-to-profile override first, then fall back to global.
const workspaceModeApiConfigs =
this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {}
const workspaceConfigId = workspaceModeApiConfigs[newMode]
// Load the saved API config for the new mode if it exists.
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
const savedConfigId = workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(newMode))
const listApiConfig = await this.providerSettingsManager.listConfig()
// Update listApiConfigMeta first to ensure UI has latest data.
@ -2563,6 +2573,10 @@ export class ClineProvider
},
profileThresholds: stateValues.profileThresholds ?? {},
lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false),
workspaceModeApiConfigs: this.context.workspaceState.get<Record<string, string>>(
"workspaceModeApiConfigs",
{},
),
includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true,
maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50,
includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true,

View file

@ -0,0 +1,160 @@
// npx vitest run core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts
import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => {
let mockProvider: {
context: {
workspaceState: {
get: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
getState: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
providerSettingsManager: {
setModeConfig: ReturnType<typeof vi.fn>
}
postMessageToWebview: ReturnType<typeof vi.fn>
getCurrentTask: ReturnType<typeof vi.fn>
}
let workspaceStateStore: Record<string, unknown>
beforeEach(() => {
vi.clearAllMocks()
workspaceStateStore = {}
mockProvider = {
context: {
workspaceState: {
get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => {
return key in workspaceStateStore ? workspaceStateStore[key] : defaultValue
}),
update: vi.fn().mockImplementation((key: string, value: unknown) => {
workspaceStateStore[key] = value
return Promise.resolve()
}),
},
},
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 a workspace mode API config for a specific mode", async () => {
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
mode: "code",
text: "config-123",
})
expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {
code: "config-123",
})
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
})
it("clears a workspace mode API config when text is undefined", async () => {
// Pre-populate with an existing mapping
workspaceStateStore["workspaceModeApiConfigs"] = { code: "config-123", architect: "config-456" }
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
mode: "code",
// text is undefined - clears the override
})
expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {
architect: "config-456",
})
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
})
it("preserves existing workspace configs when adding a new one", async () => {
workspaceStateStore["workspaceModeApiConfigs"] = { architect: "config-456" }
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
mode: "code",
text: "config-789",
})
expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {
architect: "config-456",
code: "config-789",
})
})
it("does nothing if mode is not provided", async () => {
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
// mode is undefined
text: "config-123",
})
expect(mockProvider.context.workspaceState.update).not.toHaveBeenCalled()
})
})
describe("webviewMessageHandler - clearWorkspaceModeApiConfig", () => {
let mockProvider: {
context: {
workspaceState: {
get: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
getState: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
providerSettingsManager: {
setModeConfig: ReturnType<typeof vi.fn>
}
postMessageToWebview: ReturnType<typeof vi.fn>
getCurrentTask: ReturnType<typeof vi.fn>
}
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("clears all workspace mode API configs", async () => {
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "clearWorkspaceModeApiConfig",
})
expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {})
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
})
})

View file

@ -1650,6 +1650,37 @@ export const webviewMessageHandler = async (
break
}
case "setWorkspaceModeApiConfig": {
// Set a workspace-level mode-to-profile override.
// message.mode contains the mode slug, message.text contains the profile config ID.
const modeSlug = message.mode
const configId = message.text
if (modeSlug) {
const workspaceModeApiConfigs = provider.context.workspaceState.get<Record<string, string>>(
"workspaceModeApiConfigs",
{},
)
if (configId) {
workspaceModeApiConfigs[modeSlug] = configId
} else {
delete workspaceModeApiConfigs[modeSlug]
}
await provider.context.workspaceState.update("workspaceModeApiConfigs", workspaceModeApiConfigs)
await provider.postStateToWebview()
}
break
}
case "clearWorkspaceModeApiConfig": {
// Clear all workspace-level mode-to-profile overrides.
await provider.context.workspaceState.update("workspaceModeApiConfigs", {})
await provider.postStateToWebview()
break
}
case "toggleApiConfigPin":
if (message.text) {
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}

View file

@ -22,6 +22,8 @@ interface ApiConfigSelectorProps {
togglePinnedApiConfig: (id: string) => void
lockApiConfigAcrossModes: boolean
onToggleLockApiConfig: () => void
currentMode?: string
workspaceModeApiConfigs?: Record<string, string>
}
export const ApiConfigSelector = ({
@ -36,6 +38,8 @@ export const ApiConfigSelector = ({
togglePinnedApiConfig,
lockApiConfigAcrossModes,
onToggleLockApiConfig,
currentMode,
workspaceModeApiConfigs,
}: ApiConfigSelectorProps) => {
const { t } = useAppTranslation()
const [open, setOpen] = useState(false)
@ -242,6 +246,39 @@ export const ApiConfigSelector = ({
className={lockApiConfigAcrossModes ? "text-vscode-focusBorder" : "opacity-60"}
onClick={onToggleLockApiConfig}
/>
{currentMode && (
<IconButton
iconClass={
workspaceModeApiConfigs?.[currentMode]
? "codicon-root-folder-opened"
: "codicon-root-folder"
}
title={
workspaceModeApiConfigs?.[currentMode]
? t("chat:clearWorkspaceProfile")
: t("chat:setWorkspaceProfile")
}
className={
workspaceModeApiConfigs?.[currentMode]
? "text-vscode-focusBorder"
: "opacity-60"
}
onClick={() => {
if (workspaceModeApiConfigs?.[currentMode]) {
vscode.postMessage({
type: "setWorkspaceModeApiConfig",
mode: currentMode,
})
} else {
vscode.postMessage({
type: "setWorkspaceModeApiConfig",
mode: currentMode,
text: value,
})
}
}}
/>
)}
</div>
{/* Info icon and title on the right with matching spacing */}

View file

@ -99,6 +99,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
cloudUserInfo,
enterBehavior,
lockApiConfigAcrossModes,
workspaceModeApiConfigs,
mode: currentMode,
} = useExtensionState()
// Find the ID and display text for the currently selected API configuration.
@ -1319,6 +1321,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
togglePinnedApiConfig={togglePinnedApiConfig}
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
onToggleLockApiConfig={handleToggleLockApiConfig}
currentMode={currentMode}
workspaceModeApiConfigs={workspaceModeApiConfigs}
/>
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
</div>

View file

@ -263,6 +263,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
includeCurrentTime: true,
includeCurrentCost: true,
lockApiConfigAcrossModes: false,
workspaceModeApiConfigs: {},
})
const [didHydrateState, setDidHydrateState] = useState(false)

View file

@ -142,6 +142,8 @@
"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)",
"setWorkspaceProfile": "Pin this profile to the current mode for this workspace",
"clearWorkspaceProfile": "This profile is pinned to the current mode for this workspace (click to unpin)",
"enhancePrompt": "Enhance prompt with additional context",
"modeSelector": {
"title": "Modes",