From 0fba4bb929e748bdf30f89e3301d0c22c76d80bb Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Thu, 8 May 2025 16:29:10 +0200 Subject: [PATCH 1/3] Refactor ChatTextArea and PromptsView to avoid trimming input values - In ChatTextArea, modified input handling to prevent trimming of values when setting state, ensuring that user input is preserved. - Updated PromptsView to avoid trimming role definitions and custom instructions when updating modes, allowing for more flexible input handling. - Adjusted AutosizeTextarea to ensure that the trigger for resizing is set without trimming the value, improving user experience when interacting with the text area. --- .../src/components/chat/ChatTextArea.tsx | 1151 ++++++++++++++++- .../src/components/prompts/PromptsView.tsx | 13 +- .../src/components/ui/autosize-textarea.tsx | 6 +- 3 files changed, 1140 insertions(+), 30 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 43722a5d6d..3aa1460e88 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, us import { useEvent } from "react-use" import DynamicTextArea from "react-textarea-autosize" -import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/shared/context-mentions" +import { mentionRegex, mentionRegexGlobal } from "@roo/shared/context-mentions" import { WebviewMessage } from "@roo/shared/WebviewMessage" import { Mode, getAllModes } from "@roo/shared/modes" import { ExtensionMessage } from "@roo/shared/ExtensionMessage" @@ -432,7 +432,7 @@ const ChatTextArea = forwardRef( (e: React.ChangeEvent) => { const newValue = e.target.value const newCursorPosition = e.target.selectionStart - setInputValue(newValue) + setInputValue(newValue) // Don't trim the value here setCursorPosition(newCursorPosition) const showMenu = shouldShowContextMenu(newValue, newCursorPosition) @@ -451,37 +451,27 @@ const ChatTextArea = forwardRef( // Send file search request if query is not empty if (query.length > 0) { - setSelectedMenuIndex(0) - // Don't clear results until we have new ones - // This prevents flickering - // Clear any existing timeout if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current) } - // Set a timeout to debounce the search requests - searchTimeoutRef.current = setTimeout(() => { - // Generate a request ID for this search - const reqId = Math.random().toString(36).substring(2, 9) - setSearchRequestId(reqId) - setSearchLoading(true) + const requestId = Math.random().toString() + setSearchRequestId(requestId) + setSearchLoading(true) - // Send message to extension to search files + // Set new timeout + searchTimeoutRef.current = setTimeout(() => { vscode.postMessage({ type: "searchFiles", - query: unescapeSpaces(query), - requestId: reqId, + query, + requestId, }) - }, 200) // 200ms debounce + }, 150) // Debounce time in ms } else { - setSelectedMenuIndex(3) // Set to "File" option by default + setFileSearchResults([]) } } - } else { - setSearchQuery("") - setSelectedMenuIndex(-1) - setFileSearchResults([]) // Clear file search results } }, [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading], @@ -496,7 +486,1124 @@ const ChatTextArea = forwardRef( const handleBlur = useCallback(() => { // Only hide the context menu if the user didn't click on it. if (!isMouseDownOnMenu) { - setShowContextMenu(false) + setShowContextMenu(false)import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" + import { useEvent } from "react-use" + import DynamicTextArea from "react-textarea-autosize" + + import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/shared/context-mentions" + import { WebviewMessage } from "@roo/shared/WebviewMessage" + import { Mode, getAllModes } from "@roo/shared/modes" + import { ExtensionMessage } from "@roo/shared/ExtensionMessage" + + import { vscode } from "@/utils/vscode" + import { useExtensionState } from "@/context/ExtensionStateContext" + import { useAppTranslation } from "@/i18n/TranslationContext" + import { + ContextMenuOptionType, + getContextMenuOptions, + insertMention, + removeMention, + shouldShowContextMenu, + SearchResult, + } from "@src/utils/context-mentions" + import { convertToMentionPath } from "@/utils/path-mentions" + import { SelectDropdown, DropdownOptionType, Button } from "@/components/ui" + + import Thumbnails from "../common/Thumbnails" + import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" + import ContextMenu from "./ContextMenu" + import { VolumeX, Pin, Check } from "lucide-react" + import { IconButton } from "./IconButton" + import { cn } from "@/lib/utils" + + interface ChatTextAreaProps { + inputValue: string + setInputValue: (value: string) => void + textAreaDisabled: boolean + selectApiConfigDisabled: boolean + placeholderText: string + selectedImages: string[] + setSelectedImages: React.Dispatch> + onSend: () => void + onSelectImages: () => void + shouldDisableImages: boolean + onHeightChange?: (height: number) => void + mode: Mode + setMode: (value: Mode) => void + modeShortcutText: string + } + + const ChatTextArea = forwardRef( + ( + { + inputValue, + setInputValue, + textAreaDisabled, + selectApiConfigDisabled, + placeholderText, + selectedImages, + setSelectedImages, + onSend, + onSelectImages, + shouldDisableImages, + onHeightChange, + mode, + setMode, + modeShortcutText, + }, + ref, + ) => { + const { t } = useAppTranslation() + const { + filePaths, + openedTabs, + currentApiConfigName, + listApiConfigMeta, + customModes, + cwd, + pinnedApiConfigs, + togglePinnedApiConfig, + } = useExtensionState() + + // Find the ID and display text for the currently selected API configuration + const { currentConfigId, displayName } = useMemo(() => { + const currentConfig = listApiConfigMeta?.find((config) => config.name === currentApiConfigName) + return { + currentConfigId: currentConfig?.id || "", + displayName: currentApiConfigName || "", // Use the name directly for display + } + }, [listApiConfigMeta, currentApiConfigName]) + + const [gitCommits, setGitCommits] = useState([]) + const [showDropdown, setShowDropdown] = useState(false) + const [fileSearchResults, setFileSearchResults] = useState([]) + const [searchLoading, setSearchLoading] = useState(false) + const [searchRequestId, setSearchRequestId] = useState("") + + // Close dropdown when clicking outside. + useEffect(() => { + const handleClickOutside = () => { + if (showDropdown) { + setShowDropdown(false) + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [showDropdown]) + + // Handle enhanced prompt response and search results. + useEffect(() => { + const messageHandler = (event: MessageEvent) => { + const message = event.data + + if (message.type === "enhancedPrompt") { + if (message.text) { + setInputValue(message.text) + } + + setIsEnhancingPrompt(false) + } else if (message.type === "commitSearchResults") { + const commits = message.commits.map((commit: any) => ({ + type: ContextMenuOptionType.Git, + value: commit.hash, + label: commit.subject, + description: `${commit.shortHash} by ${commit.author} on ${commit.date}`, + icon: "$(git-commit)", + })) + + setGitCommits(commits) + } else if (message.type === "fileSearchResults") { + setSearchLoading(false) + if (message.requestId === searchRequestId) { + setFileSearchResults(message.results || []) + } + } + } + + window.addEventListener("message", messageHandler) + return () => window.removeEventListener("message", messageHandler) + }, [setInputValue, searchRequestId]) + + const [isDraggingOver, setIsDraggingOver] = useState(false) + const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) + const [showContextMenu, setShowContextMenu] = useState(false) + const [cursorPosition, setCursorPosition] = useState(0) + const [searchQuery, setSearchQuery] = useState("") + const textAreaRef = useRef(null) + const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false) + const highlightLayerRef = useRef(null) + const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1) + const [selectedType, setSelectedType] = useState(null) + const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false) + const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) + const contextMenuContainerRef = useRef(null) + const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) + const [isFocused, setIsFocused] = useState(false) + + // Fetch git commits when Git is selected or when typing a hash. + useEffect(() => { + if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) { + const message: WebviewMessage = { + type: "searchCommits", + query: searchQuery || "", + } as const + vscode.postMessage(message) + } + }, [selectedType, searchQuery]) + + const handleEnhancePrompt = useCallback(() => { + if (!textAreaDisabled) { + const trimmedInput = inputValue.trim() + if (trimmedInput) { + setIsEnhancingPrompt(true) + const message = { + type: "enhancePrompt" as const, + text: trimmedInput, + } + vscode.postMessage(message) + } else { + const promptDescription = t("chat:enhancePromptDescription") + setInputValue(promptDescription) + } + } + }, [inputValue, textAreaDisabled, setInputValue, t]) + + const queryItems = useMemo(() => { + return [ + { type: ContextMenuOptionType.Problems, value: "problems" }, + { type: ContextMenuOptionType.Terminal, value: "terminal" }, + ...gitCommits, + ...openedTabs + .filter((tab) => tab.path) + .map((tab) => ({ + type: ContextMenuOptionType.OpenedFile, + value: "/" + tab.path, + })), + ...filePaths + .map((file) => "/" + file) + .filter((path) => !openedTabs.some((tab) => tab.path && "/" + tab.path === path)) // Filter out paths that are already in openedTabs + .map((path) => ({ + type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File, + value: path, + })), + ] + }, [filePaths, gitCommits, openedTabs]) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + contextMenuContainerRef.current && + !contextMenuContainerRef.current.contains(event.target as Node) + ) { + setShowContextMenu(false) + } + } + + if (showContextMenu) { + document.addEventListener("mousedown", handleClickOutside) + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside) + } + }, [showContextMenu, setShowContextMenu]) + + const handleMentionSelect = useCallback( + (type: ContextMenuOptionType, value?: string) => { + if (type === ContextMenuOptionType.NoResults) { + return + } + + if (type === ContextMenuOptionType.Mode && value) { + // Handle mode selection. + setMode(value) + setInputValue("") + setShowContextMenu(false) + vscode.postMessage({ type: "mode", text: value }) + return + } + + if ( + type === ContextMenuOptionType.File || + type === ContextMenuOptionType.Folder || + type === ContextMenuOptionType.Git + ) { + if (!value) { + setSelectedType(type) + setSearchQuery("") + setSelectedMenuIndex(0) + return + } + } + + setShowContextMenu(false) + setSelectedType(null) + + if (textAreaRef.current) { + let insertValue = value || "" + + if (type === ContextMenuOptionType.URL) { + insertValue = value || "" + } else if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) { + insertValue = value || "" + } else if (type === ContextMenuOptionType.Problems) { + insertValue = "problems" + } else if (type === ContextMenuOptionType.Terminal) { + insertValue = "terminal" + } else if (type === ContextMenuOptionType.Git) { + insertValue = value || "" + } + + const { newValue, mentionIndex } = insertMention( + textAreaRef.current.value, + cursorPosition, + insertValue, + ) + + setInputValue(newValue) + const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1 + setCursorPosition(newCursorPosition) + setIntendedCursorPosition(newCursorPosition) + + // Scroll to cursor. + setTimeout(() => { + if (textAreaRef.current) { + textAreaRef.current.blur() + textAreaRef.current.focus() + } + }, 0) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [setInputValue, cursorPosition], + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (showContextMenu) { + if (event.key === "Escape") { + setSelectedType(null) + setSelectedMenuIndex(3) // File by default + return + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault() + setSelectedMenuIndex((prevIndex) => { + const direction = event.key === "ArrowUp" ? -1 : 1 + const options = getContextMenuOptions( + searchQuery, + inputValue, + selectedType, + queryItems, + fileSearchResults, + getAllModes(customModes), + ) + const optionsLength = options.length + + if (optionsLength === 0) return prevIndex + + // Find selectable options (non-URL types) + const selectableOptions = options.filter( + (option) => + option.type !== ContextMenuOptionType.URL && + option.type !== ContextMenuOptionType.NoResults, + ) + + if (selectableOptions.length === 0) return -1 // No selectable options + + // Find the index of the next selectable option + const currentSelectableIndex = selectableOptions.findIndex( + (option) => option === options[prevIndex], + ) + + const newSelectableIndex = + (currentSelectableIndex + direction + selectableOptions.length) % + selectableOptions.length + + // Find the index of the selected option in the original options array + return options.findIndex((option) => option === selectableOptions[newSelectableIndex]) + }) + return + } + if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) { + event.preventDefault() + const selectedOption = getContextMenuOptions( + searchQuery, + inputValue, + selectedType, + queryItems, + fileSearchResults, + getAllModes(customModes), + )[selectedMenuIndex] + if ( + selectedOption && + selectedOption.type !== ContextMenuOptionType.URL && + selectedOption.type !== ContextMenuOptionType.NoResults + ) { + handleMentionSelect(selectedOption.type, selectedOption.value) + } + return + } + } + + const isComposing = event.nativeEvent?.isComposing ?? false + if (event.key === "Enter" && !event.shiftKey && !isComposing) { + event.preventDefault() + onSend() + } + + if (event.key === "Backspace" && !isComposing) { + const charBeforeCursor = inputValue[cursorPosition - 1] + const charAfterCursor = inputValue[cursorPosition + 1] + + const charBeforeIsWhitespace = + charBeforeCursor === " " || charBeforeCursor === "\n" || charBeforeCursor === "\r\n" + const charAfterIsWhitespace = + charAfterCursor === " " || charAfterCursor === "\n" || charAfterCursor === "\r\n" + // checks if char before cusor is whitespace after a mention + if ( + charBeforeIsWhitespace && + inputValue.slice(0, cursorPosition - 1).match(new RegExp(mentionRegex.source + "$")) // "$" is added to ensure the match occurs at the end of the string + ) { + const newCursorPosition = cursorPosition - 1 + // if mention is followed by another word, then instead of deleting the space separating them we just move the cursor to the end of the mention + if (!charAfterIsWhitespace) { + event.preventDefault() + textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition) + setCursorPosition(newCursorPosition) + } + setCursorPosition(newCursorPosition) + setJustDeletedSpaceAfterMention(true) + } else if (justDeletedSpaceAfterMention) { + const { newText, newPosition } = removeMention(inputValue, cursorPosition) + if (newText !== inputValue) { + event.preventDefault() + setInputValue(newText) + setIntendedCursorPosition(newPosition) // Store the new cursor position in state + } + setJustDeletedSpaceAfterMention(false) + setShowContextMenu(false) + } else { + setJustDeletedSpaceAfterMention(false) + } + } + }, + [ + onSend, + showContextMenu, + searchQuery, + selectedMenuIndex, + handleMentionSelect, + selectedType, + inputValue, + cursorPosition, + setInputValue, + justDeletedSpaceAfterMention, + queryItems, + customModes, + fileSearchResults, + ], + ) + + useLayoutEffect(() => { + if (intendedCursorPosition !== null && textAreaRef.current) { + textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition) + setIntendedCursorPosition(null) // Reset the state. + } + }, [inputValue, intendedCursorPosition]) + // Ref to store the search timeout + const searchTimeoutRef = useRef(null) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value + const newCursorPosition = e.target.selectionStart + setInputValue(newValue) + setCursorPosition(newCursorPosition) + const showMenu = shouldShowContextMenu(newValue, newCursorPosition) + + setShowContextMenu(showMenu) + if (showMenu) { + if (newValue.startsWith("/")) { + // Handle slash command + const query = newValue + setSearchQuery(query) + setSelectedMenuIndex(0) + } else { + // Existing @ mention handling + const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1) + const query = newValue.slice(lastAtIndex + 1, newCursorPosition) + setSearchQuery(query) + + // Send file search request if query is not empty + if (query.length > 0) { + setSelectedMenuIndex(0) + // Don't clear results until we have new ones + // This prevents flickering + + // Clear any existing timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + + // Set a timeout to debounce the search requests + searchTimeoutRef.current = setTimeout(() => { + // Generate a request ID for this search + const reqId = Math.random().toString(36).substring(2, 9) + setSearchRequestId(reqId) + setSearchLoading(true) + + // Send message to extension to search files + vscode.postMessage({ + type: "searchFiles", + query: unescapeSpaces(query), + requestId: reqId, + }) + }, 200) // 200ms debounce + } else { + setSelectedMenuIndex(3) // Set to "File" option by default + } + } + } else { + setSearchQuery("") + setSelectedMenuIndex(-1) + setFileSearchResults([]) // Clear file search results + } + }, + [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading], + ) + + useEffect(() => { + if (!showContextMenu) { + setSelectedType(null) + } + }, [showContextMenu]) + + const handleBlur = useCallback(() => { + // Only hide the context menu if the user didn't click on it. + if (!isMouseDownOnMenu) { + setShowContextMenu(false) + } + + setIsFocused(false) + }, [isMouseDownOnMenu]) + + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const items = e.clipboardData.items + + const pastedText = e.clipboardData.getData("text") + // Check if the pasted content is a URL, add space after so user + // can easily delete if they don't want it. + const urlRegex = /^\S+:\/\/\S+$/ + if (urlRegex.test(pastedText.trim())) { + e.preventDefault() + const trimmedUrl = pastedText.trim() + const newValue = + inputValue.slice(0, cursorPosition) + trimmedUrl + " " + inputValue.slice(cursorPosition) + setInputValue(newValue) + const newCursorPosition = cursorPosition + trimmedUrl.length + 1 + setCursorPosition(newCursorPosition) + setIntendedCursorPosition(newCursorPosition) + setShowContextMenu(false) + + // Scroll to new cursor position. + setTimeout(() => { + if (textAreaRef.current) { + textAreaRef.current.blur() + textAreaRef.current.focus() + } + }, 0) + + return + } + + const acceptedTypes = ["png", "jpeg", "webp"] + + const imageItems = Array.from(items).filter((item) => { + const [type, subtype] = item.type.split("/") + return type === "image" && acceptedTypes.includes(subtype) + }) + + if (!shouldDisableImages && imageItems.length > 0) { + e.preventDefault() + const imagePromises = imageItems.map((item) => { + return new Promise((resolve) => { + const blob = item.getAsFile() + if (!blob) { + resolve(null) + return + } + const reader = new FileReader() + reader.onloadend = () => { + if (reader.error) { + console.error(t("chat:errorReadingFile"), reader.error) + resolve(null) + } else { + const result = reader.result + resolve(typeof result === "string" ? result : null) + } + } + reader.readAsDataURL(blob) + }) + }) + const imageDataArray = await Promise.all(imagePromises) + const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) + if (dataUrls.length > 0) { + setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) + } else { + console.warn(t("chat:noValidImages")) + } + } + }, + [shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t], + ) + + const handleMenuMouseDown = useCallback(() => { + setIsMouseDownOnMenu(true) + }, []) + + const updateHighlights = useCallback(() => { + if (!textAreaRef.current || !highlightLayerRef.current) return + + const text = textAreaRef.current.value + + highlightLayerRef.current.innerHTML = text + .replace(/\n$/, "\n\n") + .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] || c) + .replace(mentionRegexGlobal, '$&') + + highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop + highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft + }, []) + + useLayoutEffect(() => { + updateHighlights() + }, [inputValue, updateHighlights]) + + const updateCursorPosition = useCallback(() => { + if (textAreaRef.current) { + setCursorPosition(textAreaRef.current.selectionStart) + } + }, []) + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => { + if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { + updateCursorPosition() + } + }, + [updateCursorPosition], + ) + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + setIsDraggingOver(false) + + const textFieldList = e.dataTransfer.getData("text") + const textUriList = e.dataTransfer.getData("application/vnd.code.uri-list") + // When textFieldList is empty, it may attempt to use textUriList obtained from drag-and-drop tabs; if not empty, it will use textFieldList. + const text = textFieldList || textUriList + 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 + } + } + + // 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("/") + return type === "image" && acceptedTypes.includes(subtype) + }) + + if (!shouldDisableImages && imageFiles.length > 0) { + const imagePromises = imageFiles.map((file) => { + return new Promise((resolve) => { + const reader = new FileReader() + reader.onloadend = () => { + if (reader.error) { + console.error(t("chat:errorReadingFile"), reader.error) + resolve(null) + } else { + const result = reader.result + resolve(typeof result === "string" ? result : null) + } + } + reader.readAsDataURL(file) + }) + }) + const imageDataArray = await Promise.all(imagePromises) + const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) + if (dataUrls.length > 0) { + setSelectedImages((prevImages) => + [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE), + ) + if (typeof vscode !== "undefined") { + vscode.postMessage({ + type: "draggedImages", + dataUrls: dataUrls, + }) + } + } else { + console.warn(t("chat:noValidImages")) + } + } + } + }, + [ + 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 ( +
+
+
{ + //Only allowed to drop images/files on shift key pressed + if (!e.shiftKey) { + setIsDraggingOver(false) + return + } + e.preventDefault() + setIsDraggingOver(true) + e.dataTransfer.dropEffect = "copy" + }} + 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) + } + }}> + {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", + "min-h-[90px]", + "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 && ( + + )} + +
+
+ {/* Mode selector - fixed width */} +
+ ({ + value: mode.slug, + label: mode.name, + type: DropdownOptionType.ITEM, + })), + { + value: "sep-1", + label: t("chat:separator"), + type: DropdownOptionType.SEPARATOR, + }, + { + value: "promptsButtonClicked", + label: t("chat:edit"), + type: DropdownOptionType.ACTION, + }, + ]} + onChange={(value) => { + setMode(value as Mode) + vscode.postMessage({ type: "mode", text: value }) + }} + shortcutText={modeShortcutText} + triggerClassName="w-full" + /> +
+ + {/* API configuration selector - flexible width */} +
+ pinnedApiConfigs && pinnedApiConfigs[config.id]) + .map((config) => ({ + value: config.id, + label: config.name, + name: config.name, // Keep name for comparison with currentApiConfigName + type: DropdownOptionType.ITEM, + pinned: true, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + // If we have pinned items and unpinned items, add a separator + ...(pinnedApiConfigs && + Object.keys(pinnedApiConfigs).length > 0 && + (listApiConfigMeta || []).some((config) => !pinnedApiConfigs[config.id]) + ? [ + { + value: "sep-pinned", + label: t("chat:separator"), + type: DropdownOptionType.SEPARATOR, + }, + ] + : []), + // Unpinned items sorted alphabetically + ...(listApiConfigMeta || []) + .filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id]) + .map((config) => ({ + value: config.id, + label: config.name, + name: config.name, // Keep name for comparison with currentApiConfigName + type: DropdownOptionType.ITEM, + pinned: false, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + { + value: "sep-2", + label: t("chat:separator"), + type: DropdownOptionType.SEPARATOR, + }, + { + value: "settingsButtonClicked", + label: t("chat:edit"), + type: DropdownOptionType.ACTION, + }, + ]} + onChange={(value) => { + if (value === "settingsButtonClicked") { + vscode.postMessage({ + type: "loadApiConfiguration", + text: value, + values: { section: "providers" }, + }) + } else { + vscode.postMessage({ type: "loadApiConfigurationById", text: value }) + } + }} + triggerClassName="w-full text-ellipsis overflow-hidden" + itemClassName="group" + renderItem={({ type, value, label, pinned }) => { + if (type !== DropdownOptionType.ITEM) { + return label + } + + const config = listApiConfigMeta?.find((c) => c.id === value) + const isCurrentConfig = config?.name === currentApiConfigName + + return ( +
+
{label}
+
+
+ +
+ +
+
+ ) + }} + /> +
+
+ + {/* Right side - action buttons */} +
+ + + +
+
+
+ ) + }, + ) + + export default ChatTextArea + } setIsFocused(false) diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index 8889591ddd..4ad9fd3cae 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -661,13 +661,13 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { // For custom modes, update the JSON file updateCustomMode(visualMode, { ...customMode, - roleDefinition: value.trim() || "", + roleDefinition: value || "", source: customMode.source || "global", }) } else { // For built-in modes, update the prompts updateAgentPrompt(visualMode, { - roleDefinition: value.trim() || undefined, + roleDefinition: value || undefined, }) } }} @@ -843,7 +843,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { // For custom modes, update the JSON file updateCustomMode(visualMode, { ...customMode, - customInstructions: value.trim() || undefined, + customInstructions: value || undefined, source: customMode.source || "global", }) } else { @@ -851,7 +851,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { const existingPrompt = customModePrompts?.[visualMode] as PromptComponent updateAgentPrompt(visualMode, { ...existingPrompt, - customInstructions: value.trim(), + customInstructions: value, }) } }} @@ -987,7 +987,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { setCustomInstructions(value || undefined) vscode.postMessage({ type: "customInstructions", - text: value.trim() || undefined, + text: value || undefined, }) }} rows={4} @@ -1062,8 +1062,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { const value = (e as unknown as CustomEvent)?.detail?.target?.value || ((e as any).target as HTMLTextAreaElement).value - const trimmedValue = value.trim() - updateSupportPrompt(activeSupportOption, trimmedValue || undefined) + updateSupportPrompt(activeSupportOption, value || undefined) }} rows={6} className="resize-y w-full" diff --git a/webview-ui/src/components/ui/autosize-textarea.tsx b/webview-ui/src/components/ui/autosize-textarea.tsx index d23f7a4360..b74c63b3ad 100644 --- a/webview-ui/src/components/ui/autosize-textarea.tsx +++ b/webview-ui/src/components/ui/autosize-textarea.tsx @@ -82,7 +82,10 @@ export const AutosizeTextarea = React.forwardRef { - setTriggerAutoSize(value as string) + // Don't trim the value when setting the trigger + if (typeof value === "string") { + setTriggerAutoSize(value) + } }, [props?.defaultValue, value]) return ( @@ -98,6 +101,7 @@ export const AutosizeTextarea = React.forwardRef { + // Ensure we're not trimming the value setTriggerAutoSize(e.target.value) onChange?.(e) }} From 52fe5dda7b6dfe47225129fae01c5cc6ce6030af Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Thu, 8 May 2025 16:45:41 +0200 Subject: [PATCH 2/3] Fix syntax error in ChatTextArea.tsx --- package-lock.json | 1 - .../src/components/chat/ChatTextArea.tsx | 1151 +---------------- 2 files changed, 22 insertions(+), 1130 deletions(-) diff --git a/package-lock.json b/package-lock.json index e72d661d76..54896fcc06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16423,7 +16423,6 @@ "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.1.tgz", "integrity": "sha512-jkhE0AsELQeCtScrcJ/7mSIdk+ZsnWjvKk3KwE96HZ6+OFVB74XhxQtHT1W6kdUfn92fRnBb29Mz82j9bV2XEQ==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "cross-spawn": "^7.0.6", diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 3aa1460e88..43722a5d6d 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, us import { useEvent } from "react-use" import DynamicTextArea from "react-textarea-autosize" -import { mentionRegex, mentionRegexGlobal } from "@roo/shared/context-mentions" +import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/shared/context-mentions" import { WebviewMessage } from "@roo/shared/WebviewMessage" import { Mode, getAllModes } from "@roo/shared/modes" import { ExtensionMessage } from "@roo/shared/ExtensionMessage" @@ -432,7 +432,7 @@ const ChatTextArea = forwardRef( (e: React.ChangeEvent) => { const newValue = e.target.value const newCursorPosition = e.target.selectionStart - setInputValue(newValue) // Don't trim the value here + setInputValue(newValue) setCursorPosition(newCursorPosition) const showMenu = shouldShowContextMenu(newValue, newCursorPosition) @@ -451,27 +451,37 @@ const ChatTextArea = forwardRef( // Send file search request if query is not empty if (query.length > 0) { + setSelectedMenuIndex(0) + // Don't clear results until we have new ones + // This prevents flickering + // Clear any existing timeout if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current) } - const requestId = Math.random().toString() - setSearchRequestId(requestId) - setSearchLoading(true) - - // Set new timeout + // Set a timeout to debounce the search requests searchTimeoutRef.current = setTimeout(() => { + // Generate a request ID for this search + const reqId = Math.random().toString(36).substring(2, 9) + setSearchRequestId(reqId) + setSearchLoading(true) + + // Send message to extension to search files vscode.postMessage({ type: "searchFiles", - query, - requestId, + query: unescapeSpaces(query), + requestId: reqId, }) - }, 150) // Debounce time in ms + }, 200) // 200ms debounce } else { - setFileSearchResults([]) + setSelectedMenuIndex(3) // Set to "File" option by default } } + } else { + setSearchQuery("") + setSelectedMenuIndex(-1) + setFileSearchResults([]) // Clear file search results } }, [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading], @@ -486,1124 +496,7 @@ const ChatTextArea = forwardRef( const handleBlur = useCallback(() => { // Only hide the context menu if the user didn't click on it. if (!isMouseDownOnMenu) { - setShowContextMenu(false)import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" - import { useEvent } from "react-use" - import DynamicTextArea from "react-textarea-autosize" - - import { mentionRegex, mentionRegexGlobal, unescapeSpaces } from "@roo/shared/context-mentions" - import { WebviewMessage } from "@roo/shared/WebviewMessage" - import { Mode, getAllModes } from "@roo/shared/modes" - import { ExtensionMessage } from "@roo/shared/ExtensionMessage" - - import { vscode } from "@/utils/vscode" - import { useExtensionState } from "@/context/ExtensionStateContext" - import { useAppTranslation } from "@/i18n/TranslationContext" - import { - ContextMenuOptionType, - getContextMenuOptions, - insertMention, - removeMention, - shouldShowContextMenu, - SearchResult, - } from "@src/utils/context-mentions" - import { convertToMentionPath } from "@/utils/path-mentions" - import { SelectDropdown, DropdownOptionType, Button } from "@/components/ui" - - import Thumbnails from "../common/Thumbnails" - import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" - import ContextMenu from "./ContextMenu" - import { VolumeX, Pin, Check } from "lucide-react" - import { IconButton } from "./IconButton" - import { cn } from "@/lib/utils" - - interface ChatTextAreaProps { - inputValue: string - setInputValue: (value: string) => void - textAreaDisabled: boolean - selectApiConfigDisabled: boolean - placeholderText: string - selectedImages: string[] - setSelectedImages: React.Dispatch> - onSend: () => void - onSelectImages: () => void - shouldDisableImages: boolean - onHeightChange?: (height: number) => void - mode: Mode - setMode: (value: Mode) => void - modeShortcutText: string - } - - const ChatTextArea = forwardRef( - ( - { - inputValue, - setInputValue, - textAreaDisabled, - selectApiConfigDisabled, - placeholderText, - selectedImages, - setSelectedImages, - onSend, - onSelectImages, - shouldDisableImages, - onHeightChange, - mode, - setMode, - modeShortcutText, - }, - ref, - ) => { - const { t } = useAppTranslation() - const { - filePaths, - openedTabs, - currentApiConfigName, - listApiConfigMeta, - customModes, - cwd, - pinnedApiConfigs, - togglePinnedApiConfig, - } = useExtensionState() - - // Find the ID and display text for the currently selected API configuration - const { currentConfigId, displayName } = useMemo(() => { - const currentConfig = listApiConfigMeta?.find((config) => config.name === currentApiConfigName) - return { - currentConfigId: currentConfig?.id || "", - displayName: currentApiConfigName || "", // Use the name directly for display - } - }, [listApiConfigMeta, currentApiConfigName]) - - const [gitCommits, setGitCommits] = useState([]) - const [showDropdown, setShowDropdown] = useState(false) - const [fileSearchResults, setFileSearchResults] = useState([]) - const [searchLoading, setSearchLoading] = useState(false) - const [searchRequestId, setSearchRequestId] = useState("") - - // Close dropdown when clicking outside. - useEffect(() => { - const handleClickOutside = () => { - if (showDropdown) { - setShowDropdown(false) - } - } - - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) - }, [showDropdown]) - - // Handle enhanced prompt response and search results. - useEffect(() => { - const messageHandler = (event: MessageEvent) => { - const message = event.data - - if (message.type === "enhancedPrompt") { - if (message.text) { - setInputValue(message.text) - } - - setIsEnhancingPrompt(false) - } else if (message.type === "commitSearchResults") { - const commits = message.commits.map((commit: any) => ({ - type: ContextMenuOptionType.Git, - value: commit.hash, - label: commit.subject, - description: `${commit.shortHash} by ${commit.author} on ${commit.date}`, - icon: "$(git-commit)", - })) - - setGitCommits(commits) - } else if (message.type === "fileSearchResults") { - setSearchLoading(false) - if (message.requestId === searchRequestId) { - setFileSearchResults(message.results || []) - } - } - } - - window.addEventListener("message", messageHandler) - return () => window.removeEventListener("message", messageHandler) - }, [setInputValue, searchRequestId]) - - const [isDraggingOver, setIsDraggingOver] = useState(false) - const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) - const [showContextMenu, setShowContextMenu] = useState(false) - const [cursorPosition, setCursorPosition] = useState(0) - const [searchQuery, setSearchQuery] = useState("") - const textAreaRef = useRef(null) - const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false) - const highlightLayerRef = useRef(null) - const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1) - const [selectedType, setSelectedType] = useState(null) - const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false) - const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) - const contextMenuContainerRef = useRef(null) - const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) - const [isFocused, setIsFocused] = useState(false) - - // Fetch git commits when Git is selected or when typing a hash. - useEffect(() => { - if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) { - const message: WebviewMessage = { - type: "searchCommits", - query: searchQuery || "", - } as const - vscode.postMessage(message) - } - }, [selectedType, searchQuery]) - - const handleEnhancePrompt = useCallback(() => { - if (!textAreaDisabled) { - const trimmedInput = inputValue.trim() - if (trimmedInput) { - setIsEnhancingPrompt(true) - const message = { - type: "enhancePrompt" as const, - text: trimmedInput, - } - vscode.postMessage(message) - } else { - const promptDescription = t("chat:enhancePromptDescription") - setInputValue(promptDescription) - } - } - }, [inputValue, textAreaDisabled, setInputValue, t]) - - const queryItems = useMemo(() => { - return [ - { type: ContextMenuOptionType.Problems, value: "problems" }, - { type: ContextMenuOptionType.Terminal, value: "terminal" }, - ...gitCommits, - ...openedTabs - .filter((tab) => tab.path) - .map((tab) => ({ - type: ContextMenuOptionType.OpenedFile, - value: "/" + tab.path, - })), - ...filePaths - .map((file) => "/" + file) - .filter((path) => !openedTabs.some((tab) => tab.path && "/" + tab.path === path)) // Filter out paths that are already in openedTabs - .map((path) => ({ - type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File, - value: path, - })), - ] - }, [filePaths, gitCommits, openedTabs]) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - contextMenuContainerRef.current && - !contextMenuContainerRef.current.contains(event.target as Node) - ) { - setShowContextMenu(false) - } - } - - if (showContextMenu) { - document.addEventListener("mousedown", handleClickOutside) - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside) - } - }, [showContextMenu, setShowContextMenu]) - - const handleMentionSelect = useCallback( - (type: ContextMenuOptionType, value?: string) => { - if (type === ContextMenuOptionType.NoResults) { - return - } - - if (type === ContextMenuOptionType.Mode && value) { - // Handle mode selection. - setMode(value) - setInputValue("") - setShowContextMenu(false) - vscode.postMessage({ type: "mode", text: value }) - return - } - - if ( - type === ContextMenuOptionType.File || - type === ContextMenuOptionType.Folder || - type === ContextMenuOptionType.Git - ) { - if (!value) { - setSelectedType(type) - setSearchQuery("") - setSelectedMenuIndex(0) - return - } - } - - setShowContextMenu(false) - setSelectedType(null) - - if (textAreaRef.current) { - let insertValue = value || "" - - if (type === ContextMenuOptionType.URL) { - insertValue = value || "" - } else if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) { - insertValue = value || "" - } else if (type === ContextMenuOptionType.Problems) { - insertValue = "problems" - } else if (type === ContextMenuOptionType.Terminal) { - insertValue = "terminal" - } else if (type === ContextMenuOptionType.Git) { - insertValue = value || "" - } - - const { newValue, mentionIndex } = insertMention( - textAreaRef.current.value, - cursorPosition, - insertValue, - ) - - setInputValue(newValue) - const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1 - setCursorPosition(newCursorPosition) - setIntendedCursorPosition(newCursorPosition) - - // Scroll to cursor. - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.blur() - textAreaRef.current.focus() - } - }, 0) - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [setInputValue, cursorPosition], - ) - - const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (showContextMenu) { - if (event.key === "Escape") { - setSelectedType(null) - setSelectedMenuIndex(3) // File by default - return - } - - if (event.key === "ArrowUp" || event.key === "ArrowDown") { - event.preventDefault() - setSelectedMenuIndex((prevIndex) => { - const direction = event.key === "ArrowUp" ? -1 : 1 - const options = getContextMenuOptions( - searchQuery, - inputValue, - selectedType, - queryItems, - fileSearchResults, - getAllModes(customModes), - ) - const optionsLength = options.length - - if (optionsLength === 0) return prevIndex - - // Find selectable options (non-URL types) - const selectableOptions = options.filter( - (option) => - option.type !== ContextMenuOptionType.URL && - option.type !== ContextMenuOptionType.NoResults, - ) - - if (selectableOptions.length === 0) return -1 // No selectable options - - // Find the index of the next selectable option - const currentSelectableIndex = selectableOptions.findIndex( - (option) => option === options[prevIndex], - ) - - const newSelectableIndex = - (currentSelectableIndex + direction + selectableOptions.length) % - selectableOptions.length - - // Find the index of the selected option in the original options array - return options.findIndex((option) => option === selectableOptions[newSelectableIndex]) - }) - return - } - if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) { - event.preventDefault() - const selectedOption = getContextMenuOptions( - searchQuery, - inputValue, - selectedType, - queryItems, - fileSearchResults, - getAllModes(customModes), - )[selectedMenuIndex] - if ( - selectedOption && - selectedOption.type !== ContextMenuOptionType.URL && - selectedOption.type !== ContextMenuOptionType.NoResults - ) { - handleMentionSelect(selectedOption.type, selectedOption.value) - } - return - } - } - - const isComposing = event.nativeEvent?.isComposing ?? false - if (event.key === "Enter" && !event.shiftKey && !isComposing) { - event.preventDefault() - onSend() - } - - if (event.key === "Backspace" && !isComposing) { - const charBeforeCursor = inputValue[cursorPosition - 1] - const charAfterCursor = inputValue[cursorPosition + 1] - - const charBeforeIsWhitespace = - charBeforeCursor === " " || charBeforeCursor === "\n" || charBeforeCursor === "\r\n" - const charAfterIsWhitespace = - charAfterCursor === " " || charAfterCursor === "\n" || charAfterCursor === "\r\n" - // checks if char before cusor is whitespace after a mention - if ( - charBeforeIsWhitespace && - inputValue.slice(0, cursorPosition - 1).match(new RegExp(mentionRegex.source + "$")) // "$" is added to ensure the match occurs at the end of the string - ) { - const newCursorPosition = cursorPosition - 1 - // if mention is followed by another word, then instead of deleting the space separating them we just move the cursor to the end of the mention - if (!charAfterIsWhitespace) { - event.preventDefault() - textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition) - setCursorPosition(newCursorPosition) - } - setCursorPosition(newCursorPosition) - setJustDeletedSpaceAfterMention(true) - } else if (justDeletedSpaceAfterMention) { - const { newText, newPosition } = removeMention(inputValue, cursorPosition) - if (newText !== inputValue) { - event.preventDefault() - setInputValue(newText) - setIntendedCursorPosition(newPosition) // Store the new cursor position in state - } - setJustDeletedSpaceAfterMention(false) - setShowContextMenu(false) - } else { - setJustDeletedSpaceAfterMention(false) - } - } - }, - [ - onSend, - showContextMenu, - searchQuery, - selectedMenuIndex, - handleMentionSelect, - selectedType, - inputValue, - cursorPosition, - setInputValue, - justDeletedSpaceAfterMention, - queryItems, - customModes, - fileSearchResults, - ], - ) - - useLayoutEffect(() => { - if (intendedCursorPosition !== null && textAreaRef.current) { - textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition) - setIntendedCursorPosition(null) // Reset the state. - } - }, [inputValue, intendedCursorPosition]) - // Ref to store the search timeout - const searchTimeoutRef = useRef(null) - - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - const newValue = e.target.value - const newCursorPosition = e.target.selectionStart - setInputValue(newValue) - setCursorPosition(newCursorPosition) - const showMenu = shouldShowContextMenu(newValue, newCursorPosition) - - setShowContextMenu(showMenu) - if (showMenu) { - if (newValue.startsWith("/")) { - // Handle slash command - const query = newValue - setSearchQuery(query) - setSelectedMenuIndex(0) - } else { - // Existing @ mention handling - const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1) - const query = newValue.slice(lastAtIndex + 1, newCursorPosition) - setSearchQuery(query) - - // Send file search request if query is not empty - if (query.length > 0) { - setSelectedMenuIndex(0) - // Don't clear results until we have new ones - // This prevents flickering - - // Clear any existing timeout - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current) - } - - // Set a timeout to debounce the search requests - searchTimeoutRef.current = setTimeout(() => { - // Generate a request ID for this search - const reqId = Math.random().toString(36).substring(2, 9) - setSearchRequestId(reqId) - setSearchLoading(true) - - // Send message to extension to search files - vscode.postMessage({ - type: "searchFiles", - query: unescapeSpaces(query), - requestId: reqId, - }) - }, 200) // 200ms debounce - } else { - setSelectedMenuIndex(3) // Set to "File" option by default - } - } - } else { - setSearchQuery("") - setSelectedMenuIndex(-1) - setFileSearchResults([]) // Clear file search results - } - }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading], - ) - - useEffect(() => { - if (!showContextMenu) { - setSelectedType(null) - } - }, [showContextMenu]) - - const handleBlur = useCallback(() => { - // Only hide the context menu if the user didn't click on it. - if (!isMouseDownOnMenu) { - setShowContextMenu(false) - } - - setIsFocused(false) - }, [isMouseDownOnMenu]) - - const handlePaste = useCallback( - async (e: React.ClipboardEvent) => { - const items = e.clipboardData.items - - const pastedText = e.clipboardData.getData("text") - // Check if the pasted content is a URL, add space after so user - // can easily delete if they don't want it. - const urlRegex = /^\S+:\/\/\S+$/ - if (urlRegex.test(pastedText.trim())) { - e.preventDefault() - const trimmedUrl = pastedText.trim() - const newValue = - inputValue.slice(0, cursorPosition) + trimmedUrl + " " + inputValue.slice(cursorPosition) - setInputValue(newValue) - const newCursorPosition = cursorPosition + trimmedUrl.length + 1 - setCursorPosition(newCursorPosition) - setIntendedCursorPosition(newCursorPosition) - setShowContextMenu(false) - - // Scroll to new cursor position. - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.blur() - textAreaRef.current.focus() - } - }, 0) - - return - } - - const acceptedTypes = ["png", "jpeg", "webp"] - - const imageItems = Array.from(items).filter((item) => { - const [type, subtype] = item.type.split("/") - return type === "image" && acceptedTypes.includes(subtype) - }) - - if (!shouldDisableImages && imageItems.length > 0) { - e.preventDefault() - const imagePromises = imageItems.map((item) => { - return new Promise((resolve) => { - const blob = item.getAsFile() - if (!blob) { - resolve(null) - return - } - const reader = new FileReader() - reader.onloadend = () => { - if (reader.error) { - console.error(t("chat:errorReadingFile"), reader.error) - resolve(null) - } else { - const result = reader.result - resolve(typeof result === "string" ? result : null) - } - } - reader.readAsDataURL(blob) - }) - }) - const imageDataArray = await Promise.all(imagePromises) - const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) - if (dataUrls.length > 0) { - setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) - } else { - console.warn(t("chat:noValidImages")) - } - } - }, - [shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t], - ) - - const handleMenuMouseDown = useCallback(() => { - setIsMouseDownOnMenu(true) - }, []) - - const updateHighlights = useCallback(() => { - if (!textAreaRef.current || !highlightLayerRef.current) return - - const text = textAreaRef.current.value - - highlightLayerRef.current.innerHTML = text - .replace(/\n$/, "\n\n") - .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] || c) - .replace(mentionRegexGlobal, '$&') - - highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop - highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft - }, []) - - useLayoutEffect(() => { - updateHighlights() - }, [inputValue, updateHighlights]) - - const updateCursorPosition = useCallback(() => { - if (textAreaRef.current) { - setCursorPosition(textAreaRef.current.selectionStart) - } - }, []) - - const handleKeyUp = useCallback( - (e: React.KeyboardEvent) => { - if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { - updateCursorPosition() - } - }, - [updateCursorPosition], - ) - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault() - setIsDraggingOver(false) - - const textFieldList = e.dataTransfer.getData("text") - const textUriList = e.dataTransfer.getData("application/vnd.code.uri-list") - // When textFieldList is empty, it may attempt to use textUriList obtained from drag-and-drop tabs; if not empty, it will use textFieldList. - const text = textFieldList || textUriList - 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 - } - } - - // 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("/") - return type === "image" && acceptedTypes.includes(subtype) - }) - - if (!shouldDisableImages && imageFiles.length > 0) { - const imagePromises = imageFiles.map((file) => { - return new Promise((resolve) => { - const reader = new FileReader() - reader.onloadend = () => { - if (reader.error) { - console.error(t("chat:errorReadingFile"), reader.error) - resolve(null) - } else { - const result = reader.result - resolve(typeof result === "string" ? result : null) - } - } - reader.readAsDataURL(file) - }) - }) - const imageDataArray = await Promise.all(imagePromises) - const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) - if (dataUrls.length > 0) { - setSelectedImages((prevImages) => - [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE), - ) - if (typeof vscode !== "undefined") { - vscode.postMessage({ - type: "draggedImages", - dataUrls: dataUrls, - }) - } - } else { - console.warn(t("chat:noValidImages")) - } - } - } - }, - [ - 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 ( -
-
-
{ - //Only allowed to drop images/files on shift key pressed - if (!e.shiftKey) { - setIsDraggingOver(false) - return - } - e.preventDefault() - setIsDraggingOver(true) - e.dataTransfer.dropEffect = "copy" - }} - 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) - } - }}> - {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", - "min-h-[90px]", - "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 && ( - - )} - -
-
- {/* Mode selector - fixed width */} -
- ({ - value: mode.slug, - label: mode.name, - type: DropdownOptionType.ITEM, - })), - { - value: "sep-1", - label: t("chat:separator"), - type: DropdownOptionType.SEPARATOR, - }, - { - value: "promptsButtonClicked", - label: t("chat:edit"), - type: DropdownOptionType.ACTION, - }, - ]} - onChange={(value) => { - setMode(value as Mode) - vscode.postMessage({ type: "mode", text: value }) - }} - shortcutText={modeShortcutText} - triggerClassName="w-full" - /> -
- - {/* API configuration selector - flexible width */} -
- pinnedApiConfigs && pinnedApiConfigs[config.id]) - .map((config) => ({ - value: config.id, - label: config.name, - name: config.name, // Keep name for comparison with currentApiConfigName - type: DropdownOptionType.ITEM, - pinned: true, - })) - .sort((a, b) => a.label.localeCompare(b.label)), - // If we have pinned items and unpinned items, add a separator - ...(pinnedApiConfigs && - Object.keys(pinnedApiConfigs).length > 0 && - (listApiConfigMeta || []).some((config) => !pinnedApiConfigs[config.id]) - ? [ - { - value: "sep-pinned", - label: t("chat:separator"), - type: DropdownOptionType.SEPARATOR, - }, - ] - : []), - // Unpinned items sorted alphabetically - ...(listApiConfigMeta || []) - .filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id]) - .map((config) => ({ - value: config.id, - label: config.name, - name: config.name, // Keep name for comparison with currentApiConfigName - type: DropdownOptionType.ITEM, - pinned: false, - })) - .sort((a, b) => a.label.localeCompare(b.label)), - { - value: "sep-2", - label: t("chat:separator"), - type: DropdownOptionType.SEPARATOR, - }, - { - value: "settingsButtonClicked", - label: t("chat:edit"), - type: DropdownOptionType.ACTION, - }, - ]} - onChange={(value) => { - if (value === "settingsButtonClicked") { - vscode.postMessage({ - type: "loadApiConfiguration", - text: value, - values: { section: "providers" }, - }) - } else { - vscode.postMessage({ type: "loadApiConfigurationById", text: value }) - } - }} - triggerClassName="w-full text-ellipsis overflow-hidden" - itemClassName="group" - renderItem={({ type, value, label, pinned }) => { - if (type !== DropdownOptionType.ITEM) { - return label - } - - const config = listApiConfigMeta?.find((c) => c.id === value) - const isCurrentConfig = config?.name === currentApiConfigName - - return ( -
-
{label}
-
-
- -
- -
-
- ) - }} - /> -
-
- - {/* Right side - action buttons */} -
- - - -
-
-
- ) - }, - ) - - export default ChatTextArea - + setShowContextMenu(false) } setIsFocused(false) From d8e0fc321290447f4010545bac24d661f13d2f9b Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Thu, 8 May 2025 16:46:33 +0200 Subject: [PATCH 3/3] Update webview-ui/src/components/chat/ChatTextArea.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- webview-ui/src/components/chat/ChatTextArea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 3aa1460e88..6bcd7de815 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -486,7 +486,7 @@ const ChatTextArea = forwardRef( const handleBlur = useCallback(() => { // Only hide the context menu if the user didn't click on it. if (!isMouseDownOnMenu) { - setShowContextMenu(false)import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" + setShowContextMenu(false) import { useEvent } from "react-use" import DynamicTextArea from "react-textarea-autosize"