mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Clean up MCP tool disabling (#5576)
This commit is contained in:
parent
6fa918c275
commit
76a3fdc256
6 changed files with 334 additions and 88 deletions
|
|
@ -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 */}
|
||||
<div className="flex items-center min-w-0 flex-1">
|
||||
<span className="codicon codicon-symbol-method mr-2 flex-shrink-0 text-vscode-symbolIcon-methodForeground"></span>
|
||||
<span
|
||||
className={`codicon codicon-symbol-method mr-2 flex-shrink-0 ${
|
||||
isToolEnabled
|
||||
? "text-vscode-symbolIcon-methodForeground"
|
||||
: "text-vscode-descriptionForeground opacity-60"
|
||||
}`}></span>
|
||||
<StandardTooltip content={tool.name}>
|
||||
<span className="font-medium truncate text-vscode-foreground">{tool.name}</span>
|
||||
<span
|
||||
className={`font-medium truncate ${
|
||||
isToolEnabled
|
||||
? "text-vscode-foreground"
|
||||
: "text-vscode-descriptionForeground opacity-60"
|
||||
}`}>
|
||||
{tool.name}
|
||||
</span>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
|
||||
{/* Controls section */}
|
||||
{serverName && (
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
{/* Always Allow checkbox */}
|
||||
{alwaysAllowMcp && (
|
||||
{/* Always Allow checkbox - only show when tool is enabled */}
|
||||
{alwaysAllowMcp && isToolEnabled && (
|
||||
<VSCodeCheckbox
|
||||
checked={tool.alwaysAllow}
|
||||
onChange={handleAlwaysAllowChange}
|
||||
|
|
@ -68,35 +82,31 @@ const McpToolRow = ({ tool, serverName, serverSource, alwaysAllowMcp, isInChatCo
|
|||
</VSCodeCheckbox>
|
||||
)}
|
||||
|
||||
{/* Enabled eye button - only show in settings context */}
|
||||
{/* Enabled toggle switch - only show in settings context */}
|
||||
{!isInChatContext && (
|
||||
<StandardTooltip content={t("mcp:tool.togglePromptInclusion")}>
|
||||
<button
|
||||
role="button"
|
||||
aria-pressed={tool.enabledForPrompt}
|
||||
<ToggleSwitch
|
||||
checked={isToolEnabled}
|
||||
onChange={handleEnabledForPromptChange}
|
||||
size="medium"
|
||||
aria-label={t("mcp:tool.togglePromptInclusion")}
|
||||
className={`p-1 rounded hover:bg-vscode-toolbar-hoverBackground transition-colors ${
|
||||
tool.enabledForPrompt
|
||||
? "text-vscode-foreground"
|
||||
: "text-vscode-descriptionForeground opacity-60"
|
||||
}`}
|
||||
onClick={handleEnabledForPromptChange}
|
||||
data-tool-prompt-toggle={tool.name}>
|
||||
<span
|
||||
className={`codicon ${
|
||||
tool.enabledForPrompt ? "codicon-eye-closed" : "codicon-eye"
|
||||
} text-base`}
|
||||
/>
|
||||
</button>
|
||||
data-testid={`tool-prompt-toggle-${tool.name}`}
|
||||
/>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<div className="mt-1 text-xs text-vscode-descriptionForeground opacity-80">{tool.description}</div>
|
||||
<div
|
||||
className={`mt-1 text-xs text-vscode-descriptionForeground ${
|
||||
isToolEnabled ? "opacity-80" : "opacity-40"
|
||||
}`}>
|
||||
{tool.description}
|
||||
</div>
|
||||
)}
|
||||
{tool.inputSchema &&
|
||||
{isToolEnabled &&
|
||||
tool.inputSchema &&
|
||||
"properties" in tool.inputSchema &&
|
||||
Object.keys(tool.inputSchema.properties as Record<string, any>).length > 0 && (
|
||||
<div className="mt-2 text-xs border border-vscode-panel-border rounded p-2">
|
||||
|
|
|
|||
|
|
@ -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" }}>
|
||||
<span className="codicon codicon-refresh" style={{ fontSize: "14px" }}></span>
|
||||
</Button>
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked={!server.disabled}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
width: "20px",
|
||||
height: "10px",
|
||||
backgroundColor: server.disabled
|
||||
? "var(--vscode-titleBar-inactiveForeground)"
|
||||
: "var(--vscode-button-background)",
|
||||
borderRadius: "5px",
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s",
|
||||
opacity: server.disabled ? 0.4 : 0.8,
|
||||
}}
|
||||
onClick={() => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
backgroundColor: "var(--vscode-titleBar-activeForeground)",
|
||||
borderRadius: "50%",
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: server.disabled ? "2px" : "12px",
|
||||
transition: "left 0.2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -353,6 +306,21 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM
|
|||
marginLeft: "8px",
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginLeft: "8px" }}>
|
||||
<ToggleSwitch
|
||||
checked={!server.disabled}
|
||||
onChange={() => {
|
||||
vscode.postMessage({
|
||||
type: "toggleMcpServer",
|
||||
serverName: server.name,
|
||||
source: server.source || "global",
|
||||
disabled: !server.disabled,
|
||||
})
|
||||
}}
|
||||
size="medium"
|
||||
aria-label={`Toggle ${server.name} server`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.status === "connected" ? (
|
||||
|
|
|
|||
|
|
@ -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(<McpToolRow tool={mockTool} serverName="test-server" />)
|
||||
|
||||
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(<McpToolRow tool={mockTool} serverName="test-server" isInChatContext={true} />)
|
||||
|
||||
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(<McpToolRow tool={mockTool} serverName="test-server" />)
|
||||
|
||||
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(<McpToolRow tool={disabledTool} serverName="test-server" />)
|
||||
|
||||
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(<McpToolRow tool={mockTool} serverName="test-server" />)
|
||||
|
||||
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(<McpToolRow tool={disabledTool} serverName="test-server" alwaysAllowMcp={true} />)
|
||||
|
||||
expect(screen.queryByText("Always allow")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows always allow checkbox when tool is enabled", () => {
|
||||
const enabledTool = { ...mockTool, enabledForPrompt: true }
|
||||
render(<McpToolRow tool={enabledTool} serverName="test-server" alwaysAllowMcp={true} />)
|
||||
|
||||
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(<McpToolRow tool={disabledToolWithSchema} serverName="test-server" />)
|
||||
|
||||
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(<McpToolRow tool={enabledToolWithSchema} serverName="test-server" />)
|
||||
|
||||
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(<McpToolRow tool={disabledTool} serverName="test-server" />)
|
||||
|
||||
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(<McpToolRow tool={enabledTool} serverName="test-server" />)
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
101
webview-ui/src/components/ui/__tests__/toggle-switch.spec.tsx
Normal file
101
webview-ui/src/components/ui/__tests__/toggle-switch.spec.tsx
Normal file
|
|
@ -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(<ToggleSwitch checked={true} onChange={onChange} aria-label="Test toggle" />)
|
||||
|
||||
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(<ToggleSwitch checked={false} onChange={onChange} aria-label="Test toggle" />)
|
||||
|
||||
const toggle = screen.getByRole("switch")
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false")
|
||||
})
|
||||
|
||||
it("calls onChange when clicked", () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ToggleSwitch checked={false} onChange={onChange} aria-label="Test toggle" />)
|
||||
|
||||
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(<ToggleSwitch checked={false} onChange={onChange} aria-label="Test toggle" />)
|
||||
|
||||
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(<ToggleSwitch checked={false} onChange={onChange} aria-label="Test toggle" />)
|
||||
|
||||
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(<ToggleSwitch checked={false} onChange={onChange} disabled={true} aria-label="Test toggle" />)
|
||||
|
||||
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(<ToggleSwitch checked={false} onChange={onChange} disabled={true} aria-label="Test toggle" />)
|
||||
|
||||
const toggle = screen.getByRole("switch")
|
||||
expect(toggle).toHaveAttribute("tabindex", "-1")
|
||||
})
|
||||
|
||||
it("renders with custom data-testid", () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ToggleSwitch checked={false} onChange={onChange} data-testid="custom-toggle" />)
|
||||
|
||||
const toggle = screen.getByTestId("custom-toggle")
|
||||
expect(toggle).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("supports medium size", () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ToggleSwitch checked={false} onChange={onChange} size="medium" aria-label="Test toggle" />)
|
||||
|
||||
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(<ToggleSwitch checked={false} onChange={onChange} aria-label="Test toggle" />)
|
||||
|
||||
const toggle = screen.getByRole("switch")
|
||||
expect(toggle).toBeInTheDocument()
|
||||
// Small size should be 16px x 8px
|
||||
expect(toggle).toHaveStyle({ width: "16px", height: "8px" })
|
||||
})
|
||||
})
|
||||
|
|
@ -18,3 +18,4 @@ export * from "./select"
|
|||
export * from "./textarea"
|
||||
export * from "./tooltip"
|
||||
export * from "./standard-tooltip"
|
||||
export * from "./toggle-switch"
|
||||
|
|
|
|||
68
webview-ui/src/components/ui/toggle-switch.tsx
Normal file
68
webview-ui/src/components/ui/toggle-switch.tsx
Normal file
|
|
@ -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<ToggleSwitchProps> = ({
|
||||
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 (
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
data-testid={dataTestId}
|
||||
style={{
|
||||
width: `${dimensions.width}px`,
|
||||
height: `${dimensions.height}px`,
|
||||
backgroundColor: checked
|
||||
? "var(--vscode-button-background)"
|
||||
: "var(--vscode-titleBar-inactiveForeground)",
|
||||
borderRadius: `${dimensions.height / 2}px`,
|
||||
position: "relative",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
transition: "background-color 0.2s",
|
||||
opacity: disabled ? 0.4 : checked ? 0.8 : 0.6,
|
||||
}}
|
||||
onClick={disabled ? undefined : onChange}
|
||||
onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
style={{
|
||||
width: `${dimensions.dotSize}px`,
|
||||
height: `${dimensions.dotSize}px`,
|
||||
backgroundColor: "var(--vscode-titleBar-activeForeground)",
|
||||
borderRadius: "50%",
|
||||
position: "absolute",
|
||||
top: `${(dimensions.height - dimensions.dotSize) / 2}px`,
|
||||
left: checked
|
||||
? `${dimensions.width - dimensions.dotSize - (dimensions.height - dimensions.dotSize) / 2}px`
|
||||
: `${(dimensions.height - dimensions.dotSize) / 2}px`,
|
||||
transition: "left 0.2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue