From b65c8d0ec6679b25ab0bd81eaf31479454e335ec Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 15 Jan 2025 22:28:43 -0500 Subject: [PATCH] Customize for Roo --- .changeset/large-humans-exist.md | 5 + src/shared/ExtensionMessage.ts | 1 + .../src/components/chat/AutoApproveMenu.tsx | 212 ++++++------ .../src/components/chat/ChatTextArea.tsx | 3 +- webview-ui/src/components/chat/ChatView.tsx | 16 +- .../chat/__tests__/AutoApproveMenu.test.tsx | 198 +++++++++++ .../__tests__/ChatView.auto-approve.test.tsx | 313 ++++++++++++++++++ .../chat/__tests__/ChatView.test.tsx | 209 ++++++++++++ .../src/context/ExtensionStateContext.tsx | 18 +- 9 files changed, 842 insertions(+), 133 deletions(-) create mode 100644 .changeset/large-humans-exist.md create mode 100644 webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx create mode 100644 webview-ui/src/components/chat/__tests__/ChatView.auto-approve.test.tsx diff --git a/.changeset/large-humans-exist.md b/.changeset/large-humans-exist.md new file mode 100644 index 0000000..55f41d2 --- /dev/null +++ b/.changeset/large-humans-exist.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add auto-approve menu (thanks Cline!) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 190321b..bbb20a6 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -93,6 +93,7 @@ export interface ExtensionState { mode: Mode modeApiConfigs?: Record enhancementApiConfigId?: string + autoApprovalEnabled?: boolean } export interface ClineMessage { diff --git a/webview-ui/src/components/chat/AutoApproveMenu.tsx b/webview-ui/src/components/chat/AutoApproveMenu.tsx index 645082d..bf5f7a1 100644 --- a/webview-ui/src/components/chat/AutoApproveMenu.tsx +++ b/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([ + 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 void> = { + readFiles: handleReadOnlyChange, + editFiles: handleWriteChange, + executeCommands: handleExecuteChange, + useBrowser: handleBrowserChange, + useMcp: handleMcpChange, + retryRequests: handleRetryChange, + } return (
{ cursor: "pointer", }} onClick={toggleExpanded}> - 0} - onChange={(e) => { - const checked = (e.target as HTMLInputElement).checked - setActions((prev) => - prev.map((action) => ({ - ...action, - enabled: checked, - })), - ) - e.stopPropagation() - }} - onClick={(e) => e.stopPropagation()} - /> - - Auto-approve: - - {enabledActions.length === 0 ? "None" : enabledActionsList} +
e.stopPropagation()}> + setAutoApprovalEnabled(!(autoApprovalEnabled ?? false))} + /> +
+
+ Auto-approve: + + {enabledActionsList || "None"} - +
{isExpanded && (
@@ -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.
{actions.map((action) => (
- toggleAction(action.id)}> - {action.label} - +
e.stopPropagation()}> + + {action.label} + +
{
))} -
-
- Max Requests: - { - const value = parseInt((e.target as HTMLInputElement).value) - if (!isNaN(value) && value > 0) { - setMaxRequests(value) - } - }} - style={{ flex: 1 }} - /> -
-
- Cline will make this many API requests before asking for approval to proceed with the task. -
-
- setEnableNotifications((prev) => !prev)}> - Enable Notifications - -
- Receive system notifications when Cline requires approval to proceed or when a task is - completed. -
-
)} ) } -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 diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 57f1ec7..810ebc0 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -527,7 +527,6 @@ const ChatTextArea = forwardRef( flexDirection: "column", gap: "8px", backgroundColor: "var(--vscode-input-background)", - minHeight: "100px", margin: "10px 15px", padding: "8px" }} @@ -652,7 +651,7 @@ const ChatTextArea = forwardRef( onHeightChange?.(height) }} placeholder={placeholderText} - minRows={4} + minRows={2} maxRows={20} autoFocus={true} style={{ diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 72c6411..e106f26 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/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(() => { diff --git a/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx b/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx new file mode 100644 index 0000000..905b02f --- /dev/null +++ b/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 + +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() + + // 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() + + // 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() + + const mainCheckbox = screen.getByRole("checkbox") + fireEvent.click(mainCheckbox) + + expect(defaultMockState.setAutoApprovalEnabled).toHaveBeenCalledWith(true) + }) + + it("toggles individual permissions", () => { + render() + + // 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() + + // 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() + + // 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() + }) +}) \ No newline at end of file diff --git a/webview-ui/src/components/chat/__tests__/ChatView.auto-approve.test.tsx b/webview-ui/src/components/chat/__tests__/ChatView.auto-approve.test.tsx new file mode 100644 index 0000000..8089bb9 --- /dev/null +++ b/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
{JSON.stringify(messages)}
+ } +})) + +jest.mock('../ChatRow', () => ({ + __esModule: true, + default: function MockChatRow({ message }: { message: any }) { + return
{JSON.stringify(message)}
+ } +})) + +jest.mock('../TaskHeader', () => ({ + __esModule: true, + default: function MockTaskHeader({ task }: { task: any }) { + return
{JSON.stringify(task)}
+ } +})) + +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( + + {}} + showHistoryView={() => {}} + /> + + ) + + // 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( + + {}} + showHistoryView={() => {}} + /> + + ) + + // 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( + + {}} + showHistoryView={() => {}} + /> + + ) + + // 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( + + {}} + showHistoryView={() => {}} + /> + + ) + + // 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' + }) + }) + }) +}) \ No newline at end of file diff --git a/webview-ui/src/components/chat/__tests__/ChatView.test.tsx b/webview-ui/src/components/chat/__tests__/ChatView.test.tsx index 584ed31..500dfa6 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.test.tsx +++ b/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( + + {}} + showHistoryView={() => {}} + /> + + ) + + // 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( + + {}} + showHistoryView={() => {}} + /> + + ) + + // 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( @@ -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: [ { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 8ecc488..8b27292 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/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(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 {children}