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
This commit is contained in:
Roo Code 2026-04-29 20:22:46 +00:00
parent b4b2463461
commit abafe2d4f5
10 changed files with 116 additions and 42 deletions

View file

@ -6,7 +6,13 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js"
* ExperimentId
*/
export const experimentIds = ["preventFocusDisruption", "imageGeneration", "runSlashCommand", "customTools"] as const
export const experimentIds = [
"preventFocusDisruption",
"imageGeneration",
"runSlashCommand",
"customTools",
"workspaceProfileOverrides",
] as const
export const experimentIdsSchema = z.enum(experimentIds)
@ -21,6 +27,7 @@ export const experimentsSchema = z.object({
imageGeneration: z.boolean().optional(),
runSlashCommand: z.boolean().optional(),
customTools: z.boolean().optional(),
workspaceProfileOverrides: z.boolean().optional(),
})
export type Experiments = z.infer<typeof experimentsSchema>

View file

@ -56,7 +56,7 @@ import { findLast } from "../../shared/array"
import { supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { experimentDefault } from "../../shared/experiments"
import { experimentDefault, experiments as experimentsUtil, EXPERIMENT_IDS } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
@ -999,9 +999,15 @@ export class ClineProvider
const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false)
if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) {
// Check workspace-level override first, then fall back to global mode config.
const workspaceModeApiConfigs =
this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {}
// Check workspace-level override first (if experiment enabled), then fall back to global mode config.
const { experiments: experimentsState } = await this.getState()
const workspaceOverridesEnabled = experimentsUtil.isEnabled(
experimentsState ?? experimentDefault,
EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES,
)
const workspaceModeApiConfigs = workspaceOverridesEnabled
? (this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {})
: {}
const workspaceConfigId = workspaceModeApiConfigs[historyItem.mode]
const savedConfigId =
workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(historyItem.mode))
@ -1438,9 +1444,15 @@ 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") ?? {}
// Check for workspace-level mode-to-profile override first (if experiment enabled), then fall back to global.
const { experiments: experimentsState } = await this.getState()
const workspaceOverridesEnabled = experimentsUtil.isEnabled(
experimentsState ?? experimentDefault,
EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES,
)
const workspaceModeApiConfigs = workspaceOverridesEnabled
? (this.context.workspaceState.get<Record<string, string>>("workspaceModeApiConfigs") ?? {})
: {}
const workspaceConfigId = workspaceModeApiConfigs[newMode]
// Load the saved API config for the new mode if it exists.

View file

@ -43,6 +43,7 @@ describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => {
currentApiConfigName: "test-config",
listApiConfigMeta: [{ name: "test-config", id: "config-123" }],
customModes: [],
experiments: { workspaceProfileOverrides: true },
}),
postStateToWebview: vi.fn(),
providerSettingsManager: {
@ -53,6 +54,23 @@ describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => {
}
})
it("does nothing when experiment is disabled", async () => {
mockProvider.getState.mockResolvedValueOnce({
currentApiConfigName: "test-config",
listApiConfigMeta: [{ name: "test-config", id: "config-123" }],
customModes: [],
experiments: { workspaceProfileOverrides: false },
})
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",
mode: "code",
text: "config-123",
})
expect(mockProvider.context.workspaceState.update).not.toHaveBeenCalled()
})
it("sets a workspace mode API config for a specific mode", async () => {
await webviewMessageHandler(mockProvider as unknown as ClineProvider, {
type: "setWorkspaceModeApiConfig",

View file

@ -47,7 +47,7 @@ import { MessageEnhancer } from "./messageEnhancer"
import { CodeIndexManager } from "../../services/code-index/manager"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { experimentDefault } from "../../shared/experiments"
import { experimentDefault, experiments as experimentsUtil, EXPERIMENT_IDS } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
import { openFile } from "../../integrations/misc/open-file"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
@ -1653,6 +1653,16 @@ export const webviewMessageHandler = async (
case "setWorkspaceModeApiConfig": {
// Set a workspace-level mode-to-profile override.
// message.mode contains the mode slug, message.text contains the profile config ID.
// Only proceed if the workspace profile overrides experiment is enabled.
const { experiments: expState } = await provider.getState()
const wsOverridesEnabled = experimentsUtil.isEnabled(
expState ?? experimentDefault,
EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES,
)
if (!wsOverridesEnabled) {
break
}
const modeSlug = message.mode
const configId = message.text

View file

@ -21,6 +21,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
customTools: false,
workspaceProfileOverrides: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false)
})
@ -31,6 +32,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
customTools: false,
workspaceProfileOverrides: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true)
})
@ -41,6 +43,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
customTools: false,
workspaceProfileOverrides: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false)
})

View file

@ -5,6 +5,7 @@ export const EXPERIMENT_IDS = {
IMAGE_GENERATION: "imageGeneration",
RUN_SLASH_COMMAND: "runSlashCommand",
CUSTOM_TOOLS: "customTools",
WORKSPACE_PROFILE_OVERRIDES: "workspaceProfileOverrides",
} as const satisfies Record<string, ExperimentId>
type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
@ -20,6 +21,7 @@ export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
IMAGE_GENERATION: { enabled: false },
RUN_SLASH_COMMAND: { enabled: false },
CUSTOM_TOOLS: { enabled: false },
WORKSPACE_PROFILE_OVERRIDES: { enabled: false },
}
export const experimentDefault = Object.fromEntries(

View file

@ -24,6 +24,7 @@ interface ApiConfigSelectorProps {
onToggleLockApiConfig: () => void
currentMode?: string
workspaceModeApiConfigs?: Record<string, string>
enableWorkspaceOverrides?: boolean
}
export const ApiConfigSelector = ({
@ -40,6 +41,7 @@ export const ApiConfigSelector = ({
onToggleLockApiConfig,
currentMode,
workspaceModeApiConfigs,
enableWorkspaceOverrides,
}: ApiConfigSelectorProps) => {
const { t } = useAppTranslation()
const [open, setOpen] = useState(false)
@ -246,39 +248,52 @@ 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,
})
}
}}
/>
)}
{currentMode &&
enableWorkspaceOverrides &&
(() => {
const pinnedConfigId = workspaceModeApiConfigs?.[currentMode]
const isPinnedToThis = pinnedConfigId === value
const isPinnedToOther = !!pinnedConfigId && pinnedConfigId !== value
return (
<IconButton
iconClass={
isPinnedToThis
? "codicon-root-folder-opened"
: isPinnedToOther
? "codicon-warning"
: "codicon-root-folder"
}
title={
isPinnedToThis
? t("chat:clearWorkspaceProfile")
: isPinnedToOther
? t("chat:reassignWorkspaceProfile")
: t("chat:setWorkspaceProfile")
}
className={
isPinnedToThis
? "text-vscode-focusBorder"
: isPinnedToOther
? "text-vscode-editorWarning-foreground opacity-80"
: "opacity-60"
}
onClick={() => {
if (isPinnedToThis) {
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

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

View file

@ -144,6 +144,7 @@
"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)",
"reassignWorkspaceProfile": "This mode is already pinned to a different profile in this workspace (click to reassign)",
"enhancePrompt": "Enhance prompt with additional context",
"modeSelector": {
"title": "Modes",

View file

@ -886,6 +886,10 @@
"refreshSuccess": "Tools refreshed successfully",
"refreshError": "Failed to refresh tools",
"toolParameters": "Parameters"
},
"WORKSPACE_PROFILE_OVERRIDES": {
"name": "Project-specific profile usage",
"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."
}
},
"promptCaching": {