Merge pull request #2969 from lc6464/feat/webchat-image-paste-dnd

feat(web): add chat image paste and drag-and-drop upload
This commit is contained in:
Mauro
2026-05-30 20:22:56 +02:00
committed by GitHub
8 changed files with 320 additions and 71 deletions
@@ -1,5 +1,10 @@
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react"
import {
type ClipboardEvent as ReactClipboardEvent,
type DragEvent as ReactDragEvent,
type KeyboardEvent as ReactKeyboardEvent,
useRef,
} from "react"
import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
@@ -30,11 +35,17 @@ interface ChatComposerProps {
attachments: ChatAttachment[]
onInputChange: (value: string) => void
onAddImages: () => void
onPaste: (event: ReactClipboardEvent<HTMLTextAreaElement>) => void
onDragEnter: (event: ReactDragEvent<HTMLDivElement>) => void
onDragLeave: (event: ReactDragEvent<HTMLDivElement>) => void
onDragOver: (event: ReactDragEvent<HTMLDivElement>) => void
onDrop: (event: ReactDragEvent<HTMLDivElement>) => void
onRemoveAttachment: (index: number) => void
onSend: () => void
onContextDetail?: () => void
inputDisabledReason: ChatInputDisabledReason | null
canSend: boolean
isDragActive: boolean
contextUsage?: ContextUsage
}
@@ -43,11 +54,17 @@ export function ChatComposer({
attachments,
onInputChange,
onAddImages,
onPaste,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onRemoveAttachment,
onSend,
onContextDetail,
inputDisabledReason,
canSend,
isDragActive,
contextUsage,
}: ChatComposerProps) {
const { t } = useTranslation()
@@ -78,8 +95,25 @@ export function ChatComposer({
}
return (
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [scrollbar-gutter:stable] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm">
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 [scrollbar-gutter:stable] overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div
className={cn(
"bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm transition-colors",
isDragActive && "border-violet-400/70 bg-violet-500/5",
)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{isDragActive && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-violet-400/70 bg-violet-500/10">
<div className="bg-background/95 text-foreground rounded-full px-4 py-2 text-sm font-medium shadow-sm">
{t("chat.dropImagesActive")}
</div>
</div>
)}
{attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => (
@@ -115,6 +149,7 @@ export function ChatComposer({
onCompositionEnd={() => {
composingRef.current = false
}}
onPaste={onPaste}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={!canInput}
+103 -63
View File
@@ -1,8 +1,14 @@
import { IconPlus } from "@tabler/icons-react"
import { useAtom } from "jotai"
import { type ChangeEvent, useEffect, useRef, useState } from "react"
import {
type ChangeEvent,
type ClipboardEvent,
type DragEvent,
useEffect,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { AssistantMessage } from "@/components/chat/assistant-message"
import {
@@ -23,6 +29,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
CHAT_IMAGE_ACCEPT,
buildChatImageAttachments,
getTransferredFiles,
hasFileTransfer,
} from "@/features/chat/image-input"
import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat"
@@ -36,32 +48,6 @@ import {
} from "@/store/chat"
import type { GatewayState } from "@/store/gateway"
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
const MAX_IMAGE_SIZE_LABEL = "7 MB"
const ALLOWED_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
])
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result)
return
}
reject(new Error("Failed to read file"))
}
reader.onerror = () =>
reject(reader.error || new Error("Failed to read file"))
reader.readAsDataURL(file)
})
}
function resolveChatInputDisabledReason({
hasDefaultModel,
connectionState,
@@ -118,10 +104,12 @@ export function ChatPage() {
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const [isAtBottom, setIsAtBottom] = useState(true)
const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const [isDragActive, setIsDragActive] = useState(false)
const [assistantDetailVisibility, setAssistantDetailVisibility] = useAtom(
assistantDetailVisibilityAtom,
)
@@ -223,6 +211,19 @@ export function ChatPage() {
setAttachments((prev) => prev.filter((_, itemIndex) => itemIndex !== index))
}
const appendImageFiles = async (files: readonly File[]) => {
if (!canInput || files.length === 0) {
return
}
const nextAttachments = await buildChatImageAttachments(files, t)
if (nextAttachments.length === 0) {
return
}
setAttachments((prev) => [...prev, ...nextAttachments])
}
const handleImageSelection = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? [])
event.target.value = ""
@@ -231,45 +232,77 @@ export function ChatPage() {
return
}
const nextAttachments: ChatAttachment[] = []
for (const file of files) {
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
toast.error(
t("chat.invalidImage", {
name: file.name,
}),
)
continue
}
await appendImageFiles(files)
}
if (file.size > MAX_IMAGE_SIZE_BYTES) {
toast.error(
t("chat.imageTooLarge", {
name: file.name,
size: MAX_IMAGE_SIZE_LABEL,
}),
)
continue
}
const resetDragState = () => {
dragDepthRef.current = 0
setIsDragActive(false)
}
try {
nextAttachments.push({
type: "image",
filename: file.name,
url: await readFileAsDataUrl(file),
})
} catch {
toast.error(
t("chat.imageReadFailed", {
name: file.name,
}),
)
}
const handleComposerPaste = async (
event: ClipboardEvent<HTMLTextAreaElement>,
) => {
const files = getTransferredFiles(event.clipboardData)
if (files.length === 0) {
return
}
if (nextAttachments.length > 0) {
setAttachments(nextAttachments.slice(0, 1))
await appendImageFiles(files)
}
const handleComposerDragEnter = (event: DragEvent<HTMLDivElement>) => {
if (!hasFileTransfer(event.dataTransfer)) {
return
}
event.preventDefault()
if (!canInput) {
return
}
dragDepthRef.current += 1
setIsDragActive(true)
}
const handleComposerDragLeave = (event: DragEvent<HTMLDivElement>) => {
if (!hasFileTransfer(event.dataTransfer)) {
return
}
event.preventDefault()
if (!canInput) {
resetDragState()
return
}
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setIsDragActive(false)
}
}
const handleComposerDragOver = (event: DragEvent<HTMLDivElement>) => {
if (!hasFileTransfer(event.dataTransfer)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = canInput ? "copy" : "none"
}
const handleComposerDrop = async (event: DragEvent<HTMLDivElement>) => {
if (!hasFileTransfer(event.dataTransfer)) {
return
}
event.preventDefault()
const files = getTransferredFiles(event.dataTransfer)
resetDragState()
if (!canInput || files.length === 0) {
return
}
await appendImageFiles(files)
}
const canSubmit =
@@ -398,7 +431,8 @@ export function ChatPage() {
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
accept={CHAT_IMAGE_ACCEPT}
multiple
className="hidden"
onChange={handleImageSelection}
/>
@@ -408,6 +442,11 @@ export function ChatPage() {
attachments={attachments}
onInputChange={setInput}
onAddImages={handleAddImages}
onPaste={handleComposerPaste}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
onRemoveAttachment={handleRemoveAttachment}
onSend={handleSend}
onContextDetail={() => {
@@ -417,6 +456,7 @@ export function ChatPage() {
}}
inputDisabledReason={inputDisabledReason}
canSend={canSubmit}
isDragActive={isDragActive}
contextUsage={contextUsage}
/>
</div>
@@ -39,7 +39,7 @@ export function UserMessage({
<img
key={`${attachment.url}-${index}`}
src={attachment.url}
alt={attachment.filename || "Uploaded image"}
alt={attachment.filename || t("chat.uploadedImage")}
className="max-h-72 max-w-full object-cover"
/>
))}