mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
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:
@@ -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
-2
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user