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
This commit is contained in:
Vorflux AI 2026-06-01 14:16:00 +00:00
parent 2684990688
commit 154ddc07c2
3 changed files with 183 additions and 14 deletions

View file

@ -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()

View file

@ -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<HTMLTextAreaElement>) => 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<FileUIPart[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
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<FileUIPart>((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 (
<>
<input
accept={ACCEPTED_FILE_TYPES}
className="hidden"
multiple
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
<motion.div
className={cn("relative z-20!")}
animate={{
@ -130,6 +177,18 @@ export default function ChatInput({
) : null}
{stackedToolbar ? (
<div className="flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-1 pt-1">
{attachments.map((att, i) => (
<AttachmentChip
// biome-ignore lint/suspicious/noArrayIndexKey: stable list
key={i}
attachment={att}
onRemove={() => setAttachments((prev) => prev.filter((_, idx) => idx !== i))}
/>
))}
</div>
)}
<textarea
ref={textareaRef}
value={value}
@ -145,11 +204,20 @@ export default function ChatInput({
<div className="flex min-w-0 flex-1 items-center gap-2">
{stackedToolbar}
</div>
<button
type="button"
aria-label="Attach file"
onClick={() => fileInputRef.current?.click()}
disabled={isResponding}
className="shrink-0 rounded-lg p-1.5 text-fg-faint hover:text-fg-primary hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Paperclip className="size-4" />
</button>
<div className="shrink-0">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
<SendButton onClick={handleSend} disabled={!value.trim() && attachments.length === 0} />
)}
</div>
</div>
@ -161,6 +229,18 @@ export default function ChatInput({
isMultiline && "flex-col",
)}
>
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 w-full px-1 pt-1">
{attachments.map((att, i) => (
<AttachmentChip
// biome-ignore lint/suspicious/noArrayIndexKey: stable list
key={i}
attachment={att}
onRemove={() => setAttachments((prev) => prev.filter((_, idx) => idx !== i))}
/>
))}
</div>
)}
<textarea
ref={textareaRef}
value={value}
@ -172,15 +252,72 @@ export default function ChatInput({
rows={1}
disabled={isResponding}
/>
<div className="transition-all duration-200">
<div className={cn("flex items-center gap-1 transition-all duration-200", isMultiline && "w-full justify-end")}>
<button
type="button"
aria-label="Attach file"
onClick={() => fileInputRef.current?.click()}
disabled={isResponding}
className="rounded-lg p-1.5 text-fg-faint hover:text-fg-primary hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Paperclip className="size-4" />
</button>
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
<SendButton onClick={handleSend} disabled={!value.trim() && attachments.length === 0} />
)}
</div>
</div>
)}
</motion.div>
</>
)
}
function AttachmentChip({
attachment,
onRemove,
}: {
attachment: FileUIPart
onRemove: () => void
}) {
const isImage = attachment.mediaType.startsWith("image/")
return (
<div
className={cn(
"relative group flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 overflow-hidden",
isImage ? "size-12" : "px-2 py-1 max-w-[140px]",
)}
>
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachment.url}
alt={attachment.filename ?? "attachment"}
className="size-full object-cover"
/>
) : (
<>
<FileText className="size-3 shrink-0 text-fg-faint" />
<span className="text-xs text-fg-secondary truncate">
{attachment.filename ?? "file"}
</span>
</>
)}
<button
type="button"
aria-label="Remove attachment"
onClick={onRemove}
className={cn(
"absolute top-0.5 right-0.5 size-4 flex items-center justify-center",
"rounded-full bg-black/60 text-white/60",
"opacity-0 group-hover:opacity-100 transition-opacity",
"hover:text-white cursor-pointer",
)}
>
<X className="size-2.5" />
</button>
</div>
)
}

View file

@ -1,7 +1,7 @@
"use client"
import { memo } from "react"
import { Copy, Check } from "lucide-react"
import { Copy, Check, FileText } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
interface UserMessageProps {
@ -20,11 +20,43 @@ export const UserMessage = memo(function UserMessage({
.map((part) => part.text)
.join(" ")
const files = message.parts.filter(
(part): part is Extract<typeof part, { type: "file" }> => part.type === "file",
)
return (
<div className="flex flex-col items-end w-full">
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
<p className="text-sm text-white">{text}</p>
</div>
<div className="flex flex-col items-end w-full gap-2">
{files.length > 0 && (
<div className="flex flex-wrap justify-end gap-2 max-w-[80%]">
{files.map((f, i) => {
const isImage = f.mediaType.startsWith("image/")
return isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
// biome-ignore lint/suspicious/noArrayIndexKey: stable list
key={i}
src={f.url}
alt={f.filename ?? "attachment"}
className="max-h-48 max-w-48 rounded-xl border border-white/10 object-cover"
/>
) : (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: stable list
key={i}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-[#1B1F24] px-3 py-2 text-xs text-white/60"
>
<FileText className="size-3.5 shrink-0" />
<span className="truncate max-w-[120px]">{f.filename ?? "file"}</span>
</div>
)
})}
</div>
)}
{text && (
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
<p className="text-sm text-white">{text}</p>
</div>
)}
<button
type="button"
onClick={() => onCopy(message.id, text)}