From 142c5dbe99f4d49ac21023830b642fe9d0e753a7 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Tue, 14 Apr 2026 20:21:05 -0500 Subject: [PATCH 01/35] fix(frontend): tighten artifact preview behavior (#12770) Co-authored-by: Claude Opus 4.6 (1M context) --- autogpt_platform/frontend/.storybook/main.ts | 1 + .../ArtifactCard/ArtifactCard.stories.tsx | 145 +++ .../ArtifactPanel/ArtifactPanel.stories.tsx | 223 ++++ .../__tests__/downloadArtifact.test.ts | 413 ++++++++ .../components/ArtifactContent.stories.tsx | 460 +++++++++ .../components/ArtifactContent.tsx | 116 ++- .../components/ArtifactReactPreview.test.tsx | 67 ++ .../__tests__/ArtifactContent.test.tsx | 970 ++++++++++++++++++ .../__tests__/useArtifactContent.test.ts | 340 +++++- .../components/reactArtifactPreview.test.ts | 31 + .../components/reactArtifactPreview.ts | 39 +- .../components/transpileReactArtifact.test.ts | 100 ++ .../components/useArtifactContent.ts | 120 ++- .../ArtifactPanel/downloadArtifact.ts | 50 +- .../components/ArtifactPanel/helpers.test.ts | 396 ++++++- .../components/ArtifactPanel/helpers.ts | 85 +- .../ArtifactPanel/useArtifactPanel.ts | 1 + .../ChatContainer/ChatContainer.tsx | 5 +- .../__tests__/useAutoOpenArtifacts.test.ts | 77 ++ .../useAutoOpenArtifacts.test.ts | 141 +-- .../ChatContainer/useAutoOpenArtifacts.ts | 86 +- .../__tests__/UsagePanelContent.test.ts | 12 +- .../src/app/(platform)/copilot/store.test.ts | 75 ++ .../src/app/(platform)/copilot/store.ts | 27 +- .../BlockOutputCard/BlockOutputCard.tsx | 36 +- .../tools/ViewAgentOutput/ViewAgentOutput.tsx | 25 +- .../__tests__/resolveForRenderer.test.ts | 52 + .../api/proxy/[...path]/route.helpers.test.ts | 282 +++++ .../app/api/proxy/[...path]/route.helpers.ts | 108 ++ .../src/app/api/proxy/[...path]/route.ts | 62 +- .../renderers/CSVRenderer.test.ts | 24 + .../OutputRenderers/renderers/CSVRenderer.tsx | 65 +- codecov.yml | 32 +- 33 files changed, 4349 insertions(+), 317 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactCard/ArtifactCard.stories.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.stories.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/__tests__/downloadArtifact.test.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.stories.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactReactPreview.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/__tests__/ArtifactContent.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/__tests__/useAutoOpenArtifacts.test.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/tools/ViewAgentOutput/__tests__/resolveForRenderer.test.ts create mode 100644 autogpt_platform/frontend/src/app/api/proxy/[...path]/route.helpers.test.ts create mode 100644 autogpt_platform/frontend/src/app/api/proxy/[...path]/route.helpers.ts diff --git a/autogpt_platform/frontend/.storybook/main.ts b/autogpt_platform/frontend/.storybook/main.ts index 4e3070bfe1..235dbf4749 100644 --- a/autogpt_platform/frontend/.storybook/main.ts +++ b/autogpt_platform/frontend/.storybook/main.ts @@ -8,6 +8,7 @@ const config: StorybookConfig = { "../src/components/molecules/**/*.stories.@(js|jsx|mjs|ts|tsx)", "../src/components/ai-elements/**/*.stories.@(js|jsx|mjs|ts|tsx)", "../src/components/renderers/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../src/app/[(]platform[)]/copilot/**/*.stories.@(js|jsx|mjs|ts|tsx)", ], addons: [ "@storybook/addon-a11y", diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactCard/ArtifactCard.stories.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactCard/ArtifactCard.stories.tsx new file mode 100644 index 0000000000..d4fc07fb48 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactCard/ArtifactCard.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { ArtifactCard } from "./ArtifactCard"; +import type { ArtifactRef } from "../../store"; +import { useCopilotUIStore } from "../../store"; + +function makeArtifact(overrides?: Partial): ArtifactRef { + return { + id: "file-001", + title: "report.html", + mimeType: "text/html", + sourceUrl: "/api/proxy/api/workspace/files/file-001/download", + origin: "agent", + ...overrides, + }; +} + +const meta: Meta = { + title: "Copilot/ArtifactCard", + component: ArtifactCard, + tags: ["autodocs"], + parameters: { + layout: "padded", + docs: { + description: { + component: + "Inline artifact card rendered in chat messages. Openable artifacts show a caret and open the ArtifactPanel on click. Download-only artifacts trigger a file download.", + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const OpenableHTML: Story = { + name: "Openable (HTML)", + args: { + artifact: makeArtifact({ + title: "dashboard.html", + mimeType: "text/html", + }), + }, +}; + +export const OpenableImage: Story = { + name: "Openable (Image)", + args: { + artifact: makeArtifact({ + id: "img-card", + title: "chart.png", + mimeType: "image/png", + }), + }, +}; + +export const OpenableCode: Story = { + name: "Openable (Code)", + args: { + artifact: makeArtifact({ + title: "script.py", + mimeType: "text/x-python", + }), + }, +}; + +export const DownloadOnly: Story = { + name: "Download Only (ZIP)", + args: { + artifact: makeArtifact({ + title: "archive.zip", + mimeType: "application/zip", + sizeBytes: 2_500_000, + }), + }, +}; + +export const PreviewableVideo: Story = { + name: "Previewable (Video)", + args: { + artifact: makeArtifact({ + title: "demo.mp4", + mimeType: "video/mp4", + sizeBytes: 15_000_000, + }), + }, + parameters: { + docs: { + description: { + story: + "Videos with supported formats (MP4, WebM, M4V) are previewable inline in the artifact panel.", + }, + }, + }, +}; + +export const WithSize: Story = { + name: "With File Size", + args: { + artifact: makeArtifact({ + title: "data.csv", + mimeType: "text/csv", + sizeBytes: 524_288, + }), + }, +}; + +export const UserUpload: Story = { + name: "User Upload Origin", + args: { + artifact: makeArtifact({ + title: "requirements.txt", + mimeType: "text/plain", + origin: "user-upload", + }), + }, +}; + +export const ActiveState: Story = { + name: "Active (Panel Open)", + args: { + artifact: makeArtifact({ id: "active-card" }), + }, + decorators: [ + (Story) => { + useCopilotUIStore.setState({ + artifactPanel: { + isOpen: true, + isMinimized: false, + isMaximized: false, + width: 600, + activeArtifact: makeArtifact({ id: "active-card" }), + history: [], + }, + }); + return ; + }, + ], +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.stories.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.stories.tsx new file mode 100644 index 0000000000..e7b457c6a9 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.stories.tsx @@ -0,0 +1,223 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { http, HttpResponse } from "msw"; +import { ArtifactPanel } from "./ArtifactPanel"; +import { useCopilotUIStore } from "../../store"; +import type { ArtifactRef } from "../../store"; + +const PROXY_BASE = "/api/proxy/api/workspace/files"; + +function makeArtifact(overrides?: Partial): ArtifactRef { + return { + id: "file-001", + title: "report.html", + mimeType: "text/html", + sourceUrl: `${PROXY_BASE}/file-001/download`, + origin: "agent", + ...overrides, + }; +} + +function openPanelWith(artifact: ArtifactRef) { + useCopilotUIStore.setState({ + artifactPanel: { + isOpen: true, + isMinimized: false, + isMaximized: false, + width: 600, + activeArtifact: artifact, + history: [], + }, + }); +} + +const meta: Meta = { + title: "Copilot/ArtifactPanel", + component: ArtifactPanel, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "Side panel for previewing workspace artifacts. Supports resize, minimize, maximize, and navigation history. Bug: panel auto-opens on chat switch instead of staying collapsed.", + }, + }, + }, + decorators: [ + (Story) => ( +
+
+

Chat area

+
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const OpenWithTextArtifact: Story = { + name: "Open — Text File", + decorators: [ + (Story) => { + openPanelWith( + makeArtifact({ title: "notes.txt", mimeType: "text/plain" }), + ); + return ; + }, + ], + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/file-001/download`, () => { + return HttpResponse.text( + "These are some notes from the agent execution.\n\nKey findings:\n1. Performance improved by 23%\n2. Memory usage reduced\n3. Error rate dropped to 0.1%", + ); + }), + ], + }, + }, +}; + +export const OpenWithHTMLArtifact: Story = { + name: "Open — HTML", + decorators: [ + (Story) => { + openPanelWith( + makeArtifact({ + id: "html-panel", + title: "dashboard.html", + mimeType: "text/html", + sourceUrl: `${PROXY_BASE}/html-panel/download`, + }), + ); + return ; + }, + ], + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/html-panel/download`, () => { + return HttpResponse.text( + `

Dashboard

HTML artifact in the panel.

`, + ); + }), + ], + }, + }, +}; + +export const OpenWithImageArtifact: Story = { + name: "Open — Image (Bug: No Loading State)", + decorators: [ + (Story) => { + openPanelWith( + makeArtifact({ + id: "img-panel", + title: "chart.png", + mimeType: "image/png", + sourceUrl: `${PROXY_BASE}/img-panel/download`, + }), + ); + return ; + }, + ], + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/img-panel/download`, () => { + return HttpResponse.text( + 'Image Preview (no skeleton)', + { headers: { "Content-Type": "image/svg+xml" } }, + ); + }), + ], + }, + docs: { + description: { + story: + "**BUG:** Image artifacts render with a bare `` tag — no loading skeleton or error handling. Compare with text/HTML artifacts which show a proper skeleton while loading.", + }, + }, + }, +}; + +export const MinimizedStrip: Story = { + name: "Minimized", + decorators: [ + (Story) => { + useCopilotUIStore.setState({ + artifactPanel: { + isOpen: true, + isMinimized: true, + isMaximized: false, + width: 600, + activeArtifact: makeArtifact(), + history: [], + }, + }); + return ; + }, + ], +}; + +export const ErrorState: Story = { + name: "Error — Failed to Load (Stale Artifact)", + decorators: [ + (Story) => { + openPanelWith( + makeArtifact({ + id: "stale-panel", + title: "old-report.html", + mimeType: "text/html", + sourceUrl: `${PROXY_BASE}/stale-panel/download`, + }), + ); + return ; + }, + ], + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/stale-panel/download`, () => { + return new HttpResponse(null, { status: 404 }); + }), + ], + }, + docs: { + description: { + story: + "Shows what users see when opening a previously generated artifact that no longer exists on the backend (404). The 'Try again' button retries the fetch.", + }, + }, + }, +}; + +export const Closed: Story = { + name: "Closed (Default State)", + decorators: [ + (Story) => { + useCopilotUIStore.setState({ + artifactPanel: { + isOpen: false, + isMinimized: false, + isMaximized: false, + width: 600, + activeArtifact: null, + history: [], + }, + }); + return ; + }, + ], + parameters: { + docs: { + description: { + story: + "The default state — panel is closed. It should only open when a user clicks on an artifact card in the chat.", + }, + }, + }, +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/__tests__/downloadArtifact.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/__tests__/downloadArtifact.test.ts new file mode 100644 index 0000000000..4095841e89 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/__tests__/downloadArtifact.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { downloadArtifact } from "../downloadArtifact"; +import type { ArtifactRef } from "../../../store"; + +function makeArtifact(overrides?: Partial): ArtifactRef { + return { + id: "file-001", + title: "report.pdf", + mimeType: "application/pdf", + sourceUrl: "/api/proxy/api/workspace/files/file-001/download", + origin: "agent", + ...overrides, + }; +} + +describe("downloadArtifact", () => { + let clickSpy: ReturnType; + let removeSpy: ReturnType; + + beforeEach(() => { + clickSpy = vi.fn(); + removeSpy = vi.fn(); + + vi.stubGlobal( + "URL", + Object.assign(URL, { + createObjectURL: vi.fn().mockReturnValue("blob:fake-url"), + revokeObjectURL: vi.fn(), + }), + ); + + vi.spyOn(document, "createElement").mockReturnValue({ + href: "", + download: "", + click: clickSpy, + remove: removeSpy, + } as unknown as HTMLAnchorElement); + + vi.spyOn(document.body, "appendChild").mockImplementation( + (node) => node as ChildNode, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("downloads file successfully on 200 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["pdf content"])), + }), + ); + + await downloadArtifact(makeArtifact()); + + expect(fetch).toHaveBeenCalledWith( + "/api/proxy/api/workspace/files/file-001/download", + ); + expect(clickSpy).toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake-url"); + }); + + it("rejects on persistent server error after exhausting retries", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }), + ); + + await expect(downloadArtifact(makeArtifact())).rejects.toThrow( + "Download failed: 500", + ); + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it("rejects on persistent network error after exhausting retries", async () => { + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + callCount++; + return Promise.reject(new Error("Network error")); + }), + ); + + await expect(downloadArtifact(makeArtifact())).rejects.toThrow( + "Network error", + ); + expect(callCount).toBe(3); + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it("retries on transient network error and succeeds", async () => { + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error("Connection reset")); + } + return Promise.resolve({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }); + }), + ); + + await downloadArtifact(makeArtifact()); + expect(callCount).toBe(2); + expect(clickSpy).toHaveBeenCalled(); + }); + + it("retries on transient 500 and succeeds", async () => { + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ ok: false, status: 500 }); + } + return Promise.resolve({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }); + }), + ); + + // Should succeed on second attempt + await downloadArtifact(makeArtifact()); + expect(callCount).toBe(2); + expect(clickSpy).toHaveBeenCalled(); + }); + + it("sanitizes dangerous filenames", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }), + ); + + const anchor = { + href: "", + download: "", + click: clickSpy, + remove: removeSpy, + }; + vi.spyOn(document, "createElement").mockReturnValue( + anchor as unknown as HTMLAnchorElement, + ); + + await downloadArtifact(makeArtifact({ title: "../../../etc/passwd" })); + + expect(anchor.download).not.toContain(".."); + expect(anchor.download).not.toContain("/"); + }); + + // ── Transient retry codes ───────────────────────────────────────── + + it("retries on 408 (Request Timeout) and succeeds", async () => { + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ ok: false, status: 408 }); + } + return Promise.resolve({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }); + }), + ); + + await downloadArtifact(makeArtifact()); + expect(callCount).toBe(2); + expect(clickSpy).toHaveBeenCalled(); + }); + + it("retries on 429 (Too Many Requests) and succeeds", async () => { + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ ok: false, status: 429 }); + } + return Promise.resolve({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }); + }), + ); + + await downloadArtifact(makeArtifact()); + expect(callCount).toBe(2); + expect(clickSpy).toHaveBeenCalled(); + }); + + // ── Non-transient errors ────────────────────────────────────────── + + it("rejects immediately on 403 (non-transient) without retry", async () => { + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ ok: false, status: 403 }); + }), + ); + + await expect(downloadArtifact(makeArtifact())).rejects.toThrow( + "Download failed: 403", + ); + expect(callCount).toBe(1); + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it("rejects immediately on 404 without retry", async () => { + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ ok: false, status: 404 }); + }), + ); + + await expect(downloadArtifact(makeArtifact())).rejects.toThrow( + "Download failed: 404", + ); + expect(callCount).toBe(1); + }); + + // ── Exhausted retries ───────────────────────────────────────────── + + it("rejects after exhausting all retries on persistent 500", async () => { + let callCount = 0; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ ok: false, status: 500 }); + }), + ); + + await expect(downloadArtifact(makeArtifact())).rejects.toThrow( + "Download failed: 500", + ); + // Initial attempt + 2 retries = 3 total + expect(callCount).toBe(3); + expect(clickSpy).not.toHaveBeenCalled(); + }); + + // ── Filename edge cases ─────────────────────────────────────────── + + it("falls back to 'download' when title is empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }), + ); + + const anchor = { + href: "", + download: "", + click: clickSpy, + remove: removeSpy, + }; + vi.spyOn(document, "createElement").mockReturnValue( + anchor as unknown as HTMLAnchorElement, + ); + + await downloadArtifact(makeArtifact({ title: "" })); + expect(anchor.download).toBe("download"); + }); + + it("falls back to 'download' when title is only dots", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }), + ); + + const anchor = { + href: "", + download: "", + click: clickSpy, + remove: removeSpy, + }; + vi.spyOn(document, "createElement").mockReturnValue( + anchor as unknown as HTMLAnchorElement, + ); + + // Dot-only names should not produce a hidden or empty filename. + await downloadArtifact(makeArtifact({ title: "...." })); + expect(anchor.download).toBe("download"); + }); + + it("replaces special chars with underscores (not empty)", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }), + ); + + const anchor = { + href: "", + download: "", + click: clickSpy, + remove: removeSpy, + }; + vi.spyOn(document, "createElement").mockReturnValue( + anchor as unknown as HTMLAnchorElement, + ); + + await downloadArtifact(makeArtifact({ title: '***???"' })); + // Special chars become underscores, not removed + expect(anchor.download).toBe("_______"); + }); + + it("strips leading dots from filename", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }), + ); + + const anchor = { + href: "", + download: "", + click: clickSpy, + remove: removeSpy, + }; + vi.spyOn(document, "createElement").mockReturnValue( + anchor as unknown as HTMLAnchorElement, + ); + + await downloadArtifact(makeArtifact({ title: "...hidden.txt" })); + expect(anchor.download).not.toMatch(/^\./); + expect(anchor.download).toContain("hidden.txt"); + }); + + it("replaces Windows-reserved characters", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }), + ); + + const anchor = { + href: "", + download: "", + click: clickSpy, + remove: removeSpy, + }; + vi.spyOn(document, "createElement").mockReturnValue( + anchor as unknown as HTMLAnchorElement, + ); + + await downloadArtifact( + makeArtifact({ title: "filewith:bad*chars?.txt" }), + ); + expect(anchor.download).not.toMatch(/[<>:*?]/); + }); + + it("replaces control characters in filename", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["content"])), + }), + ); + + const anchor = { + href: "", + download: "", + click: clickSpy, + remove: removeSpy, + }; + vi.spyOn(document, "createElement").mockReturnValue( + anchor as unknown as HTMLAnchorElement, + ); + + await downloadArtifact( + makeArtifact({ title: "file\x00with\x1fcontrol.txt" }), + ); + expect(anchor.download).not.toMatch(/[\x00-\x1f]/); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.stories.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.stories.tsx new file mode 100644 index 0000000000..6b9ef31631 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.stories.tsx @@ -0,0 +1,460 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { http, HttpResponse } from "msw"; +import { ArtifactContent } from "./ArtifactContent"; +import type { ArtifactRef } from "../../../store"; +import type { ArtifactClassification } from "../helpers"; +import { + Code, + File, + FileHtml, + FileText, + Image, + Table, +} from "@phosphor-icons/react"; + +const PROXY_BASE = "/api/proxy/api/workspace/files"; + +function makeArtifact(overrides?: Partial): ArtifactRef { + return { + id: "file-001", + title: "test.txt", + mimeType: "text/plain", + sourceUrl: `${PROXY_BASE}/file-001/download`, + origin: "agent", + ...overrides, + }; +} + +function makeClassification( + overrides?: Partial, +): ArtifactClassification { + return { + type: "text", + icon: FileText, + label: "Text", + openable: true, + hasSourceToggle: false, + ...overrides, + }; +} + +const meta: Meta = { + title: "Copilot/ArtifactContent", + component: ArtifactContent, + tags: ["autodocs"], + parameters: { + layout: "padded", + docs: { + description: { + component: + "Renders artifact content based on file type classification. Supports images, HTML, code, CSV, JSON, markdown, PDF, and plain text. Bug: image artifacts render as bare with no loading/error states.", + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const ImageArtifactPNG: Story = { + name: "Image (PNG) — No Loading Skeleton (Bug #1)", + args: { + artifact: makeArtifact({ + id: "img-png", + title: "chart.png", + mimeType: "image/png", + sourceUrl: `${PROXY_BASE}/img-png/download`, + }), + isSourceView: false, + classification: makeClassification({ type: "image", icon: Image }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/img-png/download`, () => { + return HttpResponse.text( + 'PNG Placeholder', + { headers: { "Content-Type": "image/svg+xml" } }, + ); + }), + ], + }, + docs: { + description: { + story: + "**BUG:** This renders a bare `` tag with no loading skeleton or error handling. Compare with WorkspaceFileRenderer which has proper Skeleton + onError states.", + }, + }, + }, +}; + +export const ImageArtifactSVG: Story = { + name: "Image (SVG)", + args: { + artifact: makeArtifact({ + id: "img-svg", + title: "diagram.svg", + mimeType: "image/svg+xml", + sourceUrl: `${PROXY_BASE}/img-svg/download`, + }), + isSourceView: false, + classification: makeClassification({ type: "image", icon: Image }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/img-svg/download`, () => { + return HttpResponse.text( + 'SVG OK', + { headers: { "Content-Type": "image/svg+xml" } }, + ); + }), + ], + }, + }, +}; + +export const HTMLArtifact: Story = { + name: "HTML", + args: { + artifact: makeArtifact({ + id: "html-001", + title: "page.html", + mimeType: "text/html", + sourceUrl: `${PROXY_BASE}/html-001/download`, + }), + isSourceView: false, + classification: makeClassification({ + type: "html", + icon: FileHtml, + label: "HTML", + hasSourceToggle: true, + }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/html-001/download`, () => { + return HttpResponse.text( + ` + +Artifact Preview + +

HTML Artifact

+

This is an HTML artifact rendered in a sandboxed iframe with Tailwind CSS injected.

+
+

Interactive content works via allow-scripts sandbox.

+
+ +`, + { headers: { "Content-Type": "text/html" } }, + ); + }), + ], + }, + }, +}; + +export const CodeArtifact: Story = { + name: "Code (Python)", + args: { + artifact: makeArtifact({ + id: "code-001", + title: "analysis.py", + mimeType: "text/x-python", + sourceUrl: `${PROXY_BASE}/code-001/download`, + }), + isSourceView: false, + classification: makeClassification({ + type: "code", + icon: Code, + label: "Code", + }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/code-001/download`, () => { + return HttpResponse.text( + `import pandas as pd +import matplotlib.pyplot as plt + +def analyze_data(filepath: str) -> pd.DataFrame: + """Load and analyze CSV data.""" + df = pd.read_csv(filepath) + summary = df.describe() + print(f"Loaded {len(df)} rows") + return summary + +if __name__ == "__main__": + result = analyze_data("data.csv") + print(result)`, + { headers: { "Content-Type": "text/plain" } }, + ); + }), + ], + }, + }, +}; + +export const CSVArtifact: Story = { + name: "CSV (Spreadsheet)", + args: { + artifact: makeArtifact({ + id: "csv-001", + title: "data.csv", + mimeType: "text/csv", + sourceUrl: `${PROXY_BASE}/csv-001/download`, + }), + isSourceView: false, + classification: makeClassification({ + type: "csv", + icon: Table, + label: "Spreadsheet", + hasSourceToggle: true, + }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/csv-001/download`, () => { + return HttpResponse.text( + `Name,Age,City,Score +Alice,28,New York,92 +Bob,35,San Francisco,87 +Charlie,22,Chicago,95 +Diana,31,Boston,88 +Eve,27,Seattle,91`, + { headers: { "Content-Type": "text/csv" } }, + ); + }), + ], + }, + }, +}; + +export const JSONArtifact: Story = { + name: "JSON (Data)", + args: { + artifact: makeArtifact({ + id: "json-001", + title: "config.json", + mimeType: "application/json", + sourceUrl: `${PROXY_BASE}/json-001/download`, + }), + isSourceView: false, + classification: makeClassification({ + type: "json", + icon: Code, + label: "Data", + hasSourceToggle: true, + }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/json-001/download`, () => { + return HttpResponse.text( + JSON.stringify( + { + name: "AutoGPT Agent", + version: "2.0", + capabilities: ["web_search", "code_execution", "file_io"], + settings: { maxTokens: 4096, temperature: 0.7 }, + }, + null, + 2, + ), + { headers: { "Content-Type": "application/json" } }, + ); + }), + ], + }, + }, +}; + +export const MarkdownArtifact: Story = { + name: "Markdown", + args: { + artifact: makeArtifact({ + id: "md-001", + title: "README.md", + mimeType: "text/markdown", + sourceUrl: `${PROXY_BASE}/md-001/download`, + }), + isSourceView: false, + classification: makeClassification({ + type: "markdown", + icon: FileText, + label: "Document", + hasSourceToggle: true, + }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/md-001/download`, () => { + return HttpResponse.text( + `# Project Summary + +## Overview +This is a **markdown** artifact rendered through the global renderer registry. + +## Features +- Headings and paragraphs +- **Bold** and *italic* text +- Lists and code blocks + +\`\`\`python +print("Hello from markdown!") +\`\`\` + +> Blockquotes are also supported.`, + { headers: { "Content-Type": "text/plain" } }, + ); + }), + ], + }, + }, +}; + +export const PDFArtifact: Story = { + name: "PDF", + args: { + artifact: makeArtifact({ + id: "pdf-001", + title: "report.pdf", + mimeType: "application/pdf", + sourceUrl: `${PROXY_BASE}/pdf-001/download`, + }), + isSourceView: false, + classification: makeClassification({ + type: "pdf", + icon: FileText, + label: "PDF", + }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/pdf-001/download`, () => { + return HttpResponse.arrayBuffer(new ArrayBuffer(100), { + headers: { "Content-Type": "application/pdf" }, + }); + }), + ], + }, + docs: { + description: { + story: + "PDF artifacts are rendered in an unsandboxed iframe using a blob URL (Chromium bug #413851 prevents sandboxed PDF rendering).", + }, + }, + }, +}; + +export const ErrorState: Story = { + name: "Error — Failed to Load Content", + args: { + artifact: makeArtifact({ + id: "error-001", + title: "old-report.html", + mimeType: "text/html", + sourceUrl: `${PROXY_BASE}/error-001/download`, + }), + isSourceView: false, + classification: makeClassification({ + type: "html", + icon: FileHtml, + label: "HTML", + hasSourceToggle: true, + }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/error-001/download`, () => { + return new HttpResponse(null, { status: 404 }); + }), + ], + }, + docs: { + description: { + story: + "Shows the error state when an artifact fails to load (e.g., old/expired file returning 404). Includes a 'Try again' retry button.", + }, + }, + }, +}; + +export const LoadingSkeleton: Story = { + name: "Loading State", + args: { + artifact: makeArtifact({ + id: "loading-001", + title: "loading.html", + mimeType: "text/html", + sourceUrl: `${PROXY_BASE}/loading-001/download`, + }), + isSourceView: false, + classification: makeClassification({ + type: "html", + icon: FileHtml, + label: "HTML", + }), + }, + parameters: { + msw: { + handlers: [ + http.get(`${PROXY_BASE}/loading-001/download`, async () => { + // Delay response to show loading state + await new Promise((r) => setTimeout(r, 999999)); + return HttpResponse.text("never resolves"); + }), + ], + }, + docs: { + description: { + story: + "Shows the skeleton loading state while content is being fetched.", + }, + }, + }, +}; + +export const DownloadOnly: Story = { + name: "Download Only (Binary)", + args: { + artifact: makeArtifact({ + id: "bin-001", + title: "archive.zip", + mimeType: "application/zip", + sourceUrl: `${PROXY_BASE}/bin-001/download`, + }), + isSourceView: false, + classification: makeClassification({ + type: "download-only", + icon: File, + label: "File", + openable: false, + }), + }, + parameters: { + docs: { + description: { + story: + "Download-only files (binary, video, etc.) are not rendered inline. The ArtifactPanel shows nothing for these — they are handled by ArtifactCard with a download button.", + }, + }, + }, +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx index 6e057293b5..506cbc3b60 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx @@ -2,7 +2,8 @@ import { globalRegistry } from "@/components/contextual/OutputRenderers"; import { codeRenderer } from "@/components/contextual/OutputRenderers/renderers/CodeRenderer"; -import { Suspense } from "react"; +import { Suspense, useState } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; import type { ArtifactRef } from "../../../store"; import type { ArtifactClassification } from "../helpers"; import { ArtifactReactPreview } from "./ArtifactReactPreview"; @@ -63,6 +64,90 @@ function ArtifactContentLoader({ ); } +function ArtifactImage({ src, alt }: { src: string; alt: string }) { + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(false); + + if (error) { + return ( +
+

Failed to load image

+ +
+ ); + } + + return ( +
+ {!loaded && ( + + )} + {/* eslint-disable-next-line @next/next/no-img-element */} + {alt} setLoaded(true)} + onError={() => setError(true)} + /> +
+ ); +} + +function ArtifactVideo({ src }: { src: string }) { + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(false); + + if (error) { + return ( +
+

Failed to load video

+ +
+ ); + } + + return ( +
+ {!loaded && ( + + )} +
+ ); +} + function ArtifactRenderer({ artifact, content, @@ -79,17 +164,19 @@ function ArtifactRenderer({ // Image: render directly from URL (no content fetch) if (classification.type === "image") { return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {artifact.title} -
+ ); } + // Video: render with