From e708aabf3919bdad2c020f6f5ad33836f59ef227 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 16:28:03 +0200 Subject: [PATCH 1/3] feat(sessions): add image artifact viewer Normalize image artifact MIME types and add lightweight zoom and pan controls by extending the existing image preview primitives. Generated-By: PostHog Code Task-Id: 577e32f4-830b-4174-a9c4-efa9464652a1 --- .../components/ArtifactPreview.test.tsx | 46 +++++++ .../sessions/components/ArtifactPreview.tsx | 36 ++--- .../components/artifactPreviewDocument.ts | 32 +++++ .../ui/src/primitives/SafeImagePreview.tsx | 124 ++++++++++++++---- .../hooks/useImagePanAndZoom.test.tsx | 10 ++ .../primitives/hooks/useImagePanAndZoom.ts | 15 +++ 6 files changed, 215 insertions(+), 48 deletions(-) diff --git a/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx b/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx index aa7a904c15..eed4403444 100644 --- a/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx +++ b/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ArtifactPreview } from "./ArtifactPreview"; import { artifactHtmlDocument, + artifactPreviewBlob, markdownDocument, } from "./artifactPreviewDocument"; @@ -102,6 +103,51 @@ describe("ArtifactPreview", () => { expect(frame).toHaveAttribute("sandbox", ""); }); + it.each([ + ["image.png", "image/png"], + ["image.jpg", "image/jpeg"], + ["image.gif", "image/gif"], + ["image.webp", "image/webp"], + ["image.bmp", "image/bmp"], + ["image.ico", "image/x-icon"], + ["image.tiff", "image/tiff"], + ["image.avif", "image/avif"], + ])("normalizes %s served as octet-stream", async (name, mimeType) => { + const blob = await artifactPreviewBlob( + new Blob(["image"], { type: "application/octet-stream" }), + name, + ); + + expect(blob.type).toBe(mimeType); + }); + + it("shows image controls instead of an iframe", () => { + useQuery.mockReturnValue({ + data: new Blob(["image"], { type: "image/png" }), + isLoading: false, + isError: false, + }); + + render( + , + ); + + expect(screen.getByRole("img", { name: "image.png" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Zoom in" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Zoom out" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Fit to view" }), + ).toBeInTheDocument(); + expect(screen.queryByTitle("Preview of image.png")).not.toBeInTheDocument(); + }); + it("renders GFM Markdown while escaping embedded HTML", () => { const document = markdownDocument( "# Report\n\n**Ready**\n\n| Name | Value |\n| --- | --- |\n| Cost | 12 |\n\n", diff --git a/packages/ui/src/features/sessions/components/ArtifactPreview.tsx b/packages/ui/src/features/sessions/components/ArtifactPreview.tsx index ba1d4ccb26..d1b4cd5789 100644 --- a/packages/ui/src/features/sessions/components/ArtifactPreview.tsx +++ b/packages/ui/src/features/sessions/components/ArtifactPreview.tsx @@ -4,6 +4,7 @@ import { } from "@posthog/core/sessions/sessionService"; import { useService } from "@posthog/di/react"; import { Spinner } from "@posthog/quill"; +import { isAllowedImageMimeType } from "@posthog/shared"; import { getAuthIdentity, useAuthStateValue, @@ -11,16 +12,8 @@ import { import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; -import { - artifactHtmlDocument, - markdownDocument, -} from "./artifactPreviewDocument"; - -const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown"]); - -function extension(filename: string): string { - return filename.split(".").pop()?.toLowerCase() ?? ""; -} +import { ZoomableImage } from "../../../primitives/SafeImagePreview"; +import { artifactPreviewBlob } from "./artifactPreviewDocument"; export function ArtifactPreview({ taskId, @@ -46,18 +39,7 @@ export function ArtifactPreview({ if (!url) throw new Error("Artifact is unavailable"); const response = await fetch(url); if (!response.ok) throw new Error("Artifact preview failed"); - const blob = await response.blob(); - if (MARKDOWN_EXTENSIONS.has(extension(name))) { - return new Blob([markdownDocument(await blob.text())], { - type: "text/html", - }); - } - if (extension(name) === "html" || extension(name) === "htm") { - return new Blob([artifactHtmlDocument(await blob.text())], { - type: "text/html", - }); - } - return blob; + return artifactPreviewBlob(await response.blob(), name); }, enabled: authIdentity !== null, staleTime: Infinity, @@ -89,6 +71,16 @@ export function ArtifactPreview({ ); } + if (data && isAllowedImageMimeType(data.type)) { + return ( + + ); + } return (