From 348ee4549bead2ab6b0d7f86131d596ff5829350 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Thu, 3 Sep 2026 19:06:38 +0800 Subject: [PATCH 1/9] feat(composer): add attachment input to column composer Co-authored-by: CommandCodeBot --- .../chat/composer/conversation-composer.tsx | 194 +++++++++++++++++- .../chat/composer/thread-model-selector.tsx | 2 +- app/thread-chat/styles/composer.css | 56 ++++- 3 files changed, 243 insertions(+), 9 deletions(-) diff --git a/app/thread-chat/chat/composer/conversation-composer.tsx b/app/thread-chat/chat/composer/conversation-composer.tsx index 38c5fb42..f4c9fce6 100644 --- a/app/thread-chat/chat/composer/conversation-composer.tsx +++ b/app/thread-chat/chat/composer/conversation-composer.tsx @@ -1,12 +1,38 @@ "use client" -import React, { useEffect, useRef } from "react" +import React, { + useCallback, + useEffect, + useRef, + useState, + type ChangeEvent, + type ClipboardEvent, + type DragEvent, +} from "react" +import { FileIcon, PlusIcon, XIcon } from "lucide-react" import { ThreadModelSelector } from "./thread-model-selector" import { composerMaxHeight, composerSubmission, shouldSubmitComposerKey, } from "./conversation-composer-logic" +import { + appendDemoAttachments, + createDemoAttachments, + removeDemoAttachment, + type DemoAttachment, + type DemoAttachmentSource, +} from "./attachment-composer-demo-model" +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, +} from "@/components/ui/attachment" type ConversationComposerProps = { variant: "column" | "canvas" @@ -28,6 +54,10 @@ function autoGrow(ta: HTMLTextAreaElement, maxHeight: number) { ta.style.height = Math.min(ta.scrollHeight, maxHeight) + "px" } +function hasDraggedFiles(event: DragEvent) { + return Array.from(event.dataTransfer.types).includes("Files") +} + export function ConversationComposer({ variant, threadId, @@ -43,6 +73,10 @@ export function ConversationComposer({ onBeforeSend, }: ConversationComposerProps) { const taRef = useRef(null) + const fileInputRef = useRef(null) + const dragDepthRef = useRef(0) + const [attachments, setAttachments] = useState([]) + const [isDragging, setIsDragging] = useState(false) const canvas = variant === "canvas" const maxHeight = composerMaxHeight(variant) @@ -55,9 +89,61 @@ export function ConversationComposer({ ta.style.height = "auto" onBeforeSend?.() onSend(text) + if (!canvas) setAttachments([]) ta.focus(canvas ? { preventScroll: true } : undefined) } + const appendFiles = useCallback( + (files: Iterable, source: DemoAttachmentSource) => { + const added = createDemoAttachments(files, source) + if (added.length === 0) return + setAttachments((current) => appendDemoAttachments(current, added)) + }, + [] + ) + + const handleFileChange = (event: ChangeEvent) => { + appendFiles(event.currentTarget.files ?? [], "picker") + event.currentTarget.value = "" + } + + const handleDragEnter = (event: DragEvent) => { + if (!hasDraggedFiles(event)) return + event.preventDefault() + dragDepthRef.current += 1 + setIsDragging(true) + } + + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + if (hasDraggedFiles(event)) event.dataTransfer.dropEffect = "copy" + } + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setIsDragging(false) + } + + const handleDrop = (event: DragEvent) => { + event.preventDefault() + dragDepthRef.current = 0 + setIsDragging(false) + appendFiles(event.dataTransfer.files, "drop") + } + + const handlePaste = (event: ClipboardEvent) => { + // 与 demo 不同:纯文本粘贴保持正常插入文字,只有文件/图片粘贴才进附件托盘。 + const pastedFiles = Array.from(event.clipboardData.items) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) + + if (pastedFiles.length === 0) return + event.preventDefault() + appendFiles(pastedFiles, "paste") + } + useEffect(() => { const ta = taRef.current if (!ta || !prefill || ta.value !== "") return @@ -67,6 +153,21 @@ export function ConversationComposer({ ta.setSelectionRange(ta.value.length, ta.value.length) }, [canvas, maxHeight, threadId, prefill]) + const handleBoxPointerDown = (event: React.MouseEvent) => { + // 点击 box 任意位置都把焦点交给 textarea(附件卡片的交互不吞掉这个行为之外的事)。 + if (event.target instanceof Element && event.target.closest("button, a")) { + if (event.target.closest('[data-slot="attachment"]')) return + } + const ta = taRef.current + if (!ta) return + const isFocused = document.activeElement === ta + const selection = window.getSelection() + if (isFocused || !selection?.isCollapsed) return + event.preventDefault() + ta.focus() + ta.setSelectionRange(ta.value.length, ta.value.length) + } + const textarea = (