Fix ChatTextArea layout

This commit is contained in:
Matt Rubens
2025-01-14 23:45:43 -05:00
parent b75e105fab
commit 84a0063b99
3 changed files with 306 additions and 261 deletions

View File

@@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Fix chat text input layout issues

View File

@@ -15,6 +15,7 @@ import Thumbnails from "../common/Thumbnails"
import { vscode } from "../../utils/vscode" import { vscode } from "../../utils/vscode"
import { WebviewMessage } from "../../../../src/shared/WebviewMessage" import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
import { Mode } from "../../../../src/core/prompts/types" import { Mode } from "../../../../src/core/prompts/types"
import { CaretIcon } from "../common/CaretIcon"
interface ChatTextAreaProps { interface ChatTextAreaProps {
inputValue: string inputValue: string
@@ -50,7 +51,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
ref, ref,
) => { ) => {
const { filePaths, currentApiConfigName, listApiConfigMeta } = useExtensionState() const { filePaths, currentApiConfigName, listApiConfigMeta } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [gitCommits, setGitCommits] = useState<any[]>([]) const [gitCommits, setGitCommits] = useState<any[]>([])
const [showDropdown, setShowDropdown] = useState(false) const [showDropdown, setShowDropdown] = useState(false)
@@ -376,7 +376,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (!isMouseDownOnMenu) { if (!isMouseDownOnMenu) {
setShowContextMenu(false) setShowContextMenu(false)
} }
setIsTextAreaFocused(false)
}, [isMouseDownOnMenu]) }, [isMouseDownOnMenu])
const handlePaste = useCallback( const handlePaste = useCallback(
@@ -494,65 +493,97 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[updateCursorPosition], [updateCursorPosition],
) )
const selectStyle = {
fontSize: "11px",
cursor: textAreaDisabled ? "not-allowed" : "pointer",
backgroundColor: "transparent",
border: "none",
color: "var(--vscode-foreground)",
opacity: textAreaDisabled ? 0.5 : 0.8,
outline: "none",
paddingLeft: "20px",
paddingRight: "6px",
WebkitAppearance: "none" as const,
MozAppearance: "none" as const,
appearance: "none" as const
}
const caretContainerStyle = {
position: "absolute" as const,
left: 6,
top: "50%",
transform: "translateY(-45%)",
pointerEvents: "none" as const,
opacity: textAreaDisabled ? 0.5 : 0.8
}
return ( return (
<div style={{ <div
padding: "10px 15px", className="chat-text-area"
opacity: textAreaDisabled ? 0.5 : 1, style={{
position: "relative", opacity: textAreaDisabled ? 0.5 : 1,
display: "flex", position: "relative",
}} display: "flex",
onDrop={async (e) => { flexDirection: "column",
e.preventDefault() gap: "8px",
const files = Array.from(e.dataTransfer.files) backgroundColor: "var(--vscode-input-background)",
const text = e.dataTransfer.getData("text") minHeight: "100px",
if (text) { margin: "10px 15px",
const newValue = padding: "8px"
inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition) }}
setInputValue(newValue) onDrop={async (e) => {
const newCursorPosition = cursorPosition + text.length e.preventDefault()
setCursorPosition(newCursorPosition) const files = Array.from(e.dataTransfer.files)
setIntendedCursorPosition(newCursorPosition) const text = e.dataTransfer.getData("text")
return if (text) {
} const newValue =
const acceptedTypes = ["png", "jpeg", "webp"] inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition)
const imageFiles = files.filter((file) => { setInputValue(newValue)
const [type, subtype] = file.type.split("/") const newCursorPosition = cursorPosition + text.length
return type === "image" && acceptedTypes.includes(subtype) setCursorPosition(newCursorPosition)
}) setIntendedCursorPosition(newCursorPosition)
if (!shouldDisableImages && imageFiles.length > 0) { return
const imagePromises = imageFiles.map((file) => {
return new Promise<string | null>((resolve) => {
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(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("No valid images were processed")
} }
} const acceptedTypes = ["png", "jpeg", "webp"]
}} const imageFiles = files.filter((file) => {
onDragOver={(e) => { const [type, subtype] = file.type.split("/")
e.preventDefault() return type === "image" && acceptedTypes.includes(subtype)
}}> })
if (!shouldDisableImages && imageFiles.length > 0) {
const imagePromises = imageFiles.map((file) => {
return new Promise<string | null>((resolve) => {
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(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("No valid images were processed")
}
}
}}
onDragOver={(e) => {
e.preventDefault()
}}
>
{showContextMenu && ( {showContextMenu && (
<div ref={contextMenuContainerRef}> <div ref={contextMenuContainerRef}>
<ContextMenu <ContextMenu
@@ -566,101 +597,87 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/> />
</div> </div>
)} )}
{!isTextAreaFocused && (
<div style={{
position: "relative",
flex: "1 1 auto",
display: "flex",
flexDirection: "column-reverse",
minHeight: 0,
overflow: "hidden"
}}>
<div <div
ref={highlightLayerRef}
style={{ style={{
position: "absolute", position: "absolute",
inset: "10px 15px", inset: 0,
border: "1px solid var(--vscode-input-border)",
borderRadius: 2,
pointerEvents: "none", pointerEvents: "none",
zIndex: 5, whiteSpace: "pre-wrap",
wordWrap: "break-word",
color: "transparent",
overflow: "hidden",
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
padding: "8px",
marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
zIndex: 1
}} }}
/> />
)} <DynamicTextArea
<div ref={(el) => {
ref={highlightLayerRef} if (typeof ref === "function") {
style={{ ref(el)
position: "absolute", } else if (ref) {
top: 10, ref.current = el
left: 15, }
right: 15, textAreaRef.current = el
bottom: 10, }}
pointerEvents: "none", value={inputValue}
whiteSpace: "pre-wrap", disabled={textAreaDisabled}
wordWrap: "break-word", onChange={(e) => {
color: "transparent", handleInputChange(e)
overflow: "hidden", updateHighlights()
backgroundColor: "var(--vscode-input-background)", }}
fontFamily: "var(--vscode-font-family)", onKeyDown={handleKeyDown}
fontSize: "var(--vscode-editor-font-size)", onKeyUp={handleKeyUp}
lineHeight: "var(--vscode-editor-line-height)", onBlur={handleBlur}
borderRadius: 2, onPaste={handlePaste}
borderLeft: 0, onSelect={updateCursorPosition}
borderRight: 0, onMouseUp={updateCursorPosition}
borderTop: 0, onHeightChange={(height) => {
borderColor: "transparent", if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
borderBottom: `${thumbnailsHeight + 6}px solid transparent`, setTextAreaBaseHeight(height)
padding: "9px 9px 25px 9px", }
}} onHeightChange?.(height)
/> }}
<DynamicTextArea placeholder={placeholderText}
ref={(el) => { minRows={4}
if (typeof ref === "function") { maxRows={20}
ref(el) autoFocus={true}
} else if (ref) { style={{
ref.current = el width: "100%",
} boxSizing: "border-box",
textAreaRef.current = el backgroundColor: "transparent",
}} color: "var(--vscode-input-foreground)",
value={inputValue} borderRadius: 2,
disabled={textAreaDisabled} fontFamily: "var(--vscode-font-family)",
onChange={(e) => { fontSize: "var(--vscode-editor-font-size)",
handleInputChange(e) lineHeight: "var(--vscode-editor-line-height)",
updateHighlights() resize: "none",
}} overflowX: "hidden",
onKeyDown={handleKeyDown} overflowY: "auto",
onKeyUp={handleKeyUp} border: "none",
onFocus={() => setIsTextAreaFocused(true)} padding: "8px",
onBlur={handleBlur} marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
onPaste={handlePaste} cursor: textAreaDisabled ? "not-allowed" : undefined,
onSelect={updateCursorPosition} flex: "0 1 auto",
onMouseUp={updateCursorPosition} zIndex: 2
onHeightChange={(height) => { }}
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { onScroll={() => updateHighlights()}
setTextAreaBaseHeight(height) />
} </div>
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={2}
maxRows={20}
autoFocus={true}
style={{
width: "100%",
boxSizing: "border-box",
backgroundColor: "transparent",
color: "var(--vscode-input-foreground)",
borderRadius: 2,
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
resize: "none",
overflowX: "hidden",
overflowY: "scroll",
borderLeft: 0,
borderRight: 0,
borderTop: 0,
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
borderColor: "transparent",
padding: "9px 9px 25px 9px",
marginBottom: "15px",
cursor: textAreaDisabled ? "not-allowed" : undefined,
flex: 1,
zIndex: 1,
}}
onScroll={() => updateHighlights()}
/>
{selectedImages.length > 0 && ( {selectedImages.length > 0 && (
<Thumbnails <Thumbnails
images={selectedImages} images={selectedImages}
@@ -668,129 +685,136 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onHeightChange={handleThumbnailsHeightChange} onHeightChange={handleThumbnailsHeightChange}
style={{ style={{
position: "absolute", position: "absolute",
paddingTop: 4, bottom: "36px",
bottom: 36, left: "16px",
left: 22,
right: 67,
zIndex: 2, zIndex: 2,
marginBottom: "8px"
}} }}
/> />
)} )}
<div
style={{ <div style={{
position: "absolute", display: "flex",
left: 25, justifyContent: "space-between",
bottom: 20, alignItems: "center",
zIndex: 3, marginTop: "auto",
paddingTop: "8px"
}}>
<div style={{
display: "flex", display: "flex",
gap: 8,
alignItems: "center" alignItems: "center"
}} }}>
> <div style={{ position: "relative", display: "inline-block" }}>
<select <select
value={mode} value={mode}
disabled={textAreaDisabled} disabled={textAreaDisabled}
onChange={(e) => { onChange={(e) => {
const newMode = e.target.value as Mode; const newMode = e.target.value as Mode
setMode(newMode); setMode(newMode)
vscode.postMessage({ vscode.postMessage({
type: "mode", type: "mode",
text: newMode text: newMode
}); })
}} }}
style={{
fontSize: "11px",
cursor: textAreaDisabled ? "not-allowed" : "pointer",
backgroundColor: "transparent",
border: "none",
color: "var(--vscode-input-foreground)",
opacity: textAreaDisabled ? 0.5 : 0.6,
outline: "none",
paddingLeft: 14,
WebkitAppearance: "none",
MozAppearance: "none",
appearance: "none",
backgroundImage: "url(\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\")",
backgroundRepeat: "no-repeat",
backgroundPosition: "left 0px center",
backgroundSize: "10px"
}}>
<option value="code" style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}>Code</option>
<option value="architect" style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}>Architect</option>
<option value="ask" style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}>Ask</option>
</select>
<select
value={currentApiConfigName}
disabled={textAreaDisabled}
onChange={(e) => vscode.postMessage({
type: "loadApiConfiguration",
text: e.target.value
})}
style={{
fontSize: "11px",
cursor: textAreaDisabled ? "not-allowed" : "pointer",
backgroundColor: "transparent",
border: "none",
color: "var(--vscode-input-foreground)",
opacity: textAreaDisabled ? 0.5 : 0.6,
outline: "none",
paddingLeft: 14,
WebkitAppearance: "none",
MozAppearance: "none",
appearance: "none",
backgroundImage: "url(\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\")",
backgroundRepeat: "no-repeat",
backgroundPosition: "left 0px center",
backgroundSize: "10px"
}}
>
{(listApiConfigMeta || [])?.map((config) => (
<option
key={config.name}
value={config.name}
style={{ style={{
backgroundColor: "var(--vscode-dropdown-background)", ...selectStyle,
color: "var(--vscode-dropdown-foreground)" minWidth: "70px",
flex: "0 0 auto"
}} }}
> >
{config.name} <option value="code" style={{
</option> backgroundColor: "var(--vscode-dropdown-background)",
))} color: "var(--vscode-dropdown-foreground)"
</select> }}>Code</option>
</div> <option value="architect" style={{
<div className="button-row" style={{ position: "absolute", right: 16, display: "flex", alignItems: "center", height: 31, bottom: 11, zIndex: 3, padding: "0 8px", justifyContent: "flex-end", backgroundColor: "var(--vscode-input-background)", }}> backgroundColor: "var(--vscode-dropdown-background)",
<span style={{ display: "flex", alignItems: "center", gap: 12 }}> color: "var(--vscode-dropdown-foreground)"
<div style={{ display: "flex", alignItems: "center" }}> }}>Architect</option>
{isEnhancingPrompt ? ( <option value="ask" style={{
<span className="codicon codicon-loading codicon-modifier-spin" style={{ backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-input-foreground)", color: "var(--vscode-dropdown-foreground)"
opacity: 0.5, }}>Ask</option>
fontSize: 16.5, </select>
marginRight: 10 <div style={caretContainerStyle}>
}}></span> <CaretIcon />
) : ( </div>
<span
role="button"
aria-label="enhance prompt"
data-testid="enhance-prompt-button"
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-sparkle`}
onClick={() => !textAreaDisabled && handleEnhancePrompt()}
style={{ fontSize: 16.5 }}
/>
)}
</div> </div>
<span className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`} onClick={() => !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} />
<span className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`} onClick={() => !textAreaDisabled && onSend()} style={{ fontSize: 15 }} /> <div style={{
</span> position: "relative",
display: "inline-block",
flex: "1 1 auto",
minWidth: 0,
maxWidth: "150px",
overflow: "hidden"
}}>
<select
value={currentApiConfigName}
disabled={textAreaDisabled}
onChange={(e) => vscode.postMessage({
type: "loadApiConfiguration",
text: e.target.value
})}
style={{
...selectStyle,
width: "100%",
textOverflow: "ellipsis"
}}
>
{(listApiConfigMeta || [])?.map((config) => (
<option
key={config.name}
value={config.name}
style={{
backgroundColor: "var(--vscode-dropdown-background)",
color: "var(--vscode-dropdown-foreground)"
}}
>
{config.name}
</option>
))}
</select>
<div style={caretContainerStyle}>
<CaretIcon />
</div>
</div>
</div>
<div style={{
display: "flex",
alignItems: "center",
gap: "12px"
}}>
<div style={{ display: "flex", alignItems: "center" }}>
{isEnhancingPrompt ? (
<span className="codicon codicon-loading codicon-modifier-spin" style={{
color: "var(--vscode-input-foreground)",
opacity: 0.5,
fontSize: 16.5,
marginRight: 10
}} />
) : (
<span
role="button"
aria-label="enhance prompt"
data-testid="enhance-prompt-button"
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-sparkle`}
onClick={() => !textAreaDisabled && handleEnhancePrompt()}
style={{ fontSize: 16.5 }}
/>
)}
</div>
<span
className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`}
onClick={() => !shouldDisableImages && onSelectImages()}
style={{ fontSize: 16.5 }}
/>
<span
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
onClick={() => !textAreaDisabled && onSend()}
style={{ fontSize: 15 }}
/>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,16 @@
import React from 'react'
export const CaretIcon = () => (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
)