소스 검색

Merge pull request #379 from RooVetGit/auto_approve_menu

Auto approve menu
Matt Rubens 11 달 전
부모
커밋
8ddd720d28

+ 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 {

+ 201 - 0
webview-ui/src/components/chat/AutoApproveMenu.tsx

@@ -0,0 +1,201 @@
+import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
+import { useCallback, useState } from "react"
+import { useExtensionState } from "../../context/ExtensionStateContext"
+
+interface AutoApproveAction {
+	id: string
+	label: string
+	enabled: boolean
+	shortName: string
+	description: string
+}
+
+interface AutoApproveMenuProps {
+	style?: React.CSSProperties
+}
+
+const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
+	const [isExpanded, setIsExpanded] = useState(false)
+	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",
+			shortName: "Read",
+			enabled: alwaysAllowReadOnly ?? false,
+			description: "Allows access to read any file on your computer.",
+		},
+		{
+			id: "editFiles",
+			label: "Edit files",
+			shortName: "Edit",
+			enabled: alwaysAllowWrite ?? false,
+			description: "Allows modification of any files on your computer.",
+		},
+		{
+			id: "executeCommands",
+			label: "Execute safe commands",
+			shortName: "Commands",
+			enabled: alwaysAllowExecute ?? false,
+			description:
+				"Allows execution of approved terminal commands. You can configure this in the settings panel.",
+		},
+		{
+			id: "useBrowser",
+			label: "Use the browser",
+			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",
+			shortName: "MCP",
+			enabled: alwaysAllowMcp ?? false,
+			description: "Allows use of configured MCP servers which may modify filesystem or interact with APIs.",
+		},
+		{
+			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 enabledActionsList = actions
+		.filter((action) => action.enabled)
+		.map((action) => action.shortName)
+		.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
+			style={{
+				padding: "0 15px",
+				userSelect: "none",
+				borderTop: isExpanded
+					? `0.5px solid color-mix(in srgb, var(--vscode-titleBar-inactiveForeground) 20%, transparent)`
+					: "none",
+				overflowY: "auto",
+				...style,
+			}}>
+			<div
+				style={{
+					display: "flex",
+					alignItems: "center",
+					gap: "8px",
+					padding: isExpanded ? "8px 0" : "8px 0 0 0",
+					cursor: "pointer",
+				}}
+				onClick={toggleExpanded}>
+				<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={{
+							flexShrink: 0,
+							marginLeft: isExpanded ? "2px" : "-2px",
+						}}
+					/>
+				</div>
+			</div>
+			{isExpanded && (
+				<div style={{ padding: "0" }}>
+					<div
+						style={{
+							marginBottom: "10px",
+							color: "var(--vscode-descriptionForeground)",
+							fontSize: "12px",
+						}}>
+						Auto-approve allows Cline to perform actions without asking for permission. Only enable for
+						actions you fully trust.
+					</div>
+					{actions.map((action) => (
+						<div key={action.id} style={{ margin: "6px 0" }}>
+							<div onClick={(e) => e.stopPropagation()}>
+								<VSCodeCheckbox
+									checked={action.enabled}
+									onChange={actionHandlers[action.id]}>
+									{action.label}
+								</VSCodeCheckbox>
+							</div>
+							<div
+								style={{
+									marginLeft: "28px",
+									color: "var(--vscode-descriptionForeground)",
+									fontSize: "12px",
+								}}>
+								{action.description}
+							</div>
+						</div>
+					))}
+				</div>
+			)}
+		</div>
+	)
+}
+
+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={{

+ 35 - 15
webview-ui/src/components/chat/ChatView.tsx

@@ -25,6 +25,7 @@ import BrowserSessionRow from "./BrowserSessionRow"
 import ChatRow from "./ChatRow"
 import ChatTextArea from "./ChatTextArea"
 import TaskHeader from "./TaskHeader"
+import AutoApproveMenu from "./AutoApproveMenu"
 import { AudioType } from "../../../../src/shared/WebviewMessage"
 import { validateCommand } from "../../utils/command-validation"
 
@@ -38,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)
@@ -528,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") ||
@@ -538,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(() => {
@@ -866,10 +857,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 			) : (
 				<div
 					style={{
-						flexGrow: 1,
+						flex: "1 1 0", // flex-grow: 1, flex-shrink: 1, flex-basis: 0
+						minHeight: 0,
 						overflowY: "auto",
 						display: "flex",
 						flexDirection: "column",
+						paddingBottom: "10px",
 					}}>
 					{showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
 					<div style={{ padding: "0 20px", flexShrink: 0 }}>
@@ -885,6 +878,32 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 					{taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
 				</div>
 			)}
+
+			{/* 
+			// Flex layout explanation:
+			// 1. Content div above uses flex: "1 1 0" to:
+			//    - Grow to fill available space (flex-grow: 1) 
+			//    - Shrink when AutoApproveMenu needs space (flex-shrink: 1)
+			//    - Start from zero size (flex-basis: 0) to ensure proper distribution
+			//    minHeight: 0 allows it to shrink below its content height
+			//
+			// 2. AutoApproveMenu uses flex: "0 1 auto" to:
+			//    - Not grow beyond its content (flex-grow: 0)
+			//    - Shrink when viewport is small (flex-shrink: 1) 
+			//    - Use its content size as basis (flex-basis: auto)
+			//    This ensures it takes its natural height when there's space
+			//    but becomes scrollable when the viewport is too small
+			*/}
+			{!task && (
+				<AutoApproveMenu
+					style={{
+						marginBottom: -2,
+						flex: "0 1 auto", // flex-grow: 0, flex-shrink: 1, flex-basis: auto
+						minHeight: 0,
+					}}
+				/>
+			)}
+
 			{task && (
 				<>
 					<div style={{ flexGrow: 1, display: "flex" }} ref={scrollContainerRef}>
@@ -914,6 +933,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 							initialTopMostItemIndex={groupedMessages.length - 1}
 						/>
 					</div>
+					<AutoApproveMenu />
 					{showScrollToBottom ? (
 						<div
 							style={{
@@ -938,7 +958,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 											: 0.5
 										: 0,
 								display: "flex",
-								padding: "10px 15px 0px 15px",
+								padding: `${primaryButtonText || secondaryButtonText || isStreaming ? "10" : "0"}px 15px 0px 15px`,
 							}}>
 							{primaryButtonText && !isStreaming && (
 								<VSCodeButton

+ 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>