diff --git a/products/desktop/packages/ui/src/features/sessions/components/SessionView.test.ts b/products/desktop/packages/ui/src/features/sessions/components/SessionView.test.ts new file mode 100644 index 000000000000..74764b1ebbfb --- /dev/null +++ b/products/desktop/packages/ui/src/features/sessions/components/SessionView.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { getNewAttachments } from "./SessionView"; + +describe("getNewAttachments", () => { + it("returns only attachments that were not already present", () => { + const attachments = [ + { id: "/tmp/existing.png", label: "existing.png" }, + { id: "/tmp/new.png", label: "new.png" }, + ]; + + expect( + getNewAttachments(new Set(["/tmp/existing.png"]), attachments), + ).toEqual([attachments[1]]); + }); + + it("allows a removed attachment to be uploaded again", () => { + const attachment = { id: "/tmp/retry.png", label: "retry.png" }; + + expect(getNewAttachments(new Set(), [attachment])).toEqual([attachment]); + }); +}); diff --git a/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx b/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx index 6ff998403f57..5ed90a943a98 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx @@ -78,6 +78,13 @@ import { import { Box, Button, ContextMenu, Flex, Text } from "@radix-ui/themes"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +export function getNewAttachments( + previousIds: ReadonlySet, + attachments: FileAttachment[], +): FileAttachment[] { + return attachments.filter(({ id }) => !previousIds.has(id)); +} + interface SessionViewProps { events: AcpMessage[]; taskId?: string; @@ -286,36 +293,68 @@ export function SessionView({ const editorRef = useRef(null); const sendInFlightRef = useRef(false); const composerSubmissionRef = useRef(0); - const attachmentUploadRef = useRef(0); + const attachmentIdsRef = useRef>(new Set()); + const attachmentUploadTokensRef = useRef>(new Map()); const [attachmentUploadStatuses, setAttachmentUploadStatuses] = useState< Record >({}); const handleAttachmentsChange = useCallback( (attachments: FileAttachment[]) => { - const requestId = ++attachmentUploadRef.current; + const attachmentIds = new Set(attachments.map(({ id }) => id)); + const addedAttachments = getNewAttachments( + attachmentIdsRef.current, + attachments, + ); + attachmentIdsRef.current = attachmentIds; + if (!isCloudRun || !taskId || attachments.length === 0) { setAttachmentUploadStatuses({}); return; } - setAttachmentUploadStatuses( - Object.fromEntries(attachments.map(({ id }) => [id, "uploading"])), + const uploadToken = Symbol(); + for (const { id } of addedAttachments) { + attachmentUploadTokensRef.current.set(id, uploadToken); + } + + setAttachmentUploadStatuses((statuses) => + Object.fromEntries([ + ...Object.entries(statuses).filter(([id]) => attachmentIds.has(id)), + ...addedAttachments.map(({ id }) => [id, "uploading"] as const), + ]), ); + if (addedAttachments.length === 0) return; + + const isCurrentUpload = (id: string) => + attachmentUploadTokensRef.current.get(id) === uploadToken; + void sessionService .prepareCloudAttachments( taskId, - attachments.map(({ id }) => id), + addedAttachments.map(({ id }) => id), ) .then(() => { - if (attachmentUploadRef.current === requestId) { - setAttachmentUploadStatuses({}); - } + const uploadedIds = new Set(addedAttachments.map(({ id }) => id)); + setAttachmentUploadStatuses((statuses) => + Object.fromEntries( + Object.entries(statuses).filter( + ([id]) => !uploadedIds.has(id) || !isCurrentUpload(id), + ), + ), + ); }) .catch((error) => { - if (attachmentUploadRef.current !== requestId) return; - setAttachmentUploadStatuses( - Object.fromEntries(attachments.map(({ id }) => [id, "error"])), + setAttachmentUploadStatuses((statuses) => + Object.fromEntries([ + ...Object.entries(statuses), + ...addedAttachments + .filter( + ({ id }) => + attachmentIdsRef.current.has(id) && isCurrentUpload(id), + ) + .map(({ id }) => [id, "error"] as const), + ]), ); toast.error("Failed to upload attachments", { description: