mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Custom dropdowns for mode/api profile
This commit is contained in:
parent
3168b1e1c2
commit
c84b639872
5 changed files with 301 additions and 117 deletions
|
|
@ -15,8 +15,8 @@ import Thumbnails from "../common/Thumbnails"
|
|||
import { vscode } from "../../utils/vscode"
|
||||
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
|
||||
import { Mode, getAllModes } from "../../../../src/shared/modes"
|
||||
import { CaretIcon } from "../common/CaretIcon"
|
||||
import { convertToMentionPath } from "../../utils/path-mentions"
|
||||
import { SelectDropdown } from "../ui"
|
||||
|
||||
interface ChatTextAreaProps {
|
||||
inputValue: string
|
||||
|
|
@ -541,35 +541,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
[updateCursorPosition],
|
||||
)
|
||||
|
||||
const selectStyle = {
|
||||
fontSize: "11px",
|
||||
cursor: textAreaDisabled ? "not-allowed" : "pointer",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "var(--vscode-foreground)",
|
||||
opacity: textAreaDisabled ? 0.5 : 0.8,
|
||||
outline: "none",
|
||||
paddingLeft: "20px",
|
||||
paddingRight: "6px",
|
||||
WebkitAppearance: "none" as const,
|
||||
MozAppearance: "none" as const,
|
||||
appearance: "none" as const,
|
||||
}
|
||||
|
||||
const optionStyle = {
|
||||
backgroundColor: "var(--vscode-dropdown-background)",
|
||||
color: "var(--vscode-dropdown-foreground)",
|
||||
}
|
||||
|
||||
const caretContainerStyle = {
|
||||
position: "absolute" as const,
|
||||
left: 6,
|
||||
top: "50%",
|
||||
transform: "translateY(-45%)",
|
||||
pointerEvents: "none" as const,
|
||||
opacity: textAreaDisabled ? 0.5 : 0.8,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="chat-text-area"
|
||||
|
|
@ -791,18 +762,35 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
marginTop: "auto",
|
||||
paddingTop: "2px",
|
||||
}}>
|
||||
{/* Left side - dropdowns container */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
overflow: "hidden",
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ position: "relative", display: "inline-block" }}>
|
||||
<select
|
||||
{/* Mode selector - fixed width */}
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<SelectDropdown
|
||||
value={mode}
|
||||
disabled={textAreaDisabled}
|
||||
title="Select mode for interaction"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
options={[
|
||||
// Add the shortcut text as a disabled option at the top
|
||||
{ value: "shortcut", label: modeShortcutText, disabled: true },
|
||||
// Add all modes
|
||||
...getAllModes(customModes).map((mode) => ({
|
||||
value: mode.slug,
|
||||
label: mode.name,
|
||||
})),
|
||||
// Add separator
|
||||
{ value: "sep-1", label: "────", disabled: true },
|
||||
// Add Edit option
|
||||
{ value: "prompts-action", label: "Edit..." },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
if (value === "prompts-action") {
|
||||
window.postMessage({ type: "action", action: "promptsButtonClicked" })
|
||||
return
|
||||
|
|
@ -813,53 +801,34 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
text: value,
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
...selectStyle,
|
||||
minWidth: "70px",
|
||||
flex: "0 0 auto",
|
||||
}}>
|
||||
<option
|
||||
disabled
|
||||
style={{ ...optionStyle, fontStyle: "italic", opacity: 0.6, padding: "2px 8px" }}>
|
||||
{modeShortcutText}
|
||||
</option>
|
||||
{getAllModes(customModes).map((mode) => (
|
||||
<option key={mode.slug} value={mode.slug} style={{ ...optionStyle }}>
|
||||
{mode.name}
|
||||
</option>
|
||||
))}
|
||||
<option
|
||||
disabled
|
||||
style={{
|
||||
borderTop: "1px solid var(--vscode-dropdown-border)",
|
||||
...optionStyle,
|
||||
}}>
|
||||
────
|
||||
</option>
|
||||
<option value="prompts-action" style={{ ...optionStyle }}>
|
||||
Edit...
|
||||
</option>
|
||||
</select>
|
||||
<div style={caretContainerStyle}>
|
||||
<CaretIcon />
|
||||
</div>
|
||||
shortcutText={modeShortcutText}
|
||||
triggerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API configuration selector - flexible width */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
maxWidth: "150px",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<select
|
||||
<SelectDropdown
|
||||
value={currentApiConfigName || ""}
|
||||
disabled={textAreaDisabled}
|
||||
title="Select API configuration"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
options={[
|
||||
// Add all API configurations
|
||||
...(listApiConfigMeta || []).map((config) => ({
|
||||
value: config.name,
|
||||
label: config.name,
|
||||
})),
|
||||
// Add separator
|
||||
{ value: "sep-1", label: "────", disabled: true },
|
||||
// Add Edit option
|
||||
{ value: "settings-action", label: "Edit..." },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
if (value === "settings-action") {
|
||||
window.postMessage({ type: "action", action: "settingsButtonClicked" })
|
||||
return
|
||||
|
|
@ -869,44 +838,19 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
text: value,
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
...selectStyle,
|
||||
width: "100%",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{(listApiConfigMeta || []).map((config) => (
|
||||
<option
|
||||
key={config.name}
|
||||
value={config.name}
|
||||
style={{
|
||||
...optionStyle,
|
||||
}}>
|
||||
{config.name}
|
||||
</option>
|
||||
))}
|
||||
<option
|
||||
disabled
|
||||
style={{
|
||||
borderTop: "1px solid var(--vscode-dropdown-border)",
|
||||
...optionStyle,
|
||||
}}>
|
||||
────
|
||||
</option>
|
||||
<option value="settings-action" style={{ ...optionStyle }}>
|
||||
Edit...
|
||||
</option>
|
||||
</select>
|
||||
<div style={caretContainerStyle}>
|
||||
<CaretIcon />
|
||||
</div>
|
||||
contentClassName="max-h-[300px] overflow-y-auto"
|
||||
triggerClassName="w-full text-ellipsis overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - action buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
gap: "8px", // Reduced from 12px
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{isEnhancingPrompt ? (
|
||||
|
|
@ -916,7 +860,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
color: "var(--vscode-input-foreground)",
|
||||
opacity: 0.5,
|
||||
fontSize: 16.5,
|
||||
marginRight: 10,
|
||||
marginRight: 6, // Reduced from 10
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import React from "react"
|
||||
|
||||
export const CaretIcon = () => (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
)
|
||||
110
webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx
Normal file
110
webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import React, { ReactNode } from "react"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { SelectDropdown } from "../select-dropdown"
|
||||
|
||||
// Mock the Radix UI DropdownMenu component and its children
|
||||
jest.mock("../dropdown-menu", () => {
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <div data-testid="dropdown-root">{children}</div>,
|
||||
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
[key: string]: any
|
||||
}) => (
|
||||
<button data-testid="dropdown-trigger" disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="dropdown-content">{children}</div>
|
||||
),
|
||||
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
}) => (
|
||||
<div data-testid="dropdown-item" onClick={onClick} aria-disabled={disabled}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
|
||||
}
|
||||
})
|
||||
|
||||
describe("SelectDropdown", () => {
|
||||
const options = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
{ value: "option3", label: "Option 3" },
|
||||
{ value: "sep-1", label: "────", disabled: true },
|
||||
{ value: "action", label: "Action Item" },
|
||||
]
|
||||
|
||||
const onChangeMock = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("renders correctly with default props", () => {
|
||||
render(<SelectDropdown value="option1" options={options} onChange={onChangeMock} />)
|
||||
|
||||
// Check that the selected option is displayed in the trigger, not in a menu item
|
||||
const trigger = screen.getByTestId("dropdown-trigger")
|
||||
expect(trigger).toHaveTextContent("Option 1")
|
||||
})
|
||||
|
||||
it("handles disabled state correctly", () => {
|
||||
render(<SelectDropdown value="option1" options={options} onChange={onChangeMock} disabled={true} />)
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger")
|
||||
expect(trigger).toHaveAttribute("disabled")
|
||||
})
|
||||
|
||||
it("renders with width: 100% for proper sizing", () => {
|
||||
render(<SelectDropdown value="option1" options={options} onChange={onChangeMock} />)
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger")
|
||||
expect(trigger).toHaveStyle("width: 100%")
|
||||
})
|
||||
|
||||
it("passes the selected value to the trigger", () => {
|
||||
const { rerender } = render(<SelectDropdown value="option1" options={options} onChange={onChangeMock} />)
|
||||
|
||||
// Check initial render using testId to be specific
|
||||
const trigger = screen.getByTestId("dropdown-trigger")
|
||||
expect(trigger).toHaveTextContent("Option 1")
|
||||
|
||||
// Rerender with a different value
|
||||
rerender(<SelectDropdown value="option3" options={options} onChange={onChangeMock} />)
|
||||
|
||||
// Check updated render
|
||||
expect(trigger).toHaveTextContent("Option 3")
|
||||
})
|
||||
|
||||
it("applies custom className to trigger when provided", () => {
|
||||
render(
|
||||
<SelectDropdown
|
||||
value="option1"
|
||||
options={options}
|
||||
onChange={onChangeMock}
|
||||
triggerClassName="custom-trigger-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger")
|
||||
expect(trigger.classList.toString()).toContain("custom-trigger-class")
|
||||
})
|
||||
})
|
||||
|
|
@ -13,3 +13,4 @@ export * from "./separator"
|
|||
export * from "./slider"
|
||||
export * from "./textarea"
|
||||
export * from "./tooltip"
|
||||
export * from "./select-dropdown"
|
||||
|
|
|
|||
144
webview-ui/src/components/ui/select-dropdown.tsx
Normal file
144
webview-ui/src/components/ui/select-dropdown.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import * as React from "react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "./dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectDropdownProps {
|
||||
value: string
|
||||
options: DropdownOption[]
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
contentClassName?: string
|
||||
sideOffset?: number
|
||||
align?: "start" | "center" | "end"
|
||||
shouldShowCaret?: boolean
|
||||
placeholder?: string
|
||||
shortcutText?: string
|
||||
}
|
||||
|
||||
export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownMenuTrigger>, SelectDropdownProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
title = "",
|
||||
className = "",
|
||||
triggerClassName = "",
|
||||
contentClassName = "",
|
||||
sideOffset = 4,
|
||||
align = "start",
|
||||
shouldShowCaret = true,
|
||||
placeholder = "",
|
||||
shortcutText = "",
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Find the selected option label
|
||||
const selectedOption = options.find((option) => option.value === value)
|
||||
const displayText = selectedOption?.label || placeholder || ""
|
||||
|
||||
// Handle menu item click
|
||||
const handleSelect = (optionValue: string) => {
|
||||
if (optionValue.endsWith("-action")) {
|
||||
// Handle special actions like "settings-action" or "prompts-action"
|
||||
window.postMessage({ type: "action", action: optionValue.replace("-action", "ButtonClicked") })
|
||||
return
|
||||
}
|
||||
onChange(optionValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 relative whitespace-nowrap rounded pr-1.5 py-1.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-vscode-focusBorder",
|
||||
"bg-transparent border-none text-vscode-foreground w-auto",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "opacity-80 cursor-pointer hover:opacity-100",
|
||||
triggerClassName,
|
||||
)}
|
||||
style={{
|
||||
width: "100%", // Changed to take full width of parent
|
||||
minWidth: "0",
|
||||
maxWidth: "100%",
|
||||
}}>
|
||||
{shouldShowCaret && (
|
||||
<div className="pointer-events-none opacity-80 flex-shrink-0">
|
||||
<svg
|
||||
fill="none"
|
||||
height="10"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="10">
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate">{displayText}</span>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border z-50",
|
||||
contentClassName,
|
||||
)}>
|
||||
{options.map((option, index) => {
|
||||
// Check if this is a separator (typically used for the "────" option)
|
||||
if (option.label.includes("────")) {
|
||||
return <DropdownMenuSeparator key={`sep-${index}`} />
|
||||
}
|
||||
|
||||
// Check if this is a disabled label (like the shortcut text)
|
||||
if (option.disabled && shortcutText && option.label.includes(shortcutText)) {
|
||||
return (
|
||||
<div
|
||||
key={`label-${index}`}
|
||||
className="px-2 py-1.5 text-xs font-semibold opacity-60 italic">
|
||||
{option.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`item-${option.value}`}
|
||||
disabled={option.disabled}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs focus:bg-vscode-list-hoverBackground focus:text-vscode-list-hoverForeground",
|
||||
option.value === value && "bg-vscode-list-focusBackground",
|
||||
)}
|
||||
onClick={() => handleSelect(option.value)}>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SelectDropdown.displayName = "SelectDropdown"
|
||||
Loading…
Add table
Reference in a new issue