Browse Source

Update UX for text area (#1953)

* update ux for text area

* fix z-index

* fix tests

* Update .changeset/bright-trains-crash.md

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* extract off drop method per code review bot

* address bot comment

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Chad Gauthier 11 months ago
parent
commit
418694befa

+ 10 - 0
.changeset/bright-trains-crash.md

@@ -0,0 +1,10 @@
+---
+"roo-cline": minor
+---
+
+UX fixes that:
+
+- Allow dropdowns to be controlled when text box is disabled
+- Separates and clarifies buttons and dropdowns from inputs
+- Adds a secondary placeholder for easier visibility of mode controls
+- Updates to tailwind standard

+ 290 - 254
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -25,6 +25,8 @@ import Thumbnails from "../common/Thumbnails"
 import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
 import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
 import ContextMenu from "./ContextMenu"
 import ContextMenu from "./ContextMenu"
 import { VolumeX } from "lucide-react"
 import { VolumeX } from "lucide-react"
+import { IconButton } from "./IconButton"
+import { cn } from "@/lib/utils"
 
 
 interface ChatTextAreaProps {
 interface ChatTextAreaProps {
 	inputValue: string
 	inputValue: string
@@ -113,7 +115,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 			return () => window.removeEventListener("message", messageHandler)
 			return () => window.removeEventListener("message", messageHandler)
 		}, [setInputValue, searchRequestId])
 		}, [setInputValue, searchRequestId])
 
 
-		const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
+		const [isDraggingOver, setIsDraggingOver] = useState(false)
 		const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
 		const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
 		const [showContextMenu, setShowContextMenu] = useState(false)
 		const [showContextMenu, setShowContextMenu] = useState(false)
 		const [cursorPosition, setCursorPosition] = useState(0)
 		const [cursorPosition, setCursorPosition] = useState(0)
@@ -547,14 +549,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 			[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
 			[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
 		)
 		)
 
 
-		const handleThumbnailsHeightChange = useCallback((height: number) => setThumbnailsHeight(height), [])
-
-		useEffect(() => {
-			if (selectedImages.length === 0) {
-				setThumbnailsHeight(0)
-			}
-		}, [selectedImages])
-
 		const handleMenuMouseDown = useCallback(() => {
 		const handleMenuMouseDown = useCallback(() => {
 			setIsMouseDownOnMenu(true)
 			setIsMouseDownOnMenu(true)
 		}, [])
 		}, [])
@@ -592,75 +586,51 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 			[updateCursorPosition],
 			[updateCursorPosition],
 		)
 		)
 
 
-		const [isTtsPlaying, setIsTtsPlaying] = useState(false)
-
-		useEvent("message", (event: MessageEvent) => {
-			const message: ExtensionMessage = event.data
-
-			if (message.type === "ttsStart") {
-				setIsTtsPlaying(true)
-			} else if (message.type === "ttsStop") {
-				setIsTtsPlaying(false)
-			}
-		})
-
-		return (
-			<div
-				className="chat-text-area"
-				style={{
-					opacity: textAreaDisabled ? 0.5 : 1,
-					position: "relative",
-					display: "flex",
-					flexDirection: "column",
-					gap: "8px",
-					backgroundColor: "var(--vscode-input-background)",
-					margin: "10px 15px",
-					padding: "8px",
-					outline: "none",
-					border: "1px solid",
-					borderColor: isFocused ? "var(--vscode-focusBorder)" : "transparent",
-					borderRadius: "2px",
-				}}
-				onDrop={async (e) => {
-					e.preventDefault()
-					const files = Array.from(e.dataTransfer.files)
-					const text = e.dataTransfer.getData("text")
-
-					if (text) {
-						// Split text on newlines to handle multiple files
-						const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
-
-						if (lines.length > 0) {
-							// Process each line as a separate file path
-							let newValue = inputValue.slice(0, cursorPosition)
-							let totalLength = 0
-
-							lines.forEach((line, index) => {
-								// Convert each path to a mention-friendly format
-								const mentionText = convertToMentionPath(line, cwd)
-								newValue += mentionText
-								totalLength += mentionText.length
-
-								// Add space after each mention except the last one
-								if (index < lines.length - 1) {
-									newValue += " "
-									totalLength += 1
-								}
-							})
-
-							// Add space after the last mention and append the rest of the input
-							newValue += " " + inputValue.slice(cursorPosition)
-							totalLength += 1
-
-							setInputValue(newValue)
-							const newCursorPosition = cursorPosition + totalLength
-							setCursorPosition(newCursorPosition)
-							setIntendedCursorPosition(newCursorPosition)
+		const handleDrop = useCallback(
+			async (e: React.DragEvent<HTMLDivElement>) => {
+				e.preventDefault()
+				setIsDraggingOver(false)
+
+				const text = e.dataTransfer.getData("text")
+				if (text) {
+					// Split text on newlines to handle multiple files
+					const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
+
+					if (lines.length > 0) {
+						// Process each line as a separate file path
+						let newValue = inputValue.slice(0, cursorPosition)
+						let totalLength = 0
+
+						// Using a standard for loop instead of forEach for potential performance gains.
+						for (let i = 0; i < lines.length; i++) {
+							const line = lines[i]
+							// Convert each path to a mention-friendly format
+							const mentionText = convertToMentionPath(line, cwd)
+							newValue += mentionText
+							totalLength += mentionText.length
+
+							// Add space after each mention except the last one
+							if (i < lines.length - 1) {
+								newValue += " "
+								totalLength += 1
+							}
 						}
 						}
 
 
-						return
+						// Add space after the last mention and append the rest of the input
+						newValue += " " + inputValue.slice(cursorPosition)
+						totalLength += 1
+
+						setInputValue(newValue)
+						const newCursorPosition = cursorPosition + totalLength
+						setCursorPosition(newCursorPosition)
+						setIntendedCursorPosition(newCursorPosition)
 					}
 					}
 
 
+					return
+				}
+
+				const files = Array.from(e.dataTransfer.files)
+				if (!textAreaDisabled && files.length > 0) {
 					const acceptedTypes = ["png", "jpeg", "webp"]
 					const acceptedTypes = ["png", "jpeg", "webp"]
 					const imageFiles = files.filter((file) => {
 					const imageFiles = files.filter((file) => {
 						const [type, subtype] = file.type.split("/")
 						const [type, subtype] = file.type.split("/")
@@ -699,159 +669,256 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 							console.warn(t("chat:noValidImages"))
 							console.warn(t("chat:noValidImages"))
 						}
 						}
 					}
 					}
-				}}
-				onDragOver={(e) => {
-					e.preventDefault()
-				}}>
-				{showContextMenu && (
-					<div ref={contextMenuContainerRef}>
-						<ContextMenu
-							onSelect={handleMentionSelect}
-							searchQuery={searchQuery}
-							onMouseDown={handleMenuMouseDown}
-							selectedIndex={selectedMenuIndex}
-							setSelectedIndex={setSelectedMenuIndex}
-							selectedType={selectedType}
-							queryItems={queryItems}
-							modes={getAllModes(customModes)}
-							loading={searchLoading}
-							dynamicSearchResults={fileSearchResults}
-						/>
-					</div>
-				)}
+				}
+			},
+			[
+				cursorPosition,
+				cwd,
+				inputValue,
+				setInputValue,
+				setCursorPosition,
+				setIntendedCursorPosition,
+				textAreaDisabled,
+				shouldDisableImages,
+				setSelectedImages,
+				t,
+			],
+		)
 
 
-				<div
-					style={{
-						position: "relative",
-						flex: "1 1 auto",
-						display: "flex",
-						flexDirection: "column-reverse",
-						minHeight: 0,
-						overflow: "hidden",
-					}}>
+		const [isTtsPlaying, setIsTtsPlaying] = useState(false)
+
+		useEvent("message", (event: MessageEvent) => {
+			const message: ExtensionMessage = event.data
+
+			if (message.type === "ttsStart") {
+				setIsTtsPlaying(true)
+			} else if (message.type === "ttsStop") {
+				setIsTtsPlaying(false)
+			}
+		})
+
+		const placeholderBottomText = `\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})`
+
+		return (
+			<div
+				className={cn(
+					"relative",
+					"flex",
+					"flex-col",
+					"gap-2",
+					"bg-editor-background",
+					"m-2 mt-1",
+					"p-1.5",
+					"outline-none",
+					"border",
+					"border-none",
+					"w-[calc(100%-16px)]",
+					"ml-auto",
+					"mr-auto",
+					"box-border",
+				)}>
+				<div className="relative">
 					<div
 					<div
-						ref={highlightLayerRef}
-						style={{
-							position: "absolute",
-							inset: 0,
-							pointerEvents: "none",
-							whiteSpace: "pre-wrap",
-							wordWrap: "break-word",
-							color: "transparent",
-							overflow: "hidden",
-							fontFamily: "var(--vscode-font-family)",
-							fontSize: "var(--vscode-editor-font-size)",
-							lineHeight: "var(--vscode-editor-line-height)",
-							padding: "2px",
-							paddingRight: "8px",
-							marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
-							zIndex: 1,
-						}}
-					/>
-					<DynamicTextArea
-						ref={(el) => {
-							if (typeof ref === "function") {
-								ref(el)
-							} else if (ref) {
-								ref.current = el
+						className={cn("chat-text-area", "relative", "flex", "flex-col", "outline-none")}
+						onDrop={handleDrop}
+						onDragOver={(e) => {
+							//Only allowed to drop images/files on shift key pressed
+							if (!e.shiftKey) {
+								setIsDraggingOver(false)
+								return
 							}
 							}
-							textAreaRef.current = el
-						}}
-						value={inputValue}
-						disabled={textAreaDisabled}
-						onChange={(e) => {
-							handleInputChange(e)
-							updateHighlights()
+							e.preventDefault()
+							setIsDraggingOver(true)
+							e.dataTransfer.dropEffect = "copy"
 						}}
 						}}
-						onFocus={() => setIsFocused(true)}
-						onKeyDown={handleKeyDown}
-						onKeyUp={handleKeyUp}
-						onBlur={handleBlur}
-						onPaste={handlePaste}
-						onSelect={updateCursorPosition}
-						onMouseUp={updateCursorPosition}
-						onHeightChange={(height) => {
-							if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
-								setTextAreaBaseHeight(height)
+						onDragLeave={(e) => {
+							e.preventDefault()
+							const rect = e.currentTarget.getBoundingClientRect()
+							if (
+								e.clientX <= rect.left ||
+								e.clientX >= rect.right ||
+								e.clientY <= rect.top ||
+								e.clientY >= rect.bottom
+							) {
+								setIsDraggingOver(false)
 							}
 							}
-							onHeightChange?.(height)
-						}}
-						placeholder={placeholderText}
-						minRows={3}
-						maxRows={15}
-						autoFocus={true}
-						style={{
-							width: "100%",
-							outline: "none",
-							boxSizing: "border-box",
-							backgroundColor: "transparent",
-							color: "var(--vscode-input-foreground)",
-							borderRadius: 2,
-							fontFamily: "var(--vscode-font-family)",
-							fontSize: "var(--vscode-editor-font-size)",
-							lineHeight: "var(--vscode-editor-line-height)",
-							resize: "none",
-							overflowX: "hidden",
-							overflowY: "auto",
-							border: "none",
-							padding: "2px",
-							paddingRight: "8px",
-							marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
-							cursor: textAreaDisabled ? "not-allowed" : undefined,
-							flex: "0 1 auto",
-							zIndex: 2,
-							scrollbarWidth: "none",
-						}}
-						onScroll={() => updateHighlights()}
-					/>
-					{isTtsPlaying && (
-						<Button
-							variant="ghost"
-							size="icon"
-							className="absolute top-0 right-0 opacity-25 hover:opacity-100 z-10"
-							onClick={() => vscode.postMessage({ type: "stopTts" })}>
-							<VolumeX className="size-4" />
-						</Button>
-					)}
+						}}>
+						{showContextMenu && (
+							<div
+								ref={contextMenuContainerRef}
+								className={cn(
+									"absolute",
+									"bottom-full",
+									"left-0",
+									"right-0",
+									"z-[1000]",
+									"mb-2",
+									"filter",
+									"drop-shadow-md",
+								)}>
+								<ContextMenu
+									onSelect={handleMentionSelect}
+									searchQuery={searchQuery}
+									onMouseDown={handleMenuMouseDown}
+									selectedIndex={selectedMenuIndex}
+									setSelectedIndex={setSelectedMenuIndex}
+									selectedType={selectedType}
+									queryItems={queryItems}
+									modes={getAllModes(customModes)}
+									loading={searchLoading}
+									dynamicSearchResults={fileSearchResults}
+								/>
+							</div>
+						)}
+						<div
+							className={cn(
+								"relative",
+								"flex-1",
+								"flex",
+								"flex-col-reverse",
+								"min-h-0",
+								"overflow-hidden",
+								"rounded",
+							)}>
+							<div
+								ref={highlightLayerRef}
+								className={cn(
+									"absolute",
+									"inset-0",
+									"pointer-events-none",
+									"whitespace-pre-wrap",
+									"break-words",
+									"text-transparent",
+									"overflow-hidden",
+									"font-vscode-font-family",
+									"text-vscode-editor-font-size",
+									"leading-vscode-editor-line-height",
+									"py-2",
+									"px-[9px]",
+									"z-[1000]",
+								)}
+								style={{
+									color: "transparent",
+								}}
+							/>
+							<DynamicTextArea
+								ref={(el) => {
+									if (typeof ref === "function") {
+										ref(el)
+									} else if (ref) {
+										ref.current = el
+									}
+									textAreaRef.current = el
+								}}
+								value={inputValue}
+								disabled={textAreaDisabled}
+								onChange={(e) => {
+									handleInputChange(e)
+									updateHighlights()
+								}}
+								onFocus={() => setIsFocused(true)}
+								onKeyDown={handleKeyDown}
+								onKeyUp={handleKeyUp}
+								onBlur={handleBlur}
+								onPaste={handlePaste}
+								onSelect={updateCursorPosition}
+								onMouseUp={updateCursorPosition}
+								onHeightChange={(height) => {
+									if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
+										setTextAreaBaseHeight(height)
+									}
+									onHeightChange?.(height)
+								}}
+								placeholder={placeholderText}
+								minRows={3}
+								maxRows={15}
+								autoFocus={true}
+								className={cn(
+									"w-full",
+									"text-vscode-input-foreground",
+									"font-vscode-font-family",
+									"text-vscode-editor-font-size",
+									"leading-vscode-editor-line-height",
+									textAreaDisabled ? "cursor-not-allowed" : "cursor-text",
+									"py-1.5 px-2",
+									isFocused
+										? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
+										: isDraggingOver
+											? "border-2 border-dashed border-vscode-focusBorder"
+											: "border border-transparent",
+									textAreaDisabled ? "opacity-50" : "opacity-100",
+									isDraggingOver
+										? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]"
+										: "bg-vscode-input-background",
+									"transition-background-color duration-150 ease-in-out",
+									"will-change-background-color",
+									"h-[100px]",
+									"[@media(min-width:150px)]:min-h-[80px]",
+									"[@media(min-width:425px)]:min-h-[60px]",
+									"box-border",
+									"rounded",
+									"resize-none",
+									"overflow-x-hidden",
+									"overflow-y-auto",
+									"pr-2",
+									"flex-none flex-grow",
+									"z-[2]",
+									"scrollbar-none",
+								)}
+								onScroll={() => updateHighlights()}
+							/>
+							{isTtsPlaying && (
+								<Button
+									variant="ghost"
+									size="icon"
+									className="absolute top-0 right-0 opacity-25 hover:opacity-100 z-10"
+									onClick={() => vscode.postMessage({ type: "stopTts" })}>
+									<VolumeX className="size-4" />
+								</Button>
+							)}
+							{!inputValue && (
+								<div
+									className={cn(
+										"absolute",
+										"left-2",
+										"flex",
+										"gap-2",
+										"text-xs",
+										"text-descriptionForeground",
+										"pointer-events-none",
+										"z-25",
+										"bottom-1.5",
+										"pr-2",
+										"transition-opacity",
+										"duration-200",
+										"ease-in-out",
+										textAreaDisabled ? "opacity-35" : "opacity-70",
+									)}>
+									{placeholderBottomText}
+								</div>
+							)}
+						</div>
+					</div>
 				</div>
 				</div>
 
 
 				{selectedImages.length > 0 && (
 				{selectedImages.length > 0 && (
 					<Thumbnails
 					<Thumbnails
 						images={selectedImages}
 						images={selectedImages}
 						setImages={setSelectedImages}
 						setImages={setSelectedImages}
-						onHeightChange={handleThumbnailsHeightChange}
 						style={{
 						style={{
-							position: "absolute",
-							bottom: "36px",
 							left: "16px",
 							left: "16px",
 							zIndex: 2,
 							zIndex: 2,
-							marginBottom: "4px",
+							marginBottom: 0,
 						}}
 						}}
 					/>
 					/>
 				)}
 				)}
 
 
-				<div
-					style={{
-						display: "flex",
-						justifyContent: "space-between",
-						alignItems: "center",
-						marginTop: "auto",
-						paddingTop: "2px",
-					}}>
-					{/* Left side - dropdowns container */}
-					<div
-						style={{
-							display: "flex",
-							alignItems: "center",
-							gap: "4px",
-							overflow: "hidden",
-							minWidth: 0,
-						}}>
+				<div className={cn("flex", "justify-between", "items-center", "mt-auto", "pt-0.5")}>
+					<div className={cn("flex", "items-center", "gap-1", "min-w-0")}>
 						{/* Mode selector - fixed width */}
 						{/* Mode selector - fixed width */}
-						<div style={{ flexShrink: 0 }}>
+						<div className="shrink-0">
 							<SelectDropdown
 							<SelectDropdown
 								value={mode}
 								value={mode}
-								disabled={textAreaDisabled}
 								title={t("chat:selectMode")}
 								title={t("chat:selectMode")}
 								options={[
 								options={[
 									{
 									{
@@ -886,12 +953,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 						</div>
 						</div>
 
 
 						{/* API configuration selector - flexible width */}
 						{/* API configuration selector - flexible width */}
-						<div
-							style={{
-								flex: "1 1 auto",
-								minWidth: 0,
-								overflow: "hidden",
-							}}>
+						<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
 							<SelectDropdown
 							<SelectDropdown
 								value={currentApiConfigName || ""}
 								value={currentApiConfigName || ""}
 								disabled={textAreaDisabled}
 								disabled={textAreaDisabled}
@@ -921,51 +983,25 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					</div>
 					</div>
 
 
 					{/* Right side - action buttons */}
 					{/* Right side - action buttons */}
-					<div
-						style={{
-							display: "flex",
-							alignItems: "center",
-							gap: "8px",
-							flexShrink: 0,
-						}}>
-						<div style={{ display: "flex", alignItems: "center" }}>
-							{isEnhancingPrompt ? (
-								<span
-									className="codicon codicon-loading codicon-modifier-spin"
-									style={{
-										color: "var(--vscode-input-foreground)",
-										opacity: 0.5,
-										fontSize: 16.5,
-										marginRight: 6,
-									}}
-								/>
-							) : (
-								<span
-									role="button"
-									aria-label="enhance prompt"
-									data-testid="enhance-prompt-button"
-									title={t("chat:enhancePrompt")}
-									className={`input-icon-button ${
-										textAreaDisabled ? "disabled" : ""
-									} codicon codicon-sparkle`}
-									onClick={() => !textAreaDisabled && handleEnhancePrompt()}
-									style={{ fontSize: 16.5 }}
-								/>
-							)}
-						</div>
-						<span
-							className={`input-icon-button ${
-								shouldDisableImages ? "disabled" : ""
-							} codicon codicon-device-camera`}
+					<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
+						<IconButton
+							iconClass={isEnhancingPrompt ? "codicon-loading" : "codicon-sparkle"}
+							title={t("chat:enhancePrompt")}
+							disabled={textAreaDisabled}
+							isLoading={isEnhancingPrompt}
+							onClick={handleEnhancePrompt}
+						/>
+						<IconButton
+							iconClass="codicon-device-camera"
 							title={t("chat:addImages")}
 							title={t("chat:addImages")}
-							onClick={() => !shouldDisableImages && onSelectImages()}
-							style={{ fontSize: 16.5 }}
+							disabled={shouldDisableImages}
+							onClick={onSelectImages}
 						/>
 						/>
-						<span
-							className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
+						<IconButton
+							iconClass="codicon-send"
 							title={t("chat:sendMessage")}
 							title={t("chat:sendMessage")}
-							onClick={() => !textAreaDisabled && onSend()}
-							style={{ fontSize: 15 }}
+							disabled={textAreaDisabled}
+							onClick={onSend}
 						/>
 						/>
 					</div>
 					</div>
 				</div>
 				</div>

+ 1 - 4
webview-ui/src/components/chat/ChatView.tsx

@@ -974,10 +974,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 		[],
 		[],
 	)
 	)
 
 
-	const baseText = task ? t("chat:typeMessage") : t("chat:typeTask")
-	const placeholderText =
-		baseText +
-		`\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})`
+	const placeholderText = task ? t("chat:typeMessage") : t("chat:typeTask")
 
 
 	const itemContent = useCallback(
 	const itemContent = useCallback(
 		(index: number, messageOrGroup: ClineMessage | ClineMessage[]) => {
 		(index: number, messageOrGroup: ClineMessage | ClineMessage[]) => {

+ 48 - 0
webview-ui/src/components/chat/IconButton.tsx

@@ -0,0 +1,48 @@
+import { cn } from "@/lib/utils"
+
+interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
+	iconClass: string
+	title: string
+	disabled?: boolean
+	isLoading?: boolean
+	style?: React.CSSProperties
+}
+
+export const IconButton: React.FC<IconButtonProps> = ({
+	iconClass,
+	title,
+	className,
+	disabled,
+	isLoading,
+	onClick,
+	style,
+	...props
+}) => {
+	const buttonClasses = cn(
+		"relative inline-flex items-center justify-center",
+		"bg-transparent border-none p-1.5",
+		"rounded-md min-w-[28px] min-h-[28px]",
+		"text-vscode-foreground opacity-85",
+		"transition-all duration-150",
+		"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
+		"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
+		"active:bg-[rgba(255,255,255,0.1)]",
+		disabled &&
+			"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
+		className,
+	)
+
+	const iconClasses = cn("codicon", iconClass, isLoading && "codicon-modifier-spin")
+
+	return (
+		<button
+			aria-label={title}
+			title={title}
+			className={buttonClasses}
+			onClick={!disabled ? onClick : undefined}
+			style={{ fontSize: 16.5, ...style }}
+			{...props}>
+			<span className={iconClasses} />
+		</button>
+	)
+}

+ 16 - 7
webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx

@@ -31,6 +31,16 @@ const mockConvertToMentionPath = pathMentions.convertToMentionPath as jest.Mock
 // Mock ExtensionStateContext
 // Mock ExtensionStateContext
 jest.mock("../../../context/ExtensionStateContext")
 jest.mock("../../../context/ExtensionStateContext")
 
 
+// Custom query function to get the enhance prompt button
+const getEnhancePromptButton = () => {
+	return screen.getByRole("button", {
+		name: (_, element) => {
+			// Find the button with the sparkle icon
+			return element.querySelector(".codicon-sparkle") !== null
+		},
+	})
+}
+
 describe("ChatTextArea", () => {
 describe("ChatTextArea", () => {
 	const defaultProps = {
 	const defaultProps = {
 		inputValue: "",
 		inputValue: "",
@@ -66,10 +76,9 @@ describe("ChatTextArea", () => {
 				filePaths: [],
 				filePaths: [],
 				openedTabs: [],
 				openedTabs: [],
 			})
 			})
-
 			render(<ChatTextArea {...defaultProps} textAreaDisabled={true} />)
 			render(<ChatTextArea {...defaultProps} textAreaDisabled={true} />)
-			const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
-			expect(enhanceButton).toHaveClass("disabled")
+			const enhanceButton = getEnhancePromptButton()
+			expect(enhanceButton).toHaveClass("cursor-not-allowed")
 		})
 		})
 	})
 	})
 
 
@@ -88,7 +97,7 @@ describe("ChatTextArea", () => {
 
 
 			render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
 			render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
 
 
-			const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
+			const enhanceButton = getEnhancePromptButton()
 			fireEvent.click(enhanceButton)
 			fireEvent.click(enhanceButton)
 
 
 			expect(mockPostMessage).toHaveBeenCalledWith({
 			expect(mockPostMessage).toHaveBeenCalledWith({
@@ -108,7 +117,7 @@ describe("ChatTextArea", () => {
 
 
 			render(<ChatTextArea {...defaultProps} inputValue="" />)
 			render(<ChatTextArea {...defaultProps} inputValue="" />)
 
 
-			const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
+			const enhanceButton = getEnhancePromptButton()
 			fireEvent.click(enhanceButton)
 			fireEvent.click(enhanceButton)
 
 
 			expect(mockPostMessage).not.toHaveBeenCalled()
 			expect(mockPostMessage).not.toHaveBeenCalled()
@@ -125,7 +134,7 @@ describe("ChatTextArea", () => {
 
 
 			render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
 			render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
 
 
-			const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
+			const enhanceButton = getEnhancePromptButton()
 			fireEvent.click(enhanceButton)
 			fireEvent.click(enhanceButton)
 
 
 			const loadingSpinner = screen.getByText("", { selector: ".codicon-loading" })
 			const loadingSpinner = screen.getByText("", { selector: ".codicon-loading" })
@@ -150,7 +159,7 @@ describe("ChatTextArea", () => {
 			rerender(<ChatTextArea {...defaultProps} />)
 			rerender(<ChatTextArea {...defaultProps} />)
 
 
 			// Verify the enhance button appears after apiConfiguration changes
 			// Verify the enhance button appears after apiConfiguration changes
-			expect(screen.getByRole("button", { name: /enhance prompt/i })).toBeInTheDocument()
+			expect(getEnhancePromptButton()).toBeInTheDocument()
 		})
 		})
 	})
 	})
 
 

+ 6 - 3
webview-ui/src/components/ui/select-dropdown.tsx

@@ -82,9 +82,12 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
 					disabled={disabled}
 					disabled={disabled}
 					title={title}
 					title={title}
 					className={cn(
 					className={cn(
-						"w-full min-w-0 max-w-full inline-flex items-center gap-1 relative whitespace-nowrap pr-1.5 py-1.5 text-xs outline-none",
-						"bg-transparent border-none text-vscode-foreground w-auto",
-						disabled ? "opacity-50 cursor-not-allowed" : "opacity-80 hover:opacity-100 cursor-pointer",
+						"w-full min-w-0 max-w-full inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
+						"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground w-auto",
+						"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
+						disabled
+							? "opacity-50 cursor-not-allowed"
+							: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
 						triggerClassName,
 						triggerClassName,
 					)}>
 					)}>
 					<CaretUpIcon className="pointer-events-none opacity-80 flex-shrink-0 size-3" />
 					<CaretUpIcon className="pointer-events-none opacity-80 flex-shrink-0 size-3" />