mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Update UX for text area (#1953)
* update ux for text area * fix z-index * fix tests * Update .changeset/bright-trains-crash.md Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * extract off drop method per code review bot * address bot comment --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
parent
d91467fbd6
commit
418694befa
6 changed files with 368 additions and 265 deletions
10
.changeset/bright-trains-crash.md
Normal file
10
.changeset/bright-trains-crash.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
"roo-cline": minor
|
||||
---
|
||||
|
||||
UX fixes that:
|
||||
|
||||
- Allow dropdowns to be controlled when text box is disabled
|
||||
- Separates and clarifies buttons and dropdowns from inputs
|
||||
- Adds a secondary placeholder for easier visibility of mode controls
|
||||
- Updates to tailwind standard
|
||||
|
|
@ -25,6 +25,8 @@ import Thumbnails from "../common/Thumbnails"
|
|||
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
|
||||
import ContextMenu from "./ContextMenu"
|
||||
import { VolumeX } from "lucide-react"
|
||||
import { IconButton } from "./IconButton"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ChatTextAreaProps {
|
||||
inputValue: string
|
||||
|
|
@ -113,7 +115,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
return () => window.removeEventListener("message", messageHandler)
|
||||
}, [setInputValue, searchRequestId])
|
||||
|
||||
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
|
||||
const [showContextMenu, setShowContextMenu] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
|
|
@ -547,14 +549,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
|
||||
)
|
||||
|
||||
const handleThumbnailsHeightChange = useCallback((height: number) => setThumbnailsHeight(height), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedImages.length === 0) {
|
||||
setThumbnailsHeight(0)
|
||||
}
|
||||
}, [selectedImages])
|
||||
|
||||
const handleMenuMouseDown = useCallback(() => {
|
||||
setIsMouseDownOnMenu(true)
|
||||
}, [])
|
||||
|
|
@ -592,75 +586,51 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
[updateCursorPosition],
|
||||
)
|
||||
|
||||
const [isTtsPlaying, setIsTtsPlaying] = useState(false)
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDraggingOver(false)
|
||||
|
||||
useEvent("message", (event: MessageEvent) => {
|
||||
const message: ExtensionMessage = event.data
|
||||
const text = e.dataTransfer.getData("text")
|
||||
if (text) {
|
||||
// Split text on newlines to handle multiple files
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
|
||||
|
||||
if (message.type === "ttsStart") {
|
||||
setIsTtsPlaying(true)
|
||||
} else if (message.type === "ttsStop") {
|
||||
setIsTtsPlaying(false)
|
||||
}
|
||||
})
|
||||
if (lines.length > 0) {
|
||||
// Process each line as a separate file path
|
||||
let newValue = inputValue.slice(0, cursorPosition)
|
||||
let totalLength = 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className="chat-text-area"
|
||||
style={{
|
||||
opacity: textAreaDisabled ? 0.5 : 1,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
margin: "10px 15px",
|
||||
padding: "8px",
|
||||
outline: "none",
|
||||
border: "1px solid",
|
||||
borderColor: isFocused ? "var(--vscode-focusBorder)" : "transparent",
|
||||
borderRadius: "2px",
|
||||
}}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault()
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
const text = e.dataTransfer.getData("text")
|
||||
// Using a standard for loop instead of forEach for potential performance gains.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
// Convert each path to a mention-friendly format
|
||||
const mentionText = convertToMentionPath(line, cwd)
|
||||
newValue += mentionText
|
||||
totalLength += mentionText.length
|
||||
|
||||
if (text) {
|
||||
// Split text on newlines to handle multiple files
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
|
||||
|
||||
if (lines.length > 0) {
|
||||
// Process each line as a separate file path
|
||||
let newValue = inputValue.slice(0, cursorPosition)
|
||||
let totalLength = 0
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// Convert each path to a mention-friendly format
|
||||
const mentionText = convertToMentionPath(line, cwd)
|
||||
newValue += mentionText
|
||||
totalLength += mentionText.length
|
||||
|
||||
// Add space after each mention except the last one
|
||||
if (index < lines.length - 1) {
|
||||
newValue += " "
|
||||
totalLength += 1
|
||||
}
|
||||
})
|
||||
|
||||
// Add space after the last mention and append the rest of the input
|
||||
newValue += " " + inputValue.slice(cursorPosition)
|
||||
totalLength += 1
|
||||
|
||||
setInputValue(newValue)
|
||||
const newCursorPosition = cursorPosition + totalLength
|
||||
setCursorPosition(newCursorPosition)
|
||||
setIntendedCursorPosition(newCursorPosition)
|
||||
// Add space after each mention except the last one
|
||||
if (i < lines.length - 1) {
|
||||
newValue += " "
|
||||
totalLength += 1
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
// Add space after the last mention and append the rest of the input
|
||||
newValue += " " + inputValue.slice(cursorPosition)
|
||||
totalLength += 1
|
||||
|
||||
setInputValue(newValue)
|
||||
const newCursorPosition = cursorPosition + totalLength
|
||||
setCursorPosition(newCursorPosition)
|
||||
setIntendedCursorPosition(newCursorPosition)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
if (!textAreaDisabled && files.length > 0) {
|
||||
const acceptedTypes = ["png", "jpeg", "webp"]
|
||||
const imageFiles = files.filter((file) => {
|
||||
const [type, subtype] = file.type.split("/")
|
||||
|
|
@ -699,159 +669,256 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
console.warn(t("chat:noValidImages"))
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
}}>
|
||||
{showContextMenu && (
|
||||
<div ref={contextMenuContainerRef}>
|
||||
<ContextMenu
|
||||
onSelect={handleMentionSelect}
|
||||
searchQuery={searchQuery}
|
||||
onMouseDown={handleMenuMouseDown}
|
||||
selectedIndex={selectedMenuIndex}
|
||||
setSelectedIndex={setSelectedMenuIndex}
|
||||
selectedType={selectedType}
|
||||
queryItems={queryItems}
|
||||
modes={getAllModes(customModes)}
|
||||
loading={searchLoading}
|
||||
dynamicSearchResults={fileSearchResults}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
},
|
||||
[
|
||||
cursorPosition,
|
||||
cwd,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
setCursorPosition,
|
||||
setIntendedCursorPosition,
|
||||
textAreaDisabled,
|
||||
shouldDisableImages,
|
||||
setSelectedImages,
|
||||
t,
|
||||
],
|
||||
)
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
flex: "1 1 auto",
|
||||
display: "flex",
|
||||
flexDirection: "column-reverse",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
"flex",
|
||||
"flex-col",
|
||||
"gap-2",
|
||||
"bg-editor-background",
|
||||
"m-2 mt-1",
|
||||
"p-1.5",
|
||||
"outline-none",
|
||||
"border",
|
||||
"border-none",
|
||||
"w-[calc(100%-16px)]",
|
||||
"ml-auto",
|
||||
"mr-auto",
|
||||
"box-border",
|
||||
)}>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={highlightLayerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
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: "2px",
|
||||
paddingRight: "8px",
|
||||
marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<DynamicTextArea
|
||||
ref={(el) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(el)
|
||||
} else if (ref) {
|
||||
ref.current = el
|
||||
className={cn("chat-text-area", "relative", "flex", "flex-col", "outline-none")}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => {
|
||||
//Only allowed to drop images/files on shift key pressed
|
||||
if (!e.shiftKey) {
|
||||
setIsDraggingOver(false)
|
||||
return
|
||||
}
|
||||
textAreaRef.current = el
|
||||
e.preventDefault()
|
||||
setIsDraggingOver(true)
|
||||
e.dataTransfer.dropEffect = "copy"
|
||||
}}
|
||||
value={inputValue}
|
||||
disabled={textAreaDisabled}
|
||||
onChange={(e) => {
|
||||
handleInputChange(e)
|
||||
updateHighlights()
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
onSelect={updateCursorPosition}
|
||||
onMouseUp={updateCursorPosition}
|
||||
onHeightChange={(height) => {
|
||||
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
|
||||
setTextAreaBaseHeight(height)
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
if (
|
||||
e.clientX <= rect.left ||
|
||||
e.clientX >= rect.right ||
|
||||
e.clientY <= rect.top ||
|
||||
e.clientY >= rect.bottom
|
||||
) {
|
||||
setIsDraggingOver(false)
|
||||
}
|
||||
onHeightChange?.(height)
|
||||
}}
|
||||
placeholder={placeholderText}
|
||||
minRows={3}
|
||||
maxRows={15}
|
||||
autoFocus={true}
|
||||
style={{
|
||||
width: "100%",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--vscode-input-foreground)",
|
||||
borderRadius: 2,
|
||||
fontFamily: "var(--vscode-font-family)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
resize: "none",
|
||||
overflowX: "hidden",
|
||||
overflowY: "auto",
|
||||
border: "none",
|
||||
padding: "2px",
|
||||
paddingRight: "8px",
|
||||
marginBottom: thumbnailsHeight > 0 ? `${thumbnailsHeight + 16}px` : 0,
|
||||
cursor: textAreaDisabled ? "not-allowed" : undefined,
|
||||
flex: "0 1 auto",
|
||||
zIndex: 2,
|
||||
scrollbarWidth: "none",
|
||||
}}
|
||||
onScroll={() => updateHighlights()}
|
||||
/>
|
||||
{isTtsPlaying && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-0 right-0 opacity-25 hover:opacity-100 z-10"
|
||||
onClick={() => vscode.postMessage({ type: "stopTts" })}>
|
||||
<VolumeX className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
}}>
|
||||
{showContextMenu && (
|
||||
<div
|
||||
ref={contextMenuContainerRef}
|
||||
className={cn(
|
||||
"absolute",
|
||||
"bottom-full",
|
||||
"left-0",
|
||||
"right-0",
|
||||
"z-[1000]",
|
||||
"mb-2",
|
||||
"filter",
|
||||
"drop-shadow-md",
|
||||
)}>
|
||||
<ContextMenu
|
||||
onSelect={handleMentionSelect}
|
||||
searchQuery={searchQuery}
|
||||
onMouseDown={handleMenuMouseDown}
|
||||
selectedIndex={selectedMenuIndex}
|
||||
setSelectedIndex={setSelectedMenuIndex}
|
||||
selectedType={selectedType}
|
||||
queryItems={queryItems}
|
||||
modes={getAllModes(customModes)}
|
||||
loading={searchLoading}
|
||||
dynamicSearchResults={fileSearchResults}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
"flex-1",
|
||||
"flex",
|
||||
"flex-col-reverse",
|
||||
"min-h-0",
|
||||
"overflow-hidden",
|
||||
"rounded",
|
||||
)}>
|
||||
<div
|
||||
ref={highlightLayerRef}
|
||||
className={cn(
|
||||
"absolute",
|
||||
"inset-0",
|
||||
"pointer-events-none",
|
||||
"whitespace-pre-wrap",
|
||||
"break-words",
|
||||
"text-transparent",
|
||||
"overflow-hidden",
|
||||
"font-vscode-font-family",
|
||||
"text-vscode-editor-font-size",
|
||||
"leading-vscode-editor-line-height",
|
||||
"py-2",
|
||||
"px-[9px]",
|
||||
"z-[1000]",
|
||||
)}
|
||||
style={{
|
||||
color: "transparent",
|
||||
}}
|
||||
/>
|
||||
<DynamicTextArea
|
||||
ref={(el) => {
|
||||
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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-0 right-0 opacity-25 hover:opacity-100 z-10"
|
||||
onClick={() => vscode.postMessage({ type: "stopTts" })}>
|
||||
<VolumeX className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!inputValue && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute",
|
||||
"left-2",
|
||||
"flex",
|
||||
"gap-2",
|
||||
"text-xs",
|
||||
"text-descriptionForeground",
|
||||
"pointer-events-none",
|
||||
"z-25",
|
||||
"bottom-1.5",
|
||||
"pr-2",
|
||||
"transition-opacity",
|
||||
"duration-200",
|
||||
"ease-in-out",
|
||||
textAreaDisabled ? "opacity-35" : "opacity-70",
|
||||
)}>
|
||||
{placeholderBottomText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedImages.length > 0 && (
|
||||
<Thumbnails
|
||||
images={selectedImages}
|
||||
setImages={setSelectedImages}
|
||||
onHeightChange={handleThumbnailsHeightChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "36px",
|
||||
left: "16px",
|
||||
zIndex: 2,
|
||||
marginBottom: "4px",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: "auto",
|
||||
paddingTop: "2px",
|
||||
}}>
|
||||
{/* Left side - dropdowns container */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
overflow: "hidden",
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div className={cn("flex", "justify-between", "items-center", "mt-auto", "pt-0.5")}>
|
||||
<div className={cn("flex", "items-center", "gap-1", "min-w-0")}>
|
||||
{/* Mode selector - fixed width */}
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div className="shrink-0">
|
||||
<SelectDropdown
|
||||
value={mode}
|
||||
disabled={textAreaDisabled}
|
||||
title={t("chat:selectMode")}
|
||||
options={[
|
||||
{
|
||||
|
|
@ -886,12 +953,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
</div>
|
||||
|
||||
{/* API configuration selector - flexible width */}
|
||||
<div
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
|
||||
<SelectDropdown
|
||||
value={currentApiConfigName || ""}
|
||||
disabled={textAreaDisabled}
|
||||
|
|
@ -921,51 +983,25 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
</div>
|
||||
|
||||
{/* Right side - action buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<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: 6,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
role="button"
|
||||
aria-label="enhance prompt"
|
||||
data-testid="enhance-prompt-button"
|
||||
title={t("chat:enhancePrompt")}
|
||||
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`}
|
||||
title={t("chat:addImages")}
|
||||
onClick={() => !shouldDisableImages && onSelectImages()}
|
||||
style={{ fontSize: 16.5 }}
|
||||
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
|
||||
<IconButton
|
||||
iconClass={isEnhancingPrompt ? "codicon-loading" : "codicon-sparkle"}
|
||||
title={t("chat:enhancePrompt")}
|
||||
disabled={textAreaDisabled}
|
||||
isLoading={isEnhancingPrompt}
|
||||
onClick={handleEnhancePrompt}
|
||||
/>
|
||||
<span
|
||||
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
|
||||
<IconButton
|
||||
iconClass="codicon-device-camera"
|
||||
title={t("chat:addImages")}
|
||||
disabled={shouldDisableImages}
|
||||
onClick={onSelectImages}
|
||||
/>
|
||||
<IconButton
|
||||
iconClass="codicon-send"
|
||||
title={t("chat:sendMessage")}
|
||||
onClick={() => !textAreaDisabled && onSend()}
|
||||
style={{ fontSize: 15 }}
|
||||
disabled={textAreaDisabled}
|
||||
onClick={onSend}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -974,10 +974,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
|||
[],
|
||||
)
|
||||
|
||||
const baseText = task ? t("chat:typeMessage") : t("chat:typeTask")
|
||||
const placeholderText =
|
||||
baseText +
|
||||
`\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})`
|
||||
const placeholderText = task ? t("chat:typeMessage") : t("chat:typeTask")
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: number, messageOrGroup: ClineMessage | ClineMessage[]) => {
|
||||
|
|
|
|||
48
webview-ui/src/components/chat/IconButton.tsx
Normal file
48
webview-ui/src/components/chat/IconButton.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
iconClass: string
|
||||
title: string
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export const IconButton: React.FC<IconButtonProps> = ({
|
||||
iconClass,
|
||||
title,
|
||||
className,
|
||||
disabled,
|
||||
isLoading,
|
||||
onClick,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const buttonClasses = cn(
|
||||
"relative inline-flex items-center justify-center",
|
||||
"bg-transparent border-none p-1.5",
|
||||
"rounded-md min-w-[28px] min-h-[28px]",
|
||||
"text-vscode-foreground opacity-85",
|
||||
"transition-all duration-150",
|
||||
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
|
||||
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
|
||||
"active:bg-[rgba(255,255,255,0.1)]",
|
||||
disabled &&
|
||||
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
|
||||
className,
|
||||
)
|
||||
|
||||
const iconClasses = cn("codicon", iconClass, isLoading && "codicon-modifier-spin")
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={title}
|
||||
title={title}
|
||||
className={buttonClasses}
|
||||
onClick={!disabled ? onClick : undefined}
|
||||
style={{ fontSize: 16.5, ...style }}
|
||||
{...props}>
|
||||
<span className={iconClasses} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -31,6 +31,16 @@ const mockConvertToMentionPath = pathMentions.convertToMentionPath as jest.Mock
|
|||
// Mock ExtensionStateContext
|
||||
jest.mock("../../../context/ExtensionStateContext")
|
||||
|
||||
// Custom query function to get the enhance prompt button
|
||||
const getEnhancePromptButton = () => {
|
||||
return screen.getByRole("button", {
|
||||
name: (_, element) => {
|
||||
// Find the button with the sparkle icon
|
||||
return element.querySelector(".codicon-sparkle") !== null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("ChatTextArea", () => {
|
||||
const defaultProps = {
|
||||
inputValue: "",
|
||||
|
|
@ -66,10 +76,9 @@ describe("ChatTextArea", () => {
|
|||
filePaths: [],
|
||||
openedTabs: [],
|
||||
})
|
||||
|
||||
render(<ChatTextArea {...defaultProps} textAreaDisabled={true} />)
|
||||
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
|
||||
expect(enhanceButton).toHaveClass("disabled")
|
||||
const enhanceButton = getEnhancePromptButton()
|
||||
expect(enhanceButton).toHaveClass("cursor-not-allowed")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -88,7 +97,7 @@ describe("ChatTextArea", () => {
|
|||
|
||||
render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
|
||||
|
||||
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
|
||||
const enhanceButton = getEnhancePromptButton()
|
||||
fireEvent.click(enhanceButton)
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
|
|
@ -108,7 +117,7 @@ describe("ChatTextArea", () => {
|
|||
|
||||
render(<ChatTextArea {...defaultProps} inputValue="" />)
|
||||
|
||||
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
|
||||
const enhanceButton = getEnhancePromptButton()
|
||||
fireEvent.click(enhanceButton)
|
||||
|
||||
expect(mockPostMessage).not.toHaveBeenCalled()
|
||||
|
|
@ -125,7 +134,7 @@ describe("ChatTextArea", () => {
|
|||
|
||||
render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
|
||||
|
||||
const enhanceButton = screen.getByRole("button", { name: /enhance prompt/i })
|
||||
const enhanceButton = getEnhancePromptButton()
|
||||
fireEvent.click(enhanceButton)
|
||||
|
||||
const loadingSpinner = screen.getByText("", { selector: ".codicon-loading" })
|
||||
|
|
@ -150,7 +159,7 @@ describe("ChatTextArea", () => {
|
|||
rerender(<ChatTextArea {...defaultProps} />)
|
||||
|
||||
// Verify the enhance button appears after apiConfiguration changes
|
||||
expect(screen.getByRole("button", { name: /enhance prompt/i })).toBeInTheDocument()
|
||||
expect(getEnhancePromptButton()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -82,9 +82,12 @@ export const SelectDropdown = React.forwardRef<React.ElementRef<typeof DropdownM
|
|||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
"w-full min-w-0 max-w-full inline-flex items-center gap-1 relative whitespace-nowrap pr-1.5 py-1.5 text-xs outline-none",
|
||||
"bg-transparent border-none text-vscode-foreground w-auto",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "opacity-80 hover:opacity-100 cursor-pointer",
|
||||
"w-full min-w-0 max-w-full inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
|
||||
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground w-auto",
|
||||
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
|
||||
triggerClassName,
|
||||
)}>
|
||||
<CaretUpIcon className="pointer-events-none opacity-80 flex-shrink-0 size-3" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue