Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"react-hotkeys-hook": "^4.4.4",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"react-zoom-pan-pinch": "^4.0.3",
"reflect-metadata": "catalog:",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
Expand Down
115 changes: 114 additions & 1 deletion packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ArtifactPreview } from "./ArtifactPreview";
import {
artifactHtmlDocument,
artifactPreviewBlob,
markdownDocument,
} from "./artifactPreviewDocument";

Expand Down Expand Up @@ -102,6 +103,118 @@ 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 working image controls instead of an iframe", () => {
useQuery.mockReturnValue({
data: new Blob(["image"], { type: "image/png" }),
isLoading: false,
isError: false,
});

render(
<ArtifactPreview
taskId="task-1"
runId="run-1"
artifactId="artifact-1"
name="image.png"
/>,
);

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();

const zoomOut = screen.getByRole("button", { name: "Zoom out" });
fireEvent.click(zoomOut);
fireEvent.click(zoomOut);
fireEvent.click(zoomOut);
fireEvent.click(zoomOut);
expect(screen.getByText("10%")).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Fit to view" }));
expect(screen.getByText("100%")).toBeInTheDocument();
});

it("zooms with a trackpad pinch gesture", async () => {
useQuery.mockReturnValue({
data: new Blob(["image"], { type: "image/png" }),
isLoading: false,
isError: false,
});

render(
<ArtifactPreview
taskId="task-1"
runId="run-1"
artifactId="artifact-1"
name="image.png"
/>,
);

const image = screen.getByRole("img", { name: "image.png" });
const viewport = image.closest(".react-transform-wrapper");
expect(viewport).not.toBeNull();
fireEvent.wheel(viewport as Element, {
ctrlKey: true,
deltaY: -100,
clientX: 100,
clientY: 100,
});

await waitFor(() => {
const percentage = Number.parseInt(
screen.getByText(/%$/).textContent ?? "0",
10,
);
expect(percentage).toBeGreaterThan(100);
});
});

it("shows the preview error when an image cannot be decoded", () => {
useQuery.mockReturnValue({
data: new Blob(["not an image"], { type: "image/png" }),
isLoading: false,
isError: false,
});

render(
<ArtifactPreview
taskId="task-1"
runId="run-1"
artifactId="artifact-1"
name="broken.png"
/>,
);

fireEvent.error(screen.getByRole("img", { name: "broken.png" }));
expect(
screen.getByText("This artifact can’t be previewed."),
).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<script>alert('no')</script>",
Expand Down
54 changes: 29 additions & 25 deletions packages/ui/src/features/sessions/components/ArtifactPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,38 @@ 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,
} from "@posthog/ui/features/auth/store";
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";
import { useEffect, useMemo, useState } from "react";
import { ZoomableImage } from "../../../primitives/SafeImagePreview";
import { artifactPreviewBlob } from "./artifactPreviewDocument";

function ArtifactPreviewError() {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
This artifact can’t be previewed.
</div>
);
}

const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown"]);
function ArtifactImagePreview({ src, name }: { src: string; name: string }) {
const [hasError, setHasError] = useState(false);

function extension(filename: string): string {
return filename.split(".").pop()?.toLowerCase() ?? "";
if (hasError) return <ArtifactPreviewError />;
return (
<ZoomableImage
src={src}
alt={name}
controls
className="size-full bg-(--gray-2) p-4"
onError={() => setHasError(true)}
/>
);
}

export function ArtifactPreview({
Expand All @@ -46,18 +62,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,
Expand All @@ -83,11 +88,10 @@ export function ArtifactPreview({
);
}
if (isError || !previewUrl) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
This artifact can’t be previewed.
</div>
);
return <ArtifactPreviewError />;
}
if (data && isAllowedImageMimeType(data.type)) {
return <ArtifactImagePreview src={previewUrl} name={name} />;
}
Comment thread
puemos marked this conversation as resolved.
return (
<iframe
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { getImageMimeType, isAllowedImageMimeType } from "@posthog/shared";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { applyCspToHtml } from "../../mcp-apps/utils/mcp-app-csp";

const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown"]);

function extension(filename: string): string {
return filename.split(".").pop()?.toLowerCase() ?? "";
}

export function artifactHtmlDocument(html: string): string {
return applyCspToHtml(html);
}
Expand All @@ -16,3 +23,28 @@ export function markdownDocument(markdown: string): string {
:root{font:15px/1.6 system-ui,sans-serif;color-scheme:light dark}body{box-sizing:border-box;margin:0 auto;max-width:900px;padding:32px;color:CanvasText;background:Canvas}h1,h2{border-bottom:1px solid color-mix(in srgb,CanvasText 18%,transparent);padding-bottom:.3em}h1{font-size:2em}h2{font-size:1.5em}h3{font-size:1.25em}a{color:LinkText}blockquote{margin-left:0;padding-left:1em;border-left:4px solid color-mix(in srgb,CanvasText 25%,transparent);color:GrayText}pre,code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}code{border-radius:4px;background:color-mix(in srgb,CanvasText 8%,transparent);padding:.15em .3em}pre{overflow:auto;border-radius:6px;background:color-mix(in srgb,CanvasText 8%,transparent);padding:16px}pre code{background:none;padding:0}table{border-spacing:0;border-collapse:collapse}th,td{border:1px solid color-mix(in srgb,CanvasText 20%,transparent);padding:6px 12px}img{max-width:100%}hr{border:0;border-top:1px solid color-mix(in srgb,CanvasText 20%,transparent)}
</style></head><body>${content}</body></html>`);
}

export async function artifactPreviewBlob(
blob: Blob,
filename: string,
): Promise<Blob> {
const filenameMimeType = getImageMimeType(filename);
const imageMimeType = isAllowedImageMimeType(blob.type)
? blob.type.toLowerCase()
: filenameMimeType;

if (isAllowedImageMimeType(imageMimeType)) {
return new Blob([blob], { type: imageMimeType });
}
if (MARKDOWN_EXTENSIONS.has(extension(filename))) {
return new Blob([markdownDocument(await blob.text())], {
type: "text/html",
});
}
if (["html", "htm"].includes(extension(filename))) {
return new Blob([artifactHtmlDocument(await blob.text())], {
type: "text/html",
});
}
return blob;
}
Loading
Loading