From 76a3fdc256adb0ff3a56d0f650c7d0b0c97cad5a Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 10 Jul 2025 14:36:09 -0400 Subject: [PATCH] Clean up MCP tool disabling (#5576) --- webview-ui/src/components/mcp/McpToolRow.tsx | 58 ++++---- webview-ui/src/components/mcp/McpView.tsx | 64 +++------ .../mcp/__tests__/McpToolRow.spec.tsx | 130 +++++++++++++++--- .../ui/__tests__/toggle-switch.spec.tsx | 101 ++++++++++++++ webview-ui/src/components/ui/index.ts | 1 + .../src/components/ui/toggle-switch.tsx | 68 +++++++++ 6 files changed, 334 insertions(+), 88 deletions(-) create mode 100644 webview-ui/src/components/ui/__tests__/toggle-switch.spec.tsx create mode 100644 webview-ui/src/components/ui/toggle-switch.tsx diff --git a/webview-ui/src/components/mcp/McpToolRow.tsx b/webview-ui/src/components/mcp/McpToolRow.tsx index 58b938f9f1..aa57b18fd9 100644 --- a/webview-ui/src/components/mcp/McpToolRow.tsx +++ b/webview-ui/src/components/mcp/McpToolRow.tsx @@ -4,7 +4,7 @@ import { McpTool } from "@roo/mcp" import { useAppTranslation } from "@src/i18n/TranslationContext" import { vscode } from "@src/utils/vscode" -import { StandardTooltip } from "@/components/ui" +import { StandardTooltip, ToggleSwitch } from "@/components/ui" type McpToolRowProps = { tool: McpTool @@ -16,6 +16,8 @@ type McpToolRowProps = { const McpToolRow = ({ tool, serverName, serverSource, alwaysAllowMcp, isInChatContext = false }: McpToolRowProps) => { const { t } = useAppTranslation() + const isToolEnabled = tool.enabledForPrompt ?? true + const handleAlwaysAllowChange = () => { if (!serverName) return vscode.postMessage({ @@ -46,17 +48,29 @@ const McpToolRow = ({ tool, serverName, serverSource, alwaysAllowMcp, isInChatCo onClick={(e) => e.stopPropagation()}> {/* Tool name section */}
- + - {tool.name} + + {tool.name} +
{/* Controls section */} {serverName && (
- {/* Always Allow checkbox */} - {alwaysAllowMcp && ( + {/* Always Allow checkbox - only show when tool is enabled */} + {alwaysAllowMcp && isToolEnabled && ( )} - {/* Enabled eye button - only show in settings context */} + {/* Enabled toggle switch - only show in settings context */} {!isInChatContext && ( - + data-testid={`tool-prompt-toggle-${tool.name}`} + /> )}
)} {tool.description && ( -
{tool.description}
+
+ {tool.description} +
)} - {tool.inputSchema && + {isToolEnabled && + tool.inputSchema && "properties" in tool.inputSchema && Object.keys(tool.inputSchema.properties as Record).length > 0 && (
diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index 2c83b432cc..b95ed2608c 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -22,6 +22,7 @@ import { DialogTitle, DialogDescription, DialogFooter, + ToggleSwitch, } from "@src/components/ui" import { buildDocLink } from "@src/utils/docLinks" @@ -295,54 +296,6 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM style={{ marginRight: "8px" }}> -
{ - vscode.postMessage({ - type: "toggleMcpServer", - serverName: server.name, - source: server.source || "global", - disabled: !server.disabled, - }) - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - vscode.postMessage({ - type: "toggleMcpServer", - serverName: server.name, - source: server.source || "global", - disabled: !server.disabled, - }) - } - }}> -
-
+
+ { + vscode.postMessage({ + type: "toggleMcpServer", + serverName: server.name, + source: server.source || "global", + disabled: !server.disabled, + }) + }} + size="medium" + aria-label={`Toggle ${server.name} server`} + /> +
{server.status === "connected" ? ( diff --git a/webview-ui/src/components/mcp/__tests__/McpToolRow.spec.tsx b/webview-ui/src/components/mcp/__tests__/McpToolRow.spec.tsx index 2bfe3b1338..f686a23f00 100644 --- a/webview-ui/src/components/mcp/__tests__/McpToolRow.spec.tsx +++ b/webview-ui/src/components/mcp/__tests__/McpToolRow.spec.tsx @@ -144,40 +144,40 @@ describe("McpToolRow", () => { expect(screen.getByText("Second parameter")).toBeInTheDocument() }) - it("shows eye button when serverName is provided and not in chat context", () => { + it("shows toggle switch when serverName is provided and not in chat context", () => { render() - const eyeButton = screen.getByRole("button", { name: "Toggle prompt inclusion" }) - expect(eyeButton).toBeInTheDocument() + const toggleSwitch = screen.getByRole("switch", { name: "Toggle prompt inclusion" }) + expect(toggleSwitch).toBeInTheDocument() }) - it("hides eye button when isInChatContext is true", () => { + it("hides toggle switch when isInChatContext is true", () => { render() - const eyeButton = screen.queryByRole("button", { name: "Toggle prompt inclusion" }) - expect(eyeButton).not.toBeInTheDocument() + const toggleSwitch = screen.queryByRole("switch", { name: "Toggle prompt inclusion" }) + expect(toggleSwitch).not.toBeInTheDocument() }) - it("shows correct eye icon based on enabledForPrompt state", () => { - // Test when enabled (should show eye-closed icon) + it("shows correct toggle switch state based on enabledForPrompt", () => { + // Test when enabled (should be checked) const { rerender } = render() - let eyeIcon = screen.getByRole("button", { name: "Toggle prompt inclusion" }).querySelector("span") - expect(eyeIcon).toHaveClass("codicon-eye-closed") + let toggleSwitch = screen.getByRole("switch", { name: "Toggle prompt inclusion" }) + expect(toggleSwitch).toHaveAttribute("aria-checked", "true") - // Test when disabled (should show eye icon) + // Test when disabled (should not be checked) const disabledTool = { ...mockTool, enabledForPrompt: false } rerender() - eyeIcon = screen.getByRole("button", { name: "Toggle prompt inclusion" }).querySelector("span") - expect(eyeIcon).toHaveClass("codicon-eye") + toggleSwitch = screen.getByRole("switch", { name: "Toggle prompt inclusion" }) + expect(toggleSwitch).toHaveAttribute("aria-checked", "false") }) - it("sends message to toggle enabledForPrompt when eye button is clicked", () => { + it("sends message to toggle enabledForPrompt when toggle switch is clicked", () => { render() - const eyeButton = screen.getByRole("button", { name: "Toggle prompt inclusion" }) - fireEvent.click(eyeButton) + const toggleSwitch = screen.getByRole("switch", { name: "Toggle prompt inclusion" }) + fireEvent.click(toggleSwitch) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "toggleToolEnabledForPrompt", @@ -187,4 +187,102 @@ describe("McpToolRow", () => { isEnabled: false, }) }) + + it("hides always allow checkbox when tool is disabled", () => { + const disabledTool = { ...mockTool, enabledForPrompt: false } + render() + + expect(screen.queryByText("Always allow")).not.toBeInTheDocument() + }) + + it("shows always allow checkbox when tool is enabled", () => { + const enabledTool = { ...mockTool, enabledForPrompt: true } + render() + + expect(screen.getByText("Always allow")).toBeInTheDocument() + }) + + it("hides parameters section when tool is disabled", () => { + const disabledToolWithSchema = { + ...mockTool, + enabledForPrompt: false, + inputSchema: { + type: "object", + properties: { + param1: { + type: "string", + description: "First parameter", + }, + }, + required: ["param1"], + }, + } + + render() + + expect(screen.queryByText("Parameters")).not.toBeInTheDocument() + expect(screen.queryByText("param1")).not.toBeInTheDocument() + expect(screen.queryByText("First parameter")).not.toBeInTheDocument() + }) + + it("shows parameters section when tool is enabled", () => { + const enabledToolWithSchema = { + ...mockTool, + enabledForPrompt: true, + inputSchema: { + type: "object", + properties: { + param1: { + type: "string", + description: "First parameter", + }, + }, + required: ["param1"], + }, + } + + render() + + expect(screen.getByText("Parameters")).toBeInTheDocument() + expect(screen.getByText("param1")).toBeInTheDocument() + expect(screen.getByText("First parameter")).toBeInTheDocument() + }) + + it("grays out tool name and description when tool is disabled", () => { + const disabledTool = { + ...mockTool, + enabledForPrompt: false, + description: "A disabled tool", + } + render() + + const toolName = screen.getByText("test-tool") + const toolDescription = screen.getByText("A disabled tool") + + // Check that the tool name has the grayed out classes + expect(toolName).toHaveClass("text-vscode-descriptionForeground", "opacity-60") + + // Check that the description has reduced opacity + expect(toolDescription).toHaveClass("opacity-40") + }) + + it("shows normal styling for tool name and description when tool is enabled", () => { + const enabledTool = { + ...mockTool, + enabledForPrompt: true, + description: "An enabled tool", + } + render() + + const toolName = screen.getByText("test-tool") + const toolDescription = screen.getByText("An enabled tool") + + // Check that the tool name has normal styling + expect(toolName).toHaveClass("text-vscode-foreground") + expect(toolName).not.toHaveClass("text-vscode-descriptionForeground", "opacity-60") + + // Check that the description has normal opacity + expect(toolDescription).toHaveClass("opacity-80") + expect(toolDescription).not.toHaveClass("opacity-40") + }) }) diff --git a/webview-ui/src/components/ui/__tests__/toggle-switch.spec.tsx b/webview-ui/src/components/ui/__tests__/toggle-switch.spec.tsx new file mode 100644 index 0000000000..e394e76139 --- /dev/null +++ b/webview-ui/src/components/ui/__tests__/toggle-switch.spec.tsx @@ -0,0 +1,101 @@ +import React from "react" +import { render, fireEvent, screen } from "@/utils/test-utils" + +import { ToggleSwitch } from "../toggle-switch" + +describe("ToggleSwitch", () => { + it("renders with correct initial state", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByRole("switch") + expect(toggle).toBeInTheDocument() + expect(toggle).toHaveAttribute("aria-checked", "true") + expect(toggle).toHaveAttribute("aria-label", "Test toggle") + }) + + it("renders unchecked state correctly", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByRole("switch") + expect(toggle).toHaveAttribute("aria-checked", "false") + }) + + it("calls onChange when clicked", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByRole("switch") + fireEvent.click(toggle) + + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it("calls onChange when Enter key is pressed", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByRole("switch") + fireEvent.keyDown(toggle, { key: "Enter" }) + + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it("calls onChange when Space key is pressed", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByRole("switch") + fireEvent.keyDown(toggle, { key: " " }) + + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it("does not call onChange when disabled", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByRole("switch") + fireEvent.click(toggle) + fireEvent.keyDown(toggle, { key: "Enter" }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it("has correct tabIndex when disabled", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByRole("switch") + expect(toggle).toHaveAttribute("tabindex", "-1") + }) + + it("renders with custom data-testid", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByTestId("custom-toggle") + expect(toggle).toBeInTheDocument() + }) + + it("supports medium size", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByRole("switch") + expect(toggle).toBeInTheDocument() + // Medium size should be 20px x 10px + expect(toggle).toHaveStyle({ width: "20px", height: "10px" }) + }) + + it("defaults to small size", () => { + const onChange = vi.fn() + render() + + const toggle = screen.getByRole("switch") + expect(toggle).toBeInTheDocument() + // Small size should be 16px x 8px + expect(toggle).toHaveStyle({ width: "16px", height: "8px" }) + }) +}) diff --git a/webview-ui/src/components/ui/index.ts b/webview-ui/src/components/ui/index.ts index c36d3b4769..ee28b964c5 100644 --- a/webview-ui/src/components/ui/index.ts +++ b/webview-ui/src/components/ui/index.ts @@ -18,3 +18,4 @@ export * from "./select" export * from "./textarea" export * from "./tooltip" export * from "./standard-tooltip" +export * from "./toggle-switch" diff --git a/webview-ui/src/components/ui/toggle-switch.tsx b/webview-ui/src/components/ui/toggle-switch.tsx new file mode 100644 index 0000000000..c2488851b5 --- /dev/null +++ b/webview-ui/src/components/ui/toggle-switch.tsx @@ -0,0 +1,68 @@ +import React from "react" + +export interface ToggleSwitchProps { + checked: boolean + onChange: () => void + disabled?: boolean + size?: "small" | "medium" + "aria-label"?: string + "data-testid"?: string +} + +export const ToggleSwitch: React.FC = ({ + checked, + onChange, + disabled = false, + size = "small", + "aria-label": ariaLabel, + "data-testid": dataTestId, +}) => { + const dimensions = size === "small" ? { width: 16, height: 8, dotSize: 4 } : { width: 20, height: 10, dotSize: 6 } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + if (!disabled) { + onChange() + } + } + } + + return ( +
+
+
+ ) +}