Quellcode durchsuchen

Merge pull request #1329 from RooVetGit/chat_custom_dropdown

Custom dropdowns for mode/api profile
Matt Rubens vor 10 Monaten
Ursprung
Commit
2a4756aecd

+ 69 - 110
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -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,
 									}}
 								/>
 							) : (

+ 0 - 15
webview-ui/src/components/common/CaretIcon.tsx

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

+ 246 - 0
webview-ui/src/components/ui/__tests__/select-dropdown.test.tsx

@@ -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()
+		})
+	})
+})

+ 1 - 0
webview-ui/src/components/ui/index.ts

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

+ 157 - 0
webview-ui/src/components/ui/select-dropdown.tsx

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