Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit b4b2463

Browse files
committed
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
1 parent ad25634 commit b4b2463

8 files changed

Lines changed: 254 additions & 2 deletions

File tree

packages/types/src/vscode-extension-host.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ export type ExtensionState = Pick<
308308
| "disabledTools"
309309
> & {
310310
lockApiConfigAcrossModes?: boolean
311+
workspaceModeApiConfigs?: Record<string, string>
311312
version: string
312313
clineMessages: ClineMessage[]
313314
currentTaskId?: string
@@ -499,6 +500,8 @@ export interface WebviewMessage {
499500
| "toggleApiConfigPin"
500501
| "hasOpenedModeSelector"
501502
| "lockApiConfigAcrossModes"
503+
| "setWorkspaceModeApiConfig"
504+
| "clearWorkspaceModeApiConfig"
502505
| "clearCloudAuthSkipModel"
503506
| "cloudButtonClicked"
504507
| "rooCloudSignIn"

src/core/webview/ClineProvider.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -999,7 +999,12 @@ export class ClineProvider
999999
const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false)
10001000

10011001
if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) {
1002-
const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
1002+
// Check workspace-level override first, then fall back to global mode config.
1003+
const workspaceModeApiConfigs =
1004+
this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {}
1005+
const workspaceConfigId = workspaceModeApiConfigs[historyItem.mode]
1006+
const savedConfigId =
1007+
workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(historyItem.mode))
10031008
const listApiConfig = await this.providerSettingsManager.listConfig()
10041009

10051010
// Update listApiConfigMeta first to ensure UI has latest data.
@@ -1433,8 +1438,13 @@ export class ClineProvider
14331438
return
14341439
}
14351440

1441+
// Check for workspace-level mode-to-profile override first, then fall back to global.
1442+
const workspaceModeApiConfigs =
1443+
this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {}
1444+
const workspaceConfigId = workspaceModeApiConfigs[newMode]
1445+
14361446
// Load the saved API config for the new mode if it exists.
1437-
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
1447+
const savedConfigId = workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(newMode))
14381448
const listApiConfig = await this.providerSettingsManager.listConfig()
14391449

14401450
// Update listApiConfigMeta first to ensure UI has latest data.
@@ -2563,6 +2573,10 @@ export class ClineProvider
25632573
},
25642574
profileThresholds: stateValues.profileThresholds ?? {},
25652575
lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false),
2576+
workspaceModeApiConfigs: this.context.workspaceState.get<Record<string, string>>(
2577+
"workspaceModeApiConfigs",
2578+
{},
2579+
),
25662580
includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true,
25672581
maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50,
25682582
includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true,
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// npx vitest run core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts
2+
3+
import { webviewMessageHandler } from "../webviewMessageHandler"
4+
import type { ClineProvider } from "../ClineProvider"
5+
6+
describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => {
7+
let mockProvider: {
8+
context: {
9+
workspaceState: {
10+
get: ReturnType<typeof vi.fn>
11+
update: ReturnType<typeof vi.fn>
12+
}
13+
}
14+
getState: ReturnType<typeof vi.fn>
15+
postStateToWebview: ReturnType<typeof vi.fn>
16+
providerSettingsManager: {
17+
setModeConfig: ReturnType<typeof vi.fn>
18+
}
19+
postMessageToWebview: ReturnType<typeof vi.fn>
20+
getCurrentTask: ReturnType<typeof vi.fn>
21+
}
22+
23+
let workspaceStateStore: Record<string, unknown>
24+
25+
beforeEach(() => {
26+
vi.clearAllMocks()
27+
28+
workspaceStateStore = {}
29+
30+
mockProvider = {
31+
context: {
32+
workspaceState: {
33+
get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => {
34+
return key in workspaceStateStore ? workspaceStateStore[key] : defaultValue
35+
}),
36+
update: vi.fn().mockImplementation((key: string, value: unknown) => {
37+
workspaceStateStore[key] = value
38+
return Promise.resolve()
39+
}),
40+
},
41+
},
42+
getState: vi.fn().mockResolvedValue({
43+
currentApiConfigName: "test-config",
44+
listApiConfigMeta: [{ name: "test-config", id: "config-123" }],
45+
customModes: [],
46+
}),
47+
postStateToWebview: vi.fn(),
48+
providerSettingsManager: {
49+
setModeConfig: vi.fn(),
50+
},
51+
postMessageToWebview: vi.fn(),
52+
getCurrentTask: vi.fn(),
53+
}
54+
})
55+
56+
it("sets a workspace mode API config for a specific mode", async () => {
57+
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
58+
type: "setWorkspaceModeApiConfig",
59+
mode: "code",
60+
text: "config-123",
61+
})
62+
63+
expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {
64+
code: "config-123",
65+
})
66+
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
67+
})
68+
69+
it("clears a workspace mode API config when text is undefined", async () => {
70+
// Pre-populate with an existing mapping
71+
workspaceStateStore["workspaceModeApiConfigs"] = { code: "config-123", architect: "config-456" }
72+
73+
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
74+
type: "setWorkspaceModeApiConfig",
75+
mode: "code",
76+
// text is undefined - clears the override
77+
})
78+
79+
expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {
80+
architect: "config-456",
81+
})
82+
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
83+
})
84+
85+
it("preserves existing workspace configs when adding a new one", async () => {
86+
workspaceStateStore["workspaceModeApiConfigs"] = { architect: "config-456" }
87+
88+
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
89+
type: "setWorkspaceModeApiConfig",
90+
mode: "code",
91+
text: "config-789",
92+
})
93+
94+
expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {
95+
architect: "config-456",
96+
code: "config-789",
97+
})
98+
})
99+
100+
it("does nothing if mode is not provided", async () => {
101+
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
102+
type: "setWorkspaceModeApiConfig",
103+
// mode is undefined
104+
text: "config-123",
105+
})
106+
107+
expect(mockProvider.context.workspaceState.update).not.toHaveBeenCalled()
108+
})
109+
})
110+
111+
describe("webviewMessageHandler - clearWorkspaceModeApiConfig", () => {
112+
let mockProvider: {
113+
context: {
114+
workspaceState: {
115+
get: ReturnType<typeof vi.fn>
116+
update: ReturnType<typeof vi.fn>
117+
}
118+
}
119+
getState: ReturnType<typeof vi.fn>
120+
postStateToWebview: ReturnType<typeof vi.fn>
121+
providerSettingsManager: {
122+
setModeConfig: ReturnType<typeof vi.fn>
123+
}
124+
postMessageToWebview: ReturnType<typeof vi.fn>
125+
getCurrentTask: ReturnType<typeof vi.fn>
126+
}
127+
128+
beforeEach(() => {
129+
vi.clearAllMocks()
130+
131+
mockProvider = {
132+
context: {
133+
workspaceState: {
134+
get: vi.fn(),
135+
update: vi.fn().mockResolvedValue(undefined),
136+
},
137+
},
138+
getState: vi.fn().mockResolvedValue({
139+
currentApiConfigName: "test-config",
140+
listApiConfigMeta: [{ name: "test-config", id: "config-123" }],
141+
customModes: [],
142+
}),
143+
postStateToWebview: vi.fn(),
144+
providerSettingsManager: {
145+
setModeConfig: vi.fn(),
146+
},
147+
postMessageToWebview: vi.fn(),
148+
getCurrentTask: vi.fn(),
149+
}
150+
})
151+
152+
it("clears all workspace mode API configs", async () => {
153+
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
154+
type: "clearWorkspaceModeApiConfig",
155+
})
156+
157+
expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {})
158+
expect(mockProvider.postStateToWebview).toHaveBeenCalled()
159+
})
160+
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,6 +1650,37 @@ export const webviewMessageHandler = async (
16501650
break
16511651
}
16521652

1653+
case "setWorkspaceModeApiConfig": {
1654+
// Set a workspace-level mode-to-profile override.
1655+
// message.mode contains the mode slug, message.text contains the profile config ID.
1656+
const modeSlug = message.mode
1657+
const configId = message.text
1658+
1659+
if (modeSlug) {
1660+
const workspaceModeApiConfigs = provider.context.workspaceState.get<Record<string, string>>(
1661+
"workspaceModeApiConfigs",
1662+
{},
1663+
)
1664+
1665+
if (configId) {
1666+
workspaceModeApiConfigs[modeSlug] = configId
1667+
} else {
1668+
delete workspaceModeApiConfigs[modeSlug]
1669+
}
1670+
1671+
await provider.context.workspaceState.update("workspaceModeApiConfigs", workspaceModeApiConfigs)
1672+
await provider.postStateToWebview()
1673+
}
1674+
break
1675+
}
1676+
1677+
case "clearWorkspaceModeApiConfig": {
1678+
// Clear all workspace-level mode-to-profile overrides.
1679+
await provider.context.workspaceState.update("workspaceModeApiConfigs", {})
1680+
await provider.postStateToWebview()
1681+
break
1682+
}
1683+
16531684
case "toggleApiConfigPin":
16541685
if (message.text) {
16551686
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}

webview-ui/src/components/chat/ApiConfigSelector.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ interface ApiConfigSelectorProps {
2222
togglePinnedApiConfig: (id: string) => void
2323
lockApiConfigAcrossModes: boolean
2424
onToggleLockApiConfig: () => void
25+
currentMode?: string
26+
workspaceModeApiConfigs?: Record<string, string>
2527
}
2628

2729
export const ApiConfigSelector = ({
@@ -36,6 +38,8 @@ export const ApiConfigSelector = ({
3638
togglePinnedApiConfig,
3739
lockApiConfigAcrossModes,
3840
onToggleLockApiConfig,
41+
currentMode,
42+
workspaceModeApiConfigs,
3943
}: ApiConfigSelectorProps) => {
4044
const { t } = useAppTranslation()
4145
const [open, setOpen] = useState(false)
@@ -242,6 +246,39 @@ export const ApiConfigSelector = ({
242246
className={lockApiConfigAcrossModes ? "text-vscode-focusBorder" : "opacity-60"}
243247
onClick={onToggleLockApiConfig}
244248
/>
249+
{currentMode && (
250+
<IconButton
251+
iconClass={
252+
workspaceModeApiConfigs?.[currentMode]
253+
? "codicon-root-folder-opened"
254+
: "codicon-root-folder"
255+
}
256+
title={
257+
workspaceModeApiConfigs?.[currentMode]
258+
? t("chat:clearWorkspaceProfile")
259+
: t("chat:setWorkspaceProfile")
260+
}
261+
className={
262+
workspaceModeApiConfigs?.[currentMode]
263+
? "text-vscode-focusBorder"
264+
: "opacity-60"
265+
}
266+
onClick={() => {
267+
if (workspaceModeApiConfigs?.[currentMode]) {
268+
vscode.postMessage({
269+
type: "setWorkspaceModeApiConfig",
270+
mode: currentMode,
271+
})
272+
} else {
273+
vscode.postMessage({
274+
type: "setWorkspaceModeApiConfig",
275+
mode: currentMode,
276+
text: value,
277+
})
278+
}
279+
}}
280+
/>
281+
)}
245282
</div>
246283

247284
{/* Info icon and title on the right with matching spacing */}

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
9999
cloudUserInfo,
100100
enterBehavior,
101101
lockApiConfigAcrossModes,
102+
workspaceModeApiConfigs,
103+
mode: currentMode,
102104
} = useExtensionState()
103105

104106
// Find the ID and display text for the currently selected API configuration.
@@ -1319,6 +1321,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
13191321
togglePinnedApiConfig={togglePinnedApiConfig}
13201322
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
13211323
onToggleLockApiConfig={handleToggleLockApiConfig}
1324+
currentMode={currentMode}
1325+
workspaceModeApiConfigs={workspaceModeApiConfigs}
13221326
/>
13231327
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
13241328
</div>

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
263263
includeCurrentTime: true,
264264
includeCurrentCost: true,
265265
lockApiConfigAcrossModes: false,
266+
workspaceModeApiConfigs: {},
266267
})
267268

268269
const [didHydrateState, setDidHydrateState] = useState(false)

webview-ui/src/i18n/locales/en/chat.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@
142142
"selectApiConfig": "Select API configuration",
143143
"lockApiConfigAcrossModes": "Lock API configuration across all modes in this workspace",
144144
"unlockApiConfigAcrossModes": "API configuration is locked across all modes in this workspace (click to unlock)",
145+
"setWorkspaceProfile": "Pin this profile to the current mode for this workspace",
146+
"clearWorkspaceProfile": "This profile is pinned to the current mode for this workspace (click to unpin)",
145147
"enhancePrompt": "Enhance prompt with additional context",
146148
"modeSelector": {
147149
"title": "Modes",

0 commit comments

Comments
 (0)