Custom dropdowns for mode/api profile

This commit is contained in:
Matt Rubens 2025-03-02 22:14:32 -05:00
parent 3168b1e1c2
commit c84b639872
5 changed files with 301 additions and 117 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 } 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
}}
/>
) : (

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,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")
})
})

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,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"