diff --git a/packages/app/e2e/browser/composer-attachments.spec.ts b/packages/app/e2e/browser/composer-attachments.spec.ts index ce8ca72313..94a52ace16 100644 --- a/packages/app/e2e/browser/composer-attachments.spec.ts +++ b/packages/app/e2e/browser/composer-attachments.spec.ts @@ -38,6 +38,7 @@ import { seedWorkspace } from "../support/helpers/seed-client"; import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures"; import { getServerId } from "../support/helpers/server-id"; import { openFileExplorer } from "../support/helpers/file-explorer"; +import { attachFileFromMenu, controlFileUploadCompletion } from "../support/helpers/composer"; const MINIMAL_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", @@ -52,6 +53,31 @@ const TEST_JSON = { }; test.describe("Composer attachments", () => { + test("selected file shows a loading attachment until upload is acknowledged", async ({ + page, + withWorkspace, + }) => { + const upload = await controlFileUploadCompletion(page); + const workspace = await withWorkspace({ prefix: "attach-upload-pending-" }); + await workspace.navigateTo(); + await clickNewChat(page); + await expectComposerVisible(page); + + upload.hold(); + await attachFileFromMenu(page, TEST_JSON); + await upload.waitForUpload(); + + const pending = page.getByTestId("composer-pending-file-attachment"); + await expect(pending).toContainText(TEST_JSON.name); + await expect(pending.getByRole("progressbar")).toBeVisible(); + await expect(page.getByTestId("composer-file-attachment-pill")).toHaveCount(0); + + upload.complete(); + await expect(pending).toHaveCount(0); + await expect(page.getByTestId("composer-file-attachment-pill")).toContainText(TEST_JSON.name); + await expectComposerEditable(page); + }); + test("compact Plus menu aligns attachment rows with its sheet title", async ({ page, withWorkspace, diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index 45c5a95c6e..6e78e0c66b 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -8,6 +8,16 @@ import { selectWorkspaceInSidebar } from "./sidebar"; import { getServerId } from "./server-id"; import { waitForTabBar } from "./launcher"; import { waitForSettledPosition } from "./sheet-layout"; +import { installDaemonWebSocketGate } from "./daemon-websocket-gate"; + +export async function controlFileUploadCompletion(page: Page) { + const gate = await installDaemonWebSocketGate(page); + return { + hold: () => gate.holdNextServerMessage("file.upload.response"), + waitForUpload: () => gate.waitForHeldServerMessage("file.upload.response"), + complete: () => gate.releaseHeldServerMessage("file.upload.response"), + }; +} function composerInput(page: Page) { return page.getByRole("textbox", { name: "Message agent..." }).first(); @@ -125,6 +135,16 @@ export async function expectAttachmentPill(page: Page, testID: string): Promise< await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 }); } +export async function attachFileFromMenu( + page: Page, + file: { name: string; mimeType: string; buffer: Buffer }, +): Promise { + await openAttachmentMenu(page); + const chooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("menuitem", { name: "Upload file", exact: true }).click(); + await (await chooserPromise).setFiles(file); +} + export async function dropFileOnComposer( page: Page, file: { name: string; mimeType: string; buffer: Buffer }, diff --git a/packages/app/src/attachments/picked-file.ts b/packages/app/src/attachments/selected-file.ts similarity index 92% rename from packages/app/src/attachments/picked-file.ts rename to packages/app/src/attachments/selected-file.ts index a7efdfd639..f0746ab760 100644 --- a/packages/app/src/attachments/picked-file.ts +++ b/packages/app/src/attachments/selected-file.ts @@ -2,10 +2,10 @@ import { getFileExtension } from "@/attachments/file-types"; import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands"; import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url"; -export interface PickedFile { +export interface SelectedFile { fileName: string; mimeType: string; - bytes: Uint8Array; + readBytes(): Promise; } function base64ToUint8Array(base64: string): Uint8Array { diff --git a/packages/app/src/components/project-edit-sheet.tsx b/packages/app/src/components/project-edit-sheet.tsx index a2790cb432..179d1140b8 100644 --- a/packages/app/src/components/project-edit-sheet.tsx +++ b/packages/app/src/components/project-edit-sheet.tsx @@ -81,7 +81,7 @@ export function ProjectEditSheet({ form.setPickedImage({ fileName: file.fileName, mimeType: file.mimeType, - data: Buffer.from(file.bytes).toString("base64"), + data: Buffer.from(await file.readBytes()).toString("base64"), }); }, [form, pickFiles]); diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 688ef99925..697f848a09 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -18,6 +18,7 @@ import { type MessageSubmissionRecord, } from "@/composer/submission/model"; import { + uploadFileAttachments, cancelComposerAgent, dispatchComposerAgentMessage, editQueuedComposerMessage, @@ -1039,3 +1040,86 @@ describe("findForgeItemByOption / isAttachmentSelectedForForgeItem", () => { expect(isAttachmentSelectedForForgeItem(attachments, prItem)).toBe(false); }); }); + +describe("file upload preparation", () => { + it("waits for file bytes and daemon acknowledgement before returning the attachment", async () => { + let resolveRead!: (bytes: Uint8Array) => void; + const read = new Promise((resolve) => { + resolveRead = resolve; + }); + let acknowledge!: (result: Awaited>) => void; + const response = new Promise>>( + (resolve) => { + acknowledge = resolve; + }, + ); + const sent: string[] = []; + const upload = uploadFileAttachments({ + client: { + sendAgentMessage: async () => {}, + uploadFile: async (file) => { + sent.push(file.fileName); + expect(file.bytes).toEqual(new Uint8Array([1, 2])); + return response; + }, + }, + files: [ + { fileName: "sample.bin", mimeType: "application/octet-stream", readBytes: () => read }, + ], + }); + let completed = false; + void upload.then(() => { + completed = true; + return undefined; + }); + expect(sent).toEqual([]); + resolveRead(new Uint8Array([1, 2])); + await Promise.resolve(); + expect(sent).toEqual(["sample.bin"]); + expect(completed).toBe(false); + const file = { + type: "uploaded_file" as const, + id: "file-1", + fileName: "sample.bin", + mimeType: "application/octet-stream", + size: 2, + path: "/uploads/sample.bin", + }; + acknowledge({ requestId: "req-1", file, error: null }); + await expect(upload).resolves.toEqual([{ kind: "file", attachment: file }]); + }); + + it.each(["unreadable", "oversized"])( + "does not upload a batch containing a later %s file", + async (failure) => { + let sends = 0; + await expect( + uploadFileAttachments({ + client: { + sendAgentMessage: async () => {}, + uploadFile: async () => { + sends++; + throw new Error("unexpected send"); + }, + }, + files: [ + { + fileName: "valid.bin", + mimeType: "application/octet-stream", + readBytes: async () => new Uint8Array([1]), + }, + { + fileName: "missing.bin", + mimeType: "application/octet-stream", + readBytes: async () => { + if (failure === "oversized") return new Uint8Array(50 * 1024 * 1024 + 1); + throw new Error("read failed"); + }, + }, + ], + }), + ).rejects.toThrow(failure === "unreadable" ? "read failed" : "too large"); + expect(sends).toBe(0); + }, + ); +}); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index d020e7b6b0..4ae1764a02 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -1,3 +1,4 @@ +import type { SelectedFile } from "@/attachments/selected-file"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { @@ -123,11 +124,26 @@ export async function pickAndPersistImages(input: { export async function uploadFileAttachments(input: { client: ComposerSendClient; - files: Array<{ fileName: string; mimeType: string; bytes: Uint8Array }>; + files: SelectedFile[]; }): Promise[]> { const result: Extract[] = []; + const prepared: Array<{ fileName: string; mimeType: string; bytes: Uint8Array }> = []; for (const file of input.files) { + const bytes = await file.readBytes(); + if (bytes.byteLength > 50 * 1024 * 1024) { + throw new Error( + i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }), + ); + } + prepared.push({ + fileName: file.fileName, + mimeType: file.mimeType, + bytes, + }); + } + + for (const file of prepared) { const response = await input.client.uploadFile(file); if (response.error || !response.file) { throw new Error(response.error ?? "Upload failed."); diff --git a/packages/app/src/composer/attachments/drop.test.ts b/packages/app/src/composer/attachments/drop.test.ts index 31a26a7fe0..16a75e1f3d 100644 --- a/packages/app/src/composer/attachments/drop.test.ts +++ b/packages/app/src/composer/attachments/drop.test.ts @@ -1,28 +1,25 @@ import { describe, expect, it } from "vitest"; -import { droppedItemsToPickedFiles } from "./drop"; +import { droppedItemsToSelectedFiles } from "./drop"; describe("composer dropped attachments", () => { - it("turns non-image browser files into picked files and leaves raster images for image handling", async () => { + it("turns non-image browser files into selected files and leaves raster images for image handling", async () => { const jsonFile = new File([JSON.stringify({ ok: true })], "config.json", { type: "application/json", }); const imageFile = new File([new Uint8Array([0])], "screen.png", { type: "image/png" }); - const files = await droppedItemsToPickedFiles([ + const files = droppedItemsToSelectedFiles([ { kind: "web-file", file: jsonFile }, { kind: "web-file", file: imageFile }, ]); - expect(files).toEqual([ - { - fileName: "config.json", - mimeType: "application/json", - bytes: new Uint8Array(await jsonFile.arrayBuffer()), - }, + expect(files.map(({ fileName, mimeType }) => ({ fileName, mimeType }))).toEqual([ + { fileName: "config.json", mimeType: "application/json" }, ]); + expect(await files[0]?.readBytes()).toEqual(new Uint8Array(await jsonFile.arrayBuffer())); }); - it("turns non-image desktop paths into picked files and leaves raster images for image handling", async () => { + it("reads desktop paths lazily and leaves raster images for image handling", async () => { const windowsPath = "C:\\Users\\alice\\config.json"; const posixPath = "/Users/alice/notes/readme.txt"; const imagePath = "C:\\Users\\alice\\screen.png"; @@ -32,7 +29,7 @@ describe("composer dropped attachments", () => { ]); const readPaths: string[] = []; - const files = await droppedItemsToPickedFiles( + const files = droppedItemsToSelectedFiles( [ { kind: "desktop-path", path: windowsPath }, { kind: "desktop-path", path: imagePath }, @@ -50,18 +47,14 @@ describe("composer dropped attachments", () => { }, ); - expect(readPaths).toEqual([windowsPath, posixPath]); - expect(files).toEqual([ - { - fileName: "config.json", - mimeType: "application/octet-stream", - bytes: new Uint8Array([1, 2, 3]), - }, - { - fileName: "readme.txt", - mimeType: "application/octet-stream", - bytes: new Uint8Array([4, 5]), - }, + expect(files.map(({ fileName, mimeType }) => ({ fileName, mimeType }))).toEqual([ + { fileName: "config.json", mimeType: "application/octet-stream" }, + { fileName: "readme.txt", mimeType: "application/octet-stream" }, ]); + expect(readPaths).toEqual([]); + + expect(await files[0]?.readBytes()).toEqual(new Uint8Array([1, 2, 3])); + expect(await files[1]?.readBytes()).toEqual(new Uint8Array([4, 5])); + expect(readPaths).toEqual([windowsPath, posixPath]); }); }); diff --git a/packages/app/src/composer/attachments/drop.ts b/packages/app/src/composer/attachments/drop.ts index b5a0f1b6ad..dddff293e9 100644 --- a/packages/app/src/composer/attachments/drop.ts +++ b/packages/app/src/composer/attachments/drop.ts @@ -3,7 +3,7 @@ import { isRasterImageFile, isRasterImagePath, } from "@/attachments/file-types"; -import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file"; +import { readDesktopFileBytes, type SelectedFile } from "@/attachments/selected-file"; import type { DroppedItem } from "@/components/file-drop/types"; interface DroppedAttachmentsRuntime { @@ -25,21 +25,22 @@ function fileNameFromPath(path: string): string { return path; } -export async function droppedItemsToPickedFiles( +export function droppedItemsToSelectedFiles( items: DroppedItem[], runtime: DroppedAttachmentsRuntime = defaultRuntime, -): Promise { - const files: PickedFile[] = []; +): SelectedFile[] { + const files: SelectedFile[] = []; for (const item of items) { if (item.kind === "web-file") { if (isRasterImageFile(item.file)) { continue; } + const file = item.file; files.push({ - fileName: item.file.name, - mimeType: item.file.type || getMimeTypeFromPath(item.file.name), - bytes: new Uint8Array(await item.file.arrayBuffer()), + fileName: file.name, + mimeType: file.type || getMimeTypeFromPath(file.name), + readBytes: async () => new Uint8Array(await file.arrayBuffer()), }); continue; } @@ -47,10 +48,11 @@ export async function droppedItemsToPickedFiles( if (isRasterImagePath(item.path)) { continue; } + const path = item.path; files.push({ - fileName: fileNameFromPath(item.path), - mimeType: getMimeTypeFromPath(item.path), - bytes: await runtime.readDesktopFileBytes(item.path), + fileName: fileNameFromPath(path), + mimeType: getMimeTypeFromPath(path), + readBytes: () => runtime.readDesktopFileBytes(path), }); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 2440b9eab0..d514a8fa05 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -127,14 +127,19 @@ import type { WorkspaceFileComposerAttachment, WorkspaceComposerAttachment, } from "@/attachments/types"; -import type { PickedFile } from "@/attachments/picked-file"; +import type { SelectedFile } from "@/attachments/selected-file"; import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store"; -import { droppedItemsToPickedFiles } from "@/composer/attachments/drop"; +import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop"; import { getFileTypeLabel } from "@/attachments/file-types"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; -import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill"; +import { + AttachmentFrame, + AttachmentLabel, + AttachmentPill, + AttachmentThumbnail, +} from "@/components/attachment-pill"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import { openExternalUrl } from "@/utils/open-external-url"; import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; @@ -335,8 +340,14 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null { ); } +interface PendingFileAttachment { + id: number; + file: SelectedFile; +} + interface RenderAttachmentTrayArgs { selectedAttachments: ComposerAttachment[]; + pendingFiles: PendingFileAttachment[]; isComposerLocked: boolean; handleOpenAttachment: (attachment: ComposerAttachment) => void; handleRemoveAttachment: (index: number) => void; @@ -352,12 +363,13 @@ interface RenderAttachmentTrayArgs { function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { const { selectedAttachments, + pendingFiles, isComposerLocked, handleOpenAttachment, handleRemoveAttachment, labels, } = args; - if (selectedAttachments.length === 0) return null; + if (selectedAttachments.length === 0 && pendingFiles.length === 0) return null; return ( {selectedAttachments.map((attachment, index) => @@ -370,6 +382,15 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu labels, }), )} + {pendingFiles.map(({ id, file }) => ( + + + + ))} ); } @@ -986,8 +1007,6 @@ interface ComposerProps { placeholder?: string; } -const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; - const EMPTY_ARRAY: readonly QueuedMessage[] = []; const StableMessageInput = memo(MessageInput); @@ -1362,7 +1381,9 @@ function ComposerContentImpl({ useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); const autocompleteRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); - const [isUploadingFile, setIsUploadingFile] = useState(false); + const [pendingFiles, setPendingFiles] = useState([]); + const nextPendingFileId = useRef(0); + const isUploadingFile = pendingFiles.length > 0; const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); @@ -1779,23 +1800,16 @@ function ComposerContentImpl({ [addImages, t], ); - const uploadPickedFiles = useCallback( - async (files: PickedFile[]) => { + const uploadSelectedFiles = useCallback( + async (files: SelectedFile[]) => { if (files.length === 0) return; if (!client) { toastErrorRef.current(t("composer.errors.daemonClientDisconnected")); return; } - const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES); - if (oversized) { - toastErrorRef.current( - t("composer.errors.fileTooLarge", { size: "50MB", fileName: oversized.fileName }), - ); - return; - } - - setIsUploadingFile(true); + const placeholders = files.map((file) => ({ id: nextPendingFileId.current++, file })); + setPendingFiles((pending) => [...pending, ...placeholders]); try { const uploaded = await uploadFileAttachments({ client, files }); addFiles(uploaded); @@ -1805,7 +1819,7 @@ function ComposerContentImpl({ error instanceof Error ? error.message : t("composer.errors.uploadFailed"), ); } finally { - setIsUploadingFile(false); + setPendingFiles((pending) => pending.filter((entry) => !placeholders.includes(entry))); } }, [addFiles, client, t], @@ -1819,25 +1833,25 @@ function ComposerContentImpl({ try { const files = await pickFiles(); if (!files) return; - await uploadPickedFiles(files); + await uploadSelectedFiles(files); } catch (error) { console.error("[Composer] Failed to upload file:", error); toastErrorRef.current( error instanceof Error ? error.message : t("composer.errors.uploadFailed"), ); } - }, [client, pickFiles, t, uploadPickedFiles]); + }, [client, pickFiles, t, uploadSelectedFiles]); const handleGenericFilesDropped = useCallback( async (items: DroppedItem[]) => { try { - const files = await droppedItemsToPickedFiles(items); + const files = droppedItemsToSelectedFiles(items); if (files.length === 0) return; if (!client || !isConnected) { toastErrorRef.current(t("composer.errors.daemonClientDisconnected")); return; } - await uploadPickedFiles(files); + await uploadSelectedFiles(files); } catch (error) { console.error("[Composer] Failed to upload dropped files:", error); toastErrorRef.current( @@ -1845,7 +1859,7 @@ function ComposerContentImpl({ ); } }, - [client, isConnected, t, uploadPickedFiles], + [client, isConnected, t, uploadSelectedFiles], ); const handleRemoveAttachment = useCallback( @@ -2298,6 +2312,7 @@ function ComposerContentImpl({ () => renderAttachmentTray({ selectedAttachments, + pendingFiles, isComposerLocked, handleOpenAttachment, handleRemoveAttachment, @@ -2311,7 +2326,14 @@ function ComposerContentImpl({ t("composer.attachments.removeGithub", { kind, number: numberLabel }), }, }), - [handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t], + [ + handleOpenAttachment, + handleRemoveAttachment, + isComposerLocked, + selectedAttachments, + pendingFiles, + t, + ], ); const queueList = useMemo( @@ -2637,6 +2659,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, })) as unknown as Record; +const ThemedAttachmentSpinner = withUnistyles(LoadingSpinner); const ThemedPencil = withUnistyles(Pencil); const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedGitPullRequest = withUnistyles(GitPullRequest); @@ -2663,3 +2686,7 @@ const githubIssuePillIcon = ( ); const filePillIcon = ; + +const pendingFilePillIcon = ( + +); diff --git a/packages/app/src/hooks/use-file-picker.ts b/packages/app/src/hooks/use-file-picker.ts index f75e6eb857..44f7c6b0db 100644 --- a/packages/app/src/hooks/use-file-picker.ts +++ b/packages/app/src/hooks/use-file-picker.ts @@ -4,9 +4,9 @@ import { File } from "expo-file-system"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { isWeb } from "@/constants/platform"; import { getMimeTypeFromPath } from "@/attachments/file-types"; -import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file"; +import { readDesktopFileBytes, type SelectedFile } from "@/attachments/selected-file"; -async function pickFilesWithDesktopDialog(): Promise { +async function pickFilesWithDesktopDialog(): Promise { const dialog = getDesktopHost()?.dialog; const dialogOpen = dialog?.open; if (typeof dialogOpen !== "function") { @@ -27,20 +27,18 @@ async function pickFilesWithDesktopDialog(): Promise { return null; } - const result: PickedFile[] = []; + const result: SelectedFile[] = []; for (const filePath of paths) { const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath; const mimeType = getMimeTypeFromPath(filePath); - const bytes = await readDesktopFileBytes(filePath); - - result.push({ fileName, mimeType, bytes }); + result.push({ fileName, mimeType, readBytes: () => readDesktopFileBytes(filePath) }); } return result; } -function pickFilesWithWebInput(): Promise { +function pickFilesWithWebInput(): Promise { return new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; @@ -54,13 +52,12 @@ function pickFilesWithWebInput(): Promise { return; } - const result: PickedFile[] = []; + const result: SelectedFile[] = []; for (const file of files) { - const bytes = new Uint8Array(await file.arrayBuffer()); result.push({ fileName: file.name, mimeType: file.type || getMimeTypeFromPath(file.name), - bytes, + readBytes: async () => new Uint8Array(await file.arrayBuffer()), }); } resolve(result); @@ -80,7 +77,7 @@ function pickFilesWithWebInput(): Promise { }); } -async function pickFilesWithDocumentPicker(): Promise { +async function pickFilesWithDocumentPicker(): Promise { const result = await DocumentPicker.getDocumentAsync({ multiple: true, copyToCacheDirectory: true, @@ -90,19 +87,17 @@ async function pickFilesWithDocumentPicker(): Promise { return null; } - return await Promise.all( - result.assets.map(async (asset) => ({ - fileName: asset.name, - mimeType: asset.mimeType ?? getMimeTypeFromPath(asset.name), - bytes: await new File(asset.uri).bytes(), - })), - ); + return result.assets.map((asset) => ({ + fileName: asset.name, + mimeType: asset.mimeType ?? getMimeTypeFromPath(asset.name), + readBytes: () => new File(asset.uri).bytes(), + })); } export function useFilePicker() { const isPickingRef = useRef(false); - const pickFiles = useCallback(async (): Promise => { + const pickFiles = useCallback(async (): Promise => { if (isPickingRef.current) { return null; } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 27d6f7d558..b41fa17078 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2689,6 +2689,17 @@ test("uploadFile sends metadata request and file bytes as binary chunks", async chunkSize: 5, }); + // Other tasks must run before a multi-chunk upload has queued all its bytes. + await new Promise((resolve) => setTimeout(resolve, 0)); + const framesDuringUpload = mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame); + expect(framesDuringUpload.some((frame) => frame.opcode === FileTransferOpcode.FileEnd)).toBe( + false, + ); + await vi.waitFor(() => { + const frames = mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame); + expect(frames.at(-1)?.opcode).toBe(FileTransferOpcode.FileEnd); + }); + expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({ type: "session", message: { @@ -6758,3 +6769,37 @@ test("creation reconnect observation uses connection-owned subscriptions and rel ); expect(phases).toEqual(["accepted", "failed"]); }); + +test("uploadFile stops sending chunks when the connection closes between sends", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "upload-interrupted", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connection = client.connect(); + mock.triggerOpen(); + await connection; + const upload = client.uploadFile({ + fileName: "test.bin", + mimeType: "application/octet-stream", + bytes: new Uint8Array(1024 * 1024), + }); + const rejection = expect(upload).rejects.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const beforeClose = mock.sent.length; + mock.triggerClose(); + await rejection; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mock.sent).toHaveLength(beforeClose); + expect( + mock.sent + .filter((frame) => typeof frame !== "string") + .map(assertUint8Array) + .map(decodeFileTransferFrame) + .some((frame) => frame.opcode === FileTransferOpcode.FileEnd), + ).toBe(false); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 545baef4ff..3c55e093e1 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4761,6 +4761,7 @@ export class DaemonClient { if (!bytes) { throw new Error("File bytes are required."); } + const uploadTransport = this.transport; const resolvedRequestId = this.createRequestId(input.requestId); const modifiedAt = input.modifiedAt ?? new Date().toISOString(); const responsePromise = this.sendCorrelatedRequest({ @@ -4777,37 +4778,63 @@ export class DaemonClient { options: { skipQueue: true }, }); - this.sendBinaryFrame( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileBegin, - requestId: resolvedRequestId, - metadata: { - mime: input.mimeType, - size: bytes.byteLength, - encoding: "binary", - modifiedAt, - fileName: input.fileName, - }, - }), + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, ); - - const chunkSize = input.chunkSize ?? 1024 * 1024; - for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + try { this.sendBinaryFrame( encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileChunk, + opcode: FileTransferOpcode.FileBegin, requestId: resolvedRequestId, - payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)), + metadata: { + mime: input.mimeType, + size: bytes.byteLength, + encoding: "binary", + modifiedAt, + fileName: input.fileName, + }, }), ); - } - this.sendBinaryFrame( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileEnd, - requestId: resolvedRequestId, - }), - ); + const chunkSize = input.chunkSize ?? 128 * 1024; + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + // Native WebSocket.send encodes binary synchronously. Let rendering and + // incoming messages run between bounded pieces on every platform. + await new Promise((resolve) => setTimeout(resolve, 0)); + if (settled) return await responsePromise; + if (this.transport !== uploadTransport || this.connectionState.status !== "connected") { + throw new DaemonConnectionError("Connection changed during file upload"); + } + this.sendBinaryFrame( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: resolvedRequestId, + payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)), + }), + ); + } + + this.sendBinaryFrame( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId: resolvedRequestId, + }), + ); + } catch (error) { + this.rejectWaitersForRequestId( + resolvedRequestId, + error instanceof Error ? error : new Error(String(error)), + ); + throw error; + } return responsePromise; }