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

Commit abafe2d

Browse files
committed
fix: gate workspace profile overrides behind experimental flag and show conflict state
- Add WORKSPACE_PROFILE_OVERRIDES experiment to types, schema, and config - Gate workspace override logic in ClineProvider behind experiment flag - Gate workspace override logic in webviewMessageHandler behind experiment flag - Gate workspace pin button in ApiConfigSelector behind experiment flag - Show warning icon when current mode is already pinned to a different profile - Add "reassignWorkspaceProfile" translation key for conflict state - Add "Project-specific profile usage" experimental feature setting - Add test for experiment-disabled scenario
1 parent b4b2463 commit abafe2d

10 files changed

Lines changed: 116 additions & 42 deletions

File tree

packages/types/src/experiment.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js"
66
* ExperimentId
77
*/
88

9-
export const experimentIds = ["preventFocusDisruption", "imageGeneration", "runSlashCommand", "customTools"] as const
9+
export const experimentIds = [
10+
"preventFocusDisruption",
11+
"imageGeneration",
12+
"runSlashCommand",
13+
"customTools",
14+
"workspaceProfileOverrides",
15+
] as const
1016

1117
export const experimentIdsSchema = z.enum(experimentIds)
1218

@@ -21,6 +27,7 @@ export const experimentsSchema = z.object({
2127
imageGeneration: z.boolean().optional(),
2228
runSlashCommand: z.boolean().optional(),
2329
customTools: z.boolean().optional(),
30+
workspaceProfileOverrides: z.boolean().optional(),
2431
})
2532

2633
export type Experiments = z.infer<typeof experimentsSchema>

src/core/webview/ClineProvider.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import { findLast } from "../../shared/array"
5656
import { supportPrompt } from "../../shared/support-prompt"
5757
import { GlobalFileNames } from "../../shared/globalFileNames"
5858
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
59-
import { experimentDefault } from "../../shared/experiments"
59+
import { experimentDefault, experiments as experimentsUtil, EXPERIMENT_IDS } from "../../shared/experiments"
6060
import { formatLanguage } from "../../shared/language"
6161
import { WebviewMessage } from "../../shared/WebviewMessage"
6262
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
@@ -999,9 +999,15 @@ export class ClineProvider
999999
const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false)
10001000

10011001
if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) {
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") ?? {}
1002+
// Check workspace-level override first (if experiment enabled), then fall back to global mode config.
1003+
const { experiments: experimentsState } = await this.getState()
1004+
const workspaceOverridesEnabled = experimentsUtil.isEnabled(
1005+
experimentsState ?? experimentDefault,
1006+
EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES,
1007+
)
1008+
const workspaceModeApiConfigs = workspaceOverridesEnabled
1009+
? (this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {})
1010+
: {}
10051011
const workspaceConfigId = workspaceModeApiConfigs[historyItem.mode]
10061012
const savedConfigId =
10071013
workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(historyItem.mode))
@@ -1438,9 +1444,15 @@ export class ClineProvider
14381444
return
14391445
}
14401446

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") ?? {}
1447+
// Check for workspace-level mode-to-profile override first (if experiment enabled), then fall back to global.
1448+
const { experiments: experimentsState } = await this.getState()
1449+
const workspaceOverridesEnabled = experimentsUtil.isEnabled(
1450+
experimentsState ?? experimentDefault,
1451+
EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES,
1452+
)
1453+
const workspaceModeApiConfigs = workspaceOverridesEnabled
1454+
? (this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {})
1455+
: {}
14441456
const workspaceConfigId = workspaceModeApiConfigs[newMode]
14451457

14461458
// Load the saved API config for the new mode if it exists.

src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => {
4343
currentApiConfigName: "test-config",
4444
listApiConfigMeta: [{ name: "test-config", id: "config-123" }],
4545
customModes: [],
46+
experiments: { workspaceProfileOverrides: true },
4647
}),
4748
postStateToWebview: vi.fn(),
4849
providerSettingsManager: {
@@ -53,6 +54,23 @@ describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => {
5354
}
5455
})
5556

57+
it("does nothing when experiment is disabled", async () => {
58+
mockProvider.getState.mockResolvedValueOnce({
59+
currentApiConfigName: "test-config",
60+
listApiConfigMeta: [{ name: "test-config", id: "config-123" }],
61+
customModes: [],
62+
experiments: { workspaceProfileOverrides: false },
63+
})
64+
65+
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
66+
type: "setWorkspaceModeApiConfig",
67+
mode: "code",
68+
text: "config-123",
69+
})
70+
71+
expect(mockProvider.context.workspaceState.update).not.toHaveBeenCalled()
72+
})
73+
5674
it("sets a workspace mode API config for a specific mode", async () => {
5775
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
5876
type: "setWorkspaceModeApiConfig",

src/core/webview/webviewMessageHandler.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { MessageEnhancer } from "./messageEnhancer"
4747

4848
import { CodeIndexManager } from "../../services/code-index/manager"
4949
import { checkExistKey } from "../../shared/checkExistApiConfig"
50-
import { experimentDefault } from "../../shared/experiments"
50+
import { experimentDefault, experiments as experimentsUtil, EXPERIMENT_IDS } from "../../shared/experiments"
5151
import { Terminal } from "../../integrations/terminal/Terminal"
5252
import { openFile } from "../../integrations/misc/open-file"
5353
import { openImage, saveImage } from "../../integrations/misc/image-handler"
@@ -1653,6 +1653,16 @@ export const webviewMessageHandler = async (
16531653
case "setWorkspaceModeApiConfig": {
16541654
// Set a workspace-level mode-to-profile override.
16551655
// message.mode contains the mode slug, message.text contains the profile config ID.
1656+
// Only proceed if the workspace profile overrides experiment is enabled.
1657+
const { experiments: expState } = await provider.getState()
1658+
const wsOverridesEnabled = experimentsUtil.isEnabled(
1659+
expState ?? experimentDefault,
1660+
EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES,
1661+
)
1662+
if (!wsOverridesEnabled) {
1663+
break
1664+
}
1665+
16561666
const modeSlug = message.mode
16571667
const configId = message.text
16581668

src/shared/__tests__/experiments.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe("experiments", () => {
2121
imageGeneration: false,
2222
runSlashCommand: false,
2323
customTools: false,
24+
workspaceProfileOverrides: false,
2425
}
2526
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false)
2627
})
@@ -31,6 +32,7 @@ describe("experiments", () => {
3132
imageGeneration: false,
3233
runSlashCommand: false,
3334
customTools: false,
35+
workspaceProfileOverrides: false,
3436
}
3537
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true)
3638
})
@@ -41,6 +43,7 @@ describe("experiments", () => {
4143
imageGeneration: false,
4244
runSlashCommand: false,
4345
customTools: false,
46+
workspaceProfileOverrides: false,
4447
}
4548
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false)
4649
})

src/shared/experiments.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const EXPERIMENT_IDS = {
55
IMAGE_GENERATION: "imageGeneration",
66
RUN_SLASH_COMMAND: "runSlashCommand",
77
CUSTOM_TOOLS: "customTools",
8+
WORKSPACE_PROFILE_OVERRIDES: "workspaceProfileOverrides",
89
} as const satisfies Record<string, ExperimentId>
910

1011
type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
@@ -20,6 +21,7 @@ export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
2021
IMAGE_GENERATION: { enabled: false },
2122
RUN_SLASH_COMMAND: { enabled: false },
2223
CUSTOM_TOOLS: { enabled: false },
24+
WORKSPACE_PROFILE_OVERRIDES: { enabled: false },
2325
}
2426

2527
export const experimentDefault = Object.fromEntries(

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

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface ApiConfigSelectorProps {
2424
onToggleLockApiConfig: () => void
2525
currentMode?: string
2626
workspaceModeApiConfigs?: Record<string, string>
27+
enableWorkspaceOverrides?: boolean
2728
}
2829

2930
export const ApiConfigSelector = ({
@@ -40,6 +41,7 @@ export const ApiConfigSelector = ({
4041
onToggleLockApiConfig,
4142
currentMode,
4243
workspaceModeApiConfigs,
44+
enableWorkspaceOverrides,
4345
}: ApiConfigSelectorProps) => {
4446
const { t } = useAppTranslation()
4547
const [open, setOpen] = useState(false)
@@ -246,39 +248,52 @@ export const ApiConfigSelector = ({
246248
className={lockApiConfigAcrossModes ? "text-vscode-focusBorder" : "opacity-60"}
247249
onClick={onToggleLockApiConfig}
248250
/>
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-
)}
251+
{currentMode &&
252+
enableWorkspaceOverrides &&
253+
(() => {
254+
const pinnedConfigId = workspaceModeApiConfigs?.[currentMode]
255+
const isPinnedToThis = pinnedConfigId === value
256+
const isPinnedToOther = !!pinnedConfigId && pinnedConfigId !== value
257+
return (
258+
<IconButton
259+
iconClass={
260+
isPinnedToThis
261+
? "codicon-root-folder-opened"
262+
: isPinnedToOther
263+
? "codicon-warning"
264+
: "codicon-root-folder"
265+
}
266+
title={
267+
isPinnedToThis
268+
? t("chat:clearWorkspaceProfile")
269+
: isPinnedToOther
270+
? t("chat:reassignWorkspaceProfile")
271+
: t("chat:setWorkspaceProfile")
272+
}
273+
className={
274+
isPinnedToThis
275+
? "text-vscode-focusBorder"
276+
: isPinnedToOther
277+
? "text-vscode-editorWarning-foreground opacity-80"
278+
: "opacity-60"
279+
}
280+
onClick={() => {
281+
if (isPinnedToThis) {
282+
vscode.postMessage({
283+
type: "setWorkspaceModeApiConfig",
284+
mode: currentMode,
285+
})
286+
} else {
287+
vscode.postMessage({
288+
type: "setWorkspaceModeApiConfig",
289+
mode: currentMode,
290+
text: value,
291+
})
292+
}
293+
}}
294+
/>
295+
)
296+
})()}
282297
</div>
283298

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

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
101101
lockApiConfigAcrossModes,
102102
workspaceModeApiConfigs,
103103
mode: currentMode,
104+
experiments,
104105
} = useExtensionState()
105106

106107
// Find the ID and display text for the currently selected API configuration.
@@ -1323,6 +1324,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
13231324
onToggleLockApiConfig={handleToggleLockApiConfig}
13241325
currentMode={currentMode}
13251326
workspaceModeApiConfigs={workspaceModeApiConfigs}
1327+
enableWorkspaceOverrides={!!experiments?.workspaceProfileOverrides}
13261328
/>
13271329
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
13281330
</div>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
"unlockApiConfigAcrossModes": "API configuration is locked across all modes in this workspace (click to unlock)",
145145
"setWorkspaceProfile": "Pin this profile to the current mode for this workspace",
146146
"clearWorkspaceProfile": "This profile is pinned to the current mode for this workspace (click to unpin)",
147+
"reassignWorkspaceProfile": "This mode is already pinned to a different profile in this workspace (click to reassign)",
147148
"enhancePrompt": "Enhance prompt with additional context",
148149
"modeSelector": {
149150
"title": "Modes",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,10 @@
886886
"refreshSuccess": "Tools refreshed successfully",
887887
"refreshError": "Failed to refresh tools",
888888
"toolParameters": "Parameters"
889+
},
890+
"WORKSPACE_PROFILE_OVERRIDES": {
891+
"name": "Project-specific profile usage",
892+
"description": "When enabled, you can pin provider profiles to specific modes on a per-workspace basis. Workspace overrides take priority over global mode-to-profile mappings."
889893
}
890894
},
891895
"promptCaching": {

0 commit comments

Comments
 (0)