Show pending file attachments and keep uploads responsive (#4958)

Show a spinner attachment while a selected file is read and uploaded, yield between 128 KiB chunks so the UI stays responsive, validate every file in a batch before sending any of them, and leave one SelectedFile shape across the picker, drag-and-drop, and the upload path.
This commit is contained in:
Mohamed Boudra
2026-09-16 20:06:02 +02:00
committed by GitHub
parent 4f710d9930
commit 46529146ac
12 changed files with 340 additions and 105 deletions
@@ -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,
@@ -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<void> {
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 },
@@ -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<Uint8Array>;
}
function base64ToUint8Array(base64: string): Uint8Array {
@@ -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]);
+84
View File
@@ -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<Uint8Array>((resolve) => {
resolveRead = resolve;
});
let acknowledge!: (result: Awaited<ReturnType<ComposerSendClient["uploadFile"]>>) => void;
const response = new Promise<Awaited<ReturnType<ComposerSendClient["uploadFile"]>>>(
(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);
},
);
});
+17 -1
View File
@@ -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<Extract<ComposerAttachment, { kind: "file" }>[]> {
const result: Extract<ComposerAttachment, { kind: "file" }>[] = [];
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.");
@@ -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]);
});
});
+12 -10
View File
@@ -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<PickedFile[]> {
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),
});
}
+52 -25
View File
@@ -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 (
<View style={styles.attachmentTray} testID="composer-attachment-tray">
{selectedAttachments.map((attachment, index) =>
@@ -370,6 +382,15 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
labels,
}),
)}
{pendingFiles.map(({ id, file }) => (
<AttachmentFrame key={id} testID="composer-pending-file-attachment">
<AttachmentLabel
icon={pendingFilePillIcon}
title={file.fileName}
subtitle={getFileTypeLabel(file.fileName) ?? ""}
/>
</AttachmentFrame>
))}
</View>
);
}
@@ -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<ComposerAutocompleteHandle>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const [pendingFiles, setPendingFiles] = useState<PendingFileAttachment[]>([]);
const nextPendingFileId = useRef(0);
const isUploadingFile = pendingFiles.length > 0;
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const [sendError, setSendError] = useState<string | null>(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<string, object>;
const ThemedAttachmentSpinner = withUnistyles(LoadingSpinner);
const ThemedPencil = withUnistyles(Pencil);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
@@ -2663,3 +2686,7 @@ const githubIssuePillIcon = (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const filePillIcon = <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
const pendingFilePillIcon = (
<ThemedAttachmentSpinner size={18} uniProps={iconForegroundMutedMapping} />
);
+14 -19
View File
@@ -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<PickedFile[] | null> {
async function pickFilesWithDesktopDialog(): Promise<SelectedFile[] | null> {
const dialog = getDesktopHost()?.dialog;
const dialogOpen = dialog?.open;
if (typeof dialogOpen !== "function") {
@@ -27,20 +27,18 @@ async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> {
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<PickedFile[] | null> {
function pickFilesWithWebInput(): Promise<SelectedFile[] | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
@@ -54,13 +52,12 @@ function pickFilesWithWebInput(): Promise<PickedFile[] | null> {
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<PickedFile[] | null> {
});
}
async function pickFilesWithDocumentPicker(): Promise<PickedFile[] | null> {
async function pickFilesWithDocumentPicker(): Promise<SelectedFile[] | null> {
const result = await DocumentPicker.getDocumentAsync({
multiple: true,
copyToCacheDirectory: true,
@@ -90,19 +87,17 @@ async function pickFilesWithDocumentPicker(): Promise<PickedFile[] | null> {
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<PickedFile[] | null> => {
const pickFiles = useCallback(async (): Promise<SelectedFile[] | null> => {
if (isPickingRef.current) {
return null;
}
+45
View File
@@ -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);
});
+51 -24
View File
@@ -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<void>((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;
}