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))} + /> + ))} +
+ )}