diff --git a/webview-ui/src/components/ChatTextArea.tsx b/webview-ui/src/components/ChatTextArea.tsx new file mode 100644 index 0000000..c500418 --- /dev/null +++ b/webview-ui/src/components/ChatTextArea.tsx @@ -0,0 +1,218 @@ +import React, { forwardRef, useState, useCallback, useEffect } from "react" +import DynamicTextArea from "react-textarea-autosize" +import Thumbnails from "./Thumbnails" +import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" + +interface ChatTextAreaProps { + inputValue: string + setInputValue: (value: string) => void + textAreaDisabled: boolean + placeholderText: string + selectedImages: string[] + setSelectedImages: React.Dispatch> + onSend: () => void + onSelectImages: () => void + shouldDisableImages: boolean + onHeightChange?: (height: number) => void +} + +const ChatTextArea = forwardRef( + ( + { + inputValue, + setInputValue, + textAreaDisabled, + placeholderText, + selectedImages, + setSelectedImages, + onSend, + onSelectImages, + shouldDisableImages, + onHeightChange, + }, + ref + ) => { + const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) + const [thumbnailsHeight, setThumbnailsHeight] = useState(0) + const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const isComposing = event.nativeEvent?.isComposing ?? false + if (event.key === "Enter" && !event.shiftKey && !isComposing) { + event.preventDefault() + onSend() + } + }, + [onSend] + ) + + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const items = e.clipboardData.items + const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg) + 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("Error reading file:", 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) + //.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it + if (dataUrls.length > 0) { + setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) + } else { + console.warn("No valid images were processed") + } + } + }, + [shouldDisableImages, setSelectedImages] + ) + + const handleThumbnailsHeightChange = useCallback((height: number) => { + setThumbnailsHeight(height) + }, []) + + useEffect(() => { + if (selectedImages.length === 0) { + setThumbnailsHeight(0) + } + }, [selectedImages]) + + return ( +
+ {!isTextAreaFocused && ( +
+ )} + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => setIsTextAreaFocused(true)} + onBlur={() => setIsTextAreaFocused(false)} + onPaste={handlePaste} + onHeightChange={(height) => { + if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { + setTextAreaBaseHeight(height) + } + onHeightChange?.(height) + }} + placeholder={placeholderText} + maxRows={10} + autoFocus={true} + style={{ + width: "100%", + boxSizing: "border-box", + backgroundColor: "var(--vscode-input-background)", + color: "var(--vscode-input-foreground)", + //border: "1px solid var(--vscode-input-border)", + borderRadius: 2, + fontFamily: "var(--vscode-font-family)", + fontSize: "var(--vscode-editor-font-size)", + lineHeight: "var(--vscode-editor-line-height)", + resize: "none", + overflow: "hidden", + // Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410) + borderTop: "9px solid transparent", + borderBottom: `${thumbnailsHeight + 9}px solid transparent`, + borderColor: "transparent", + // borderRight: "54px solid transparent", + // borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead + // Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused + // boxShadow: "0px 0px 0px 1px var(--vscode-input-border)", + padding: "0 49px 0 9px", + cursor: textAreaDisabled ? "not-allowed" : undefined, + flex: 1, + }} + /> + {selectedImages.length > 0 && ( + + )} +
+
+
{ + if (!shouldDisableImages) { + onSelectImages() + } + }} + style={{ + marginRight: 5.5, + fontSize: 16.5, + }} + /> +
{ + if (!textAreaDisabled) { + onSend() + } + }} + style={{ fontSize: 15 }}>
+
+
+
+ ) + } +) + +export default ChatTextArea diff --git a/webview-ui/src/components/ChatView.tsx b/webview-ui/src/components/ChatView.tsx index c8e5ad0..78a7812 100644 --- a/webview-ui/src/components/ChatView.tsx +++ b/webview-ui/src/components/ChatView.tsx @@ -1,6 +1,5 @@ import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" -import { KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" -import DynamicTextArea from "react-textarea-autosize" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useEvent, useMount } from "react-use" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import { ClaudeAsk, ClaudeSayTool, ExtensionMessage } from "../../../src/shared/ExtensionMessage" @@ -10,11 +9,11 @@ import { getApiMetrics } from "../../../src/shared/getApiMetrics" import { useExtensionState } from "../context/ExtensionStateContext" import { vscode } from "../utils/vscode" import Announcement from "./Announcement" +import { normalizeApiConfiguration } from "./ApiOptions" import ChatRow from "./ChatRow" +import ChatTextArea from "./ChatTextArea" import HistoryPreview from "./HistoryPreview" import TaskHeader from "./TaskHeader" -import Thumbnails from "./Thumbnails" -import { normalizeApiConfiguration } from "./ApiOptions" interface ChatViewProps { isHidden: boolean @@ -23,7 +22,7 @@ interface ChatViewProps { showHistoryView: () => void } -const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images +export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => { const { version, claudeMessages: messages, taskHistory, apiConfiguration } = useExtensionState() @@ -37,10 +36,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie const [inputValue, setInputValue] = useState("") const textAreaRef = useRef(null) const [textAreaDisabled, setTextAreaDisabled] = useState(false) - const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [selectedImages, setSelectedImages] = useState([]) - const [thumbnailsHeight, setThumbnailsHeight] = useState(0) - const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) // we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed) const [claudeAsk, setClaudeAsk] = useState(undefined) @@ -268,17 +264,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie // setSecondaryButtonText(undefined) }, [claudeAsk, startNewTask]) - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - const isComposing = event.nativeEvent?.isComposing ?? false - if (event.key === "Enter" && !event.shiftKey && !isComposing) { - event.preventDefault() - handleSendMessage() - } - }, - [handleSendMessage] - ) - const handleTaskCloseButtonClick = useCallback(() => { startNewTask() }, [startNewTask]) @@ -294,59 +279,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie const shouldDisableImages = !selectedModelInfo.supportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE - const handlePaste = useCallback( - async (e: React.ClipboardEvent) => { - const items = e.clipboardData.items - const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg) - 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("Error reading file:", 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) - //.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it - if (dataUrls.length > 0) { - setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) - } else { - console.warn("No valid images were processed") - } - } - }, - [shouldDisableImages, setSelectedImages] - ) - - useEffect(() => { - if (selectedImages.length === 0) { - setThumbnailsHeight(0) - } - }, [selectedImages]) - - const handleThumbnailsHeightChange = useCallback((height: number) => { - setThumbnailsHeight(height) - }, []) - const handleMessage = useCallback( (e: MessageEvent) => { const message: ExtensionMessage = e.data @@ -601,118 +533,22 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
)} - -
- {!isTextAreaFocused && ( -
- )} - setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - onFocus={() => setIsTextAreaFocused(true)} - onBlur={() => setIsTextAreaFocused(false)} - onPaste={handlePaste} - onHeightChange={(height, meta) => { - if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { - setTextAreaBaseHeight(height) - } - //virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" }) - virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" }) - }} - placeholder={placeholderText} - maxRows={10} - autoFocus={true} - style={{ - width: "100%", - boxSizing: "border-box", - backgroundColor: "var(--vscode-input-background)", - color: "var(--vscode-input-foreground)", - //border: "1px solid var(--vscode-input-border)", - borderRadius: 2, - fontFamily: "var(--vscode-font-family)", - fontSize: "var(--vscode-editor-font-size)", - lineHeight: "var(--vscode-editor-line-height)", - resize: "none", - overflow: "hidden", - // Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410) - borderTop: "9px solid transparent", - borderBottom: `${thumbnailsHeight + 9}px solid transparent`, - borderColor: "transparent", - // borderRight: "54px solid transparent", - // borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead - // Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused - // boxShadow: "0px 0px 0px 1px var(--vscode-input-border)", - padding: "0 49px 0 9px", - cursor: textAreaDisabled ? "not-allowed" : undefined, - flex: 1, - }} - /> - {selectedImages.length > 0 && ( - - )} -
-
-
{ - if (!shouldDisableImages) { - selectImages() - } - }} - style={{ - marginRight: 5.5, - fontSize: 16.5, - }} - /> -
{ - if (!textAreaDisabled) { - handleSendMessage() - } - }} - style={{ fontSize: 15 }}>
-
-
-
+ { + //virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" }) + virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" }) + }} + />
) }