Browse Source

Customize for Roo

Matt Rubens 1 year ago
parent
commit
b65c8d0ec6

+ 5 - 0
.changeset/large-humans-exist.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Add auto-approve menu (thanks Cline!)

+ 1 - 0
src/shared/ExtensionMessage.ts

@@ -93,6 +93,7 @@ export interface ExtensionState {
 	mode: Mode
 	modeApiConfigs?: Record<Mode, string>
 	enhancementApiConfigId?: string
+	autoApprovalEnabled?: boolean
 }
 
 export interface ClineMessage {

+ 96 - 116
webview-ui/src/components/chat/AutoApproveMenu.tsx

@@ -1,11 +1,12 @@
-import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
+import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
 import { useCallback, useState } from "react"
-import styled from "styled-components"
+import { useExtensionState } from "../../context/ExtensionStateContext"
 
 interface AutoApproveAction {
 	id: string
 	label: string
 	enabled: boolean
+	shortName: string
 	description: string
 }
 
@@ -13,58 +14,97 @@ interface AutoApproveMenuProps {
 	style?: React.CSSProperties
 }
 
-const DEFAULT_MAX_REQUESTS = 50
-
 const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
 	const [isExpanded, setIsExpanded] = useState(false)
-	const [actions, setActions] = useState<AutoApproveAction[]>([
+	const {
+		alwaysAllowReadOnly,
+		setAlwaysAllowReadOnly,
+		alwaysAllowWrite,
+		setAlwaysAllowWrite,
+		alwaysAllowExecute,
+		setAlwaysAllowExecute,
+		alwaysAllowBrowser,
+		setAlwaysAllowBrowser,
+		alwaysAllowMcp,
+		setAlwaysAllowMcp,
+		alwaysApproveResubmit,
+		setAlwaysApproveResubmit,
+		autoApprovalEnabled,
+		setAutoApprovalEnabled,
+	} = useExtensionState()
+
+	const actions: AutoApproveAction[] = [
 		{
 			id: "readFiles",
 			label: "Read files and directories",
-			enabled: false,
+			shortName: "Read",
+			enabled: alwaysAllowReadOnly ?? false,
 			description: "Allows access to read any file on your computer.",
 		},
 		{
 			id: "editFiles",
 			label: "Edit files",
-			enabled: false,
+			shortName: "Edit",
+			enabled: alwaysAllowWrite ?? false,
 			description: "Allows modification of any files on your computer.",
 		},
 		{
 			id: "executeCommands",
 			label: "Execute safe commands",
-			enabled: false,
+			shortName: "Commands",
+			enabled: alwaysAllowExecute ?? false,
 			description:
-				"Allows automatic execution of safe terminal commands. The model will determine if a command is potentially destructive and ask for explicit approval.",
+				"Allows execution of approved terminal commands. You can configure this in the settings panel.",
 		},
 		{
 			id: "useBrowser",
 			label: "Use the browser",
-			enabled: false,
+			shortName: "Browser",
+			enabled: alwaysAllowBrowser ?? false,
 			description: "Allows ability to launch and interact with any website in a headless browser.",
 		},
 		{
 			id: "useMcp",
 			label: "Use MCP servers",
-			enabled: false,
+			shortName: "MCP",
+			enabled: alwaysAllowMcp ?? false,
 			description: "Allows use of configured MCP servers which may modify filesystem or interact with APIs.",
 		},
-	])
-	const [maxRequests, setMaxRequests] = useState(DEFAULT_MAX_REQUESTS)
-	const [enableNotifications, setEnableNotifications] = useState(false)
+		{
+			id: "retryRequests",
+			label: "Retry failed requests",
+			shortName: "Retries",
+			enabled: alwaysApproveResubmit ?? false,
+			description: "Automatically retry failed API requests when the provider returns an error response.",
+		},
+	]
 
 	const toggleExpanded = useCallback(() => {
 		setIsExpanded((prev) => !prev)
 	}, [])
 
-	const toggleAction = useCallback((actionId: string) => {
-		setActions((prev) =>
-			prev.map((action) => (action.id === actionId ? { ...action, enabled: !action.enabled } : action)),
-		)
-	}, [])
+	const enabledActionsList = actions
+		.filter((action) => action.enabled)
+		.map((action) => action.shortName)
+		.join(", ")
 
-	const enabledActions = actions.filter((action) => action.enabled)
-	const enabledActionsList = enabledActions.map((action) => action.label).join(", ")
+	// Individual checkbox handlers - each one only updates its own state
+	const handleReadOnlyChange = useCallback(() => setAlwaysAllowReadOnly(!(alwaysAllowReadOnly ?? false)), [alwaysAllowReadOnly, setAlwaysAllowReadOnly])
+	const handleWriteChange = useCallback(() => setAlwaysAllowWrite(!(alwaysAllowWrite ?? false)), [alwaysAllowWrite, setAlwaysAllowWrite])
+	const handleExecuteChange = useCallback(() => setAlwaysAllowExecute(!(alwaysAllowExecute ?? false)), [alwaysAllowExecute, setAlwaysAllowExecute])
+	const handleBrowserChange = useCallback(() => setAlwaysAllowBrowser(!(alwaysAllowBrowser ?? false)), [alwaysAllowBrowser, setAlwaysAllowBrowser])
+	const handleMcpChange = useCallback(() => setAlwaysAllowMcp(!(alwaysAllowMcp ?? false)), [alwaysAllowMcp, setAlwaysAllowMcp])
+	const handleRetryChange = useCallback(() => setAlwaysApproveResubmit(!(alwaysApproveResubmit ?? false)), [alwaysApproveResubmit, setAlwaysApproveResubmit])
+
+	// Map action IDs to their specific handlers
+	const actionHandlers: Record<AutoApproveAction['id'], () => void> = {
+		readFiles: handleReadOnlyChange,
+		editFiles: handleWriteChange,
+		executeCommands: handleExecuteChange,
+		useBrowser: handleBrowserChange,
+		useMcp: handleMcpChange,
+		retryRequests: handleRetryChange,
+	}
 
 	return (
 		<div
@@ -86,39 +126,41 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
 					cursor: "pointer",
 				}}
 				onClick={toggleExpanded}>
-				<VSCodeCheckbox
-					checked={enabledActions.length > 0}
-					onChange={(e) => {
-						const checked = (e.target as HTMLInputElement).checked
-						setActions((prev) =>
-							prev.map((action) => ({
-								...action,
-								enabled: checked,
-							})),
-						)
-						e.stopPropagation()
-					}}
-					onClick={(e) => e.stopPropagation()}
-				/>
-				<CollapsibleSection>
-					<span style={{ color: "var(--vscode-foreground)" }}>Auto-approve:</span>
-					<span
-						style={{
-							whiteSpace: "nowrap",
-							overflow: "hidden",
-							textOverflow: "ellipsis",
-						}}>
-						{enabledActions.length === 0 ? "None" : enabledActionsList}
+				<div onClick={(e) => e.stopPropagation()}>
+					<VSCodeCheckbox
+						checked={autoApprovalEnabled ?? false}
+						onChange={() => setAutoApprovalEnabled(!(autoApprovalEnabled ?? false))}
+					/>
+				</div>
+				<div style={{
+					display: 'flex',
+					alignItems: 'center',
+					gap: '4px',
+					flex: 1,
+					minWidth: 0
+				}}>
+					<span style={{
+						color: "var(--vscode-foreground)",
+						flexShrink: 0
+					}}>Auto-approve:</span>
+					<span style={{
+						color: "var(--vscode-descriptionForeground)",
+						overflow: "hidden",
+						textOverflow: "ellipsis",
+						whiteSpace: "nowrap",
+						flex: 1,
+						minWidth: 0
+					}}>
+						{enabledActionsList || "None"}
 					</span>
 					<span
 						className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}
 						style={{
-							// fontSize: "14px",
 							flexShrink: 0,
 							marginLeft: isExpanded ? "2px" : "-2px",
 						}}
 					/>
-				</CollapsibleSection>
+				</div>
 			</div>
 			{isExpanded && (
 				<div style={{ padding: "0" }}>
@@ -129,13 +171,17 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
 							fontSize: "12px",
 						}}>
 						Auto-approve allows Cline to perform actions without asking for permission. Only enable for
-						actions you fully trust, and consider setting a low request limit as a safeguard.
+						actions you fully trust.
 					</div>
 					{actions.map((action) => (
 						<div key={action.id} style={{ margin: "6px 0" }}>
-							<VSCodeCheckbox checked={action.enabled} onChange={() => toggleAction(action.id)}>
-								{action.label}
-							</VSCodeCheckbox>
+							<div onClick={(e) => e.stopPropagation()}>
+								<VSCodeCheckbox
+									checked={action.enabled}
+									onChange={actionHandlers[action.id]}>
+									{action.label}
+								</VSCodeCheckbox>
+							</div>
 							<div
 								style={{
 									marginLeft: "28px",
@@ -146,76 +192,10 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
 							</div>
 						</div>
 					))}
-					<div
-						style={{
-							height: "0.5px",
-							background: "var(--vscode-titleBar-inactiveForeground)",
-							margin: "15px 0",
-							opacity: 0.2,
-						}}
-					/>
-					<div
-						style={{
-							display: "flex",
-							alignItems: "center",
-							gap: "8px",
-							marginTop: "10px",
-							marginBottom: "8px",
-							color: "var(--vscode-foreground)",
-						}}>
-						<span style={{ flexShrink: 1, minWidth: 0 }}>Max Requests:</span>
-						<VSCodeTextField
-							value={maxRequests.toString()}
-							onChange={(e) => {
-								const value = parseInt((e.target as HTMLInputElement).value)
-								if (!isNaN(value) && value > 0) {
-									setMaxRequests(value)
-								}
-							}}
-							style={{ flex: 1 }}
-						/>
-					</div>
-					<div
-						style={{
-							color: "var(--vscode-descriptionForeground)",
-							fontSize: "12px",
-							marginBottom: "10px",
-						}}>
-						Cline will make this many API requests before asking for approval to proceed with the task.
-					</div>
-					<div style={{ margin: "6px 0" }}>
-						<VSCodeCheckbox
-							checked={enableNotifications}
-							onChange={() => setEnableNotifications((prev) => !prev)}>
-							Enable Notifications
-						</VSCodeCheckbox>
-						<div
-							style={{
-								marginLeft: "28px",
-								color: "var(--vscode-descriptionForeground)",
-								fontSize: "12px",
-							}}>
-							Receive system notifications when Cline requires approval to proceed or when a task is
-							completed.
-						</div>
-					</div>
 				</div>
 			)}
 		</div>
 	)
 }
 
-const CollapsibleSection = styled.div`
-	display: flex;
-	align-items: center;
-	gap: 4px;
-	color: var(--vscode-descriptionForeground);
-	flex: 1;
-	min-width: 0;
-
-	&:hover {
-		color: var(--vscode-foreground);
-	}
-`
-
 export default AutoApproveMenu

+ 1 - 2
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -527,7 +527,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					flexDirection: "column",
 					gap: "8px",
 					backgroundColor: "var(--vscode-input-background)",
-					minHeight: "100px",
 					margin: "10px 15px",
 					padding: "8px"
 				}}
@@ -652,7 +651,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 							onHeightChange?.(height)
 						}}
 						placeholder={placeholderText}
-						minRows={4}
+						minRows={2}
 						maxRows={20}
 						autoFocus={true}
 						style={{

+ 3 - 13
webview-ui/src/components/chat/ChatView.tsx

@@ -39,7 +39,7 @@ interface ChatViewProps {
 export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
 
 const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
-	const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, allowedCommands, writeDelayMs, mode, setMode } = useExtensionState()
+	const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, allowedCommands, writeDelayMs, mode, setMode, autoApprovalEnabled } = useExtensionState()
 
 	//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
 	const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
@@ -529,7 +529,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 
 	const isAutoApproved = useCallback(
 		(message: ClineMessage | undefined) => {
-			if (!message || message.type !== "ask") return false
+			if (!autoApprovalEnabled || !message || message.type !== "ask") return false
 
 			return (
 				(alwaysAllowBrowser && message.ask === "browser_action_launch") ||
@@ -539,17 +539,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 				(alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message))
 			)
 		},
-		[
-			alwaysAllowBrowser,
-			alwaysAllowReadOnly,
-			alwaysAllowWrite,
-			alwaysAllowExecute,
-			alwaysAllowMcp,
-			isReadOnlyToolAction,
-			isWriteToolAction,
-			isAllowedCommand,
-			isMcpToolAlwaysAllowed
-		]
+		[autoApprovalEnabled, alwaysAllowBrowser, alwaysAllowReadOnly, isReadOnlyToolAction, alwaysAllowWrite, isWriteToolAction, alwaysAllowExecute, isAllowedCommand, alwaysAllowMcp, isMcpToolAlwaysAllowed]
 	)
 
 	useEffect(() => {

+ 198 - 0
webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx

@@ -0,0 +1,198 @@
+import { render, fireEvent, screen } from "@testing-library/react"
+import { useExtensionState } from "../../../context/ExtensionStateContext"
+import AutoApproveMenu from "../AutoApproveMenu"
+import { codeMode, defaultPrompts } from "../../../../../src/shared/modes"
+
+// Mock the ExtensionStateContext hook
+jest.mock("../../../context/ExtensionStateContext")
+
+const mockUseExtensionState = useExtensionState as jest.MockedFunction<typeof useExtensionState>
+
+describe("AutoApproveMenu", () => {
+    const defaultMockState = {
+        // Required state properties
+        version: "1.0.0",
+        clineMessages: [],
+        taskHistory: [],
+        shouldShowAnnouncement: false,
+        allowedCommands: [],
+        soundEnabled: false,
+        soundVolume: 0.5,
+        diffEnabled: false,
+        fuzzyMatchThreshold: 1.0,
+        preferredLanguage: "English",
+        writeDelayMs: 1000,
+        browserViewportSize: "900x600",
+        screenshotQuality: 75,
+        terminalOutputLineLimit: 500,
+        mcpEnabled: true,
+        requestDelaySeconds: 5,
+        currentApiConfigName: "default",
+        listApiConfigMeta: [],
+        mode: codeMode,
+        customPrompts: defaultPrompts,
+        enhancementApiConfigId: "",
+        didHydrateState: true,
+        showWelcome: false,
+        theme: {},
+        glamaModels: {},
+        openRouterModels: {},
+        openAiModels: [],
+        mcpServers: [],
+        filePaths: [],
+
+        // Auto-approve specific properties
+        alwaysAllowReadOnly: false,
+        alwaysAllowWrite: false,
+        alwaysAllowExecute: false,
+        alwaysAllowBrowser: false,
+        alwaysAllowMcp: false,
+        alwaysApproveResubmit: false,
+        autoApprovalEnabled: false,
+
+        // Required setter functions
+        setApiConfiguration: jest.fn(),
+        setCustomInstructions: jest.fn(),
+        setAlwaysAllowReadOnly: jest.fn(),
+        setAlwaysAllowWrite: jest.fn(),
+        setAlwaysAllowExecute: jest.fn(),
+        setAlwaysAllowBrowser: jest.fn(),
+        setAlwaysAllowMcp: jest.fn(),
+        setShowAnnouncement: jest.fn(),
+        setAllowedCommands: jest.fn(),
+        setSoundEnabled: jest.fn(),
+        setSoundVolume: jest.fn(),
+        setDiffEnabled: jest.fn(),
+        setBrowserViewportSize: jest.fn(),
+        setFuzzyMatchThreshold: jest.fn(),
+        setPreferredLanguage: jest.fn(),
+        setWriteDelayMs: jest.fn(),
+        setScreenshotQuality: jest.fn(),
+        setTerminalOutputLineLimit: jest.fn(),
+        setMcpEnabled: jest.fn(),
+        setAlwaysApproveResubmit: jest.fn(),
+        setRequestDelaySeconds: jest.fn(),
+        setCurrentApiConfigName: jest.fn(),
+        setListApiConfigMeta: jest.fn(),
+        onUpdateApiConfig: jest.fn(),
+        setMode: jest.fn(),
+        setCustomPrompts: jest.fn(),
+        setEnhancementApiConfigId: jest.fn(),
+        setAutoApprovalEnabled: jest.fn(),
+    }
+
+    beforeEach(() => {
+        mockUseExtensionState.mockReturnValue(defaultMockState)
+    })
+
+    afterEach(() => {
+        jest.clearAllMocks()
+    })
+
+    it("renders with initial collapsed state", () => {
+        render(<AutoApproveMenu />)
+        
+        // Check for main checkbox and label
+        expect(screen.getByText("Auto-approve:")).toBeInTheDocument()
+        expect(screen.getByText("None")).toBeInTheDocument()
+        
+        // Verify the menu is collapsed (actions not visible)
+        expect(screen.queryByText("Read files and directories")).not.toBeInTheDocument()
+    })
+
+    it("expands menu when clicked", () => {
+        render(<AutoApproveMenu />)
+        
+        // Click to expand
+        fireEvent.click(screen.getByText("Auto-approve:"))
+        
+        // Verify menu items are visible
+        expect(screen.getByText("Read files and directories")).toBeInTheDocument()
+        expect(screen.getByText("Edit files")).toBeInTheDocument()
+        expect(screen.getByText("Execute safe commands")).toBeInTheDocument()
+        expect(screen.getByText("Use the browser")).toBeInTheDocument()
+        expect(screen.getByText("Use MCP servers")).toBeInTheDocument()
+        expect(screen.getByText("Retry failed requests")).toBeInTheDocument()
+    })
+
+    it("toggles main auto-approval checkbox", () => {
+        render(<AutoApproveMenu />)
+        
+        const mainCheckbox = screen.getByRole("checkbox")
+        fireEvent.click(mainCheckbox)
+        
+        expect(defaultMockState.setAutoApprovalEnabled).toHaveBeenCalledWith(true)
+    })
+
+    it("toggles individual permissions", () => {
+        render(<AutoApproveMenu />)
+        
+        // Expand menu
+        fireEvent.click(screen.getByText("Auto-approve:"))
+        
+        // Click read files checkbox
+        fireEvent.click(screen.getByText("Read files and directories"))
+        expect(defaultMockState.setAlwaysAllowReadOnly).toHaveBeenCalledWith(true)
+        
+        // Click edit files checkbox
+        fireEvent.click(screen.getByText("Edit files"))
+        expect(defaultMockState.setAlwaysAllowWrite).toHaveBeenCalledWith(true)
+        
+        // Click execute commands checkbox
+        fireEvent.click(screen.getByText("Execute safe commands"))
+        expect(defaultMockState.setAlwaysAllowExecute).toHaveBeenCalledWith(true)
+    })
+
+    it("displays enabled actions in summary", () => {
+        mockUseExtensionState.mockReturnValue({
+            ...defaultMockState,
+            alwaysAllowReadOnly: true,
+            alwaysAllowWrite: true,
+            autoApprovalEnabled: true,
+        })
+        
+        render(<AutoApproveMenu />)
+        
+        // Check that enabled actions are shown in summary
+        expect(screen.getByText("Read, Edit")).toBeInTheDocument()
+    })
+
+    it("preserves checkbox states", () => {
+        // Mock state with some permissions enabled
+        const mockState = {
+            ...defaultMockState,
+            alwaysAllowReadOnly: true,
+            alwaysAllowWrite: true,
+        }
+        
+        // Update mock to return our state
+        mockUseExtensionState.mockReturnValue(mockState)
+        
+        render(<AutoApproveMenu />)
+        
+        // Expand menu
+        fireEvent.click(screen.getByText("Auto-approve:"))
+        
+        // Verify read and edit checkboxes are checked
+        expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
+        expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
+        
+        // Verify the setters haven't been called yet
+        expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
+        expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
+        
+        // Collapse menu
+        fireEvent.click(screen.getByText("Auto-approve:"))
+        
+        // Expand again
+        fireEvent.click(screen.getByText("Auto-approve:"))
+        
+        // Verify checkboxes are still present
+        expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
+        expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
+        
+        // Verify the setters still haven't been called
+        expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
+        expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
+    })
+})

+ 313 - 0
webview-ui/src/components/chat/__tests__/ChatView.auto-approve.test.tsx

@@ -0,0 +1,313 @@
+import React from 'react'
+import { render, waitFor } from '@testing-library/react'
+import ChatView from '../ChatView'
+import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
+import { vscode } from '../../../utils/vscode'
+
+// Mock vscode API
+jest.mock('../../../utils/vscode', () => ({
+    vscode: {
+        postMessage: jest.fn(),
+    },
+}))
+
+// Mock all problematic dependencies
+jest.mock('rehype-highlight', () => ({
+    __esModule: true,
+    default: () => () => {},
+}))
+
+jest.mock('hast-util-to-text', () => ({
+    __esModule: true,
+    default: () => '',
+}))
+
+// Mock components that use ESM dependencies
+jest.mock('../BrowserSessionRow', () => ({
+    __esModule: true,
+    default: function MockBrowserSessionRow({ messages }: { messages: any[] }) {
+        return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
+    }
+}))
+
+jest.mock('../ChatRow', () => ({
+    __esModule: true,
+    default: function MockChatRow({ message }: { message: any }) {
+        return <div data-testid="chat-row">{JSON.stringify(message)}</div>
+    }
+}))
+
+jest.mock('../TaskHeader', () => ({
+    __esModule: true,
+    default: function MockTaskHeader({ task }: { task: any }) {
+        return <div data-testid="task-header">{JSON.stringify(task)}</div>
+    }
+}))
+
+jest.mock('../AutoApproveMenu', () => ({
+    __esModule: true,
+    default: () => null,
+}))
+
+jest.mock('../../common/CodeBlock', () => ({
+    __esModule: true,
+    default: () => null,
+    CODE_BLOCK_BG_COLOR: 'rgb(30, 30, 30)',
+}))
+
+jest.mock('../../common/CodeAccordian', () => ({
+    __esModule: true,
+    default: () => null,
+}))
+
+jest.mock('../ContextMenu', () => ({
+    __esModule: true,
+    default: () => null,
+}))
+
+// Mock window.postMessage to trigger state hydration
+const mockPostMessage = (state: any) => {
+    window.postMessage({
+        type: 'state',
+        state: {
+            version: '1.0.0',
+            clineMessages: [],
+            taskHistory: [],
+            shouldShowAnnouncement: false,
+            allowedCommands: [],
+            alwaysAllowExecute: false,
+            autoApprovalEnabled: true,
+            ...state
+        }
+    }, '*')
+}
+
+describe('ChatView - Auto Approval Tests', () => {
+    beforeEach(() => {
+        jest.clearAllMocks()
+    })
+
+    it('auto-approves read operations when enabled', async () => {
+        render(
+            <ExtensionStateContextProvider>
+                <ChatView 
+                    isHidden={false}
+                    showAnnouncement={false}
+                    hideAnnouncement={() => {}}
+                    showHistoryView={() => {}}
+                />
+            </ExtensionStateContextProvider>
+        )
+
+        // First hydrate state with initial task
+        mockPostMessage({
+            alwaysAllowReadOnly: true,
+            autoApprovalEnabled: true,
+            clineMessages: [
+                {
+                    type: 'say',
+                    say: 'task',
+                    ts: Date.now() - 2000,
+                    text: 'Initial task'
+                }
+            ]
+        })
+
+        // Then send the read tool ask message
+        mockPostMessage({
+            alwaysAllowReadOnly: true,
+            autoApprovalEnabled: true,
+            clineMessages: [
+                {
+                    type: 'say',
+                    say: 'task',
+                    ts: Date.now() - 2000,
+                    text: 'Initial task'
+                },
+                {
+                    type: 'ask',
+                    ask: 'tool',
+                    ts: Date.now(),
+                    text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }),
+                    partial: false
+                }
+            ]
+        })
+
+        // Wait for the auto-approval message
+        await waitFor(() => {
+            expect(vscode.postMessage).toHaveBeenCalledWith({
+                type: 'askResponse',
+                askResponse: 'yesButtonClicked'
+            })
+        })
+    })
+
+    it('does not auto-approve when autoApprovalEnabled is false', async () => {
+        render(
+            <ExtensionStateContextProvider>
+                <ChatView 
+                    isHidden={false}
+                    showAnnouncement={false}
+                    hideAnnouncement={() => {}}
+                    showHistoryView={() => {}}
+                />
+            </ExtensionStateContextProvider>
+        )
+
+        // First hydrate state with initial task
+        mockPostMessage({
+            alwaysAllowReadOnly: true,
+            autoApprovalEnabled: false,
+            clineMessages: [
+                {
+                    type: 'say',
+                    say: 'task',
+                    ts: Date.now() - 2000,
+                    text: 'Initial task'
+                }
+            ]
+        })
+
+        // Then send the read tool ask message
+        mockPostMessage({
+            alwaysAllowReadOnly: true,
+            autoApprovalEnabled: false,
+            clineMessages: [
+                {
+                    type: 'say',
+                    say: 'task',
+                    ts: Date.now() - 2000,
+                    text: 'Initial task'
+                },
+                {
+                    type: 'ask',
+                    ask: 'tool',
+                    ts: Date.now(),
+                    text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }),
+                    partial: false
+                }
+            ]
+        })
+
+        // Verify no auto-approval message was sent
+        expect(vscode.postMessage).not.toHaveBeenCalledWith({
+            type: 'askResponse',
+            askResponse: 'yesButtonClicked'
+        })
+    })
+
+    it('auto-approves write operations when enabled', async () => {
+        render(
+            <ExtensionStateContextProvider>
+                <ChatView 
+                    isHidden={false}
+                    showAnnouncement={false}
+                    hideAnnouncement={() => {}}
+                    showHistoryView={() => {}}
+                />
+            </ExtensionStateContextProvider>
+        )
+
+        // First hydrate state with initial task
+        mockPostMessage({
+            alwaysAllowWrite: true,
+            autoApprovalEnabled: true,
+            writeDelayMs: 0,
+            clineMessages: [
+                {
+                    type: 'say',
+                    say: 'task',
+                    ts: Date.now() - 2000,
+                    text: 'Initial task'
+                }
+            ]
+        })
+
+        // Then send the write tool ask message
+        mockPostMessage({
+            alwaysAllowWrite: true,
+            autoApprovalEnabled: true,
+            writeDelayMs: 0,
+            clineMessages: [
+                {
+                    type: 'say',
+                    say: 'task',
+                    ts: Date.now() - 2000,
+                    text: 'Initial task'
+                },
+                {
+                    type: 'ask',
+                    ask: 'tool',
+                    ts: Date.now(),
+                    text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }),
+                    partial: false
+                }
+            ]
+        })
+
+        // Wait for the auto-approval message
+        await waitFor(() => {
+            expect(vscode.postMessage).toHaveBeenCalledWith({
+                type: 'askResponse',
+                askResponse: 'yesButtonClicked'
+            })
+        })
+    })
+
+    it('auto-approves browser actions when enabled', async () => {
+        render(
+            <ExtensionStateContextProvider>
+                <ChatView 
+                    isHidden={false}
+                    showAnnouncement={false}
+                    hideAnnouncement={() => {}}
+                    showHistoryView={() => {}}
+                />
+            </ExtensionStateContextProvider>
+        )
+
+        // First hydrate state with initial task
+        mockPostMessage({
+            alwaysAllowBrowser: true,
+            autoApprovalEnabled: true,
+            clineMessages: [
+                {
+                    type: 'say',
+                    say: 'task',
+                    ts: Date.now() - 2000,
+                    text: 'Initial task'
+                }
+            ]
+        })
+
+        // Then send the browser action ask message
+        mockPostMessage({
+            alwaysAllowBrowser: true,
+            autoApprovalEnabled: true,
+            clineMessages: [
+                {
+                    type: 'say',
+                    say: 'task',
+                    ts: Date.now() - 2000,
+                    text: 'Initial task'
+                },
+                {
+                    type: 'ask',
+                    ask: 'browser_action_launch',
+                    ts: Date.now(),
+                    text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
+                    partial: false
+                }
+            ]
+        })
+
+        // Wait for the auto-approval message
+        await waitFor(() => {
+            expect(vscode.postMessage).toHaveBeenCalledWith({
+                type: 'askResponse',
+                askResponse: 'yesButtonClicked'
+            })
+        })
+    })
+})

+ 209 - 0
webview-ui/src/components/chat/__tests__/ChatView.test.tsx

@@ -46,6 +46,11 @@ jest.mock('../ChatRow', () => ({
   }
 }))
 
+jest.mock('../AutoApproveMenu', () => ({
+  __esModule: true,
+  default: () => null,
+}))
+
 interface ChatTextAreaProps {
   onSend: (value: string) => void;
   inputValue?: string;
@@ -139,6 +144,187 @@ describe('ChatView - Auto Approval Tests', () => {
     jest.clearAllMocks()
   })
 
+  it('defaults autoApprovalEnabled to true if any individual auto-approval flags are true', async () => {
+    render(
+      <ExtensionStateContextProvider>
+        <ChatView
+          isHidden={false}
+          showAnnouncement={false}
+          hideAnnouncement={() => {}}
+          showHistoryView={() => {}}
+        />
+      </ExtensionStateContextProvider>
+    )
+
+    // Test cases with different individual flags
+    const testCases = [
+      { alwaysAllowBrowser: true },
+      { alwaysAllowReadOnly: true },
+      { alwaysAllowWrite: true },
+      { alwaysAllowExecute: true },
+      { alwaysAllowMcp: true }
+    ]
+
+    for (const flags of testCases) {
+      // Reset state
+      mockPostMessage({
+        ...flags,
+        clineMessages: []
+      })
+
+      // Send an action that should be auto-approved
+      mockPostMessage({
+        ...flags,
+        clineMessages: [
+          {
+            type: 'say',
+            say: 'task',
+            ts: Date.now() - 2000,
+            text: 'Initial task'
+          },
+          {
+            type: 'ask',
+            ask: flags.alwaysAllowBrowser ? 'browser_action_launch' :
+                 flags.alwaysAllowReadOnly ? 'tool' :
+                 flags.alwaysAllowWrite ? 'tool' :
+                 flags.alwaysAllowExecute ? 'command' :
+                 'use_mcp_server',
+            ts: Date.now(),
+            text: flags.alwaysAllowBrowser ? JSON.stringify({ action: 'launch', url: 'http://example.com' }) :
+                  flags.alwaysAllowReadOnly ? JSON.stringify({ tool: 'readFile', path: 'test.txt' }) :
+                  flags.alwaysAllowWrite ? JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }) :
+                  flags.alwaysAllowExecute ? 'npm test' :
+                  JSON.stringify({ type: 'use_mcp_tool', serverName: 'test', toolName: 'test' }),
+            partial: false
+          }
+        ]
+      })
+
+      // Wait for auto-approval
+      await waitFor(() => {
+        expect(vscode.postMessage).toHaveBeenCalledWith({
+          type: 'askResponse',
+          askResponse: 'yesButtonClicked'
+        })
+      })
+    }
+
+    // Verify no auto-approval when no flags are true
+    jest.clearAllMocks()
+    mockPostMessage({
+      alwaysAllowBrowser: false,
+      alwaysAllowReadOnly: false,
+      alwaysAllowWrite: false,
+      alwaysAllowExecute: false,
+      alwaysAllowMcp: false,
+      clineMessages: [
+        {
+          type: 'say',
+          say: 'task',
+          ts: Date.now() - 2000,
+          text: 'Initial task'
+        },
+        {
+          type: 'ask',
+          ask: 'browser_action_launch',
+          ts: Date.now(),
+          text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
+          partial: false
+        }
+      ]
+    })
+
+    // Wait a bit to ensure no auto-approval happens
+    await new Promise(resolve => setTimeout(resolve, 100))
+    expect(vscode.postMessage).not.toHaveBeenCalledWith({
+      type: 'askResponse',
+      askResponse: 'yesButtonClicked'
+    })
+  })
+
+  it('does not auto-approve any actions when autoApprovalEnabled is false', () => {
+    render(
+      <ExtensionStateContextProvider>
+        <ChatView
+          isHidden={false}
+          showAnnouncement={false}
+          hideAnnouncement={() => {}}
+          showHistoryView={() => {}}
+        />
+      </ExtensionStateContextProvider>
+    )
+
+    // First hydrate state with initial task
+    mockPostMessage({
+      autoApprovalEnabled: false,
+      alwaysAllowBrowser: true,
+      alwaysAllowReadOnly: true,
+      alwaysAllowWrite: true,
+      alwaysAllowExecute: true,
+      allowedCommands: ['npm test'],
+      clineMessages: [
+        {
+          type: 'say',
+          say: 'task',
+          ts: Date.now() - 2000,
+          text: 'Initial task'
+        }
+      ]
+    })
+
+    // Test various types of actions that should not be auto-approved
+    const testCases = [
+      {
+        ask: 'browser_action_launch',
+        text: JSON.stringify({ action: 'launch', url: 'http://example.com' })
+      },
+      {
+        ask: 'tool',
+        text: JSON.stringify({ tool: 'readFile', path: 'test.txt' })
+      },
+      {
+        ask: 'tool',
+        text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' })
+      },
+      {
+        ask: 'command',
+        text: 'npm test'
+      }
+    ]
+
+    testCases.forEach(testCase => {
+      mockPostMessage({
+        autoApprovalEnabled: false,
+        alwaysAllowBrowser: true,
+        alwaysAllowReadOnly: true,
+        alwaysAllowWrite: true,
+        alwaysAllowExecute: true,
+        allowedCommands: ['npm test'],
+        clineMessages: [
+          {
+            type: 'say',
+            say: 'task',
+            ts: Date.now() - 2000,
+            text: 'Initial task'
+          },
+          {
+            type: 'ask',
+            ask: testCase.ask,
+            ts: Date.now(),
+            text: testCase.text,
+            partial: false
+          }
+        ]
+      })
+
+      // Verify no auto-approval message was sent
+      expect(vscode.postMessage).not.toHaveBeenCalledWith({
+        type: 'askResponse',
+        askResponse: 'yesButtonClicked'
+      })
+    })
+  })
+
   it('auto-approves browser actions when alwaysAllowBrowser is enabled', async () => {
     render(
       <ExtensionStateContextProvider>
@@ -153,6 +339,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
     // First hydrate state with initial task
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowBrowser: true,
       clineMessages: [
         {
@@ -166,6 +353,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
     // Then send the browser action ask message
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowBrowser: true,
       clineMessages: [
         {
@@ -207,6 +395,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
     // First hydrate state with initial task
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowReadOnly: true,
       clineMessages: [
         {
@@ -220,6 +409,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
     // Then send the read-only tool ask message
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowReadOnly: true,
       clineMessages: [
         {
@@ -262,6 +452,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
       // First hydrate state with initial task
       mockPostMessage({
+        autoApprovalEnabled: true,
         alwaysAllowWrite: true,
         writeDelayMs: 0,
         clineMessages: [
@@ -276,6 +467,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
       // Then send the write tool ask message
       mockPostMessage({
+        autoApprovalEnabled: true,
         alwaysAllowWrite: true,
         writeDelayMs: 0,
         clineMessages: [
@@ -318,6 +510,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
       // First hydrate state with initial task
       mockPostMessage({
+        autoApprovalEnabled: true,
         alwaysAllowWrite: true,
         clineMessages: [
           {
@@ -331,6 +524,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
       // Then send a non-tool write operation message
       mockPostMessage({
+        autoApprovalEnabled: true,
         alwaysAllowWrite: true,
         clineMessages: [
           {
@@ -371,6 +565,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
     // First hydrate state with initial task
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowExecute: true,
       allowedCommands: ['npm test'],
       clineMessages: [
@@ -385,6 +580,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
     // Then send the command ask message
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowExecute: true,
       allowedCommands: ['npm test'],
       clineMessages: [
@@ -427,6 +623,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
     // First hydrate state with initial task
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowExecute: true,
       allowedCommands: ['npm test'],
       clineMessages: [
@@ -441,6 +638,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
     // Then send the disallowed command ask message
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowExecute: true,
       allowedCommands: ['npm test'],
       clineMessages: [
@@ -498,6 +696,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
         // First hydrate state with initial task
         mockPostMessage({
+          autoApprovalEnabled: true,
           alwaysAllowExecute: true,
           allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'],
           clineMessages: [
@@ -512,6 +711,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
         // Then send the chained command ask message
         mockPostMessage({
+          autoApprovalEnabled: true,
           alwaysAllowExecute: true,
           allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'],
           clineMessages: [
@@ -585,6 +785,7 @@ describe('ChatView - Auto Approval Tests', () => {
 
         // Then send the chained command ask message
         mockPostMessage({
+          autoApprovalEnabled: true,
           alwaysAllowExecute: true,
           allowedCommands: ['npm test', 'Select-String'],
           clineMessages: [
@@ -643,6 +844,7 @@ describe('ChatView - Auto Approval Tests', () => {
         jest.clearAllMocks()
 
         mockPostMessage({
+          autoApprovalEnabled: true,
           alwaysAllowExecute: true,
           allowedCommands: ['npm test', 'Select-String'],
           clineMessages: [
@@ -656,6 +858,7 @@ describe('ChatView - Auto Approval Tests', () => {
         })
 
         mockPostMessage({
+          autoApprovalEnabled: true,
           alwaysAllowExecute: true,
           allowedCommands: ['npm test', 'Select-String'],
           clineMessages: [
@@ -688,6 +891,7 @@ describe('ChatView - Auto Approval Tests', () => {
         jest.clearAllMocks()
 
         mockPostMessage({
+          autoApprovalEnabled: true,
           alwaysAllowExecute: true,
           allowedCommands: ['npm test', 'Select-String'],
           clineMessages: [
@@ -701,6 +905,7 @@ describe('ChatView - Auto Approval Tests', () => {
         })
 
         mockPostMessage({
+          autoApprovalEnabled: true,
           alwaysAllowExecute: true,
           allowedCommands: ['npm test', 'Select-String'],
           clineMessages: [
@@ -748,6 +953,7 @@ describe('ChatView - Sound Playing Tests', () => {
 
     // First hydrate state with initial task and streaming
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowBrowser: true,
       clineMessages: [
         {
@@ -768,6 +974,7 @@ describe('ChatView - Sound Playing Tests', () => {
 
     // Then send the browser action ask message (streaming finished)
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowBrowser: true,
       clineMessages: [
         {
@@ -807,6 +1014,7 @@ describe('ChatView - Sound Playing Tests', () => {
 
     // First hydrate state with initial task and streaming
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowBrowser: false,
       clineMessages: [
         {
@@ -827,6 +1035,7 @@ describe('ChatView - Sound Playing Tests', () => {
 
     // Then send the browser action ask message (streaming finished)
     mockPostMessage({
+      autoApprovalEnabled: true,
       alwaysAllowBrowser: false,
       clineMessages: [
         {

+ 16 - 2
webview-ui/src/context/ExtensionStateContext.tsx

@@ -63,6 +63,8 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setCustomPrompts: (value: CustomPrompts) => void
 	enhancementApiConfigId?: string
 	setEnhancementApiConfigId: (value: string) => void
+	autoApprovalEnabled?: boolean
+	setAutoApprovalEnabled: (value: boolean) => void
 }
 
 export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -121,11 +123,22 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		const message: ExtensionMessage = event.data
 		switch (message.type) {
 			case "state": {
+				const newState = message.state!
+				// Set autoApprovalEnabled to true if undefined and any individual flag is true
+				if (newState.autoApprovalEnabled === undefined) {
+					newState.autoApprovalEnabled = !!(
+						newState.alwaysAllowBrowser ||
+						newState.alwaysAllowReadOnly ||
+						newState.alwaysAllowWrite ||
+						newState.alwaysAllowExecute ||
+						newState.alwaysAllowMcp ||
+						newState.alwaysApproveResubmit)
+				}
 				setState(prevState => ({
 					...prevState,
-					...message.state!
+					...newState
 				}))
-				const config = message.state?.apiConfiguration
+				const config = newState.apiConfiguration
 				const hasKey = checkExistKey(config)
 				setShowWelcome(!hasKey)
 				setDidHydrateState(true)
@@ -237,6 +250,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })),
 		setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })),
 		setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),
+		setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })),
 	}
 
 	return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>