Batch settings updates from the webview to the extension host (#9165)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Chris Estreich 2025-11-12 12:29:06 -08:00 • committed by GitHub
parent e70c1754bd
commit 62d8cc0d66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 438 additions and 694 deletions

View file

@ -704,7 +704,13 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
signal: AbortSignal.timeout(10000),
})
return clerkOrganizationMembershipsSchema.parse(await response.json()).response
if (response.ok) {
return clerkOrganizationMembershipsSchema.parse(await response.json()).response
}
const errorMessage = `Failed to get organization memberships: ${response.status} ${response.statusText}`
this.log(`[auth] ${errorMessage}`)
throw new Error(errorMessage)
}
private async getOrganizationMetadata(

View file

@ -145,6 +145,10 @@ export class ClineProvider
private pendingOperations: Map<string, PendingEditOperation> = new Map()
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null
private cloudOrganizationsCacheTimestamp: number | null = null
private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds
public isViewLaunched = false
public settingsImportedAt?: number
public readonly latestAnnouncementId = "nov-2025-v3.30.0-pr-fixer" // v3.30.0 PR Fixer announcement
@ -1919,7 +1923,19 @@ export class ClineProvider
try {
if (!CloudService.instance.isCloudAgent) {
cloudOrganizations = await CloudService.instance.getOrganizationMemberships()
const now = Date.now()
if (
this.cloudOrganizationsCache !== null &&
this.cloudOrganizationsCacheTimestamp !== null &&
now - this.cloudOrganizationsCacheTimestamp < ClineProvider.CLOUD_ORGANIZATIONS_CACHE_DURATION_MS
) {
cloudOrganizations = this.cloudOrganizationsCache!
} else {
cloudOrganizations = await CloudService.instance.getOrganizationMemberships()
this.cloudOrganizationsCache = cloudOrganizations
this.cloudOrganizationsCacheTimestamp = now
}
}
} catch (error) {
// Ignore this error.

View file

@ -1,4 +1,4 @@
// npx vitest core/webview/__tests__/ClineProvider.spec.ts
// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts
import Anthropic from "@anthropic-ai/sdk"
import * as vscode from "vscode"
@ -786,7 +786,7 @@ describe("ClineProvider", () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
await messageHandler({ type: "writeDelayMs", value: 2000 })
await messageHandler({ type: "updateSettings", updatedSettings: { writeDelayMs: 2000 } })
expect(updateGlobalStateSpy).toHaveBeenCalledWith("writeDelayMs", 2000)
expect(mockContext.globalState.update).toHaveBeenCalledWith("writeDelayMs", 2000)
@ -800,24 +800,24 @@ describe("ClineProvider", () => {
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
// Simulate setting sound to enabled
await messageHandler({ type: "soundEnabled", bool: true })
await messageHandler({ type: "updateSettings", updatedSettings: { soundEnabled: true } })
expect(updateGlobalStateSpy).toHaveBeenCalledWith("soundEnabled", true)
expect(mockContext.globalState.update).toHaveBeenCalledWith("soundEnabled", true)
expect(mockPostMessage).toHaveBeenCalled()
// Simulate setting sound to disabled
await messageHandler({ type: "soundEnabled", bool: false })
await messageHandler({ type: "updateSettings", updatedSettings: { soundEnabled: false } })
expect(mockContext.globalState.update).toHaveBeenCalledWith("soundEnabled", false)
expect(mockPostMessage).toHaveBeenCalled()
// Simulate setting tts to enabled
await messageHandler({ type: "ttsEnabled", bool: true })
await messageHandler({ type: "updateSettings", updatedSettings: { ttsEnabled: true } })
expect(setTtsEnabled).toHaveBeenCalledWith(true)
expect(mockContext.globalState.update).toHaveBeenCalledWith("ttsEnabled", true)
expect(mockPostMessage).toHaveBeenCalled()
// Simulate setting tts to disabled
await messageHandler({ type: "ttsEnabled", bool: false })
await messageHandler({ type: "updateSettings", updatedSettings: { ttsEnabled: false } })
expect(setTtsEnabled).toHaveBeenCalledWith(false)
expect(mockContext.globalState.update).toHaveBeenCalledWith("ttsEnabled", false)
expect(mockPostMessage).toHaveBeenCalled()
@ -856,7 +856,7 @@ describe("ClineProvider", () => {
test("handles autoCondenseContext message", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
await messageHandler({ type: "autoCondenseContext", bool: false })
await messageHandler({ type: "updateSettings", updatedSettings: { autoCondenseContext: false } })
expect(updateGlobalStateSpy).toHaveBeenCalledWith("autoCondenseContext", false)
expect(mockContext.globalState.update).toHaveBeenCalledWith("autoCondenseContext", false)
expect(mockPostMessage).toHaveBeenCalled()
@ -876,7 +876,7 @@ describe("ClineProvider", () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
await messageHandler({ type: "autoCondenseContextPercent", value: 75 })
await messageHandler({ type: "updateSettings", updatedSettings: { autoCondenseContextPercent: 75 } })
expect(updateGlobalStateSpy).toHaveBeenCalledWith("autoCondenseContextPercent", 75)
expect(mockContext.globalState.update).toHaveBeenCalledWith("autoCondenseContextPercent", 75)
@ -984,7 +984,7 @@ describe("ClineProvider", () => {
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
// Test browserToolEnabled
await messageHandler({ type: "browserToolEnabled", bool: true })
await messageHandler({ type: "updateSettings", updatedSettings: { browserToolEnabled: true } })
expect(mockContext.globalState.update).toHaveBeenCalledWith("browserToolEnabled", true)
expect(mockPostMessage).toHaveBeenCalled()
@ -1002,13 +1002,13 @@ describe("ClineProvider", () => {
expect((await provider.getState()).showRooIgnoredFiles).toBe(false)
// Test showRooIgnoredFiles with true
await messageHandler({ type: "showRooIgnoredFiles", bool: true })
await messageHandler({ type: "updateSettings", updatedSettings: { showRooIgnoredFiles: true } })
expect(mockContext.globalState.update).toHaveBeenCalledWith("showRooIgnoredFiles", true)
expect(mockPostMessage).toHaveBeenCalled()
expect((await provider.getState()).showRooIgnoredFiles).toBe(true)
// Test showRooIgnoredFiles with false
await messageHandler({ type: "showRooIgnoredFiles", bool: false })
await messageHandler({ type: "updateSettings", updatedSettings: { showRooIgnoredFiles: false } })
expect(mockContext.globalState.update).toHaveBeenCalledWith("showRooIgnoredFiles", false)
expect(mockPostMessage).toHaveBeenCalled()
expect((await provider.getState()).showRooIgnoredFiles).toBe(false)
@ -1019,13 +1019,13 @@ describe("ClineProvider", () => {
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
// Test alwaysApproveResubmit
await messageHandler({ type: "alwaysApproveResubmit", bool: true })
await messageHandler({ type: "updateSettings", updatedSettings: { alwaysApproveResubmit: true } })
expect(updateGlobalStateSpy).toHaveBeenCalledWith("alwaysApproveResubmit", true)
expect(mockContext.globalState.update).toHaveBeenCalledWith("alwaysApproveResubmit", true)
expect(mockPostMessage).toHaveBeenCalled()
// Test requestDelaySeconds
await messageHandler({ type: "requestDelaySeconds", value: 10 })
await messageHandler({ type: "updateSettings", updatedSettings: { requestDelaySeconds: 10 } })
expect(mockContext.globalState.update).toHaveBeenCalledWith("requestDelaySeconds", 10)
expect(mockPostMessage).toHaveBeenCalled()
})
@ -1092,7 +1092,7 @@ describe("ClineProvider", () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
await messageHandler({ type: "maxWorkspaceFiles", value: 300 })
await messageHandler({ type: "updateSettings", updatedSettings: { maxWorkspaceFiles: 300 } })
expect(updateGlobalStateSpy).toHaveBeenCalledWith("maxWorkspaceFiles", 300)
expect(mockContext.globalState.update).toHaveBeenCalledWith("maxWorkspaceFiles", 300)

View file

@ -721,8 +721,8 @@ describe("webviewMessageHandler - mcpEnabled", () => {
it("delegates enable=true to McpHub and posts updated state", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "mcpEnabled",
bool: true,
type: "updateSettings",
updatedSettings: { mcpEnabled: true },
})
expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)
@ -733,8 +733,8 @@ describe("webviewMessageHandler - mcpEnabled", () => {
it("delegates enable=false to McpHub and posts updated state", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "mcpEnabled",
bool: false,
type: "updateSettings",
updatedSettings: { mcpEnabled: false },
})
expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)
@ -747,8 +747,8 @@ describe("webviewMessageHandler - mcpEnabled", () => {
;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(undefined)
await webviewMessageHandler(mockClineProvider, {
type: "mcpEnabled",
bool: true,
type: "updateSettings",
updatedSettings: { mcpEnabled: true },
})
expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)

View file

@ -10,9 +10,11 @@ import {
type GlobalState,
type ClineMessage,
type TelemetrySetting,
type UserSettingsConfig,
TelemetryEventName,
UserSettingsConfig,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
RooCodeSettings,
Experiments,
ExperimentId,
} from "@roo-code/types"
import { CloudService } from "@roo-code/cloud"
import { TelemetryService } from "@roo-code/telemetry"
@ -507,16 +509,10 @@ export const webviewMessageHandler = async (
try {
await provider.createTask(message.text, message.images)
// Task created successfully - notify the UI to reset
await provider.postMessageToWebview({
type: "invoke",
invoke: "newChat",
})
await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" })
} catch (error) {
// For all errors, reset the UI and show error
await provider.postMessageToWebview({
type: "invoke",
invoke: "newChat",
})
await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" })
// Show error to user
vscode.window.showErrorMessage(
`Failed to create task: ${error instanceof Error ? error.message : String(error)}`,
@ -526,69 +522,111 @@ export const webviewMessageHandler = async (
case "customInstructions":
await provider.updateCustomInstructions(message.text)
break
case "alwaysAllowReadOnly":
await updateGlobalState("alwaysAllowReadOnly", message.bool ?? undefined)
await provider.postStateToWebview()
break
case "alwaysAllowReadOnlyOutsideWorkspace":
await updateGlobalState("alwaysAllowReadOnlyOutsideWorkspace", message.bool ?? undefined)
await provider.postStateToWebview()
break
case "alwaysAllowWrite":
await updateGlobalState("alwaysAllowWrite", message.bool ?? undefined)
await provider.postStateToWebview()
break
case "alwaysAllowWriteOutsideWorkspace":
await updateGlobalState("alwaysAllowWriteOutsideWorkspace", message.bool ?? undefined)
await provider.postStateToWebview()
break
case "alwaysAllowWriteProtected":
await updateGlobalState("alwaysAllowWriteProtected", message.bool ?? undefined)
await provider.postStateToWebview()
break
case "alwaysAllowExecute":
await updateGlobalState("alwaysAllowExecute", message.bool ?? undefined)
await provider.postStateToWebview()
break
case "alwaysAllowBrowser":
await updateGlobalState("alwaysAllowBrowser", message.bool ?? undefined)
await provider.postStateToWebview()
break
case "alwaysAllowMcp":
await updateGlobalState("alwaysAllowMcp", message.bool)
await provider.postStateToWebview()
break
case "alwaysAllowModeSwitch":
await updateGlobalState("alwaysAllowModeSwitch", message.bool)
await provider.postStateToWebview()
break
case "allowedMaxRequests":
await updateGlobalState("allowedMaxRequests", message.value)
await provider.postStateToWebview()
break
case "allowedMaxCost":
await updateGlobalState("allowedMaxCost", message.value)
await provider.postStateToWebview()
break
case "alwaysAllowSubtasks":
await updateGlobalState("alwaysAllowSubtasks", message.bool)
await provider.postStateToWebview()
break
case "alwaysAllowUpdateTodoList":
await updateGlobalState("alwaysAllowUpdateTodoList", message.bool)
await provider.postStateToWebview()
break
case "askResponse":
provider.getCurrentTask()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
break
case "autoCondenseContext":
await updateGlobalState("autoCondenseContext", message.bool)
await provider.postStateToWebview()
break
case "autoCondenseContextPercent":
await updateGlobalState("autoCondenseContextPercent", message.value)
await provider.postStateToWebview()
case "updateSettings":
if (message.updatedSettings) {
for (const [key, value] of Object.entries(message.updatedSettings)) {
let newValue = value
if (key === "language") {
newValue = value ?? "en"
changeLanguage(newValue as Language)
} else if (key === "allowedCommands") {
const commands = value ?? []
newValue = Array.isArray(commands)
? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
: []
await vscode.workspace
.getConfiguration(Package.name)
.update("allowedCommands", newValue, vscode.ConfigurationTarget.Global)
} else if (key === "deniedCommands") {
const commands = value ?? []
newValue = Array.isArray(commands)
? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
: []
await vscode.workspace
.getConfiguration(Package.name)
.update("deniedCommands", newValue, vscode.ConfigurationTarget.Global)
} else if (key === "ttsEnabled") {
newValue = value ?? true
setTtsEnabled(newValue as boolean)
} else if (key === "ttsSpeed") {
newValue = value ?? 1.0
setTtsSpeed(newValue as number)
} else if (key === "terminalShellIntegrationTimeout") {
if (value !== undefined) {
Terminal.setShellIntegrationTimeout(value as number)
}
} else if (key === "terminalShellIntegrationDisabled") {
if (value !== undefined) {
Terminal.setShellIntegrationDisabled(value as boolean)
}
} else if (key === "terminalCommandDelay") {
if (value !== undefined) {
Terminal.setCommandDelay(value as number)
}
} else if (key === "terminalPowershellCounter") {
if (value !== undefined) {
Terminal.setPowershellCounter(value as boolean)
}
} else if (key === "terminalZshClearEolMark") {
if (value !== undefined) {
Terminal.setTerminalZshClearEolMark(value as boolean)
}
} else if (key === "terminalZshOhMy") {
if (value !== undefined) {
Terminal.setTerminalZshOhMy(value as boolean)
}
} else if (key === "terminalZshP10k") {
if (value !== undefined) {
Terminal.setTerminalZshP10k(value as boolean)
}
} else if (key === "terminalZdotdir") {
if (value !== undefined) {
Terminal.setTerminalZdotdir(value as boolean)
}
} else if (key === "terminalCompressProgressBar") {
if (value !== undefined) {
Terminal.setCompressProgressBar(value as boolean)
}
} else if (key === "mcpEnabled") {
newValue = value ?? true
const mcpHub = provider.getMcpHub()
if (mcpHub) {
await mcpHub.handleMcpEnabledChange(newValue as boolean)
}
} else if (key === "experiments") {
if (!value) {
continue
}
newValue = {
...(getGlobalState("experiments") ?? experimentDefault),
...(value as Record<ExperimentId, boolean>),
}
} else if (key === "customSupportPrompts") {
if (!value) {
continue
}
}
await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue)
}
await provider.postStateToWebview()
}
break
case "terminalOperation":
if (message.terminalOperation) {
provider.getCurrentTask()?.handleTerminalOperation(message.terminalOperation)
@ -1053,38 +1091,6 @@ export const webviewMessageHandler = async (
case "cancelTask":
await provider.cancelTask()
break
case "allowedCommands": {
// Validate and sanitize the commands array
const commands = message.commands ?? []
const validCommands = Array.isArray(commands)
? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
: []
await updateGlobalState("allowedCommands", validCommands)
// Also update workspace settings.
await vscode.workspace
.getConfiguration(Package.name)
.update("allowedCommands", validCommands, vscode.ConfigurationTarget.Global)
break
}
case "deniedCommands": {
// Validate and sanitize the commands array
const commands = message.commands ?? []
const validCommands = Array.isArray(commands)
? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
: []
await updateGlobalState("deniedCommands", validCommands)
// Also update workspace settings.
await vscode.workspace
.getConfiguration(Package.name)
.update("deniedCommands", validCommands, vscode.ConfigurationTarget.Global)
break
}
case "openCustomModesSettings": {
const customModesFilePath = await provider.customModesManager.getCustomModesFilePath()
@ -1219,18 +1225,6 @@ export const webviewMessageHandler = async (
}
break
}
case "mcpEnabled":
const mcpEnabled = message.bool ?? true
await updateGlobalState("mcpEnabled", mcpEnabled)
const mcpHubInstance = provider.getMcpHub()
if (mcpHubInstance) {
await mcpHubInstance.handleMcpEnabledChange(mcpEnabled)
}
await provider.postStateToWebview()
break
case "enableMcpServerCreation":
await updateGlobalState("enableMcpServerCreation", message.bool ?? true)
await provider.postStateToWebview()
@ -1244,21 +1238,24 @@ export const webviewMessageHandler = async (
)
}
break
case "taskSyncEnabled":
const enabled = message.bool ?? false
const updatedSettings: Partial<UserSettingsConfig> = {
taskSyncEnabled: enabled,
}
// If disabling task sync, also disable remote control
const updatedSettings: Partial<UserSettingsConfig> = { taskSyncEnabled: enabled }
// If disabling task sync, also disable remote control.
if (!enabled) {
updatedSettings.extensionBridgeEnabled = false
}
try {
await CloudService.instance.updateUserSettings(updatedSettings)
} catch (error) {
provider.log(`Failed to update cloud settings for task sync: ${error}`)
}
break
case "refreshAllMcpServers": {
const mcpHub = provider.getMcpHub()
@ -1268,16 +1265,7 @@ export const webviewMessageHandler = async (
break
}
case "soundEnabled":
const soundEnabled = message.bool ?? true
await updateGlobalState("soundEnabled", soundEnabled)
await provider.postStateToWebview()
break
case "soundVolume":
const soundVolume = message.value ?? 0.5
await updateGlobalState("soundVolume", soundVolume)
await provider.postStateToWebview()
break
case "ttsEnabled":
const ttsEnabled = message.bool ?? true
await updateGlobalState("ttsEnabled", ttsEnabled)
@ -1302,40 +1290,7 @@ export const webviewMessageHandler = async (
case "stopTts":
stopTts()
break
case "diffEnabled":
const diffEnabled = message.bool ?? true
await updateGlobalState("diffEnabled", diffEnabled)
await provider.postStateToWebview()
break
case "enableCheckpoints":
const enableCheckpoints = message.bool ?? true
await updateGlobalState("enableCheckpoints", enableCheckpoints)
await provider.postStateToWebview()
break
case "checkpointTimeout":
const checkpointTimeout = message.value ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS
await updateGlobalState("checkpointTimeout", checkpointTimeout)
await provider.postStateToWebview()
break
case "browserViewportSize":
const browserViewportSize = message.text ?? "900x600"
await updateGlobalState("browserViewportSize", browserViewportSize)
await provider.postStateToWebview()
break
case "remoteBrowserHost":
await updateGlobalState("remoteBrowserHost", message.text)
await provider.postStateToWebview()
break
case "remoteBrowserEnabled":
// Store the preference in global state
// remoteBrowserEnabled now means "enable remote browser connection"
await updateGlobalState("remoteBrowserEnabled", message.bool ?? false)
// If disabling remote browser connection, clear the remoteBrowserHost
if (!message.bool) {
await updateGlobalState("remoteBrowserHost", undefined)
}
await provider.postStateToWebview()
break
case "testBrowserConnection":
// If no text is provided, try auto-discovery
if (!message.text) {
@ -1372,10 +1327,7 @@ export const webviewMessageHandler = async (
})
}
break
case "fuzzyMatchThreshold":
await updateGlobalState("fuzzyMatchThreshold", message.value)
await provider.postStateToWebview()
break
case "updateVSCodeSetting": {
const { setting, value } = message
@ -1412,129 +1364,10 @@ export const webviewMessageHandler = async (
}
break
case "alwaysApproveResubmit":
await updateGlobalState("alwaysApproveResubmit", message.bool ?? false)
await provider.postStateToWebview()
break
case "requestDelaySeconds":
await updateGlobalState("requestDelaySeconds", message.value ?? 5)
await provider.postStateToWebview()
break
case "writeDelayMs":
await updateGlobalState("writeDelayMs", message.value)
await provider.postStateToWebview()
break
case "diagnosticsEnabled":
await updateGlobalState("diagnosticsEnabled", message.bool ?? true)
await provider.postStateToWebview()
break
case "terminalOutputLineLimit":
// Validate that the line limit is a positive number
const lineLimit = message.value
if (typeof lineLimit === "number" && lineLimit > 0) {
await updateGlobalState("terminalOutputLineLimit", lineLimit)
await provider.postStateToWebview()
} else {
vscode.window.showErrorMessage(
t("common:errors.invalid_line_limit") || "Terminal output line limit must be a positive number",
)
}
break
case "terminalOutputCharacterLimit":
// Validate that the character limit is a positive number
const charLimit = message.value
if (typeof charLimit === "number" && charLimit > 0) {
await updateGlobalState("terminalOutputCharacterLimit", charLimit)
await provider.postStateToWebview()
} else {
vscode.window.showErrorMessage(
t("common:errors.invalid_character_limit") ||
"Terminal output character limit must be a positive number",
)
}
break
case "terminalShellIntegrationTimeout":
await updateGlobalState("terminalShellIntegrationTimeout", message.value)
await provider.postStateToWebview()
if (message.value !== undefined) {
Terminal.setShellIntegrationTimeout(message.value)
}
break
case "terminalShellIntegrationDisabled":
await updateGlobalState("terminalShellIntegrationDisabled", message.bool)
await provider.postStateToWebview()
if (message.bool !== undefined) {
Terminal.setShellIntegrationDisabled(message.bool)
}
break
case "terminalCommandDelay":
await updateGlobalState("terminalCommandDelay", message.value)
await provider.postStateToWebview()
if (message.value !== undefined) {
Terminal.setCommandDelay(message.value)
}
break
case "terminalPowershellCounter":
await updateGlobalState("terminalPowershellCounter", message.bool)
await provider.postStateToWebview()
if (message.bool !== undefined) {
Terminal.setPowershellCounter(message.bool)
}
break
case "terminalZshClearEolMark":
await updateGlobalState("terminalZshClearEolMark", message.bool)
await provider.postStateToWebview()
if (message.bool !== undefined) {
Terminal.setTerminalZshClearEolMark(message.bool)
}
break
case "terminalZshOhMy":
await updateGlobalState("terminalZshOhMy", message.bool)
await provider.postStateToWebview()
if (message.bool !== undefined) {
Terminal.setTerminalZshOhMy(message.bool)
}
break
case "terminalZshP10k":
await updateGlobalState("terminalZshP10k", message.bool)
await provider.postStateToWebview()
if (message.bool !== undefined) {
Terminal.setTerminalZshP10k(message.bool)
}
break
case "terminalZdotdir":
await updateGlobalState("terminalZdotdir", message.bool)
await provider.postStateToWebview()
if (message.bool !== undefined) {
Terminal.setTerminalZdotdir(message.bool)
}
break
case "terminalCompressProgressBar":
await updateGlobalState("terminalCompressProgressBar", message.bool)
await provider.postStateToWebview()
if (message.bool !== undefined) {
Terminal.setCompressProgressBar(message.bool)
}
break
case "mode":
await provider.handleModeSwitch(message.text as Mode)
break
case "updateSupportPrompt":
try {
if (!message?.values) {
return
}
// Replace all prompts with the new values from the cached state
await updateGlobalState("customSupportPrompts", message.values)
await provider.postStateToWebview()
} catch (error) {
provider.log(
`Error update support prompt: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage(t("common:errors.update_support_prompt"))
}
break
case "updatePrompt":
if (message.promptMode && message.customPrompt !== undefined) {
const existingPrompts = getGlobalState("customModePrompts") ?? {}
@ -1594,96 +1427,12 @@ export const webviewMessageHandler = async (
}
break
}
case "screenshotQuality":
await updateGlobalState("screenshotQuality", message.value)
await provider.postStateToWebview()
break
case "maxOpenTabsContext":
const tabCount = Math.min(Math.max(0, message.value ?? 20), 500)
await updateGlobalState("maxOpenTabsContext", tabCount)
await provider.postStateToWebview()
break
case "maxWorkspaceFiles":
const fileCount = Math.min(Math.max(0, message.value ?? 200), 500)
await updateGlobalState("maxWorkspaceFiles", fileCount)
await provider.postStateToWebview()
break
case "alwaysAllowFollowupQuestions":
await updateGlobalState("alwaysAllowFollowupQuestions", message.bool ?? false)
await provider.postStateToWebview()
break
case "followupAutoApproveTimeoutMs":
await updateGlobalState("followupAutoApproveTimeoutMs", message.value)
await provider.postStateToWebview()
break
case "browserToolEnabled":
await updateGlobalState("browserToolEnabled", message.bool ?? true)
await provider.postStateToWebview()
break
case "language":
changeLanguage(message.text ?? "en")
await updateGlobalState("language", message.text as Language)
await provider.postStateToWebview()
break
case "openRouterImageApiKey":
await provider.contextProxy.setValue("openRouterImageApiKey", message.text)
await provider.postStateToWebview()
break
case "openRouterImageGenerationSelectedModel":
await provider.contextProxy.setValue("openRouterImageGenerationSelectedModel", message.text)
await provider.postStateToWebview()
break
case "showRooIgnoredFiles":
await updateGlobalState("showRooIgnoredFiles", message.bool ?? false)
await provider.postStateToWebview()
break
case "hasOpenedModeSelector":
await updateGlobalState("hasOpenedModeSelector", message.bool ?? true)
await provider.postStateToWebview()
break
case "maxReadFileLine":
await updateGlobalState("maxReadFileLine", message.value)
await provider.postStateToWebview()
break
case "maxImageFileSize":
await updateGlobalState("maxImageFileSize", message.value)
await provider.postStateToWebview()
break
case "maxTotalImageSize":
await updateGlobalState("maxTotalImageSize", message.value)
await provider.postStateToWebview()
break
case "maxConcurrentFileReads":
const valueToSave = message.value // Capture the value intended for saving
await updateGlobalState("maxConcurrentFileReads", valueToSave)
await provider.postStateToWebview()
break
case "includeDiagnosticMessages":
// Only apply default if the value is truly undefined (not false)
const includeValue = message.bool !== undefined ? message.bool : true
await updateGlobalState("includeDiagnosticMessages", includeValue)
await provider.postStateToWebview()
break
case "includeCurrentTime":
await updateGlobalState("includeCurrentTime", message.bool ?? true)
await provider.postStateToWebview()
break
case "includeCurrentCost":
await updateGlobalState("includeCurrentCost", message.bool ?? true)
await provider.postStateToWebview()
break
case "maxDiagnosticMessages":
await updateGlobalState("maxDiagnosticMessages", message.value ?? 50)
await provider.postStateToWebview()
break
case "setHistoryPreviewCollapsed": // Add the new case handler
await updateGlobalState("historyPreviewCollapsed", message.bool ?? false)
// No need to call postStateToWebview here as the UI already updated optimistically
break
case "setReasoningBlockCollapsed":
await updateGlobalState("reasoningBlockCollapsed", message.bool ?? true)
// No need to call postStateToWebview here as the UI already updated optimistically
break
case "toggleApiConfigPin":
if (message.text) {
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}
@ -1703,27 +1452,17 @@ export const webviewMessageHandler = async (
await updateGlobalState("enhancementApiConfigId", message.text)
await provider.postStateToWebview()
break
case "includeTaskHistoryInEnhance":
await updateGlobalState("includeTaskHistoryInEnhance", message.bool ?? true)
await provider.postStateToWebview()
break
case "condensingApiConfigId":
await updateGlobalState("condensingApiConfigId", message.text)
await provider.postStateToWebview()
break
case "updateCondensingPrompt":
// Store the condensing prompt in customSupportPrompts["CONDENSE"] instead of customCondensingPrompt
// Store the condensing prompt in customSupportPrompts["CONDENSE"]
// instead of customCondensingPrompt.
const currentSupportPrompts = getGlobalState("customSupportPrompts") ?? {}
const updatedSupportPrompts = { ...currentSupportPrompts, CONDENSE: message.text }
await updateGlobalState("customSupportPrompts", updatedSupportPrompts)
// Also update the old field for backward compatibility during migration
// Also update the old field for backward compatibility during migration.
await updateGlobalState("customCondensingPrompt", message.text)
await provider.postStateToWebview()
break
case "profileThresholds":
await updateGlobalState("profileThresholds", message.values)
await provider.postStateToWebview()
break
case "autoApprovalEnabled":
await updateGlobalState("autoApprovalEnabled", message.bool ?? false)
await provider.postStateToWebview()
@ -1755,7 +1494,6 @@ export const webviewMessageHandler = async (
})
if (result.success && result.enhancedText) {
// Capture telemetry for prompt enhancement
MessageEnhancer.captureTelemetry(currentCline?.taskId, includeTaskHistoryInEnhance)
await provider.postMessageToWebview({ type: "enhancedPrompt", text: result.enhancedText })
} else {
@ -2009,21 +1747,7 @@ export const webviewMessageHandler = async (
vscode.window.showErrorMessage(t("common:errors.list_api_config"))
}
break
case "updateExperimental": {
if (!message.values) {
break
}
const updatedExperiments = {
...(getGlobalState("experiments") ?? experimentDefault),
...message.values,
}
await updateGlobalState("experiments", updatedExperiments)
await provider.postStateToWebview()
break
}
case "updateMcpTimeout":
if (message.serverName && typeof message.timeout === "number") {
try {
@ -2373,8 +2097,10 @@ export const webviewMessageHandler = async (
if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) {
TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, telemetrySetting)
}
// Update the telemetry state
await updateGlobalState("telemetrySetting", telemetrySetting)
if (TelemetryService.hasInstance()) {
TelemetryService.instance.updateTelemetryState(isOptedIn)
}
@ -3161,6 +2887,7 @@ export const webviewMessageHandler = async (
break
}
case "dismissUpsell": {
if (message.upsellId) {
try {
@ -3195,5 +2922,32 @@ export const webviewMessageHandler = async (
})
break
}
default: {
// console.log(`Unhandled message type: ${message.type}`)
//
// Currently unhandled:
//
// "currentApiConfigName" |
// "codebaseIndexEnabled" |
// "enhancedPrompt" |
// "systemPrompt" |
// "exportModeResult" |
// "importModeResult" |
// "checkRulesDirectoryResult" |
// "browserConnectionResult" |
// "vsCodeSetting" |
// "indexingStatusUpdate" |
// "indexCleared" |
// "marketplaceInstallResult" |
// "shareTaskSuccess" |
// "playSound" |
// "draggedImages" |
// "setApiConfigPassword" |
// "setopenAiCustomModelInfo" |
// "marketplaceButtonClicked" |
// "cancelMarketplaceInstall" |
// "imageGenerationSettings"
break
}
}
}

View file

@ -219,9 +219,7 @@ export type ExtensionState = Pick<
| "currentApiConfigName"
| "listApiConfigMeta"
| "pinnedApiConfigs"
// | "lastShownAnnouncementId"
| "customInstructions"
// | "taskHistory" // Optional in GlobalSettings, required here.
| "dismissedUpsells"
| "autoApprovalEnabled"
| "alwaysAllowReadOnly"
@ -229,10 +227,8 @@ export type ExtensionState = Pick<
| "alwaysAllowWrite"
| "alwaysAllowWriteOutsideWorkspace"
| "alwaysAllowWriteProtected"
// | "writeDelayMs" // Optional in GlobalSettings, required here.
| "alwaysAllowBrowser"
| "alwaysApproveResubmit"
// | "requestDelaySeconds" // Optional in GlobalSettings, required here.
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
@ -250,16 +246,11 @@ export type ExtensionState = Pick<
| "remoteBrowserEnabled"
| "cachedChromeHostUrl"
| "remoteBrowserHost"
// | "enableCheckpoints" // Optional in GlobalSettings, required here.
| "ttsEnabled"
| "ttsSpeed"
| "soundEnabled"
| "soundVolume"
// | "maxOpenTabsContext" // Optional in GlobalSettings, required here.
// | "maxWorkspaceFiles" // Optional in GlobalSettings, required here.
// | "showRooIgnoredFiles" // Optional in GlobalSettings, required here.
// | "maxReadFileLine" // Optional in GlobalSettings, required here.
| "maxConcurrentFileReads" // Optional in GlobalSettings, required here.
| "maxConcurrentFileReads"
| "terminalOutputLineLimit"
| "terminalOutputCharacterLimit"
| "terminalShellIntegrationTimeout"
@ -274,14 +265,8 @@ export type ExtensionState = Pick<
| "diagnosticsEnabled"
| "diffEnabled"
| "fuzzyMatchThreshold"
// | "experiments" // Optional in GlobalSettings, required here.
| "language"
// | "telemetrySetting" // Optional in GlobalSettings, required here.
// | "mcpEnabled" // Optional in GlobalSettings, required here.
// | "enableMcpServerCreation" // Optional in GlobalSettings, required here.
// | "mode" // Optional in GlobalSettings, required here.
| "modeApiConfigs"
// | "customModes" // Optional in GlobalSettings, required here.
| "customModePrompts"
| "customSupportPrompts"
| "enhancementApiConfigId"

View file

@ -1,6 +1,7 @@
import { z } from "zod"
import {
type RooCodeSettings,
type ProviderSettings,
type PromptComponent,
type ModeConfig,
@ -38,17 +39,6 @@ export interface WebviewMessage {
| "renameApiConfiguration"
| "getListApiConfiguration"
| "customInstructions"
| "allowedCommands"
| "deniedCommands"
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
| "alwaysAllowWrite"
| "alwaysAllowWriteOutsideWorkspace"
| "alwaysAllowWriteProtected"
| "alwaysAllowExecute"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowUpdateTodoList"
| "followupAutoApproveTimeoutMs"
| "webviewDidLaunch"
| "newTask"
| "askResponse"
@ -80,30 +70,12 @@ export interface WebviewMessage {
| "updateVSCodeSetting"
| "getVSCodeSetting"
| "vsCodeSetting"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "allowedMaxRequests"
| "allowedMaxCost"
| "alwaysAllowSubtasks"
| "alwaysAllowUpdateTodoList"
| "autoCondenseContext"
| "autoCondenseContextPercent"
| "condensingApiConfigId"
| "updateCondensingPrompt"
| "playSound"
| "playTts"
| "stopTts"
| "soundEnabled"
| "ttsEnabled"
| "ttsSpeed"
| "soundVolume"
| "diffEnabled"
| "enableCheckpoints"
| "checkpointTimeout"
| "browserViewportSize"
| "screenshotQuality"
| "remoteBrowserHost"
| "openKeyboardShortcuts"
| "openMcpSettings"
| "openProjectMcpSettings"
@ -113,9 +85,6 @@ export interface WebviewMessage {
| "toggleToolEnabledForPrompt"
| "toggleMcpServer"
| "updateMcpTimeout"
| "fuzzyMatchThreshold"
| "writeDelayMs"
| "diagnosticsEnabled"
| "enhancePrompt"
| "enhancedPrompt"
| "draggedImages"
@ -123,34 +92,17 @@ export interface WebviewMessage {
| "deleteMessageConfirm"
| "submitEditedMessage"
| "editMessageConfirm"
| "terminalOutputLineLimit"
| "terminalOutputCharacterLimit"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
| "terminalCommandDelay"
| "terminalPowershellCounter"
| "terminalZshClearEolMark"
| "terminalZshOhMy"
| "terminalZshP10k"
| "terminalZdotdir"
| "terminalCompressProgressBar"
| "mcpEnabled"
| "enableMcpServerCreation"
| "remoteControlEnabled"
| "taskSyncEnabled"
| "searchCommits"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "setApiConfigPassword"
| "mode"
| "updatePrompt"
| "updateSupportPrompt"
| "getSystemPrompt"
| "copySystemPrompt"
| "systemPrompt"
| "enhancementApiConfigId"
| "includeTaskHistoryInEnhance"
| "updateExperimental"
| "autoApprovalEnabled"
| "updateCustomMode"
| "deleteCustomMode"
@ -159,29 +111,14 @@ export interface WebviewMessage {
| "checkpointDiff"
| "checkpointRestore"
| "deleteMcpServer"
| "maxOpenTabsContext"
| "maxWorkspaceFiles"
| "humanRelayResponse"
| "humanRelayCancel"
| "browserToolEnabled"
| "codebaseIndexEnabled"
| "telemetrySetting"
| "showRooIgnoredFiles"
| "testBrowserConnection"
| "browserConnectionResult"
| "remoteBrowserEnabled"
| "language"
| "maxReadFileLine"
| "maxImageFileSize"
| "maxTotalImageSize"
| "maxConcurrentFileReads"
| "includeDiagnosticMessages"
| "maxDiagnosticMessages"
| "includeCurrentTime"
| "includeCurrentCost"
| "searchFiles"
| "toggleApiConfigPin"
| "setHistoryPreviewCollapsed"
| "hasOpenedModeSelector"
| "cloudButtonClicked"
| "rooCloudSignIn"
@ -196,9 +133,6 @@ export interface WebviewMessage {
| "indexingStatusUpdate"
| "indexCleared"
| "focusPanelRequest"
| "profileThresholds"
| "setHistoryPreviewCollapsed"
| "setReasoningBlockCollapsed"
| "openExternal"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"
@ -209,7 +143,6 @@ export interface WebviewMessage {
| "marketplaceInstallResult"
| "fetchMarketplaceData"
| "switchTab"
| "profileThresholds"
| "shareTaskSuccess"
| "exportMode"
| "exportModeResult"
@ -226,13 +159,12 @@ export interface WebviewMessage {
| "insertTextIntoTextarea"
| "showMdmAuthRequiredNotification"
| "imageGenerationSettings"
| "openRouterImageApiKey"
| "openRouterImageGenerationSelectedModel"
| "queueMessage"
| "removeQueuedMessage"
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
| "updateSettings"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@ -309,6 +241,7 @@ export interface WebviewMessage {
codebaseIndexVercelAiGatewayApiKey?: string
codebaseIndexOpenRouterApiKey?: string
}
updatedSettings?: RooCodeSettings
}
export const checkoutDiffPayloadSchema = z.object({

View file

@ -2,15 +2,22 @@ import React from "react"
import { ListChecks, LayoutList, Settings, CheckCheck, X } from "lucide-react"
import { vscode } from "@/utils/vscode"
import { cn } from "@/lib/utils"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip, ToggleSwitch, Button } from "@/components/ui"
import { AutoApproveSetting, autoApproveSettingsConfig } from "../settings/AutoApproveToggle"
import { useAutoApprovalToggles } from "@/hooks/useAutoApprovalToggles"
import { useAutoApprovalState } from "@/hooks/useAutoApprovalState"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip, ToggleSwitch, Button } from "@/components/ui"
import { AutoApproveSetting, autoApproveSettingsConfig } from "../settings/AutoApproveToggle"
interface AutoApproveDropdownProps {
disabled?: boolean
triggerClassName?: string
@ -39,7 +46,7 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }:
const baseToggles = useAutoApprovalToggles()
// Include alwaysApproveResubmit in addition to the base toggles
// Include alwaysApproveResubmit in addition to the base toggles.
const toggles = React.useMemo(
() => ({
...baseToggles,
@ -50,9 +57,8 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }:
const onAutoApproveToggle = React.useCallback(
(key: AutoApproveSetting, value: boolean) => {
vscode.postMessage({ type: key, bool: value })
vscode.postMessage({ type: "updateSettings", updatedSettings: { [key]: value } })
// Update the specific toggle state
switch (key) {
case "alwaysAllowReadOnly":
setAlwaysAllowReadOnly(value)
@ -86,7 +92,7 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }:
break
}
// If enabling any option, ensure autoApprovalEnabled is true
// If enabling any option, ensure autoApprovalEnabled is true.
if (value && !autoApprovalEnabled) {
setAutoApprovalEnabled(true)
vscode.postMessage({ type: "autoApprovalEnabled", bool: true })

View file

@ -89,8 +89,11 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
setAllowedCommands(newAllowed)
setDeniedCommands(newDenied)
vscode.postMessage({ type: "allowedCommands", commands: newAllowed })
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
vscode.postMessage({
type: "updateSettings",
updatedSettings: { allowedCommands: newAllowed, deniedCommands: newDenied },
})
}
const handleDenyPatternChange = (pattern: string) => {
@ -100,8 +103,11 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
setAllowedCommands(newAllowed)
setDeniedCommands(newDenied)
vscode.postMessage({ type: "allowedCommands", commands: newAllowed })
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
vscode.postMessage({
type: "updateSettings",
updatedSettings: { allowedCommands: newAllowed, deniedCommands: newDenied },
})
}
const onMessage = useCallback(

View file

@ -1,3 +1,5 @@
// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/CommandExecution.spec.tsx
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
@ -111,8 +113,13 @@ describe("CommandExecution", () => {
expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git push"])
expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "git push"] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] })
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: ["npm", "git push"],
deniedCommands: ["rm"],
},
})
})
it("should handle deny command change", () => {
@ -127,8 +134,13 @@ describe("CommandExecution", () => {
expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"])
expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker run"])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm", "docker run"] })
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: ["npm"],
deniedCommands: ["rm", "docker run"],
},
})
})
it("should toggle allowed command", () => {
@ -151,8 +163,13 @@ describe("CommandExecution", () => {
// "npm test" is already in allowedCommands, so it should be removed
expect(stateWithNpmTest.setAllowedCommands).toHaveBeenCalledWith([])
expect(stateWithNpmTest.setDeniedCommands).toHaveBeenCalledWith(["rm"])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: [] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] })
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: [],
deniedCommands: ["rm"],
},
})
})
it("should toggle denied command", () => {
@ -175,8 +192,13 @@ describe("CommandExecution", () => {
// "rm -rf" is already in deniedCommands, so it should be removed
expect(stateWithRmRf.setAllowedCommands).toHaveBeenCalledWith(["npm"])
expect(stateWithRmRf.setDeniedCommands).toHaveBeenCalledWith([])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] })
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: ["npm"],
deniedCommands: [],
},
})
})
it("should parse command with Output: separator", () => {
@ -311,8 +333,13 @@ Output here`
// "rm file.txt" should be removed from denied and added to allowed
expect(stateWithRmInDenied.setAllowedCommands).toHaveBeenCalledWith(["npm", "rm file.txt"])
expect(stateWithRmInDenied.setDeniedCommands).toHaveBeenCalledWith([])
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "rm file.txt"] })
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] })
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: ["npm", "rm file.txt"],
deniedCommands: [],
},
})
})
describe("integration with CommandPatternSelector", () => {

View file

@ -10,9 +10,13 @@ const McpEnabledToggle = () => {
const handleChange = (e: Event | FormEvent<HTMLElement>) => {
const target = ("target" in e ? e.target : null) as HTMLInputElement | null
if (!target) return
if (!target) {
return
}
setMcpEnabled(target.checked)
vscode.postMessage({ type: "mcpEnabled", bool: target.checked })
vscode.postMessage({ type: "updateSettings", updatedSettings: { mcpEnabled: target.checked } })
}
return (

View file

@ -99,7 +99,7 @@ export const AutoApproveSettings = ({
const newCommands = [...currentCommands, commandInput]
setCachedStateField("allowedCommands", newCommands)
setCommandInput("")
vscode.postMessage({ type: "allowedCommands", commands: newCommands })
vscode.postMessage({ type: "updateSettings", updatedSettings: { allowedCommands: newCommands } })
}
}
@ -110,7 +110,7 @@ export const AutoApproveSettings = ({
const newCommands = [...currentCommands, deniedCommandInput]
setCachedStateField("deniedCommands", newCommands)
setDeniedCommandInput("")
vscode.postMessage({ type: "deniedCommands", commands: newCommands })
vscode.postMessage({ type: "updateSettings", updatedSettings: { deniedCommands: newCommands } })
}
}
@ -341,7 +341,11 @@ export const AutoApproveSettings = ({
onClick={() => {
const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index)
setCachedStateField("allowedCommands", newCommands)
vscode.postMessage({ type: "allowedCommands", commands: newCommands })
vscode.postMessage({
type: "updateSettings",
updatedSettings: { allowedCommands: newCommands },
})
}}>
<div className="flex flex-row items-center gap-1">
<div>{cmd}</div>
@ -392,7 +396,11 @@ export const AutoApproveSettings = ({
onClick={() => {
const newCommands = (deniedCommands ?? []).filter((_, i) => i !== index)
setCachedStateField("deniedCommands", newCommands)
vscode.postMessage({ type: "deniedCommands", commands: newCommands })
vscode.postMessage({
type: "updateSettings",
updatedSettings: { deniedCommands: newCommands },
})
}}>
<div className="flex flex-row items-center gap-1">
<div>{cmd}</div>

View file

@ -1,25 +1,17 @@
import React, { useCallback } from "react"
import { Slider } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { DEFAULT_CONSECUTIVE_MISTAKE_LIMIT } from "@roo-code/types"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Slider } from "@/components/ui"
interface ConsecutiveMistakeLimitControlProps {
value: number
onChange: (value: number) => void
}
export const ConsecutiveMistakeLimitControl: React.FC<ConsecutiveMistakeLimitControlProps> = ({ value, onChange }) => {
export const ConsecutiveMistakeLimitControl = ({ value, onChange }: ConsecutiveMistakeLimitControlProps) => {
const { t } = useAppTranslation()
const handleValueChange = useCallback(
(newValue: number) => {
// Ensure value is not negative
const validValue = Math.max(0, newValue)
onChange(validValue)
},
[onChange],
)
return (
<div className="flex flex-col gap-1">
<label className="block font-medium mb-1">{t("settings:providers.consecutiveMistakeLimit.label")}</label>
@ -29,7 +21,7 @@ export const ConsecutiveMistakeLimitControl: React.FC<ConsecutiveMistakeLimitCon
min={0}
max={10}
step={1}
onValueChange={(newValue) => handleValueChange(newValue[0])}
onValueChange={(newValue) => onChange(Math.max(0, newValue[0]))}
/>
<span className="w-10">{Math.max(0, value ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT)}</span>
</div>

View file

@ -93,11 +93,9 @@ export const ContextManagementSettings = ({
...profileThresholds,
[selectedThresholdProfile]: value,
}
setCachedStateField("profileThresholds", newThresholds)
vscode.postMessage({
type: "profileThresholds",
values: newThresholds,
})
vscode.postMessage({ type: "updateSettings", updatedSettings: { profileThresholds: newThresholds } })
}
}
return (

View file

@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next"
import { vscode } from "@/utils/vscode"
import { useCallback } from "react"
import { FormattedTextField, unlimitedDecimalFormatter } from "../common/FormattedTextField"
interface MaxCostInputProps {
@ -11,14 +10,6 @@ interface MaxCostInputProps {
export function MaxCostInput({ allowedMaxCost, onValueChange }: MaxCostInputProps) {
const { t } = useTranslation()
const handleValueChange = useCallback(
(value: number | undefined) => {
onValueChange(value)
vscode.postMessage({ type: "allowedMaxCost", value })
},
[onValueChange],
)
return (
<>
<label className="flex items-center gap-2 text-sm font-medium whitespace-nowrap">
@ -27,7 +18,7 @@ export function MaxCostInput({ allowedMaxCost, onValueChange }: MaxCostInputProp
</label>
<FormattedTextField
value={allowedMaxCost}
onValueChange={handleValueChange}
onValueChange={onValueChange}
formatter={unlimitedDecimalFormatter}
placeholder={t("settings:autoApprove.apiCostLimit.unlimited")}
style={{ maxWidth: "200px" }}

View file

@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next"
import { vscode } from "@/utils/vscode"
import { useCallback } from "react"
import { FormattedTextField, unlimitedIntegerFormatter } from "../common/FormattedTextField"
interface MaxRequestsInputProps {
@ -11,14 +10,6 @@ interface MaxRequestsInputProps {
export function MaxRequestsInput({ allowedMaxRequests, onValueChange }: MaxRequestsInputProps) {
const { t } = useTranslation()
const handleValueChange = useCallback(
(value: number | undefined) => {
onValueChange(value)
vscode.postMessage({ type: "allowedMaxRequests", value })
},
[onValueChange],
)
return (
<>
<label className="flex items-center gap-2 text-sm font-medium whitespace-nowrap">
@ -27,7 +18,7 @@ export function MaxRequestsInput({ allowedMaxRequests, onValueChange }: MaxReque
</label>
<FormattedTextField
value={allowedMaxRequests}
onValueChange={handleValueChange}
onValueChange={onValueChange}
formatter={unlimitedIntegerFormatter}
placeholder={t("settings:autoApprove.apiRequestLimit.unlimited")}
style={{ maxWidth: "200px" }}

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react"
import { useState, useEffect, FormEvent } from "react"
import { VSCodeTextArea, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { MessageSquare } from "lucide-react"
import { supportPrompt, SupportPromptType } from "@roo/support-prompt"
@ -15,9 +16,9 @@ import {
SelectValue,
StandardTooltip,
} from "@src/components/ui"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
import { MessageSquare } from "lucide-react"
interface PromptsSettingsProps {
customSupportPrompts: Record<string, string | undefined>
@ -215,8 +216,8 @@ const PromptsSettings = ({
} else {
setCondensingApiConfigId(newConfigId)
vscode.postMessage({
type: "condensingApiConfigId",
text: newConfigId,
type: "updateSettings",
updatedSettings: { condensingApiConfigId: newConfigId },
})
}
}}>
@ -257,12 +258,20 @@ const PromptsSettings = ({
<div>
<VSCodeCheckbox
checked={includeTaskHistoryInEnhance}
onChange={(e: any) => {
const value = e.target.checked
setIncludeTaskHistoryInEnhance(value)
onChange={(e: Event | FormEvent<HTMLElement>) => {
const target = (
"target" in e ? e.target : null
) as HTMLInputElement | null
if (!target) {
return
}
setIncludeTaskHistoryInEnhance(target.checked)
vscode.postMessage({
type: "includeTaskHistoryInEnhance",
bool: value,
type: "updateSettings",
updatedSettings: { includeTaskHistoryInEnhance: target.checked },
})
}}>
<span className="font-medium">

View file

@ -1,33 +1,20 @@
import React, { useCallback } from "react"
import { Slider } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Slider } from "@/components/ui"
interface RateLimitSecondsControlProps {
value: number
onChange: (value: number) => void
}
export const RateLimitSecondsControl: React.FC<RateLimitSecondsControlProps> = ({ value, onChange }) => {
export const RateLimitSecondsControl = ({ value, onChange }: RateLimitSecondsControlProps) => {
const { t } = useAppTranslation()
const handleValueChange = useCallback(
(newValue: number) => {
onChange(newValue)
},
[onChange],
)
return (
<div className="flex flex-col gap-1">
<label className="block font-medium mb-1">{t("settings:providers.rateLimitSeconds.label")}</label>
<div className="flex items-center gap-2">
<Slider
value={[value]}
min={0}
max={60}
step={1}
onValueChange={(newValue) => handleValueChange(newValue[0])}
/>
<Slider value={[value]} min={0} max={60} step={1} onValueChange={(newValue) => onChange(newValue[0])} />
<span className="w-10">{value}s</span>
</div>
<div className="text-sm text-vscode-descriptionForeground">

View file

@ -27,7 +27,12 @@ import {
Glasses,
} from "lucide-react"
import type { ProviderSettings, ExperimentId, TelemetrySetting } from "@roo-code/types"
import {
type ProviderSettings,
type ExperimentId,
type TelemetrySetting,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
} from "@roo-code/types"
import { vscode } from "@src/utils/vscode"
import { cn } from "@src/lib/utils"
@ -285,20 +290,20 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
const setOpenRouterImageApiKey = useCallback((apiKey: string) => {
setCachedState((prevState) => {
// Only set change detected if value actually changed
if (prevState.openRouterImageApiKey !== apiKey) {
setChangeDetected(true)
}
return { ...prevState, openRouterImageApiKey: apiKey }
})
}, [])
const setImageGenerationSelectedModel = useCallback((model: string) => {
setCachedState((prevState) => {
// Only set change detected if value actually changed
if (prevState.openRouterImageGenerationSelectedModel !== model) {
setChangeDetected(true)
}
return { ...prevState, openRouterImageGenerationSelectedModel: model }
})
}, [])
@ -321,83 +326,89 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
const handleSubmit = () => {
if (isSettingValid) {
vscode.postMessage({ type: "language", text: language })
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
vscode.postMessage({
type: "alwaysAllowReadOnlyOutsideWorkspace",
bool: alwaysAllowReadOnlyOutsideWorkspace,
type: "updateSettings",
updatedSettings: {
language,
alwaysAllowReadOnly: alwaysAllowReadOnly ?? undefined,
alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? undefined,
alwaysAllowWrite: alwaysAllowWrite ?? undefined,
alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? undefined,
alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? undefined,
alwaysAllowExecute: alwaysAllowExecute ?? undefined,
alwaysAllowBrowser: alwaysAllowBrowser ?? undefined,
alwaysAllowMcp,
alwaysAllowModeSwitch,
allowedCommands: allowedCommands ?? [],
deniedCommands: deniedCommands ?? [],
// Note that we use `null` instead of `undefined` since `JSON.stringify`
// will omit `undefined` when serializing the object and passing it to the
// extension host. We may need to do the same for other nullable fields.
allowedMaxRequests: allowedMaxRequests ?? null,
allowedMaxCost: allowedMaxCost ?? null,
autoCondenseContext,
autoCondenseContextPercent,
browserToolEnabled: browserToolEnabled ?? true,
soundEnabled: soundEnabled ?? true,
soundVolume: soundVolume ?? 0.5,
ttsEnabled,
ttsSpeed,
diffEnabled: diffEnabled ?? true,
enableCheckpoints: enableCheckpoints ?? false,
checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
browserViewportSize: browserViewportSize ?? "900x600",
remoteBrowserHost: remoteBrowserEnabled ? remoteBrowserHost : undefined,
remoteBrowserEnabled: remoteBrowserEnabled ?? false,
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
writeDelayMs,
screenshotQuality: screenshotQuality ?? 75,
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
terminalOutputCharacterLimit: terminalOutputCharacterLimit ?? 50_000,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000,
terminalShellIntegrationDisabled,
terminalCommandDelay,
terminalPowershellCounter,
terminalZshClearEolMark,
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
terminalCompressProgressBar,
mcpEnabled,
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: requestDelaySeconds ?? 5,
maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500),
maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500),
showRooIgnoredFiles: showRooIgnoredFiles ?? true,
maxReadFileLine: maxReadFileLine ?? -1,
maxImageFileSize: maxImageFileSize ?? 5,
maxTotalImageSize: maxTotalImageSize ?? 20,
maxConcurrentFileReads: cachedState.maxConcurrentFileReads ?? 5,
includeDiagnosticMessages:
includeDiagnosticMessages !== undefined ? includeDiagnosticMessages : true,
maxDiagnosticMessages: maxDiagnosticMessages ?? 50,
alwaysAllowSubtasks,
alwaysAllowUpdateTodoList,
alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false,
followupAutoApproveTimeoutMs,
condensingApiConfigId: condensingApiConfigId || "",
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
profileThresholds,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
experiments,
customSupportPrompts,
},
})
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
vscode.postMessage({ type: "alwaysAllowWriteOutsideWorkspace", bool: alwaysAllowWriteOutsideWorkspace })
vscode.postMessage({ type: "alwaysAllowWriteProtected", bool: alwaysAllowWriteProtected })
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
vscode.postMessage({ type: "deniedCommands", commands: deniedCommands ?? [] })
vscode.postMessage({ type: "allowedMaxRequests", value: allowedMaxRequests ?? undefined })
vscode.postMessage({ type: "allowedMaxCost", value: allowedMaxCost ?? undefined })
vscode.postMessage({ type: "autoCondenseContext", bool: autoCondenseContext })
vscode.postMessage({ type: "autoCondenseContextPercent", value: autoCondenseContextPercent })
vscode.postMessage({ type: "browserToolEnabled", bool: browserToolEnabled })
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
vscode.postMessage({ type: "ttsEnabled", bool: ttsEnabled })
vscode.postMessage({ type: "ttsSpeed", value: ttsSpeed })
vscode.postMessage({ type: "soundVolume", value: soundVolume })
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
vscode.postMessage({ type: "checkpointTimeout", value: checkpointTimeout })
vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost })
vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled })
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 })
vscode.postMessage({ type: "terminalOutputCharacterLimit", value: terminalOutputCharacterLimit ?? 50000 })
vscode.postMessage({ type: "terminalShellIntegrationTimeout", value: terminalShellIntegrationTimeout })
vscode.postMessage({ type: "terminalShellIntegrationDisabled", bool: terminalShellIntegrationDisabled })
vscode.postMessage({ type: "terminalCommandDelay", value: terminalCommandDelay })
vscode.postMessage({ type: "terminalPowershellCounter", bool: terminalPowershellCounter })
vscode.postMessage({ type: "terminalZshClearEolMark", bool: terminalZshClearEolMark })
vscode.postMessage({ type: "terminalZshOhMy", bool: terminalZshOhMy })
vscode.postMessage({ type: "terminalZshP10k", bool: terminalZshP10k })
vscode.postMessage({ type: "terminalZdotdir", bool: terminalZdotdir })
vscode.postMessage({ type: "terminalCompressProgressBar", bool: terminalCompressProgressBar })
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
vscode.postMessage({ type: "maxOpenTabsContext", value: maxOpenTabsContext })
vscode.postMessage({ type: "maxWorkspaceFiles", value: maxWorkspaceFiles ?? 200 })
vscode.postMessage({ type: "showRooIgnoredFiles", bool: showRooIgnoredFiles })
vscode.postMessage({ type: "maxReadFileLine", value: maxReadFileLine ?? -1 })
vscode.postMessage({ type: "maxImageFileSize", value: maxImageFileSize ?? 5 })
vscode.postMessage({ type: "maxTotalImageSize", value: maxTotalImageSize ?? 20 })
vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 })
vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages })
vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 })
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
vscode.postMessage({ type: "updateExperimental", values: experiments })
vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
vscode.postMessage({ type: "alwaysAllowSubtasks", bool: alwaysAllowSubtasks })
vscode.postMessage({ type: "alwaysAllowFollowupQuestions", bool: alwaysAllowFollowupQuestions })
vscode.postMessage({ type: "alwaysAllowUpdateTodoList", bool: alwaysAllowUpdateTodoList })
vscode.postMessage({ type: "followupAutoApproveTimeoutMs", value: followupAutoApproveTimeoutMs })
vscode.postMessage({ type: "condensingApiConfigId", text: condensingApiConfigId || "" })
// These have more complex logic so they aren't (yet) handled
// by the `updateSettings` message.
vscode.postMessage({ type: "updateCondensingPrompt", text: customCondensingPrompt || "" })
vscode.postMessage({ type: "updateSupportPrompt", values: customSupportPrompts || {} })
vscode.postMessage({ type: "includeTaskHistoryInEnhance", bool: includeTaskHistoryInEnhance ?? true })
vscode.postMessage({ type: "setReasoningBlockCollapsed", bool: reasoningBlockCollapsed ?? true })
vscode.postMessage({ type: "includeCurrentTime", bool: includeCurrentTime ?? true })
vscode.postMessage({ type: "includeCurrentCost", bool: includeCurrentCost ?? true })
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
vscode.postMessage({ type: "profileThresholds", values: profileThresholds })
vscode.postMessage({ type: "openRouterImageApiKey", text: openRouterImageApiKey })
vscode.postMessage({
type: "openRouterImageGenerationSelectedModel",
text: openRouterImageGenerationSelectedModel,
})
setChangeDetected(false)
}
}

View file

@ -1,3 +1,5 @@
// pnpm --filter @roo-code/vscode-webview test src/components/settings/__tests__/SettingsView.spec.tsx
import { render, screen, fireEvent } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
@ -311,8 +313,10 @@ describe("SettingsView - Sound Settings", () => {
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "ttsEnabled",
bool: true,
type: "updateSettings",
updatedSettings: expect.objectContaining({
ttsEnabled: true,
}),
}),
)
})
@ -336,8 +340,10 @@ describe("SettingsView - Sound Settings", () => {
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "soundEnabled",
bool: true,
type: "updateSettings",
updatedSettings: expect.objectContaining({
soundEnabled: true,
}),
}),
)
})
@ -396,10 +402,14 @@ describe("SettingsView - Sound Settings", () => {
fireEvent.click(saveButton)
// Verify message sent to VSCode
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "ttsSpeed",
value: 0.75,
})
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "updateSettings",
updatedSettings: expect.objectContaining({
ttsSpeed: 0.75,
}),
}),
)
})
it("updates volume and sends message to VSCode when slider changes", () => {
@ -422,10 +432,14 @@ describe("SettingsView - Sound Settings", () => {
fireEvent.click(saveButtons[0])
// Verify message sent to VSCode
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "soundVolume",
value: 0.75,
})
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "updateSettings",
updatedSettings: expect.objectContaining({
soundVolume: 0.75,
}),
}),
)
})
})
@ -484,8 +498,10 @@ describe("SettingsView - Allowed Commands", () => {
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "allowedCommands",
commands: ["npm test"],
type: "updateSettings",
updatedSettings: {
allowedCommands: ["npm test"],
},
})
})
@ -515,8 +531,10 @@ describe("SettingsView - Allowed Commands", () => {
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenLastCalledWith({
type: "allowedCommands",
commands: [],
type: "updateSettings",
updatedSettings: {
allowedCommands: [],
},
})
})
@ -631,8 +649,10 @@ describe("SettingsView - Duplicate Commands", () => {
// Verify VSCode messages were sent
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "allowedCommands",
commands: ["npm test"],
type: "updateSettings",
updatedSettings: expect.objectContaining({
allowedCommands: ["npm test"],
}),
}),
)
})