diff --git a/.gitignore b/.gitignore index 0c032bbf76..428ab15c1c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,13 @@ out out-* node_modules coverage/ -mock/ .DS_Store +# Webview UI specific ignores +webview-ui/node_modules +webview-ui/dist + # IDEs .idea diff --git a/webview-ui/src/components/chat/AutoApproveMenu.tsx b/webview-ui/src/components/chat/AutoApproveMenu.tsx index ac02d6b8c4..df5fb0b779 100644 --- a/webview-ui/src/components/chat/AutoApproveMenu.tsx +++ b/webview-ui/src/components/chat/AutoApproveMenu.tsx @@ -6,6 +6,7 @@ import { vscode } from "@src/utils/vscode" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useAppTranslation } from "@src/i18n/TranslationContext" import { AutoApproveToggle, AutoApproveSetting, autoApproveSettingsConfig } from "../settings/AutoApproveToggle" +import { useAutoApproveState } from "@src/hooks/useAutoApproveState" interface AutoApproveMenuProps { style?: React.CSSProperties @@ -39,61 +40,18 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { const { t } = useAppTranslation() - const onAutoApproveToggle = useCallback( - (key: AutoApproveSetting, value: boolean) => { - vscode.postMessage({ type: key, bool: value }) - - switch (key) { - case "alwaysAllowReadOnly": - setAlwaysAllowReadOnly(value) - break - case "alwaysAllowWrite": - setAlwaysAllowWrite(value) - break - case "alwaysAllowExecute": - setAlwaysAllowExecute(value) - break - case "alwaysAllowBrowser": - setAlwaysAllowBrowser(value) - break - case "alwaysAllowMcp": - setAlwaysAllowMcp(value) - break - case "alwaysAllowModeSwitch": - setAlwaysAllowModeSwitch(value) - break - case "alwaysAllowSubtasks": - setAlwaysAllowSubtasks(value) - break - case "alwaysApproveResubmit": - setAlwaysApproveResubmit(value) - break - } - }, - [ - setAlwaysAllowReadOnly, - setAlwaysAllowWrite, - setAlwaysAllowExecute, - setAlwaysAllowBrowser, - setAlwaysAllowMcp, - setAlwaysAllowModeSwitch, - setAlwaysAllowSubtasks, - setAlwaysApproveResubmit, - ], - ) - const toggleExpanded = useCallback(() => setIsExpanded((prev) => !prev), []) const toggles = useMemo( () => ({ - alwaysAllowReadOnly: alwaysAllowReadOnly, - alwaysAllowWrite: alwaysAllowWrite, - alwaysAllowExecute: alwaysAllowExecute, - alwaysAllowBrowser: alwaysAllowBrowser, - alwaysAllowMcp: alwaysAllowMcp, - alwaysAllowModeSwitch: alwaysAllowModeSwitch, - alwaysAllowSubtasks: alwaysAllowSubtasks, - alwaysApproveResubmit: alwaysApproveResubmit, + alwaysAllowReadOnly, + alwaysAllowWrite, + alwaysAllowExecute, + alwaysAllowBrowser, + alwaysAllowMcp, + alwaysAllowModeSwitch, + alwaysAllowSubtasks, + alwaysApproveResubmit, }), [ alwaysAllowReadOnly, @@ -107,10 +65,47 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { ], ) - const enabledActionsList = Object.entries(toggles) - .filter(([_key, value]) => !!value) - .map(([key]) => t(autoApproveSettingsConfig[key as AutoApproveSetting].labelKey)) - .join(", ") + // Memoize setters object to prevent recreating it on every render + const setters = useMemo( + () => ({ + setAlwaysAllowReadOnly, + setAlwaysAllowWrite, + setAlwaysAllowExecute, + setAlwaysAllowBrowser, + setAlwaysAllowMcp, + setAlwaysAllowModeSwitch, + setAlwaysAllowSubtasks, + setAlwaysApproveResubmit, + setAutoApprovalEnabled, + }), + [ + setAlwaysAllowReadOnly, + setAlwaysAllowWrite, + setAlwaysAllowExecute, + setAlwaysAllowBrowser, + setAlwaysAllowMcp, + setAlwaysAllowModeSwitch, + setAlwaysAllowSubtasks, + setAlwaysApproveResubmit, + setAutoApprovalEnabled, + ], + ) + + // Use the centralized auto-approve state hook + const { hasAnyAutoApprovedAction, updateAutoApprovalState, handleMasterToggle } = useAutoApproveState({ + toggles, + setters, + }) + + const displayedAutoApproveText = useMemo(() => { + if (hasAnyAutoApprovedAction) { + return Object.entries(toggles) + .filter(([_key, value]) => !!value) + .map(([key]) => t(autoApproveSettingsConfig[key as AutoApproveSetting].labelKey)) + .join(", ") + } + return t("chat:autoApprove.none") + }, [hasAnyAutoApprovedAction, toggles, t]) const handleOpenSettings = useCallback( () => @@ -140,12 +135,9 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { onClick={toggleExpanded}>
e.stopPropagation()}> { - const newValue = !(autoApprovalEnabled ?? false) - setAutoApprovalEnabled(newValue) - vscode.postMessage({ type: "autoApprovalEnabled", bool: newValue }) - }} + checked={hasAnyAutoApprovedAction} + disabled={false} + onChange={() => handleMasterToggle()} />
{ flex: 1, minWidth: 0, }}> - {enabledActionsList || t("chat:autoApprove.none")} + {displayedAutoApproveText} { />
- + {/* 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 ( + + ) + })}
) } diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx new file mode 100644 index 0000000000..6426292069 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -0,0 +1,257 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import "@testing-library/jest-dom" +import { AutoApproveSettings } from "../AutoApproveSettings" + +// Mock UI components +jest.mock("@/components/ui", () => ({ + Button: ({ children, onClick, ...props }: any) => ( + + ), + Input: ({ onChange, onKeyDown, ...props }: any) => , + Slider: ({ value, onValueChange, ...props }: any) => ( + onValueChange?.([parseInt(e.target.value)])} + {...props} + /> + ), +})) + +// Mock dependencies +jest.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: jest.fn(), + }, +})) + +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: jest.fn((key: string) => { + const translations: Record = { + "settings:sections.autoApprove": "Auto Approve", + "settings: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.readOnly.outsideWorkspace.label": "Allow outside workspace", + "settings:autoApprove.readOnly.outsideWorkspace.description": + "Allow read-only operations outside the workspace", + "settings:autoApprove.write.outsideWorkspace.label": "Allow outside workspace", + "settings:autoApprove.write.outsideWorkspace.description": + "Allow write operations outside the workspace", + "settings:autoApprove.write.delayLabel": "Delay before writing files", + "settings:autoApprove.retry.delayLabel": "Delay before retrying requests", + "settings:autoApprove.execute.allowedCommands": "Allowed Commands", + "settings:autoApprove.execute.allowedCommandsDescription": "Commands that can be auto-approved", + "settings:autoApprove.execute.commandPlaceholder": "Enter command", + "settings:autoApprove.execute.addButton": "Add", + } + return translations[key] || key + }), + }), +})) + +describe("AutoApproveSettings", () => { + const defaultProps = { + alwaysAllowReadOnly: false, + alwaysAllowReadOnlyOutsideWorkspace: false, + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + writeDelayMs: 1000, + alwaysAllowBrowser: false, + alwaysApproveResubmit: false, + requestDelaySeconds: 30, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: false, + allowedCommands: [], + setCachedStateField: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("Additional settings visibility", () => { + it("should show read-only additional settings when read-only is enabled", () => { + render() + + expect(screen.getByTestId("always-allow-readonly-outside-workspace-checkbox")).toBeInTheDocument() + }) + + it("should show write additional settings when write is enabled", () => { + render() + + expect(screen.getByTestId("always-allow-write-outside-workspace-checkbox")).toBeInTheDocument() + expect(screen.getByTestId("write-delay-slider")).toBeInTheDocument() + }) + + it("should show retry additional settings when retry is enabled", () => { + render() + + expect(screen.getByTestId("request-delay-slider")).toBeInTheDocument() + }) + + it("should show execute additional settings when execute is enabled", () => { + render() + + expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() + expect(screen.getByTestId("command-input")).toBeInTheDocument() + expect(screen.getByTestId("add-command-button")).toBeInTheDocument() + }) + }) + + describe("Command management", () => { + it("should add a new command when add button is clicked", () => { + const setCachedStateField = jest.fn() + render( + , + ) + + const commandInput = screen.getByTestId("command-input") + const addButton = screen.getByTestId("add-command-button") + + fireEvent.change(commandInput, { target: { value: "npm test" } }) + fireEvent.click(addButton) + + expect(setCachedStateField).toHaveBeenCalledWith("allowedCommands", ["npm test"]) + }) + + it("should add a new command when Enter key is pressed", () => { + const setCachedStateField = jest.fn() + render( + , + ) + + const commandInput = screen.getByTestId("command-input") + + fireEvent.change(commandInput, { target: { value: "npm build" } }) + fireEvent.keyDown(commandInput, { key: "Enter" }) + + expect(setCachedStateField).toHaveBeenCalledWith("allowedCommands", ["npm build"]) + }) + + it("should not add duplicate commands", () => { + const setCachedStateField = jest.fn() + render( + , + ) + + const commandInput = screen.getByTestId("command-input") + const addButton = screen.getByTestId("add-command-button") + + fireEvent.change(commandInput, { target: { value: "npm test" } }) + fireEvent.click(addButton) + + expect(setCachedStateField).not.toHaveBeenCalled() + }) + + it("should remove commands when remove button is clicked", () => { + const setCachedStateField = jest.fn() + render( + , + ) + + const removeButton = screen.getByTestId("remove-command-0") + fireEvent.click(removeButton) + + expect(setCachedStateField).toHaveBeenCalledWith("allowedCommands", ["npm build"]) + }) + }) + + describe("Slider interactions", () => { + it("should update write delay when slider changes", () => { + const setCachedStateField = jest.fn() + render( + , + ) + + const slider = screen.getByTestId("write-delay-slider") + fireEvent.change(slider, { target: { value: "2000" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("writeDelayMs", 2000) + }) + + it("should update request delay when slider changes", () => { + const setCachedStateField = jest.fn() + render( + , + ) + + const slider = screen.getByTestId("request-delay-slider") + fireEvent.change(slider, { target: { value: "60" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("requestDelaySeconds", 60) + }) + }) + + describe("Checkbox interactions", () => { + it("should update outside workspace setting for read-only", () => { + const setCachedStateField = jest.fn() + render( + , + ) + + const checkbox = screen.getByTestId("always-allow-readonly-outside-workspace-checkbox") + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("alwaysAllowReadOnlyOutsideWorkspace", true) + }) + + it("should update outside workspace setting for write", () => { + const setCachedStateField = jest.fn() + render( + , + ) + + const checkbox = screen.getByTestId("always-allow-write-outside-workspace-checkbox") + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("alwaysAllowWriteOutsideWorkspace", true) + }) + }) +}) diff --git a/webview-ui/src/hooks/useAutoApproveState.ts b/webview-ui/src/hooks/useAutoApproveState.ts new file mode 100644 index 0000000000..130d7cf19c --- /dev/null +++ b/webview-ui/src/hooks/useAutoApproveState.ts @@ -0,0 +1,135 @@ +import { useCallback, useMemo } from "react" +import { vscode } from "@/utils/vscode" +import { AutoApproveSetting, autoApproveSettingsConfig } from "@/components/settings/AutoApproveToggle" + +type AutoApproveToggles = { + alwaysAllowReadOnly?: boolean + alwaysAllowWrite?: boolean + alwaysAllowBrowser?: boolean + alwaysApproveResubmit?: boolean + alwaysAllowMcp?: boolean + alwaysAllowModeSwitch?: boolean + alwaysAllowSubtasks?: boolean + alwaysAllowExecute?: boolean +} + +type AutoApproveStateSetters = { + setAlwaysAllowReadOnly?: (value: boolean) => void + setAlwaysAllowWrite?: (value: boolean) => void + setAlwaysAllowBrowser?: (value: boolean) => void + setAlwaysApproveResubmit?: (value: boolean) => void + setAlwaysAllowMcp?: (value: boolean) => void + setAlwaysAllowModeSwitch?: (value: boolean) => void + setAlwaysAllowSubtasks?: (value: boolean) => void + setAlwaysAllowExecute?: (value: boolean) => void + setAutoApprovalEnabled?: (value: boolean) => void +} + +type SetCachedStateFieldFunction = (key: any, value: any) => void + +interface UseAutoApproveStateProps { + toggles: AutoApproveToggles + setters?: AutoApproveStateSetters + setCachedStateField?: SetCachedStateFieldFunction +} + +export const useAutoApproveState = ({ toggles, setters, setCachedStateField }: UseAutoApproveStateProps) => { + // Calculate if any auto-approve action is enabled + const hasAnyAutoApprovedAction = useMemo(() => { + return Object.values(toggles).some((value) => !!value) + }, [toggles]) + + // Update individual auto-approval setting + const updateAutoApprovalState = useCallback( + (key: AutoApproveSetting, value: boolean) => { + // Send vscode message for individual setting + vscode.postMessage({ type: key, bool: value }) + + // Update the specific setting state using appropriate setter + if (setters) { + switch (key) { + case "alwaysAllowReadOnly": + setters.setAlwaysAllowReadOnly?.(value) + break + case "alwaysAllowWrite": + setters.setAlwaysAllowWrite?.(value) + break + case "alwaysAllowExecute": + setters.setAlwaysAllowExecute?.(value) + break + case "alwaysAllowBrowser": + setters.setAlwaysAllowBrowser?.(value) + break + case "alwaysAllowMcp": + setters.setAlwaysAllowMcp?.(value) + break + case "alwaysAllowModeSwitch": + setters.setAlwaysAllowModeSwitch?.(value) + break + case "alwaysAllowSubtasks": + setters.setAlwaysAllowSubtasks?.(value) + break + case "alwaysApproveResubmit": + setters.setAlwaysApproveResubmit?.(value) + break + } + + // Update main auto-approval setting after state update + if (setters.setAutoApprovalEnabled) { + // Calculate if any will be enabled after this update + const updatedToggles = { ...toggles, [key]: value } + const hasAnyEnabled = Object.values(updatedToggles).some((v) => !!v) + setters.setAutoApprovalEnabled(hasAnyEnabled) + vscode.postMessage({ type: "autoApprovalEnabled", bool: hasAnyEnabled }) + } + } else if (setCachedStateField) { + // Fallback to setCachedStateField for settings page + setCachedStateField(key, value) + } + }, + [toggles, setters, setCachedStateField], + ) + + // Handler for master checkbox toggle - toggles ALL individual settings + const handleMasterToggle = useCallback( + (enabled?: boolean) => { + const newValue = enabled !== undefined ? enabled : !hasAnyAutoApprovedAction + + // Batch all updates to reduce re-renders + if (setters) { + // Update all individual settings in one batch + setters.setAlwaysAllowReadOnly?.(newValue) + setters.setAlwaysAllowWrite?.(newValue) + setters.setAlwaysAllowExecute?.(newValue) + setters.setAlwaysAllowBrowser?.(newValue) + setters.setAlwaysAllowMcp?.(newValue) + setters.setAlwaysAllowModeSwitch?.(newValue) + setters.setAlwaysAllowSubtasks?.(newValue) + setters.setAlwaysApproveResubmit?.(newValue) + + // Update main auto-approval setting + if (setters.setAutoApprovalEnabled) { + setters.setAutoApprovalEnabled(newValue) + } + } else if (setCachedStateField) { + // Fallback to setCachedStateField for settings page + Object.keys(autoApproveSettingsConfig).forEach((key) => { + setCachedStateField(key as AutoApproveSetting, newValue) + }) + } + + // Send all vscode messages in one batch + Object.keys(autoApproveSettingsConfig).forEach((key) => { + vscode.postMessage({ type: key as AutoApproveSetting, bool: newValue }) + }) + vscode.postMessage({ type: "autoApprovalEnabled", bool: newValue }) + }, + [hasAnyAutoApprovedAction, setters, setCachedStateField], + ) + + return { + hasAnyAutoApprovedAction, + updateAutoApprovalState, + handleMasterToggle, + } +} diff --git a/webview-ui/src/setupTests.tsx b/webview-ui/src/setupTests.tsx index 2bacf60e3a..3cbf3bce87 100644 --- a/webview-ui/src/setupTests.tsx +++ b/webview-ui/src/setupTests.tsx @@ -31,6 +31,14 @@ Object.defineProperty(window, "matchMedia", { })), }) +// Mock ResizeObserver +global.ResizeObserver = class ResizeObserver { + constructor(_cb: ResizeObserverCallback) {} + observe() {} + disconnect() {} + unobserve() {} +} + // Mock lucide-react icons globally using Proxy for dynamic icon handling jest.mock("lucide-react", () => { return new Proxy(