Merge pull request #1329 from RooVetGit/chat_custom_dropdown

Custom dropdowns for mode/api profile
This commit is contained in:
Matt Rubens 2025-03-03 23:12:20 -05:00 committed by GitHub
commit 2a4756aecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 473 additions and 125 deletions

View file

@ -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, DropdownOptionType } 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,122 +762,110 @@ 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
if (value === "prompts-action") {
window.postMessage({ type: "action", action: "promptsButtonClicked" })
return
}
options={[
// Add the shortcut text as a disabled option at the top
{
value: "shortcut",
label: modeShortcutText,
disabled: true,
type: DropdownOptionType.SHORTCUT,
},
// Add all modes
...getAllModes(customModes).map((mode) => ({
value: mode.slug,
label: mode.name,
type: DropdownOptionType.ITEM,
})),
// Add separator
{
value: "sep-1",
label: "Separator",
type: DropdownOptionType.SEPARATOR,
},
// Add Edit option
{
value: "promptsButtonClicked",
label: "Edit...",
type: DropdownOptionType.ACTION,
},
]}
onChange={(value) => {
setMode(value as Mode)
vscode.postMessage({
type: "mode",
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
if (value === "settings-action") {
window.postMessage({ type: "action", action: "settingsButtonClicked" })
return
}
options={[
// Add all API configurations
...(listApiConfigMeta || []).map((config) => ({
value: config.name,
label: config.name,
type: DropdownOptionType.ITEM,
})),
// Add separator
{
value: "sep-2",
label: "Separator",
type: DropdownOptionType.SEPARATOR,
},
// Add Edit option
{
value: "settingsButtonClicked",
label: "Edit...",
type: DropdownOptionType.ACTION,
},
]}
onChange={(value) => {
vscode.postMessage({
type: "loadApiConfiguration",
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",
flexShrink: 0,
}}>
<div style={{ display: "flex", alignItems: "center" }}>
{isEnhancingPrompt ? (
@ -916,7 +875,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
color: "var(--vscode-input-foreground)",
opacity: 0.5,
fontSize: 16.5,
marginRight: 10,
marginRight: 6,
}}
/>
) : (

View file

@ -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>
)

View file

@ -0,0 +1,246 @@
import React, { ReactNode } from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { SelectDropdown, DropdownOptionType } from "../select-dropdown"
// Mock window.postMessage
const postMessageMock = jest.fn()
Object.defineProperty(window, "postMessage", {
writable: true,
value: postMessageMock,
})
// 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")
})
// Tests for the new functionality
describe("Option types", () => {
it("renders separator options correctly", () => {
const optionsWithTypedSeparator = [
{ value: "option1", label: "Option 1" },
{ value: "sep-1", label: "Separator", type: DropdownOptionType.SEPARATOR },
{ value: "option2", label: "Option 2" },
]
render(<SelectDropdown value="option1" options={optionsWithTypedSeparator} onChange={onChangeMock} />)
// Check for separator
const separators = screen.getAllByTestId("dropdown-separator")
expect(separators.length).toBe(1)
})
it("renders string separator (backward compatibility) correctly", () => {
const optionsWithStringSeparator = [
{ value: "option1", label: "Option 1" },
{ value: "sep-1", label: "────", disabled: true },
{ value: "option2", label: "Option 2" },
]
render(<SelectDropdown value="option1" options={optionsWithStringSeparator} onChange={onChangeMock} />)
// Check for separator
const separators = screen.getAllByTestId("dropdown-separator")
expect(separators.length).toBe(1)
})
it("renders shortcut options correctly", () => {
const shortcutText = "Ctrl+K"
const optionsWithShortcut = [
{ value: "shortcut", label: shortcutText, type: DropdownOptionType.SHORTCUT },
{ value: "option1", label: "Option 1" },
]
render(
<SelectDropdown
value="option1"
options={optionsWithShortcut}
onChange={onChangeMock}
shortcutText={shortcutText}
/>,
)
// The shortcut text should be rendered as a div, not a dropdown item
expect(screen.queryByText(shortcutText)).toBeInTheDocument()
const dropdownItems = screen.getAllByTestId("dropdown-item")
expect(dropdownItems.length).toBe(1) // Only one regular option
})
it("handles action options correctly", () => {
const optionsWithAction = [
{ value: "option1", label: "Option 1" },
{ value: "settingsButtonClicked", label: "Settings", type: DropdownOptionType.ACTION },
]
render(<SelectDropdown value="option1" options={optionsWithAction} onChange={onChangeMock} />)
// Get all dropdown items
const dropdownItems = screen.getAllByTestId("dropdown-item")
// Click the action item
fireEvent.click(dropdownItems[1])
// Check that postMessage was called with the correct action
expect(postMessageMock).toHaveBeenCalledWith({
type: "action",
action: "settingsButtonClicked",
})
// The onChange callback should not be called for action items
expect(onChangeMock).not.toHaveBeenCalled()
})
it("only treats options with explicit ACTION type as actions", () => {
const optionsForTest = [
{ value: "option1", label: "Option 1" },
// This should be treated as a regular option despite the -action suffix
{ value: "settings-action", label: "Regular option with action suffix" },
// This should be treated as an action
{ value: "settingsButtonClicked", label: "Settings", type: DropdownOptionType.ACTION },
]
render(<SelectDropdown value="option1" options={optionsForTest} onChange={onChangeMock} />)
// Get all dropdown items
const dropdownItems = screen.getAllByTestId("dropdown-item")
// Click the second option (with action suffix but no ACTION type)
fireEvent.click(dropdownItems[1])
// Should trigger onChange, not postMessage
expect(onChangeMock).toHaveBeenCalledWith("settings-action")
expect(postMessageMock).not.toHaveBeenCalled()
// Reset mocks
onChangeMock.mockReset()
postMessageMock.mockReset()
// Click the third option (ACTION type)
fireEvent.click(dropdownItems[2])
// Should trigger postMessage with "settingsButtonClicked", not onChange
expect(postMessageMock).toHaveBeenCalledWith({
type: "action",
action: "settingsButtonClicked",
})
expect(onChangeMock).not.toHaveBeenCalled()
})
it("calls onChange for regular menu items", () => {
render(<SelectDropdown value="option1" options={options} onChange={onChangeMock} />)
// Get all dropdown items
const dropdownItems = screen.getAllByTestId("dropdown-item")
// Click the second option (index 1)
fireEvent.click(dropdownItems[1])
// Check that onChange was called with the correct value
expect(onChangeMock).toHaveBeenCalledWith("option2")
// postMessage should not be called for regular items
expect(postMessageMock).not.toHaveBeenCalled()
})
})
})

View file

@ -13,3 +13,4 @@ export * from "./separator"
export * from "./slider"
export * from "./textarea"
export * from "./tooltip"
export * from "./select-dropdown"

View file

@ -0,0 +1,157 @@
import * as React from "react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "./dropdown-menu"
import { cn } from "@/lib/utils"
// Constants for option types
export enum DropdownOptionType {
ITEM = "item",
SEPARATOR = "separator",
SHORTCUT = "shortcut",
ACTION = "action",
}
export interface DropdownOption {
value: string
label: string
disabled?: boolean
type?: DropdownOptionType // Optional type to specify special behaviors
}
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 = (option: DropdownOption) => {
// Check if this is an action option by its explicit type
if (option.type === DropdownOptionType.ACTION) {
window.postMessage({
type: "action",
action: option.value,
})
return
}
onChange(option.value)
}
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%", // 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) => {
// Handle separator type
if (option.type === DropdownOptionType.SEPARATOR || option.label.includes("────")) {
return <DropdownMenuSeparator key={`sep-${index}`} />
}
// Handle shortcut text type (disabled label for keyboard shortcuts)
if (
option.type === DropdownOptionType.SHORTCUT ||
(option.disabled && shortcutText && option.label.includes(shortcutText))
) {
return (
<div key={`label-${index}`} className="px-2 py-1.5 text-xs opacity-50">
{option.label}
</div>
)
}
// Regular menu items
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)}>
{option.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
},
)
SelectDropdown.displayName = "SelectDropdown"