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>
This commit is contained in:
Chad Gauthier 2025-03-24 16:46:34 -05:00 committed by GitHub
parent d91467fbd6
commit 418694befa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 368 additions and 265 deletions

View file

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

View file

@ -25,6 +25,8 @@ import Thumbnails from "../common/Thumbnails"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import { VolumeX } from "lucide-react"
import { IconButton } from "./IconButton"
import { cn } from "@/lib/utils"
interface ChatTextAreaProps {
inputValue: string
@ -113,7 +115,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return () => window.removeEventListener("message", messageHandler)
}, [setInputValue, searchRequestId])
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
const [showContextMenu, setShowContextMenu] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
@ -547,14 +549,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
)
const handleThumbnailsHeightChange = useCallback((height: number) => setThumbnailsHeight(height), [])
useEffect(() => {
if (selectedImages.length === 0) {
setThumbnailsHeight(0)
}
}, [selectedImages])
const handleMenuMouseDown = useCallback(() => {
setIsMouseDownOnMenu(true)
}, [])
@ -592,75 +586,51 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[updateCursorPosition],
)
const [isTtsPlaying, setIsTtsPlaying] = useState(false)
const handleDrop = useCallback(
async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDraggingOver(false)
useEvent("message", (event: MessageEvent) => {
const message: ExtensionMessage = event.data
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 (message.type === "ttsStart") {
setIsTtsPlaying(true)
} else if (message.type === "ttsStop") {
setIsTtsPlaying(false)
}
})
if (lines.length > 0) {
// Process each line as a separate file path
let newValue = inputValue.slice(0, cursorPosition)
let totalLength = 0
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")
// 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
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)
// 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 imageFiles = files.filter((file) => {
const [type, subtype] = file.type.split("/")
@ -699,159 +669,256 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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
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
e.preventDefault()
setIsDraggingOver(true)
e.dataTransfer.dropEffect = "copy"
}}
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)
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>
{selectedImages.length > 0 && (
<Thumbnails
images={selectedImages}
setImages={setSelectedImages}
onHeightChange={handleThumbnailsHeightChange}
style={{
position: "absolute",
bottom: "36px",
left: "16px",
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 */}
<div style={{ flexShrink: 0 }}>
<div className="shrink-0">
<SelectDropdown
value={mode}
disabled={textAreaDisabled}
title={t("chat:selectMode")}
options={[
{
@ -886,12 +953,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
{/* 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
value={currentApiConfigName || ""}
disabled={textAreaDisabled}
@ -921,51 +983,25 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
{/* 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`}
title={t("chat:addImages")}
onClick={() => !shouldDisableImages && onSelectImages()}
style={{ fontSize: 16.5 }}
<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}
/>
<span
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
<IconButton
iconClass="codicon-device-camera"
title={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={onSelectImages}
/>
<IconButton
iconClass="codicon-send"
title={t("chat:sendMessage")}
onClick={() => !textAreaDisabled && onSend()}
style={{ fontSize: 15 }}
disabled={textAreaDisabled}
onClick={onSend}
/>
</div>
</div>

View file

@ -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(
(index: number, messageOrGroup: ClineMessage | ClineMessage[]) => {

View file

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

View file

@ -31,6 +31,16 @@ const mockConvertToMentionPath = pathMentions.convertToMentionPath as jest.Mock
// Mock 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", () => {
const defaultProps = {
inputValue: "",
@ -66,10 +76,9 @@ describe("ChatTextArea", () => {
filePaths: [],
openedTabs: [],
})
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" />)
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
const enhanceButton = getEnhancePromptButton()
fireEvent.click(enhanceButton)
expect(mockPostMessage).toHaveBeenCalledWith({
@ -108,7 +117,7 @@ describe("ChatTextArea", () => {
render(<ChatTextArea {...defaultProps} inputValue="" />)
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
const enhanceButton = getEnhancePromptButton()
fireEvent.click(enhanceButton)
expect(mockPostMessage).not.toHaveBeenCalled()
@ -125,7 +134,7 @@ describe("ChatTextArea", () => {
render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
const enhanceButton = getEnhancePromptButton()
fireEvent.click(enhanceButton)
const loadingSpinner = screen.getByText("", { selector: ".codicon-loading" })
@ -150,7 +159,7 @@ describe("ChatTextArea", () => {
rerender(<ChatTextArea {...defaultProps} />)
// Verify the enhance button appears after apiConfiguration changes
expect(screen.getByRole("button", { name: /enhance prompt/i })).toBeInTheDocument()
expect(getEnhancePromptButton()).toBeInTheDocument()
})
})

View file

@ -82,9 +82,12 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
disabled={disabled}
title={title}
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,
)}>
<CaretUpIcon className="pointer-events-none opacity-80 flex-shrink-0 size-3" />