From 154ddc07c2a484b21f79bf11281410cbe9081999 Mon Sep 17 00:00:00 2001 From: Vorflux AI Date: Mon, 1 Jun 2026 14:16:00 +0000 Subject: [PATCH] feat: add image and PDF attachment support to Nova chat - Add paperclip button to ChatInput (both stacked and inline variants) - File picker accepts images (jpeg/png/gif/webp) and PDFs up to 10MB - Attachments are converted to FileUIPart (data URLs) via FileReader - Preview chips shown in input: thumbnails for images, filename for PDFs - Remove-chip button on hover - handleSend forwards FileUIPart[] to sendMessage via AI SDK - UserMessage renders image thumbnails and PDF file chips inline - canSend allows sending with attachments even when text is empty --- apps/web/components/chat/index.tsx | 6 +- apps/web/components/chat/input/index.tsx | 149 +++++++++++++++++- .../components/chat/message/user-message.tsx | 42 ++++- 3 files changed, 183 insertions(+), 14 deletions(-) diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index d140b469..1625ec1b 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -332,12 +332,12 @@ export function ChatSidebar({ } }, []) - const handleSend = () => { - if (!input.trim() || status === "submitted" || status === "streaming") + const handleSend = (attachments?: import("ai").FileUIPart[]) => { + if ((!input.trim() && (!attachments || attachments.length === 0)) || status === "submitted" || status === "streaming") return if (!threadId) setThreadId(fallbackChatId) analytics.chatMessageSent({ source: "typed" }) - sendMessage({ text: input }) + sendMessage({ text: input, files: attachments }) setInput("") userJustSentRef.current = true scrollToBottom() diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index d24276d0..3ce95425 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -1,17 +1,21 @@ "use client" -import { ChevronUpIcon } from "lucide-react" +import { ChevronUpIcon, Paperclip, X, FileText } from "lucide-react" import NovaOrb from "@/components/nova/nova-orb" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" -import { type ReactNode, useEffect, useRef, useState } from "react" +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react" import { motion } from "motion/react" +import type { FileUIPart } from "ai" import { SendButton, StopButton } from "./actions" +const ACCEPTED_FILE_TYPES = "image/jpeg,image/png,image/gif,image/webp,application/pdf" +const MAX_FILE_SIZE_MB = 10 + interface ChatInputProps { value: string onChange: (e: React.ChangeEvent) => void - onSend: () => void + onSend: (attachments?: FileUIPart[]) => void onStop: () => void onKeyDown?: (e: React.KeyboardEvent) => void isResponding?: boolean @@ -39,7 +43,41 @@ export default function ChatInput({ }: ChatInputProps) { const [isMultiline, setIsMultiline] = useState(false) const [isExpanded, setIsExpanded] = useState(false) + const [attachments, setAttachments] = useState([]) const textareaRef = useRef(null) + const fileInputRef = useRef(null) + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []) + e.target.value = "" + const parts = await Promise.all( + files + .filter((f) => f.size <= MAX_FILE_SIZE_MB * 1024 * 1024) + .map( + (file) => + new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => + resolve({ + type: "file" as const, + mediaType: file.type, + filename: file.name, + url: reader.result as string, + }) + reader.readAsDataURL(file) + }), + ), + ) + setAttachments((prev) => [...prev, ...parts]) + }, + [], + ) + + const handleSend = useCallback(() => { + onSend(attachments.length > 0 ? attachments : undefined) + setAttachments([]) + }, [onSend, attachments]) useEffect(() => { if (!showStatusStrip && isExpanded) { @@ -62,6 +100,15 @@ export default function ChatInput({ } return ( + <> + + {attachments.length > 0 && ( +
+ {attachments.map((att, i) => ( + setAttachments((prev) => prev.filter((_, idx) => idx !== i))} + /> + ))} +
+ )}