diff --git a/.changeset/bright-trains-crash.md b/.changeset/bright-trains-crash.md new file mode 100644 index 0000000000..dd0b9d07c3 --- /dev/null +++ b/.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 diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index b0b6362fce..a6cacfe7cd 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -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( return () => window.removeEventListener("message", messageHandler) }, [setInputValue, searchRequestId]) - const [thumbnailsHeight, setThumbnailsHeight] = useState(0) + const [isDraggingOver, setIsDraggingOver] = useState(false) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) const [showContextMenu, setShowContextMenu] = useState(false) const [cursorPosition, setCursorPosition] = useState(0) @@ -547,14 +549,6 @@ const ChatTextArea = forwardRef( [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( [updateCursorPosition], ) - const [isTtsPlaying, setIsTtsPlaying] = useState(false) + const handleDrop = useCallback( + async (e: React.DragEvent) => { + 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 ( -
{ - 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( console.warn(t("chat:noValidImages")) } } - }} - onDragOver={(e) => { - e.preventDefault() - }}> - {showContextMenu && ( -
- -
- )} + } + }, + [ + cursorPosition, + cwd, + inputValue, + setInputValue, + setCursorPosition, + setIntendedCursorPosition, + textAreaDisabled, + shouldDisableImages, + setSelectedImages, + t, + ], + ) -
+ 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 ( +
+
0 ? `${thumbnailsHeight + 16}px` : 0, - zIndex: 1, - }} - /> - { - 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 && ( - - )} + }}> + {showContextMenu && ( +
+ +
+ )} +
+
+ { + 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 && ( + + )} + {!inputValue && ( +
+ {placeholderBottomText} +
+ )} +
+
{selectedImages.length > 0 && ( )} -
- {/* Left side - dropdowns container */} -
+
+
{/* Mode selector - fixed width */} -
+
(
{/* API configuration selector - flexible width */} -
+
(
{/* Right side - action buttons */} -
-
- {isEnhancingPrompt ? ( - - ) : ( - !textAreaDisabled && handleEnhancePrompt()} - style={{ fontSize: 16.5 }} - /> - )} -
- !shouldDisableImages && onSelectImages()} - style={{ fontSize: 16.5 }} +
+ - + !textAreaDisabled && onSend()} - style={{ fontSize: 15 }} + disabled={textAreaDisabled} + onClick={onSend} />
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 554e8ac0e3..2157738ea2 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/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( (index: number, messageOrGroup: ClineMessage | ClineMessage[]) => { diff --git a/webview-ui/src/components/chat/IconButton.tsx b/webview-ui/src/components/chat/IconButton.tsx new file mode 100644 index 0000000000..80f59ee74b --- /dev/null +++ b/webview-ui/src/components/chat/IconButton.tsx @@ -0,0 +1,48 @@ +import { cn } from "@/lib/utils" + +interface IconButtonProps extends React.ButtonHTMLAttributes { + iconClass: string + title: string + disabled?: boolean + isLoading?: boolean + style?: React.CSSProperties +} + +export const IconButton: React.FC = ({ + 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 ( + + ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx index e7abb1f65e..9baf2a7c3c 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx @@ -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() - 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() - 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() - 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() - 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() // Verify the enhance button appears after apiConfiguration changes - expect(screen.getByRole("button", { name: /enhance prompt/i })).toBeInTheDocument() + expect(getEnhancePromptButton()).toBeInTheDocument() }) }) diff --git a/webview-ui/src/components/ui/select-dropdown.tsx b/webview-ui/src/components/ui/select-dropdown.tsx index 892d30255f..5360eba9d9 100644 --- a/webview-ui/src/components/ui/select-dropdown.tsx +++ b/webview-ui/src/components/ui/select-dropdown.tsx @@ -82,9 +82,12 @@ export const SelectDropdown = React.forwardRef