From 8576d1a9e97294acdd578a1505275be14dc3ec8f Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 25 Mar 2026 17:35:57 +0000 Subject: [PATCH] fix(site): persist file attachments across navigations on create form (#23609) --- site/src/pages/AgentsPage/AgentsPage.test.ts | 223 +++++++++++++++++- .../AgentsPage/components/AgentChatInput.tsx | 2 +- .../components/AgentCreateForm.stories.tsx | 57 +++++ .../AgentsPage/components/AgentCreateForm.tsx | 9 +- .../AgentsPage/hooks/useFileAttachments.ts | 164 ++++++++++++- 5 files changed, 441 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentsPage.test.ts b/site/src/pages/AgentsPage/AgentsPage.test.ts index 33c4fc1d56..2c365bf717 100644 --- a/site/src/pages/AgentsPage/AgentsPage.test.ts +++ b/site/src/pages/AgentsPage/AgentsPage.test.ts @@ -1,9 +1,13 @@ import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { emptyInputStorageKey, useEmptyStateDraft, } from "./components/AgentCreateForm"; +import { + persistedAttachmentsStorageKey, + useFileAttachments, +} from "./hooks/useFileAttachments"; describe("useEmptyStateDraft", () => { beforeEach(() => { @@ -166,3 +170,220 @@ describe("useEmptyStateDraft", () => { unmount(); }); }); + +describe("useFileAttachments persistence", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const renderFileAttachments = () => + renderHook(() => useFileAttachments("org-1", { persist: true })); + + const makePersistedEntry = ( + overrides: Partial<{ + fileId: string; + fileName: string; + fileType: string; + lastModified: number; + }> = {}, + ) => ({ + fileId: "file-1", + fileName: "photo.png", + fileType: "image/png", + lastModified: 1000, + ...overrides, + }); + + it("restores uploaded attachments from localStorage on mount", () => { + const entry = makePersistedEntry(); + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([entry]), + ); + + const { result, unmount } = renderFileAttachments(); + + expect(result.current.attachments).toHaveLength(1); + expect(result.current.attachments[0].name).toBe("photo.png"); + expect(result.current.attachments[0].type).toBe("image/png"); + + const file = result.current.attachments[0]; + const state = result.current.uploadStates.get(file); + expect(state).toEqual({ status: "uploaded", fileId: "file-1" }); + + const previewUrl = result.current.previewUrls.get(file); + expect(previewUrl).toBe("/api/experimental/chats/files/file-1"); + unmount(); + }); + + it("does not create preview URLs for non-image attachments", () => { + const entry = makePersistedEntry({ + fileType: "text/plain", + fileName: "notes.txt", + }); + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([entry]), + ); + + const { result, unmount } = renderFileAttachments(); + + expect(result.current.attachments).toHaveLength(1); + const file = result.current.attachments[0]; + expect(result.current.previewUrls.has(file)).toBe(false); + expect(result.current.uploadStates.get(file)).toEqual({ + status: "uploaded", + fileId: "file-1", + }); + unmount(); + }); + + it("returns empty state when nothing is persisted", () => { + const { result, unmount } = renderFileAttachments(); + + expect(result.current.attachments).toHaveLength(0); + expect(result.current.uploadStates.size).toBe(0); + expect(result.current.previewUrls.size).toBe(0); + unmount(); + }); + + it("does not restore when persist option is false", () => { + const entry = makePersistedEntry(); + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([entry]), + ); + + const { result, unmount } = renderHook(() => + useFileAttachments("org-1", { persist: false }), + ); + + expect(result.current.attachments).toHaveLength(0); + unmount(); + }); + + it("does not restore when no options argument is passed", () => { + const entry = makePersistedEntry(); + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([entry]), + ); + + const { result, unmount } = renderHook(() => useFileAttachments("org-1")); + + expect(result.current.attachments).toHaveLength(0); + unmount(); + }); + + it("clears persisted attachments on resetAttachments", () => { + const entry = makePersistedEntry(); + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([entry]), + ); + + const { result, unmount } = renderFileAttachments(); + + act(() => { + result.current.resetAttachments(); + }); + + expect(localStorage.getItem(persistedAttachmentsStorageKey)).toBeNull(); + expect(result.current.attachments).toHaveLength(0); + unmount(); + }); + + it("removes the correct entry when an attachment is removed", () => { + const entries = [ + makePersistedEntry({ fileId: "file-1", fileName: "a.png" }), + makePersistedEntry({ fileId: "file-2", fileName: "b.png" }), + ]; + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify(entries), + ); + + const { result, unmount } = renderFileAttachments(); + expect(result.current.attachments).toHaveLength(2); + + act(() => { + result.current.handleRemoveAttachment(0); + }); + + expect(result.current.attachments).toHaveLength(1); + expect(result.current.attachments[0].name).toBe("b.png"); + + const stored = JSON.parse( + localStorage.getItem(persistedAttachmentsStorageKey)!, + ); + expect(stored).toHaveLength(1); + expect(stored[0].fileId).toBe("file-2"); + unmount(); + }); + + it("handles corrupt localStorage gracefully", () => { + localStorage.setItem(persistedAttachmentsStorageKey, "not-valid-json"); + + const { result, unmount } = renderFileAttachments(); + + expect(result.current.attachments).toHaveLength(0); + unmount(); + }); + + it("persists attachment metadata after successful upload", async () => { + const { API } = await import("#/api/api"); + vi.spyOn(API.experimental, "uploadChatFile").mockResolvedValue({ + id: "new-file-id", + }); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); + + const { result, unmount } = renderFileAttachments(); + + const file = new File(["hello"], "test.png", { type: "image/png" }); + + act(() => { + result.current.handleAttach([file]); + }); + + // Wait for the async upload to complete and state to update. + await vi.waitFor(() => { + const state = result.current.uploadStates.get(file); + expect(state?.status).toBe("uploaded"); + }); + + const stored = JSON.parse( + localStorage.getItem(persistedAttachmentsStorageKey)!, + ); + expect(stored).toHaveLength(1); + expect(stored[0].fileId).toBe("new-file-id"); + expect(stored[0].fileName).toBe("test.png"); + unmount(); + }); + + it("does not persist attachment metadata when upload fails", async () => { + const { API } = await import("#/api/api"); + vi.spyOn(API.experimental, "uploadChatFile").mockRejectedValue( + new Error("server error"), + ); + + const { result, unmount } = renderFileAttachments(); + + const file = new File(["hello"], "test.png", { type: "image/png" }); + + act(() => { + result.current.handleAttach([file]); + }); + + await vi.waitFor(() => { + const state = result.current.uploadStates.get(file); + expect(state?.status).toBe("error"); + }); + + expect(localStorage.getItem(persistedAttachmentsStorageKey)).toBeNull(); + unmount(); + }); +}); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 202dc591db..6130a88db8 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -443,7 +443,7 @@ export const AttachmentPreview: FC<{ )}