feat: multi-file upload queue for Add Document (#789)

Adds multi-select and multi-file drag-and-drop on the Add Document Upload files tab, a per-file queue with status, retry for failed rows, and batched uploads to the existing `POST /v3/documents/file` endpoint with a client-side concurrency limit of 3.

Optional title/description apply only when a single file is queued. .md / .mdx (and text/markdown) are accepted.

Upload progress is shown as a bottom strip with a slow width animation
This commit is contained in:
MaheshtheDev 2026-03-19 21:14:17 +00:00
parent 4033087232
commit bc9120f751
4 changed files with 476 additions and 154 deletions

View file

@ -1,84 +1,161 @@
"use client"
import { useState, useEffect } from "react"
import { useEffect, useCallback, useRef, useState } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { FileIcon } from "lucide-react"
import { FileIcon, XIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
import { useHotkeys } from "react-hotkeys-hook"
import { toast } from "sonner"
export const FILE_ACCEPT =
"image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown"
export type FileQueueItemStatus = "pending" | "uploading" | "success" | "error"
export interface FileQueueItem {
id: string
file: File
status: FileQueueItemStatus
errorMessage?: string
}
export interface FileData {
file: File | null
items: FileQueueItem[]
title: string
description: string
}
interface FileContentProps {
onSubmit?: (data: { file: File; title: string; description: string }) => void
onDataChange?: (data: FileData) => void
data: FileData
onDataChange: (data: FileData) => void
onRequestSubmit: () => void
isSubmitting?: boolean
isOpen?: boolean
}
function isAcceptedFile(file: File): boolean {
const name = file.name.toLowerCase()
const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : ""
const allowedExt = new Set([
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".csv",
".txt",
".md",
".mdx",
])
if (allowedExt.has(ext)) return true
if (file.type.startsWith("image/")) return true
if (file.type === "text/markdown") return true
return false
}
function fileQueueKey(file: File): string {
return `${file.name}:${file.size}:${file.lastModified}`
}
export function FileContent({
onSubmit,
data,
onDataChange,
onRequestSubmit,
isSubmitting,
isOpen,
}: FileContentProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const canSubmit = selectedFile !== null && !isSubmitting
const anyUploading = data.items.some((i) => i.status === "uploading")
const canSubmit =
data.items.some((i) => i.status === "pending") &&
!isSubmitting &&
!anyUploading
const handleSubmit = () => {
if (canSubmit && onSubmit && selectedFile) {
onSubmit({ file: selectedFile, title, description })
}
}
const updateData = useCallback(
(partial: Partial<FileData>) => {
onDataChange({ ...data, ...partial })
},
[data, onDataChange],
)
const updateData = (
newFile: File | null,
newTitle: string,
newDescription: string,
) => {
onDataChange?.({
file: newFile,
title: newTitle,
description: newDescription,
})
}
const addFiles = useCallback(
(fileList: FileList | File[]) => {
const incoming = Array.from(fileList)
const accepted = incoming.filter(isAcceptedFile)
const rejected = incoming.length - accepted.length
if (rejected > 0) {
toast.error(
rejected === 1
? "One file type is not supported"
: `${rejected} files are not supported`,
)
}
if (accepted.length === 0) return
const handleFileChange = (file: File | null) => {
setSelectedFile(file)
updateData(file, title, description)
}
const existingKeys = new Set(data.items.map((i) => fileQueueKey(i.file)))
let duplicateCount = 0
const toAdd: FileQueueItem[] = []
for (const file of accepted) {
const key = fileQueueKey(file)
if (existingKeys.has(key)) {
duplicateCount++
continue
}
existingKeys.add(key)
toAdd.push({
id: crypto.randomUUID(),
file,
status: "pending",
})
}
if (duplicateCount > 0) {
toast.message(
duplicateCount === 1
? "Skipped duplicate file"
: `Skipped ${duplicateCount} duplicate files`,
)
}
if (toAdd.length === 0) return
onDataChange({
...data,
items: [...data.items, ...toAdd],
})
},
[data, onDataChange],
)
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle)
updateData(selectedFile, newTitle, description)
}
const removeItem = useCallback(
(id: string) => {
onDataChange({
...data,
items: data.items.filter((i) => i.id !== id),
})
},
[data, onDataChange],
)
const handleDescriptionChange = (newDescription: string) => {
setDescription(newDescription)
updateData(selectedFile, title, newDescription)
}
const handleTitleChange = useCallback(
(title: string) => updateData({ title }),
[updateData],
)
useHotkeys("mod+enter", handleSubmit, {
enabled: isOpen && canSubmit,
const handleDescriptionChange = useCallback(
(description: string) => updateData({ description }),
[updateData],
)
useHotkeys("mod+enter", onRequestSubmit, {
enabled: Boolean(isOpen && canSubmit),
enableOnFormTags: ["INPUT", "TEXTAREA"],
})
// Reset content when modal closes
useEffect(() => {
if (!isOpen) {
setSelectedFile(null)
setTitle("")
setDescription("")
onDataChange?.({ file: null, title: "", description: "" })
if (!isOpen && inputRef.current) {
inputRef.current.value = ""
}
}, [isOpen, onDataChange])
}, [isOpen])
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
@ -93,24 +170,27 @@ export function FileContent({
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) {
handleFileChange(file)
if (e.dataTransfer.files?.length) {
addFiles(e.dataTransfer.files)
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleFileChange(file)
const list = e.target.files
if (list?.length) {
addFiles(list)
}
e.target.value = ""
}
const showTitleDescription = data.items.length <= 1
const hasItems = data.items.length > 0
return (
<div className={cn("h-full flex flex-col gap-6 pt-4", dmSansClassName())}>
<div className="flex flex-col gap-2">
<p className="text-[16px] font-medium pl-2">
Upload a file (image, pdf, document, sheet)
Upload files (images, PDF, documents, sheets, markdown)
</p>
<label
onDragOver={handleDragOver}
@ -121,57 +201,167 @@ export function FileContent({
isDragging
? "border-[#4BA0FA] bg-[#4BA0FA]/10"
: "border-[#737373]/30 hover:border-[#737373]/50",
isSubmitting && "opacity-50 pointer-events-none",
(isSubmitting || anyUploading) && "opacity-50 pointer-events-none",
)}
>
<input
ref={inputRef}
type="file"
multiple
onChange={handleFileSelect}
disabled={isSubmitting}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt"
accept={FILE_ACCEPT}
/>
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-[#0F1217]">
<FileIcon className="size-6 text-[#737373]" />
</div>
{selectedFile ? (
<div className="text-center">
<p className="text-white font-medium">{selectedFile.name}</p>
<p className="text-[#737373] text-sm">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
{hasItems ? (
<p className="text-center text-[#737373] text-sm pointer-events-none">
Add more files or use the list below
</p>
) : (
<div className="text-center">
<div className="text-center pointer-events-none">
<p className="text-white">
<span className="text-[#4BA0FA]">Click to upload</span> or drag
and drop
</p>
<p className="text-[#737373] text-sm mt-1">
Multiple files allowed
</p>
</div>
)}
</label>
</div>
<div className="flex flex-col gap-2">
<p className="text-[14px] font-semibold pl-2">Title (optional)</p>
<input
type="text"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Give this file a title"
disabled={isSubmitting}
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
/>
</div>
<div className="flex flex-col gap-2">
<p className="text-[14px] font-semibold pl-2">Description (optional)</p>
<textarea
value={description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Add notes or context about this file"
disabled={isSubmitting}
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
/>
</div>
{hasItems ? (
<ul
id="file-upload-queue"
className="flex flex-col gap-2 max-h-[220px] overflow-y-auto scrollbar-thin pr-1"
>
{data.items.map((item) => (
<li
key={item.id}
className="relative overflow-hidden rounded-[12px] bg-[#14161A] shadow-inside-out text-sm"
>
{item.status === "uploading" ? (
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-0.5 overflow-hidden pointer-events-none",
"bg-[rgb(46_53_61/0.65)]",
)}
aria-hidden
>
<div
className={cn(
"h-full w-0 bg-linear-to-r from-[#3580c4] via-[#4ba0fa] to-[#6ec5fc]",
"animate-file-upload-grow motion-reduce:animate-none",
"motion-reduce:w-[88%] motion-reduce:opacity-85",
)}
/>
</div>
) : null}
{item.status === "success" ? (
<div
className="absolute bottom-0 left-0 right-0 h-0.5 pointer-events-none bg-emerald-500/80"
aria-hidden
/>
) : null}
<div className="flex items-start gap-3 p-3 pb-3.5">
<div className="min-w-0 flex-1">
{item.status === "uploading" ? (
<span className="sr-only">Uploading {item.file.name}</span>
) : null}
<p className="text-white font-medium truncate">
{item.file.name}
</p>
<p className="text-[#737373] text-xs">
{(item.file.size / 1024 / 1024).toFixed(2)} MB
</p>
{item.status === "error" && item.errorMessage ? (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertCircleIcon className="size-3.5 shrink-0" />
{item.errorMessage}
</p>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0">
{item.status === "pending" ? (
<button
type="button"
onClick={() => removeItem(item.id)}
disabled={isSubmitting}
className="p-1.5 rounded-lg text-[#737373] hover:text-white hover:bg-[#2E353D] disabled:opacity-50"
aria-label={`Remove ${item.file.name}`}
>
<XIcon className="size-4" />
</button>
) : null}
{item.status === "success" ? (
<CheckIcon className="size-4 text-green-500" aria-hidden />
) : null}
{item.status === "error" ? (
<button
type="button"
onClick={() => {
onDataChange({
...data,
items: data.items.map((i) =>
i.id === item.id
? {
...i,
status: "pending" as const,
errorMessage: undefined,
}
: i,
),
})
}}
disabled={isSubmitting}
className="text-xs text-[#4BA0FA] hover:underline disabled:opacity-50"
>
Retry
</button>
) : null}
</div>
</div>
</li>
))}
</ul>
) : null}
{showTitleDescription ? (
<>
<div className="flex flex-col gap-2">
<p className="text-[14px] font-semibold pl-2">Title (optional)</p>
<input
type="text"
value={data.title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Give this file a title"
disabled={isSubmitting}
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
/>
</div>
<div className="flex flex-col gap-2">
<p className="text-[14px] font-semibold pl-2">
Description (optional)
</p>
<textarea
value={data.description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Add notes or context about this file"
disabled={isSubmitting}
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
/>
</div>
</>
) : (
<p className="text-[#737373] text-sm pl-2">
Title and description apply only when uploading a single file. With
multiple files, each document uses its file name.
</p>
)}
</div>
)
}

View file

@ -1,6 +1,6 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useState, useEffect, useCallback, useRef } from "react"
import { useQueryState } from "nuqs"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
@ -72,8 +72,8 @@ const tabs = [
{
id: "file" as const,
icon: FileTextIcon,
title: "Upload a file",
description: "Turn any image, PDF or document into contextual memories",
title: "Upload files",
description: "Turn images, PDFs, documents, and markdown into memories",
},
{
id: "connect" as const,
@ -113,10 +113,12 @@ export function AddDocument({
description: "",
})
const [fileData, setFileData] = useState<FileData>({
file: null,
items: [],
title: "",
description: "",
})
const fileDataRef = useRef(fileData)
fileDataRef.current = fileData
const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({
onClose,
@ -139,6 +141,12 @@ export function AddDocument({
setLocalSelectedProject(globalSelectedProject)
}, [globalSelectedProject])
useEffect(() => {
if (!isOpen) {
setFileData({ items: [], title: "", description: "" })
}
}, [isOpen])
// Submit handlers
const handleNoteSubmit = useCallback(
(content: string) => {
@ -163,17 +171,55 @@ export function AddDocument({
)
const handleFileSubmit = useCallback(
(data: { file: File; title: string; description: string }) => {
if (!data.file) {
toast.error("Please select a file")
async (data: FileData) => {
const pending = data.items.filter((i) => i.status === "pending")
if (pending.length === 0) {
toast.error("Please add at least one file")
return
}
fileMutation.mutate({
file: data.file,
title: data.title || undefined,
description: data.description || undefined,
project: localSelectedProject,
})
const applyMeta = pending.length === 1
setFileData((prev) => ({
...prev,
items: prev.items.map((i) =>
i.status === "pending" ? { ...i, status: "uploading" as const } : i,
),
}))
try {
const result = await fileMutation.mutateAsync({
fileEntries: pending.map((i) => ({ id: i.id, file: i.file })),
title: applyMeta ? data.title || undefined : undefined,
description: applyMeta ? data.description || undefined : undefined,
project: localSelectedProject,
})
setFileData((prev) => ({
...prev,
items: prev.items.map((i) => {
if (i.status !== "uploading") return i
const fail = result.failures.find((f) => f.id === i.id)
if (fail) {
return {
...i,
status: "error" as const,
errorMessage: fail.message,
}
}
return { ...i, status: "success" as const }
}),
}))
} catch {
setFileData((prev) => ({
...prev,
items: prev.items.map((i) =>
i.status === "uploading"
? {
...i,
status: "error" as const,
errorMessage: "Upload failed",
}
: i,
),
}))
}
},
[fileMutation, localSelectedProject],
)
@ -200,13 +246,7 @@ export function AddDocument({
handleLinkSubmit(linkData)
break
case "file":
if (fileData.file) {
handleFileSubmit(
fileData as { file: File; title: string; description: string },
)
} else {
toast.error("Please select a file")
}
void handleFileSubmit(fileData)
break
}
}
@ -214,6 +254,10 @@ export function AddDocument({
const isSubmitting =
noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
const fileTabHasPending = fileData.items.some((i) => i.status === "pending")
const fileTabSubmitDisabled =
activeTab === "file" && (!fileTabHasPending || isSubmitting)
return (
<div className="h-full flex flex-col md:flex-row text-white md:space-x-5 space-y-3 md:space-y-0">
<div
@ -393,8 +437,11 @@ export function AddDocument({
)}
{activeTab === "file" && (
<FileContent
onSubmit={handleFileSubmit}
data={fileData}
onDataChange={handleFileDataChange}
onRequestSubmit={() => {
void handleFileSubmit(fileDataRef.current)
}}
isSubmitting={fileMutation.isPending}
isOpen={isOpen}
/>
@ -434,7 +481,9 @@ export function AddDocument({
<Button
variant="insideOut"
onClick={handleButtonClick}
disabled={isSubmitting}
disabled={
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
}
>
{isSubmitting ? (
<>

View file

@ -4,6 +4,8 @@
@theme {
--color-onboarding: #525966;
--animate-file-upload-grow: file-upload-grow 6s cubic-bezier(0.22, 1, 0.36, 1)
forwards;
}
:root {
@ -131,3 +133,12 @@
pointer-events: none;
height: 0;
}
@keyframes file-upload-grow {
0% {
width: 0%;
}
100% {
width: 92%;
}
}

View file

@ -211,6 +211,15 @@ function restoreQueriesFromSnapshot(
}
}
const FILE_UPLOAD_CONCURRENCY = 3
export type FileUploadEntry = { id: string; file: File }
export type FileUploadBatchResult = {
failures: { id: string; message: string }[]
successCount: number
}
export function useDocumentMutations({
onClose,
}: UseDocumentMutationsOptions = {}) {
@ -353,71 +362,119 @@ export function useDocumentMutations({
const fileMutation = useMutation({
mutationFn: async ({
file,
fileEntries,
title,
description,
project,
}: {
file: File
fileEntries: FileUploadEntry[]
title?: string
description?: string
project: string
}) => {
const formData = new FormData()
formData.append("file", file)
formData.append("containerTags", JSON.stringify([project]))
formData.append("entityContext", entityContext)
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
}): Promise<FileUploadBatchResult> => {
const applyMeta = fileEntries.length === 1
const failures: { id: string; message: string }[] = []
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
{
method: "POST",
body: formData,
credentials: "include",
},
)
const uploadOne = async (entry: FileUploadEntry) => {
const formData = new FormData()
formData.append("file", entry.file)
formData.append("containerTags", JSON.stringify([project]))
formData.append("entityContext", entityContext)
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || "Failed to upload file")
}
const data = await response.json()
if (title || description) {
await $fetch(`@patch/documents/${data.id}`, {
body: {
metadata: {
...(title && { title }),
...(description && { description }),
sm_source: "consumer",
},
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
{
method: "POST",
body: formData,
credentials: "include",
},
})
)
if (!response.ok) {
let message = "Failed to upload file"
try {
const error = (await response.json()) as { error?: string }
if (error.error) message = error.error
} catch {
// ignore JSON parse errors
}
throw new Error(message)
}
const data = (await response.json()) as { id: string }
if (applyMeta && (title || description)) {
await $fetch(`@patch/documents/${data.id}`, {
body: {
metadata: {
...(title && { title }),
...(description && { description }),
sm_source: "consumer",
},
},
})
}
}
return data
for (let i = 0; i < fileEntries.length; i += FILE_UPLOAD_CONCURRENCY) {
const slice = fileEntries.slice(i, i + FILE_UPLOAD_CONCURRENCY)
await Promise.all(
slice.map(async (entry) => {
try {
await uploadOne(entry)
} catch (e) {
failures.push({
id: entry.id,
message: e instanceof Error ? e.message : "Upload failed",
})
}
}),
)
}
const successCount = fileEntries.length - failures.length
if (successCount === 0) {
const firstFailure = failures[0]
throw new Error(
failures.length === 1 && firstFailure
? firstFailure.message
: `All ${failures.length} uploads failed`,
)
}
return { failures, successCount }
},
onMutate: async ({ file, title, description, project }) => {
onMutate: async ({ fileEntries, title, description, project }) => {
if (fileEntries.length !== 1) {
return {
previousQueries: undefined as [unknown, unknown][] | undefined,
}
}
const previousQueries = await cancelAndSnapshotQueries(queryClient)
const entry = fileEntries[0]
if (!entry) {
return {
previousQueries: undefined as [unknown, unknown][] | undefined,
}
}
const now = new Date().toISOString()
const optimisticMemory: OptimisticMemory = {
id: `temp-file-${crypto.randomUUID()}`,
content: "",
url: null,
title: title || file.name,
description: description || `Uploading ${file.name}...`,
title: title || entry.file.name,
description: description || `Uploading ${entry.file.name}...`,
containerTags: [project],
createdAt: now,
updatedAt: now,
status: "processing",
type: "file",
metadata: {
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
fileName: entry.file.name,
fileSize: entry.file.size,
mimeType: entry.file.type,
},
memoryEntries: [],
}
@ -429,19 +486,34 @@ export function useDocumentMutations({
return { previousQueries }
},
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
onError: (error, variables, context) => {
if (variables.fileEntries.length === 1) {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
}
toast.error("Failed to upload file", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentAdded({ type: "file", project_id: variables.project })
toast.success("File uploaded successfully!", {
description: "Your file is being processed",
})
onSuccess: (data, variables) => {
for (let i = 0; i < data.successCount; i++) {
analytics.documentAdded({ type: "file", project_id: variables.project })
}
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
if (data.failures.length === 0) {
toast.success(
data.successCount === 1
? "File uploaded successfully!"
: `${data.successCount} files uploaded successfully!`,
{
description: "Your files are being processed",
},
)
onClose?.()
return
}
toast.warning("Some uploads failed", {
description: `${data.successCount} uploaded, ${data.failures.length} failed — fix or retry below`,
})
},
})