-
+
{/* Auto-approve API request count limit input row inspired by Cline */}
{
+ useEffect(() => {
// if last message is an ask, show user ask UI
// if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost.
// basically as long as a task is active, the conversation history will be persisted
@@ -390,7 +390,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction {
if (messages.length === 0) {
diff --git a/webview-ui/src/components/chat/__tests__/AutoApproveMenu.spec.tsx b/webview-ui/src/components/chat/__tests__/AutoApproveMenu.spec.tsx
new file mode 100644
index 0000000000..ddb12602ec
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/AutoApproveMenu.spec.tsx
@@ -0,0 +1,322 @@
+import React from "react"
+import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+import "@testing-library/jest-dom"
+import AutoApproveMenu from "../AutoApproveMenu"
+import { useExtensionState } from "@src/context/ExtensionStateContext"
+import { vscode } from "@src/utils/vscode"
+
+// Mock dependencies
+jest.mock("@src/context/ExtensionStateContext")
+jest.mock("@src/utils/vscode", () => ({
+ vscode: {
+ postMessage: jest.fn(),
+ },
+}))
+
+jest.mock("@src/i18n/TranslationContext", () => ({
+ useAppTranslation: () => ({
+ t: jest.fn((key: string) => {
+ const translations: Record = {
+ "chat:autoApprove.title": "Auto Approve",
+ "chat:autoApprove.none": "None",
+ "chat:autoApprove.description": "Auto-approve certain actions",
+ "settings:autoApprove.readOnly.label": "Read-only",
+ "settings:autoApprove.write.label": "Write files",
+ "settings:autoApprove.execute.label": "Execute",
+ "settings:autoApprove.browser.label": "Browser",
+ "settings:autoApprove.mcp.label": "MCP",
+ "settings:autoApprove.modeSwitch.label": "Mode Switch",
+ "settings:autoApprove.subtasks.label": "Subtasks",
+ "settings:autoApprove.retry.label": "Retry",
+ "settings:autoApprove.apiRequestLimit.title": "API Request Limit",
+ "settings:autoApprove.apiRequestLimit.unlimited": "Unlimited",
+ "settings:autoApprove.apiRequestLimit.description": "Maximum number of API requests",
+ }
+ return translations[key] || key
+ }),
+ }),
+}))
+
+jest.mock("react-i18next", () => ({
+ Trans: ({ i18nKey, children }: { i18nKey?: string; children?: React.ReactNode }) => {
+ const translations: Record = {
+ "chat:autoApprove.description": "Auto-approve certain actions",
+ }
+ return
{translations[i18nKey as string] || children}
+ },
+}))
+
+// Mock useExtensionState
+const mockUseExtensionState = jest.mocked(useExtensionState)
+
+describe("AutoApproveMenu", () => {
+ const defaultState = {
+ autoApprovalEnabled: false,
+ alwaysAllowReadOnly: false,
+ alwaysAllowWrite: false,
+ alwaysAllowExecute: false,
+ alwaysAllowBrowser: false,
+ alwaysAllowMcp: false,
+ alwaysAllowModeSwitch: false,
+ alwaysAllowSubtasks: false,
+ alwaysApproveResubmit: false,
+ allowedMaxRequests: undefined,
+ }
+
+ const mockSetters = {
+ setAutoApprovalEnabled: jest.fn(),
+ setAlwaysAllowReadOnly: jest.fn(),
+ setAlwaysAllowWrite: jest.fn(),
+ setAlwaysAllowExecute: jest.fn(),
+ setAlwaysAllowBrowser: jest.fn(),
+ setAlwaysAllowMcp: jest.fn(),
+ setAlwaysAllowModeSwitch: jest.fn(),
+ setAlwaysAllowSubtasks: jest.fn(),
+ setAlwaysApproveResubmit: jest.fn(),
+ setAllowedMaxRequests: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseExtensionState.mockReturnValue({
+ ...defaultState,
+ ...mockSetters,
+ } as any)
+ })
+
+ describe("Initial state", () => {
+ it("should show 'None' when no auto-approve settings are enabled", () => {
+ render()
+ expect(screen.getByText("None")).toBeInTheDocument()
+ })
+
+ it("should have unchecked main checkbox when no settings are enabled", () => {
+ render()
+ const mainCheckbox = screen.getByRole("checkbox")
+ expect(mainCheckbox).not.toBeChecked()
+ })
+ })
+
+ describe("Individual toggle enables main checkbox", () => {
+ it("should enable main checkbox when first individual toggle is enabled", async () => {
+ const { rerender } = render()
+
+ // Initially unchecked
+ expect(screen.getByRole("checkbox")).not.toBeChecked()
+
+ // Simulate enabling read-only toggle
+ mockUseExtensionState.mockReturnValue({
+ ...defaultState,
+ alwaysAllowReadOnly: true,
+ ...mockSetters,
+ } as any)
+
+ rerender()
+
+ // Main checkbox should now be checked
+ expect(screen.getByRole("checkbox")).toBeChecked()
+ expect(screen.getByText("Read-only")).toBeInTheDocument()
+ })
+
+ it("should show enabled actions in display text", () => {
+ mockUseExtensionState.mockReturnValue({
+ ...defaultState,
+ alwaysAllowReadOnly: true,
+ alwaysAllowWrite: true,
+ ...mockSetters,
+ } as any)
+
+ render()
+
+ expect(screen.getByText("Read-only, Write files")).toBeInTheDocument()
+ })
+ })
+
+ describe("Main checkbox toggle behavior", () => {
+ it("should enable all toggles when main checkbox is clicked (none enabled)", async () => {
+ render()
+
+ const mainCheckbox = screen.getByRole("checkbox")
+ fireEvent.click(mainCheckbox)
+
+ // Should call setters for all individual toggles with true
+ await waitFor(() => {
+ expect(mockSetters.setAlwaysAllowReadOnly).toHaveBeenCalledWith(true)
+ expect(mockSetters.setAlwaysAllowWrite).toHaveBeenCalledWith(true)
+ expect(mockSetters.setAlwaysAllowExecute).toHaveBeenCalledWith(true)
+ expect(mockSetters.setAlwaysAllowBrowser).toHaveBeenCalledWith(true)
+ expect(mockSetters.setAlwaysAllowMcp).toHaveBeenCalledWith(true)
+ expect(mockSetters.setAlwaysAllowModeSwitch).toHaveBeenCalledWith(true)
+ expect(mockSetters.setAlwaysAllowSubtasks).toHaveBeenCalledWith(true)
+ expect(mockSetters.setAlwaysApproveResubmit).toHaveBeenCalledWith(true)
+ })
+
+ // Should send vscode messages for all toggles
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "alwaysAllowReadOnly", bool: true })
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "alwaysAllowWrite", bool: true })
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "autoApprovalEnabled", bool: true })
+ })
+
+ it("should disable all toggles when main checkbox is clicked (some enabled)", async () => {
+ mockUseExtensionState.mockReturnValue({
+ ...defaultState,
+ alwaysAllowReadOnly: true,
+ alwaysAllowWrite: true,
+ ...mockSetters,
+ } as any)
+
+ render()
+
+ const mainCheckbox = screen.getByRole("checkbox")
+ fireEvent.click(mainCheckbox)
+
+ // Should call setters for all individual toggles with false
+ await waitFor(() => {
+ expect(mockSetters.setAlwaysAllowReadOnly).toHaveBeenCalledWith(false)
+ expect(mockSetters.setAlwaysAllowWrite).toHaveBeenCalledWith(false)
+ expect(mockSetters.setAlwaysAllowExecute).toHaveBeenCalledWith(false)
+ expect(mockSetters.setAlwaysAllowBrowser).toHaveBeenCalledWith(false)
+ expect(mockSetters.setAlwaysAllowMcp).toHaveBeenCalledWith(false)
+ expect(mockSetters.setAlwaysAllowModeSwitch).toHaveBeenCalledWith(false)
+ expect(mockSetters.setAlwaysAllowSubtasks).toHaveBeenCalledWith(false)
+ expect(mockSetters.setAlwaysApproveResubmit).toHaveBeenCalledWith(false)
+ })
+
+ // Should send vscode messages for all toggles with false
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "alwaysAllowReadOnly", bool: false })
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "autoApprovalEnabled", bool: false })
+ })
+ })
+
+ describe("Bidirectional state synchronization", () => {
+ it("should disable main auto-approval when last individual toggle is disabled", async () => {
+ // Start with only one toggle enabled
+ mockUseExtensionState.mockReturnValue({
+ ...defaultState,
+ alwaysAllowReadOnly: true,
+ ...mockSetters,
+ } as any)
+
+ const { rerender } = render()
+
+ // Expand to show individual toggles
+ fireEvent.click(screen.getByText("Auto Approve"))
+
+ // Simulate disabling the last toggle
+ mockUseExtensionState.mockReturnValue({
+ ...defaultState,
+ alwaysAllowReadOnly: false,
+ ...mockSetters,
+ } as any)
+
+ rerender()
+
+ // Main checkbox should now be unchecked
+ expect(screen.getByRole("checkbox")).not.toBeChecked()
+ expect(screen.getByText("None")).toBeInTheDocument()
+ })
+
+ it("should enable main auto-approval when any individual toggle is enabled", async () => {
+ const { rerender } = render()
+
+ // Initially no toggles enabled
+ expect(screen.getByRole("checkbox")).not.toBeChecked()
+
+ // Simulate enabling one toggle
+ mockUseExtensionState.mockReturnValue({
+ ...defaultState,
+ alwaysAllowExecute: true,
+ ...mockSetters,
+ } as any)
+
+ rerender()
+
+ // Main checkbox should now be checked
+ expect(screen.getByRole("checkbox")).toBeChecked()
+ expect(screen.getByText("Execute")).toBeInTheDocument()
+ })
+ })
+
+ describe("Expandable behavior", () => {
+ it("should expand and show individual toggles when clicked", () => {
+ render()
+
+ // Initially collapsed - description should not be visible
+ expect(screen.queryByText(/Auto-approve certain actions/)).not.toBeInTheDocument()
+
+ // Click to expand
+ fireEvent.click(screen.getByText("Auto Approve"))
+
+ // Should show description (indicating expanded state)
+ expect(screen.getByText(/Auto-approve certain actions/)).toBeInTheDocument()
+ })
+
+ it("should show correct chevron direction when expanded/collapsed", () => {
+ render()
+
+ // Initially should show right chevron
+ expect(document.querySelector(".codicon-chevron-right")).toBeInTheDocument()
+
+ // Click to expand
+ fireEvent.click(screen.getByText("Auto Approve"))
+
+ // Should show down chevron
+ expect(document.querySelector(".codicon-chevron-down")).toBeInTheDocument()
+ })
+ })
+
+ describe("API request limit", () => {
+ it("should show unlimited placeholder when no limit is set", () => {
+ render()
+
+ // Expand to show settings
+ fireEvent.click(screen.getByText("Auto Approve"))
+
+ const input = screen.getByPlaceholderText("Unlimited")
+ expect(input).toHaveValue("")
+ })
+
+ it("should handle numeric input for API request limit", async () => {
+ render()
+
+ // Expand to show settings
+ fireEvent.click(screen.getByText("Auto Approve"))
+
+ const input = screen.getByPlaceholderText("Unlimited")
+ fireEvent.input(input, { target: { value: "10" } })
+
+ await waitFor(() => {
+ expect(mockSetters.setAllowedMaxRequests).toHaveBeenCalledWith(10)
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedMaxRequests", value: 10 })
+ })
+ })
+
+ it("should filter out non-numeric characters", async () => {
+ render()
+
+ // Expand to show settings
+ fireEvent.click(screen.getByText("Auto Approve"))
+
+ const input = screen.getByPlaceholderText("Unlimited") as HTMLInputElement
+
+ // Mock the input value behavior
+ let inputValue = "abc123def"
+ Object.defineProperty(input, "value", {
+ get() {
+ return inputValue
+ },
+ set(val) {
+ inputValue = val.replace(/[^0-9]/g, "")
+ },
+ })
+
+ // Simulate the input event with the filtered behavior
+ fireEvent.input(input, { target: { value: "abc123def" } })
+
+ await waitFor(() => {
+ expect(mockSetters.setAllowedMaxRequests).toHaveBeenCalledWith(123)
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedMaxRequests", value: 123 })
+ })
+ })
+ })
+})
diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx
index 4f44ada43c..3b51af646a 100644
--- a/webview-ui/src/components/settings/AutoApproveSettings.tsx
+++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx
@@ -10,6 +10,7 @@ import { SetCachedStateField } from "./types"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
import { AutoApproveToggle } from "./AutoApproveToggle"
+import { useAutoApproveState } from "@/hooks/useAutoApproveState"
type AutoApproveSettingsProps = HTMLAttributes & {
alwaysAllowReadOnly?: boolean
@@ -62,6 +63,24 @@ export const AutoApproveSettings = ({
const { t } = useAppTranslation()
const [commandInput, setCommandInput] = useState("")
+ // Prepare toggles object for the hook
+ const toggles = {
+ alwaysAllowReadOnly,
+ alwaysAllowWrite,
+ alwaysAllowBrowser,
+ alwaysApproveResubmit,
+ alwaysAllowMcp,
+ alwaysAllowModeSwitch,
+ alwaysAllowSubtasks,
+ alwaysAllowExecute,
+ }
+
+ // Use the centralized auto-approve state hook
+ const { updateAutoApprovalState } = useAutoApproveState({
+ toggles,
+ setCachedStateField,
+ })
+
const handleAddCommand = () => {
const currentCommands = allowedCommands ?? []
@@ -83,17 +102,7 @@ export const AutoApproveSettings = ({
- setCachedStateField(key, value)}
- />
+
{/* ADDITIONAL SETTINGS */}
diff --git a/webview-ui/src/components/settings/AutoApproveToggle.tsx b/webview-ui/src/components/settings/AutoApproveToggle.tsx
index ffad47e2ac..9b20a301f3 100644
--- a/webview-ui/src/components/settings/AutoApproveToggle.tsx
+++ b/webview-ui/src/components/settings/AutoApproveToggle.tsx
@@ -87,9 +87,10 @@ export const autoApproveSettingsConfig: Record void
+ isOverallApprovalEnabled?: boolean // New prop
}
-export const AutoApproveToggle = ({ onToggle, ...props }: AutoApproveToggleProps) => {
+export const AutoApproveToggle = ({ onToggle, isOverallApprovalEnabled, ...props }: AutoApproveToggleProps) => {
const { t } = useAppTranslation()
return (
@@ -99,22 +100,28 @@ export const AutoApproveToggle = ({ onToggle, ...props }: AutoApproveToggleProps
"[@media(min-width:600px)]:gap-4",
"[@media(min-width:800px)]:max-w-[800px]",
)}>
- {Object.values(autoApproveSettingsConfig).map(({ key, descriptionKey, labelKey, icon, testId }) => (
-
- ))}
+ {Object.values(autoApproveSettingsConfig).map(({ key, descriptionKey, labelKey, icon, testId }) => {
+ const isButtonActive = props[key] // This reflects the actual state of the individual toggle
+ const isButtonVisuallyEnabled = isOverallApprovalEnabled === false ? false : isButtonActive
+
+ return (
+
+ )
+ })}