|
|
@@ -8,10 +8,13 @@ import DynamicTextArea from "react-textarea-autosize"
|
|
|
import { cn } from "../../../lib/utils"
|
|
|
import { StandardTooltip } from "../../../components/ui"
|
|
|
import { SelectDropdown, type DropdownOption } from "../../../components/ui/select-dropdown"
|
|
|
-import { sessionInputAtomFamily } from "../state/atoms/sessions"
|
|
|
+import { sessionInputAtomFamily, sessionImagesAtomFamily } from "../state/atoms/sessions"
|
|
|
import { sessionTodoStatsAtomFamily } from "../state/atoms/todos"
|
|
|
import { AgentTodoList } from "./AgentTodoList"
|
|
|
import { addToQueueAtom } from "../state/atoms/messageQueue"
|
|
|
+import { useImagePaste } from "../hooks/useImagePaste"
|
|
|
+import { ImageThumbnail } from "./ImageThumbnail"
|
|
|
+import { AddImageButton } from "./AddImageButton"
|
|
|
|
|
|
interface ChatInputProps {
|
|
|
sessionId: string
|
|
|
@@ -40,6 +43,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
}) => {
|
|
|
const { t } = useTranslation("agentManager")
|
|
|
const [messageText, setMessageText] = useAtom(sessionInputAtomFamily(sessionId))
|
|
|
+ const [selectedImages, setSelectedImages] = useAtom(sessionImagesAtomFamily(sessionId))
|
|
|
const todoStats = useAtomValue(sessionTodoStatsAtomFamily(sessionId))
|
|
|
const modelsConfig = useAtomValue(modelsConfigAtom)
|
|
|
const [isFocused, setIsFocused] = useState(false)
|
|
|
@@ -62,29 +66,43 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
}, [sessionId])
|
|
|
|
|
|
const trimmedMessage = messageText.trim()
|
|
|
- const isEmpty = trimmedMessage.length === 0
|
|
|
+ const hasText = trimmedMessage.length > 0
|
|
|
+ const hasImages = selectedImages.length > 0
|
|
|
+ const isEmpty = !hasText && !hasImages
|
|
|
const isSessionCompleted = sessionStatus === "done" || sessionStatus === "error" || sessionStatus === "stopped"
|
|
|
|
|
|
- // Send is disabled when empty
|
|
|
+ // Send is disabled when empty (no text AND no images)
|
|
|
// Note: Users CAN queue multiple messages while one is sending (for running sessions)
|
|
|
// Note: Users CAN send messages to completed sessions (to resume them)
|
|
|
const sendDisabled = isEmpty
|
|
|
|
|
|
+ // Use shared hook for image paste and file selection handling
|
|
|
+ const { handlePaste, handleFileSelect, openFileBrowser, removeImage, canAddMore, fileInputRef, acceptedMimeTypes } =
|
|
|
+ useImagePaste({
|
|
|
+ selectedImages,
|
|
|
+ setSelectedImages,
|
|
|
+ })
|
|
|
+
|
|
|
const handleSend = () => {
|
|
|
if (isEmpty) return
|
|
|
|
|
|
+ // Prepare images array (only include if there are images)
|
|
|
+ const images = hasImages ? selectedImages : undefined
|
|
|
+
|
|
|
if (isSessionCompleted) {
|
|
|
// Resume a completed session with a new message (sent directly, not queued)
|
|
|
vscode.postMessage({
|
|
|
type: "agentManager.resumeSession",
|
|
|
sessionId,
|
|
|
sessionLabel,
|
|
|
- content: trimmedMessage,
|
|
|
+ content: trimmedMessage || "", // Can be empty if only images
|
|
|
+ images,
|
|
|
})
|
|
|
setMessageText("")
|
|
|
+ setSelectedImages([])
|
|
|
} else {
|
|
|
// For running sessions, queue the message instead of sending directly
|
|
|
- const queuedMsg = addToQueue({ sessionId, content: trimmedMessage })
|
|
|
+ const queuedMsg = addToQueue({ sessionId, content: trimmedMessage || "", images })
|
|
|
|
|
|
if (queuedMsg) {
|
|
|
// Notify the extension that a message has been queued
|
|
|
@@ -93,10 +111,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
sessionId,
|
|
|
messageId: queuedMsg.id,
|
|
|
sessionLabel,
|
|
|
- content: trimmedMessage,
|
|
|
+ content: trimmedMessage || "",
|
|
|
+ images,
|
|
|
})
|
|
|
|
|
|
setMessageText("")
|
|
|
+ setSelectedImages([])
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -192,25 +212,24 @@ If any step fails, ask the user for help.`
|
|
|
|
|
|
return (
|
|
|
<div className="am-chat-input-container">
|
|
|
- {/* Unified wrapper when todos present - handles border and focus state */}
|
|
|
+ {/* Unified wrapper - handles border and focus state for textarea + toolbar */}
|
|
|
<div
|
|
|
className={cn(
|
|
|
"relative flex-1 flex flex-col min-h-0 overflow-hidden rounded",
|
|
|
- hasTodos && [
|
|
|
- "border bg-vscode-input-background",
|
|
|
- isFocused
|
|
|
- ? "border-vscode-focusBorder outline outline-vscode-focusBorder"
|
|
|
- : "border-vscode-input-border",
|
|
|
- ],
|
|
|
+ "border bg-vscode-input-background",
|
|
|
+ isFocused
|
|
|
+ ? "border-vscode-focusBorder outline outline-vscode-focusBorder"
|
|
|
+ : "border-vscode-input-border",
|
|
|
)}>
|
|
|
{/* Todo list above input */}
|
|
|
{hasTodos && <AgentTodoList stats={todoStats} isIntegrated />}
|
|
|
- <div className={cn("relative", "flex-1", "flex", "flex-col-reverse", "min-h-0", "overflow-hidden")}>
|
|
|
+ <div className={cn("relative", "flex-1", "flex", "flex-col", "min-h-0", "overflow-visible")}>
|
|
|
<DynamicTextArea
|
|
|
ref={textareaRef}
|
|
|
value={messageText}
|
|
|
onChange={(e) => setMessageText(e.target.value)}
|
|
|
onKeyDown={handleKeyDown}
|
|
|
+ onPaste={handlePaste}
|
|
|
onFocus={() => setIsFocused(true)}
|
|
|
onBlur={() => setIsFocused(false)}
|
|
|
aria-label={t("chatInput.ariaLabel")}
|
|
|
@@ -224,86 +243,139 @@ If any step fails, ask the user for help.`
|
|
|
"text-vscode-editor-font-size",
|
|
|
"leading-vscode-editor-line-height",
|
|
|
"cursor-text",
|
|
|
- "!pt-3 !pl-3 pr-9",
|
|
|
- // Only show border when no todos (standalone mode)
|
|
|
- !hasTodos && [
|
|
|
- isFocused
|
|
|
- ? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
|
|
|
- : "border border-vscode-input-border",
|
|
|
- "rounded",
|
|
|
- ],
|
|
|
+ "!pt-3 !pl-3 pr-3 !pb-2",
|
|
|
"bg-vscode-input-background",
|
|
|
+ "!border-none !outline-none",
|
|
|
+ "focus:!border-none focus:!outline-none focus:ring-0",
|
|
|
+ "focus-visible:!border-none focus-visible:!outline-none focus-visible:ring-0",
|
|
|
"transition-background-color duration-150 ease-in-out",
|
|
|
"will-change-background-color",
|
|
|
- "min-h-[90px]",
|
|
|
+ "min-h-[70px]",
|
|
|
"box-border",
|
|
|
"resize-none",
|
|
|
"overflow-x-hidden",
|
|
|
"overflow-y-auto",
|
|
|
- "!pb-10",
|
|
|
"flex-none flex-grow",
|
|
|
- "z-[2]",
|
|
|
"scrollbar-none",
|
|
|
"scrollbar-hide",
|
|
|
)}
|
|
|
/>
|
|
|
|
|
|
- {/* Transparent overlay at bottom */}
|
|
|
- <div
|
|
|
- className="absolute bottom-[1px] left-2 right-2 h-10 bg-gradient-to-t from-[var(--vscode-input-background)] via-[var(--vscode-input-background)] to-transparent pointer-events-none z-[2]"
|
|
|
- aria-hidden="true"
|
|
|
- />
|
|
|
+ {/* Bottom toolbar - inside the bordered container */}
|
|
|
+ <div className="flex items-center justify-between px-3 pb-2 bg-vscode-input-background">
|
|
|
+ {/* Left side: Image thumbnails and hint */}
|
|
|
+ <div className="flex items-center gap-2 min-w-0 flex-1">
|
|
|
+ {selectedImages.length > 0 && (
|
|
|
+ <div className="flex items-center gap-1.5 flex-shrink-0">
|
|
|
+ {selectedImages.map((image, index) => (
|
|
|
+ <ImageThumbnail key={index} src={image} index={index} onRemove={removeImage} />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!messageText && !hasImages && (
|
|
|
+ <div className="overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-vscode-descriptionForeground opacity-70 select-none">
|
|
|
+ {t("chatInput.hint")}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
|
|
|
- {/* Floating Actions */}
|
|
|
- <div className="absolute bottom-2 right-2 z-30 flex items-center gap-1">
|
|
|
- {/* Model indicator (read-only) - disabled SelectDropdown for visual consistency */}
|
|
|
- {modelId && modelOptions.length > 0 && (
|
|
|
- <div className="am-model-selector mr-1">
|
|
|
- <SelectDropdown
|
|
|
- value={modelId}
|
|
|
- options={modelOptions}
|
|
|
- onChange={() => {}} // No-op since disabled
|
|
|
- disabled={true}
|
|
|
- triggerClassName="am-model-selector-trigger"
|
|
|
- contentClassName="am-model-selector-content"
|
|
|
- align="end"
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
- {showFinishToBranch && (
|
|
|
- <StandardTooltip
|
|
|
- content={
|
|
|
- worktreeBranchName
|
|
|
- ? t("chatInput.finishToBranchTitle", { branch: worktreeBranchName })
|
|
|
- : t("chatInput.finishToBranchTitleNoBranch")
|
|
|
- }>
|
|
|
- <button
|
|
|
- aria-label={
|
|
|
+ {/* Right side: Actions */}
|
|
|
+ <div className="flex items-center gap-1 flex-shrink-0">
|
|
|
+ <AddImageButton
|
|
|
+ onClick={openFileBrowser}
|
|
|
+ onFileSelect={handleFileSelect}
|
|
|
+ fileInputRef={fileInputRef}
|
|
|
+ acceptedMimeTypes={acceptedMimeTypes}
|
|
|
+ disabled={!canAddMore}
|
|
|
+ />
|
|
|
+ {/* Model indicator (read-only) - disabled SelectDropdown for visual consistency */}
|
|
|
+ {modelId && modelOptions.length > 0 && (
|
|
|
+ <div className="am-model-selector mr-1">
|
|
|
+ <SelectDropdown
|
|
|
+ value={modelId}
|
|
|
+ options={modelOptions}
|
|
|
+ onChange={() => {}} // No-op since disabled
|
|
|
+ disabled={true}
|
|
|
+ triggerClassName="am-model-selector-trigger"
|
|
|
+ contentClassName="am-model-selector-content"
|
|
|
+ align="end"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {showFinishToBranch && (
|
|
|
+ <StandardTooltip
|
|
|
+ content={
|
|
|
worktreeBranchName
|
|
|
? t("chatInput.finishToBranchTitle", { branch: worktreeBranchName })
|
|
|
: t("chatInput.finishToBranchTitleNoBranch")
|
|
|
- }
|
|
|
- onClick={handleFinishToBranch}
|
|
|
- className={cn(
|
|
|
- "relative inline-flex items-center justify-center",
|
|
|
- "bg-transparent border-none p-1.5",
|
|
|
- "rounded-md min-w-[28px] min-h-[28px]",
|
|
|
- "opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
|
|
|
- "transition-all duration-150",
|
|
|
- "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)]",
|
|
|
- "cursor-pointer",
|
|
|
- )}>
|
|
|
- <GitBranch size={14} />
|
|
|
- </button>
|
|
|
- </StandardTooltip>
|
|
|
- )}
|
|
|
- {showCreatePR && (
|
|
|
- <StandardTooltip content={t("chatInput.createPRTitle")}>
|
|
|
+ }>
|
|
|
+ <button
|
|
|
+ aria-label={
|
|
|
+ worktreeBranchName
|
|
|
+ ? t("chatInput.finishToBranchTitle", { branch: worktreeBranchName })
|
|
|
+ : t("chatInput.finishToBranchTitleNoBranch")
|
|
|
+ }
|
|
|
+ onClick={handleFinishToBranch}
|
|
|
+ className={cn(
|
|
|
+ "relative inline-flex items-center justify-center",
|
|
|
+ "bg-transparent border-none p-1.5",
|
|
|
+ "rounded-md min-w-[28px] min-h-[28px]",
|
|
|
+ "opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
|
|
|
+ "transition-all duration-150",
|
|
|
+ "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)]",
|
|
|
+ "cursor-pointer",
|
|
|
+ )}>
|
|
|
+ <GitBranch size={14} />
|
|
|
+ </button>
|
|
|
+ </StandardTooltip>
|
|
|
+ )}
|
|
|
+ {showCreatePR && (
|
|
|
+ <StandardTooltip content={t("chatInput.createPRTitle")}>
|
|
|
+ <button
|
|
|
+ aria-label={t("chatInput.createPRTitle")}
|
|
|
+ onClick={handleCreatePR}
|
|
|
+ className={cn(
|
|
|
+ "relative inline-flex items-center justify-center",
|
|
|
+ "bg-transparent border-none p-1.5",
|
|
|
+ "rounded-md min-w-[28px] min-h-[28px]",
|
|
|
+ "opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
|
|
|
+ "transition-all duration-150",
|
|
|
+ "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)]",
|
|
|
+ "cursor-pointer",
|
|
|
+ )}>
|
|
|
+ <GitPullRequest size={14} />
|
|
|
+ </button>
|
|
|
+ </StandardTooltip>
|
|
|
+ )}
|
|
|
+ {isActive && showCancel && (
|
|
|
+ <StandardTooltip content={t("chatInput.cancelTitle")}>
|
|
|
+ <button
|
|
|
+ aria-label={t("chatInput.cancelTitle")}
|
|
|
+ onClick={handleCancel}
|
|
|
+ className={cn(
|
|
|
+ "relative inline-flex items-center justify-center",
|
|
|
+ "bg-transparent border-none p-1.5",
|
|
|
+ "rounded-md min-w-[28px] min-h-[28px]",
|
|
|
+ "opacity-60 hover:opacity-100 text-vscode-errorForeground",
|
|
|
+ "transition-all duration-150",
|
|
|
+ "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)]",
|
|
|
+ "cursor-pointer",
|
|
|
+ )}>
|
|
|
+ <Square size={14} fill="currentColor" />
|
|
|
+ </button>
|
|
|
+ </StandardTooltip>
|
|
|
+ )}
|
|
|
+ <StandardTooltip content={t("chatInput.sendTitle")}>
|
|
|
<button
|
|
|
- aria-label={t("chatInput.createPRTitle")}
|
|
|
- onClick={handleCreatePR}
|
|
|
+ aria-label={t("chatInput.sendTitle")}
|
|
|
+ disabled={sendDisabled}
|
|
|
+ onClick={handleSend}
|
|
|
className={cn(
|
|
|
"relative inline-flex items-center justify-center",
|
|
|
"bg-transparent border-none p-1.5",
|
|
|
@@ -313,71 +385,16 @@ If any step fails, ask the user for help.`
|
|
|
"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)]",
|
|
|
- "cursor-pointer",
|
|
|
- )}>
|
|
|
- <GitPullRequest size={14} />
|
|
|
- </button>
|
|
|
- </StandardTooltip>
|
|
|
- )}
|
|
|
- {isActive && showCancel && (
|
|
|
- <StandardTooltip content={t("chatInput.cancelTitle")}>
|
|
|
- <button
|
|
|
- aria-label={t("chatInput.cancelTitle")}
|
|
|
- onClick={handleCancel}
|
|
|
- className={cn(
|
|
|
- "relative inline-flex items-center justify-center",
|
|
|
- "bg-transparent border-none p-1.5",
|
|
|
- "rounded-md min-w-[28px] min-h-[28px]",
|
|
|
- "opacity-60 hover:opacity-100 text-vscode-errorForeground",
|
|
|
- "transition-all duration-150",
|
|
|
- "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)]",
|
|
|
- "cursor-pointer",
|
|
|
+ !sendDisabled && "cursor-pointer",
|
|
|
+ sendDisabled &&
|
|
|
+ "opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
|
|
|
)}>
|
|
|
- <Square size={14} fill="currentColor" />
|
|
|
+ {/* rtl support */}
|
|
|
+ <SendHorizontal className="w-4 h-4 rtl:-scale-x-100" />
|
|
|
</button>
|
|
|
</StandardTooltip>
|
|
|
- )}
|
|
|
- <StandardTooltip content={t("chatInput.sendTitle")}>
|
|
|
- <button
|
|
|
- aria-label={t("chatInput.sendTitle")}
|
|
|
- disabled={sendDisabled}
|
|
|
- onClick={handleSend}
|
|
|
- className={cn(
|
|
|
- "relative inline-flex items-center justify-center",
|
|
|
- "bg-transparent border-none p-1.5",
|
|
|
- "rounded-md min-w-[28px] min-h-[28px]",
|
|
|
- "opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
|
|
|
- "transition-all duration-150",
|
|
|
- "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)]",
|
|
|
- !sendDisabled && "cursor-pointer",
|
|
|
- sendDisabled &&
|
|
|
- "opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
|
|
|
- )}>
|
|
|
- {/* rtl support */}
|
|
|
- <SendHorizontal className="w-4 h-4 rtl:-scale-x-100" />
|
|
|
- </button>
|
|
|
- </StandardTooltip>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Hint Text inside input */}
|
|
|
- {!messageText && (
|
|
|
- <div
|
|
|
- className="absolute left-3 right-[100px] z-30 flex items-center h-8 overflow-hidden text-ellipsis whitespace-nowrap"
|
|
|
- style={{
|
|
|
- bottom: "0.25rem",
|
|
|
- color: "var(--vscode-descriptionForeground)",
|
|
|
- opacity: 0.7,
|
|
|
- fontSize: "11px",
|
|
|
- userSelect: "none",
|
|
|
- pointerEvents: "none",
|
|
|
- }}>
|
|
|
- {t("chatInput.hint")}
|
|
|
</div>
|
|
|
- )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|