From 0fe948d1d0df7f8e13a006e52ea311529b83cef5 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 23:04:56 +0000 Subject: [PATCH] feat(25-02): add chatApi.uploadFile and useChatFileUpload hook - Add uploadFile method to chatApi using XHR for progress tracking - Add ChatFileUploadResponse to shared type imports - Create useChatFileUpload hook with PendingFile lifecycle management - Hook manages uploading/done/error states with progress callbacks --- ui/src/api/chat.ts | 37 +++++++++++++++ ui/src/hooks/useChatFileUpload.ts | 78 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 ui/src/hooks/useChatFileUpload.ts diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts index 1206134e..e584df62 100644 --- a/ui/src/api/chat.ts +++ b/ui/src/api/chat.ts @@ -7,6 +7,7 @@ import type { ChatMessageSearchResponse, ChatBookmarkToggleResponse, ChatBookmarkListResponse, + ChatFileUploadResponse, } from "@paperclipai/shared"; export const chatApi = { @@ -211,4 +212,40 @@ export const chatApi = { // Returns a download URL — use window.location.href to trigger return `/api/conversations/${conversationId}/export?format=${format}`; }, + + async uploadFile( + conversationId: string, + file: File, + opts?: { source?: string; projectId?: string }, + onProgress?: (percent: number) => void, + ): Promise { + const formData = new FormData(); + formData.append("file", file); + if (opts?.source) formData.append("source", opts.source); + if (opts?.projectId) formData.append("projectId", opts.projectId); + + // Use XMLHttpRequest for progress tracking + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", `/api/conversations/${conversationId}/files`); + xhr.withCredentials = true; + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onProgress) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText) as ChatFileUploadResponse); + } else { + reject(new Error(`Upload failed: ${xhr.status}`)); + } + }; + + xhr.onerror = () => reject(new Error("Upload failed")); + xhr.send(formData); + }); + }, }; diff --git a/ui/src/hooks/useChatFileUpload.ts b/ui/src/hooks/useChatFileUpload.ts new file mode 100644 index 00000000..acc949cd --- /dev/null +++ b/ui/src/hooks/useChatFileUpload.ts @@ -0,0 +1,78 @@ +import { useState, useCallback } from "react"; +import { chatApi } from "../api/chat"; +import type { ChatFile } from "@paperclipai/shared"; + +export interface PendingFile { + id: string; // temp client-side id before upload completes + file: File; // raw File object + name: string; + mimeType: string; + sizeBytes: number; + progress: number; // 0-100 + status: "uploading" | "done" | "error"; + uploadedFile?: ChatFile; // set when upload completes + contentPath?: string; + error?: string; +} + +export function useChatFileUpload(conversationId: string | null) { + const [pendingFiles, setPendingFiles] = useState([]); + + const addFile = useCallback(async (file: File) => { + if (!conversationId) return; + + const tempId = crypto.randomUUID(); + const pending: PendingFile = { + id: tempId, + file, + name: file.name, + mimeType: file.type || "application/octet-stream", + sizeBytes: file.size, + progress: 0, + status: "uploading", + }; + + setPendingFiles((prev) => [...prev, pending]); + + try { + const result = await chatApi.uploadFile( + conversationId, + file, + { source: "user_upload" }, + (percent) => { + setPendingFiles((prev) => + prev.map((p) => (p.id === tempId ? { ...p, progress: percent } : p)) + ); + }, + ); + + setPendingFiles((prev) => + prev.map((p) => + p.id === tempId + ? { ...p, status: "done", progress: 100, uploadedFile: result.file, contentPath: result.contentPath } + : p + ) + ); + } catch (err) { + setPendingFiles((prev) => + prev.map((p) => + p.id === tempId ? { ...p, status: "error", error: (err as Error).message } : p + ) + ); + } + }, [conversationId]); + + const removeFile = useCallback((tempId: string) => { + setPendingFiles((prev) => prev.filter((p) => p.id !== tempId)); + }, []); + + const clearCompleted = useCallback(() => { + setPendingFiles((prev) => prev.filter((p) => p.status !== "done")); + }, []); + + const completedFileIds = pendingFiles + .filter((p) => p.status === "done" && p.uploadedFile) + .map((p) => p.uploadedFile!.id); + + return { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds }; +}