From 4a84fee3d016b23b0160ced705da17587e774d2c Mon Sep 17 00:00:00 2001 From: Abu Date: Wed, 18 Mar 2026 16:16:49 -0400 Subject: [PATCH 1/3] abu/multimodal --- apps/web/src/components/attachment-chip.tsx | 55 ++++++++ apps/web/src/hooks/use-file-attachments.ts | 128 ++++++++++++++++++ apps/web/src/routes/chat/index.tsx | 112 ++++++++++++++- .../convex-backend/convex/_generated/api.d.ts | 2 + packages/convex-backend/convex/files.ts | 29 ++++ 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/attachment-chip.tsx create mode 100644 apps/web/src/hooks/use-file-attachments.ts create mode 100644 packages/convex-backend/convex/files.ts diff --git a/apps/web/src/components/attachment-chip.tsx b/apps/web/src/components/attachment-chip.tsx new file mode 100644 index 0000000..0ba9762 --- /dev/null +++ b/apps/web/src/components/attachment-chip.tsx @@ -0,0 +1,55 @@ +import { FileText, Loader2, X } from "lucide-react"; +import type { PendingAttachment } from "../hooks/use-file-attachments"; + +export function AttachmentChip({ + attachment, + onRemove, +}: { + attachment: PendingAttachment; + onRemove: () => void; +}) { + const isImage = attachment.mimeType.startsWith("image/"); + const isPdf = attachment.mimeType === "application/pdf"; + + return ( +
+ {isImage && attachment.previewUrl ? ( + {attachment.fileName} + ) : isPdf ? ( +
+ + + {attachment.fileName} + +
+ ) : null} + + {/* Uploading spinner overlay */} + {attachment.status === "uploading" && ( +
+ +
+ )} + + {/* Error overlay */} + {attachment.status === "error" && ( +
+ Error +
+ )} + + {/* Remove button — appears on hover */} + +
+ ); +} diff --git a/apps/web/src/hooks/use-file-attachments.ts b/apps/web/src/hooks/use-file-attachments.ts new file mode 100644 index 0000000..eec8e5d --- /dev/null +++ b/apps/web/src/hooks/use-file-attachments.ts @@ -0,0 +1,128 @@ +import { api } from "@harness/convex-backend/convex/_generated/api"; +import { useConvex } from "convex/react"; +import { useCallback, useRef, useState } from "react"; +import toast from "react-hot-toast"; + +const ACCEPTED_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "application/pdf", +]); + +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB +const MAX_PDF_BYTES = 20 * 1024 * 1024; // 20 MB + +export interface PendingAttachment { + localId: string; + previewUrl: string | null; // object URL for images, null for PDFs + mimeType: string; + status: "uploading" | "ready" | "error"; + storageId?: string; + fileName: string; + fileSize: number; +} + +export function useFileAttachments() { + const [attachments, setAttachments] = useState([]); + const convex = useConvex(); + const localIdCounter = useRef(0); + + const uploadOne = useCallback( + async (file: File, localId: string) => { + try { + const uploadUrl = await convex.mutation( + api.files.generateUploadUrl, + {}, + ); + const res = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }); + if (!res.ok) throw new Error("Upload failed"); + const { storageId } = await res.json(); + setAttachments((prev) => + prev.map((a) => + a.localId === localId ? { ...a, status: "ready", storageId } : a, + ), + ); + } catch { + setAttachments((prev) => + prev.map((a) => + a.localId === localId ? { ...a, status: "error" } : a, + ), + ); + toast.error(`Failed to upload ${file.name}`); + } + }, + [convex], + ); + + const addFiles = useCallback( + (files: File[]) => { + for (const file of files) { + if (!ACCEPTED_TYPES.has(file.type)) { + toast.error(`${file.name}: only images (PNG, JPG, GIF, WebP) and PDFs are supported`); + continue; + } + const maxBytes = + file.type === "application/pdf" ? MAX_PDF_BYTES : MAX_IMAGE_BYTES; + if (file.size > maxBytes) { + const limit = file.type === "application/pdf" ? "20 MB" : "10 MB"; + toast.error(`${file.name}: exceeds ${limit} limit`); + continue; + } + + const localId = String(++localIdCounter.current); + const previewUrl = file.type.startsWith("image/") + ? URL.createObjectURL(file) + : null; + + setAttachments((prev) => [ + ...prev, + { + localId, + previewUrl, + mimeType: file.type, + status: "uploading", + fileName: file.name, + fileSize: file.size, + }, + ]); + + // Fire and forget — state updates happen inside uploadOne + uploadOne(file, localId); + } + }, + [uploadOne], + ); + + const removeAttachment = useCallback((localId: string) => { + setAttachments((prev) => { + const target = prev.find((a) => a.localId === localId); + if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl); + return prev.filter((a) => a.localId !== localId); + }); + }, []); + + const clearAttachments = useCallback(() => { + setAttachments((prev) => { + for (const a of prev) { + if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); + } + return []; + }); + }, []); + + const hasUploading = attachments.some((a) => a.status === "uploading"); + + return { + attachments, + addFiles, + removeAttachment, + clearAttachments, + hasUploading, + }; +} diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 45d42a4..8af31e9 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -23,6 +23,7 @@ import { MessageSquare, PanelLeftClose, PanelLeftOpen, + Paperclip, Plus, Search, // Icon for search Settings, @@ -102,6 +103,11 @@ import { useChatStream, } from "../../lib/use-chat-stream"; import { cn } from "../../lib/utils"; +import { AttachmentChip } from "../../components/attachment-chip"; +import { + type PendingAttachment, + useFileAttachments, +} from "../../hooks/use-file-attachments"; export const Route = createFileRoute("/chat/")({ validateSearch: (search: Record) => ({ @@ -2595,6 +2601,11 @@ function ChatInput({ }) { const [text, setText] = useState(""); const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const [isDragOver, setIsDragOver] = useState(false); + + const { attachments, addFiles, removeAttachment, clearAttachments, hasUploading } = + useFileAttachments(); // Fill input from suggested prompt click useEffect(() => { @@ -2648,6 +2659,7 @@ function ChatInput({ setText(""); setHistoryIndex(-1); setDraft(""); + clearAttachments(); // If streaming, just enqueue — don't interrupt if (isStreaming && conversationId) { @@ -2742,10 +2754,72 @@ function ChatInput({ } }; + const handlePaste = (e: React.ClipboardEvent) => { + const files = Array.from(e.clipboardData.files).filter( + (f) => f.type.startsWith("image/") || f.type === "application/pdf", + ); + if (files.length > 0) { + e.preventDefault(); + addFiles(files); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer.types.includes("Files")) { + setIsDragOver(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + // Only clear if leaving the container entirely (not moving to a child) + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragOver(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) addFiles(files); + }; + const showStopButton = isStreaming && !text.trim(); return ( -
+
+ {/* Drop overlay */} + {isDragOver && ( +
+
+ + Drop files to attach +
+
+ )} + + {/* Hidden file input */} + { + if (e.target.files) addFiles(Array.from(e.target.files)); + e.target.value = ""; + }} + /> +
{/* Queued messages as chips above the input */} @@ -2799,7 +2873,41 @@ function ChatInput({ )} + {/* Attachment preview strip */} + + {attachments.length > 0 && ( + + {attachments.map((attachment) => ( + removeAttachment(attachment.localId)} + /> + ))} + + )} + +
+ {/* Attach button */} + + + + + Attach image or PDF + +