feat: redesign Auto-Approve UI with dropdown and two-column layout

- Move AutoApproveMenu below text area with dropdown trigger similar to ModeSelector
- Add Stamp icon for dropdown trigger
- Implement text ellipsis for long text in dropdown trigger
- Change to two-column layout for better organization
- Add Select All/Select None functionality with Lucide icons (ListChecks, LayoutList)
- Update tests to work with new dropdown UI
This commit is contained in:
Roo Code 2025-08-26 17:37:11 +00:00
parent a79c3d04a6
commit e19492fb57
4 changed files with 224 additions and 118 deletions

View file

@ -1,14 +1,18 @@
import { memo, useCallback, useMemo, useState } from "react"
import { Trans } from "react-i18next"
import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { Stamp, ListChecks, LayoutList } from "lucide-react"
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 { StandardTooltip } from "@src/components/ui"
import { AutoApproveSetting, autoApproveSettingsConfig } from "../settings/AutoApproveToggle"
import { AutoApproveToggleDropdown } from "./AutoApproveToggleDropdown"
import { StandardTooltip, Popover, PopoverContent, PopoverTrigger } from "@src/components/ui"
import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
import { cn } from "@src/lib/utils"
import { useRooPortal } from "@src/components/ui/hooks/useRooPortal"
interface AutoApproveMenuProps {
style?: React.CSSProperties
@ -16,6 +20,7 @@ interface AutoApproveMenuProps {
const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
const [isExpanded, setIsExpanded] = useState(false)
const portalContainer = useRooPortal("roo-portal")
const {
autoApprovalEnabled,
@ -123,10 +128,6 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
],
)
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev)
}, [])
const enabledActionsList = Object.entries(toggles)
.filter(([_key, value]) => !!value)
.map(([key]) => t(autoApproveSettingsConfig[key as AutoApproveSetting].labelKey))
@ -146,101 +147,118 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
[],
)
// Handler for Select All
const handleSelectAll = useCallback(() => {
const allSettings: AutoApproveSetting[] = Object.keys(toggles) as AutoApproveSetting[]
allSettings.forEach((key) => {
if (!toggles[key]) {
onAutoApproveToggle(key, true)
}
})
}, [toggles, onAutoApproveToggle])
// Handler for Select None
const handleSelectNone = useCallback(() => {
const allSettings: AutoApproveSetting[] = Object.keys(toggles) as AutoApproveSetting[]
allSettings.forEach((key) => {
if (toggles[key]) {
onAutoApproveToggle(key, false)
}
})
}, [toggles, onAutoApproveToggle])
const trigger = (
<PopoverTrigger
className={cn(
"inline-flex items-center gap-1.5 relative whitespace-nowrap px-2 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
"opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
)}
style={style}>
<Stamp className="size-3.5 opacity-80 flex-shrink-0" />
<span className="font-medium">{t("chat:autoApprove.title")}</span>
<span className="text-vscode-descriptionForeground truncate max-w-[200px]">{displayText}</span>
</PopoverTrigger>
)
return (
<div
style={{
padding: "0 15px",
userSelect: "none",
borderTop: isExpanded
? `0.5px solid color-mix(in srgb, var(--vscode-titleBar-inactiveForeground) 20%, transparent)`
: "none",
overflowY: "auto",
...style,
}}>
{isExpanded && (
<div className="flex flex-col gap-2 py-4">
<div
style={{
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
<Trans
i18nKey="chat:autoApprove.description"
components={{
settingsLink: <VSCodeLink href="#" onClick={handleOpenSettings} />,
}}
/>
<Popover open={isExpanded} onOpenChange={setIsExpanded}>
<StandardTooltip content={t("chat:autoApprove.tooltip")}>{trigger}</StandardTooltip>
<PopoverContent
align="start"
sideOffset={4}
container={portalContainer}
className="p-0 overflow-hidden min-w-[400px] max-w-[500px]">
<div className="flex flex-col w-full">
{/* Header with master toggle */}
<div className="flex items-center justify-between p-3 border-b border-vscode-dropdown-border">
<div className="flex items-center gap-2">
<StandardTooltip
content={!hasEnabledOptions ? t("chat:autoApprove.selectOptionsFirst") : undefined}>
<VSCodeCheckbox
checked={effectiveAutoApprovalEnabled}
disabled={!hasEnabledOptions}
aria-label={
hasEnabledOptions
? t("chat:autoApprove.toggleAriaLabel")
: t("chat:autoApprove.disabledAriaLabel")
}
onChange={() => {
if (hasEnabledOptions) {
const newValue = !(autoApprovalEnabled ?? false)
setAutoApprovalEnabled(newValue)
vscode.postMessage({ type: "autoApprovalEnabled", bool: newValue })
}
}}
/>
</StandardTooltip>
<h4 className="m-0 font-medium text-sm">{t("chat:autoApprove.title")}</h4>
</div>
<div className="flex items-center gap-1">
<StandardTooltip content={t("chat:autoApprove.selectAll")}>
<button
onClick={handleSelectAll}
className="p-1 rounded hover:bg-vscode-list-hoverBackground transition-colors">
<ListChecks className="size-4" />
</button>
</StandardTooltip>
<StandardTooltip content={t("chat:autoApprove.selectNone")}>
<button
onClick={handleSelectNone}
className="p-1 rounded hover:bg-vscode-list-hoverBackground transition-colors">
<LayoutList className="size-4" />
</button>
</StandardTooltip>
</div>
</div>
<AutoApproveToggle {...toggles} onToggle={onAutoApproveToggle} />
</div>
)}
{/* Description */}
<div className="px-3 py-2 border-b border-vscode-dropdown-border">
<div
style={{
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
<Trans
i18nKey="chat:autoApprove.description"
components={{
settingsLink: <VSCodeLink href="#" onClick={handleOpenSettings} />,
}}
/>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "2px 0 0 0",
cursor: "pointer",
}}
onClick={toggleExpanded}>
<div onClick={(e) => e.stopPropagation()}>
<StandardTooltip
content={!hasEnabledOptions ? t("chat:autoApprove.selectOptionsFirst") : undefined}>
<VSCodeCheckbox
checked={effectiveAutoApprovalEnabled}
disabled={!hasEnabledOptions}
aria-label={
hasEnabledOptions
? t("chat:autoApprove.toggleAriaLabel")
: t("chat:autoApprove.disabledAriaLabel")
}
onChange={() => {
if (hasEnabledOptions) {
const newValue = !(autoApprovalEnabled ?? false)
setAutoApprovalEnabled(newValue)
vscode.postMessage({ type: "autoApprovalEnabled", bool: newValue })
}
// If no options enabled, do nothing
}}
/>
</StandardTooltip>
{/* Two-column layout for toggles */}
<div className="p-3 max-h-[400px] overflow-y-auto">
<div className="grid grid-cols-2 gap-x-4">
<AutoApproveToggleDropdown {...toggles} onToggle={onAutoApproveToggle} />
</div>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flex: 1,
minWidth: 0,
}}>
<span
style={{
color: "var(--vscode-foreground)",
flexShrink: 0,
}}>
{t("chat:autoApprove.title")}
</span>
<span
style={{
color: "var(--vscode-descriptionForeground)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0,
}}>
{displayText}
</span>
<span
className={`codicon codicon-chevron-right flex-shrink-0 transition-transform duration-200 ease-in-out ${
isExpanded ? "-rotate-90 ml-[2px]" : "rotate-0 -ml-[2px]"
}`}
/>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,73 @@
import type { GlobalSettings } from "@roo-code/types"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { cn } from "@/lib/utils"
import { StandardTooltip } from "@/components/ui"
import { autoApproveSettingsConfig, AutoApproveSetting } from "../settings/AutoApproveToggle"
type AutoApproveToggles = Pick<
GlobalSettings,
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowBrowser"
| "alwaysApproveResubmit"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
| "alwaysAllowExecute"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowUpdateTodoList"
>
type AutoApproveToggleDropdownProps = AutoApproveToggles & {
onToggle: (key: AutoApproveSetting, value: boolean) => void
}
export const AutoApproveToggleDropdown = ({ onToggle, ...props }: AutoApproveToggleDropdownProps) => {
const { t } = useAppTranslation()
// Split settings into two columns for better layout
const settings = Object.values(autoApproveSettingsConfig)
const halfLength = Math.ceil(settings.length / 2)
const leftColumn = settings.slice(0, halfLength)
const rightColumn = settings.slice(halfLength)
const renderToggleItem = ({
key,
descriptionKey,
labelKey,
icon,
testId,
}: (typeof autoApproveSettingsConfig)[AutoApproveSetting]) => (
<StandardTooltip key={key} content={t(descriptionKey || "")}>
<button
onClick={() => onToggle(key, !props[key])}
aria-label={t(labelKey)}
aria-pressed={!!props[key]}
data-testid={testId}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left",
"transition-colors hover:bg-vscode-list-hoverBackground",
props[key]
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
: "opacity-70",
)}>
<span className={cn("codicon", `codicon-${icon}`, "text-sm flex-shrink-0")} />
<span className="flex-1 truncate">{t(labelKey)}</span>
<span
className={cn(
"text-[10px] px-1 rounded",
props[key] ? "bg-vscode-badge-background text-vscode-badge-foreground" : "",
)}>
{props[key] ? "✓" : ""}
</span>
</button>
</StandardTooltip>
)
return (
<>
<div className="flex flex-col gap-1">{leftColumn.map(renderToggleItem)}</div>
<div className="flex flex-col gap-1">{rightColumn.map(renderToggleItem)}</div>
</>
)
}

View file

@ -1882,11 +1882,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// This ensures it takes its natural height when there's space
// but becomes scrollable when the viewport is too small
*/}
{!task && (
<div className="mb-1 flex-initial min-h-0">
<AutoApproveMenu />
</div>
)}
{task && (
<>
@ -1909,9 +1904,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
initialTopMostItemIndex={groupedMessages.length - 1}
/>
</div>
<div className={`flex-initial min-h-0 ${!areButtonsVisible ? "mb-1" : ""}`}>
<AutoApproveMenu />
</div>
{areButtonsVisible && (
<div
className={`flex h-9 items-center mb-1 px-[15px] ${
@ -2025,6 +2017,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
modeShortcutText={modeShortcutText}
/>
{/* Auto-Approve Menu positioned below text area */}
<div className="px-3 pb-2">
<AutoApproveMenu />
</div>
{isProfileDisabled && (
<div className="px-3">
<ProfileViolationWarning />

View file

@ -115,7 +115,7 @@ describe("AutoApproveMenu", () => {
expect(screen.getByText("Read-only operations")).toBeInTheDocument()
})
it("should not allow toggling master checkbox when no options are selected", () => {
it("should not allow toggling master checkbox when no options are selected", async () => {
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
...defaultExtensionState,
autoApprovalEnabled: false,
@ -124,6 +124,15 @@ describe("AutoApproveMenu", () => {
render(<AutoApproveMenu />)
// Click to open the dropdown
const trigger = screen.getByText("Auto-approve")
fireEvent.click(trigger)
// Wait for the dropdown to open
await waitFor(() => {
expect(screen.getByRole("checkbox")).toBeInTheDocument()
})
// Click on the master checkbox
const masterCheckbox = screen.getByRole("checkbox")
fireEvent.click(masterCheckbox)
@ -132,7 +141,7 @@ describe("AutoApproveMenu", () => {
expect(mockPostMessage).not.toHaveBeenCalled()
})
it("should toggle master checkbox when options are selected", () => {
it("should toggle master checkbox when options are selected", async () => {
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
...defaultExtensionState,
autoApprovalEnabled: true,
@ -141,6 +150,15 @@ describe("AutoApproveMenu", () => {
render(<AutoApproveMenu />)
// Click to open the dropdown
const trigger = screen.getByText("Auto-approve")
fireEvent.click(trigger)
// Wait for the dropdown to open
await waitFor(() => {
expect(screen.getByRole("checkbox")).toBeInTheDocument()
})
// Click on the master checkbox
const masterCheckbox = screen.getByRole("checkbox")
fireEvent.click(masterCheckbox)
@ -164,9 +182,9 @@ describe("AutoApproveMenu", () => {
render(<AutoApproveMenu />)
// Expand the menu
const menuContainer = screen.getByText("Auto-approve").parentElement
fireEvent.click(menuContainer!)
// Click to open the dropdown
const trigger = screen.getByText("Auto-approve")
fireEvent.click(trigger)
// Wait for the menu to expand and find the read-only button
await waitFor(() => {
@ -192,9 +210,9 @@ describe("AutoApproveMenu", () => {
render(<AutoApproveMenu />)
// Expand the menu
const menuContainer = screen.getByText("Auto-approve").parentElement
fireEvent.click(menuContainer!)
// Click to open the dropdown
const trigger = screen.getByText("Auto-approve")
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByTestId("always-allow-write-toggle")).toBeInTheDocument()
@ -240,9 +258,9 @@ describe("AutoApproveMenu", () => {
render(<AutoApproveMenu />)
// Expand the menu
const menuContainer = screen.getByText("Auto-approve").parentElement
fireEvent.click(menuContainer!)
// Click to open the dropdown
const trigger = screen.getByText("Auto-approve")
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByTestId("always-allow-readonly-toggle")).toBeInTheDocument()
@ -279,9 +297,9 @@ describe("AutoApproveMenu", () => {
render(<AutoApproveMenu />)
// Expand the menu
const menuContainer = screen.getByText("Auto-approve").parentElement
fireEvent.click(menuContainer!)
// Click to open the dropdown
const trigger = screen.getByText("Auto-approve")
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByTestId("always-allow-readonly-toggle")).toBeInTheDocument()