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 } from "../../../../src/shared/context-mentions" import { WebviewMessage } from "../../../../src/shared/WebviewMessage" import { Mode, getAllModes } from "../../../../src/shared/modes" import { ExtensionMessage } from "../../../../src/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 "@/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 = (event: MouseEvent) => { 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, 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, 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: 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 text = e.dataTransfer.getData("text") if (text) { // Split text on newlines to handle multiple files const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "") if (lines.length > 0) { // Process each line as a separate file path let newValue = inputValue.slice(0, cursorPosition) let totalLength = 0 // Using a standard for loop instead of forEach for potential performance gains. for (let i = 0; i < lines.length; i++) { const line = lines[i] // Convert each path to a mention-friendly format const mentionText = convertToMentionPath(line, cwd) newValue += mentionText totalLength += mentionText.length // Add space after each mention except the last one if (i < lines.length - 1) { newValue += " " totalLength += 1 } } // 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", "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 && ( )}
{/* 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 }) } }} contentClassName="max-h-[300px] overflow-y-auto" 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