;
+ token: string | null;
+ fallback: ReactNode;
+}) {
+ const state = useArtifactBytes(artifactId, version, mode, token);
+
+ if (state.status === 'loading') {
+ return (
+
+
+
+ );
+ }
+ if (state.status === 'error') {
+ return <>{fallback}>;
+ }
+ if (state.status === 'image') {
+ return (
+
+

+
+ );
+ }
+ // state.status === 'text'
+ if (mode === 'iframe-html' || mode === 'iframe-svg') {
+ return (
+
+ );
+ }
+ if (mode === 'text-markdown') {
+ return ;
+ }
+ return (
+
+ {state.text}
+
+ );
+}
+
export default function DocumentArtifactView({
artifact,
onRefresh,
@@ -238,47 +310,65 @@ export default function DocumentArtifactView({
}
};
+ const downloadCard = (
+
+ );
+
const renderPreview = () => {
+ // Preferred path: the version's `spec` already carries renderable
+ // html/svg/mermaid markup — render it in the sandboxed frame, no fetch.
if (mode === 'frame') {
const source = asPreviewString(
isCurrent ? artifact.spec : selectedRow?.preview_text,
);
- if (!source) {
+ if (source) {
+ return ;
+ }
+ // No usable spec content: fall through to the bytes path below.
+ }
+
+ // Inline text already on hand (code/data kinds expose it as preview_text /
+ // spec) — render it without a fetch.
+ if (mode === 'text') {
+ const text = isCurrent
+ ? asPreviewString(artifact.spec) || (selectedRow?.preview_text ?? '')
+ : (selectedRow?.preview_text ?? '');
+ if (text) {
return (
-
- No preview for this version.
-
+
+ {text}
+
);
}
- return ;
+ // No inline text: try fetching the bytes below.
}
- if (mode === 'image') {
- return (
-
- );
+
+ // Bytes path: no usable spec/inline content, so decide the preview by the
+ // version's mime (kind fallback) and fetch the stored bytes for an inline
+ // render. Anything not previewable (office docs, pdf, octet-stream) and any
+ // fetch failure fall back to the download card.
+ const bytesMode: BytesPreviewMode = bytesPreviewModeForMime(
+ selectedRow?.mime_type,
+ artifact.kind,
+ );
+ if (bytesMode === 'card' || !selectedRow) {
+ return downloadCard;
}
- if (mode === 'card') {
- return (
-
- );
- }
- const text = isCurrent
- ? asPreviewString(artifact.spec) || (selectedRow?.preview_text ?? '')
- : (selectedRow?.preview_text ?? '');
return (
-
- {text || 'No preview for this version.'}
-
+
);
};
diff --git a/frontend/src/components/MarkdownPreview.tsx b/frontend/src/components/MarkdownPreview.tsx
new file mode 100644
index 00000000..4a2634e5
--- /dev/null
+++ b/frontend/src/components/MarkdownPreview.tsx
@@ -0,0 +1,74 @@
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+/**
+ * Minimal GFM markdown renderer for artifact text previews. ReactMarkdown
+ * parses to React elements (no `dangerouslySetInnerHTML`), so untrusted
+ * markdown bytes never become raw DOM in the app origin.
+ */
+export default function MarkdownPreview({ content }: { content: string }) {
+ return (
+
+
+ {children}
+
+ );
+ },
+ ul({ children }) {
+ return (
+
+ );
+ },
+ ol({ children }) {
+ return (
+
+ {children}
+
+ );
+ },
+ h1({ children }) {
+ return {children}
;
+ },
+ h2({ children }) {
+ return {children}
;
+ },
+ h3({ children }) {
+ return {children}
;
+ },
+ code({ children, ...rest }) {
+ return (
+
+ {children}
+
+ );
+ },
+ blockquote({ children }) {
+ return (
+
+ {children}
+
+ );
+ },
+ }}
+ >
+ {content}
+
+
+ );
+}
diff --git a/frontend/src/components/artifactViewUtils.test.ts b/frontend/src/components/artifactViewUtils.test.ts
index 5b1f1d7f..33f6794a 100644
--- a/frontend/src/components/artifactViewUtils.test.ts
+++ b/frontend/src/components/artifactViewUtils.test.ts
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
import {
buildPreviewDocument,
+ bytesPreviewModeForMime,
displayFilename,
filenameFromContentDisposition,
findCurrentVersion,
formatBytes,
isDocumentArtifact,
+ MAX_INLINE_TEXT_BYTES,
PREVIEW_CSP,
PREVIEW_CSP_MERMAID,
previewModeForKind,
@@ -76,6 +78,68 @@ describe('previewModeForKind', () => {
});
});
+describe('bytesPreviewModeForMime', () => {
+ it('routes html/svg markup to the sandboxed iframe', () => {
+ expect(bytesPreviewModeForMime('text/html')).toBe('iframe-html');
+ expect(bytesPreviewModeForMime('image/svg+xml')).toBe('iframe-svg');
+ });
+
+ it('routes raster images to the inline image renderer', () => {
+ expect(bytesPreviewModeForMime('image/png')).toBe('image');
+ expect(bytesPreviewModeForMime('image/jpeg')).toBe('image');
+ expect(bytesPreviewModeForMime('image/gif')).toBe('image');
+ expect(bytesPreviewModeForMime('image/webp')).toBe('image');
+ });
+
+ it('routes markdown to the markdown renderer and other text to plain', () => {
+ expect(bytesPreviewModeForMime('text/markdown')).toBe('text-markdown');
+ expect(bytesPreviewModeForMime('text/x-markdown')).toBe('text-markdown');
+ expect(bytesPreviewModeForMime('text/plain')).toBe('text-plain');
+ expect(bytesPreviewModeForMime('text/csv')).toBe('text-plain');
+ expect(bytesPreviewModeForMime('application/json')).toBe('text-plain');
+ expect(bytesPreviewModeForMime('text/whatever')).toBe('text-plain');
+ });
+
+ it('ignores charset parameters and casing on the mime', () => {
+ expect(bytesPreviewModeForMime('TEXT/HTML; charset=utf-8')).toBe(
+ 'iframe-html',
+ );
+ expect(bytesPreviewModeForMime('text/plain;charset=UTF-8')).toBe(
+ 'text-plain',
+ );
+ });
+
+ it('routes office docs / pdf / unknown to the download card', () => {
+ expect(
+ bytesPreviewModeForMime(
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ ),
+ ).toBe('card');
+ expect(bytesPreviewModeForMime('application/pdf')).toBe('card');
+ expect(bytesPreviewModeForMime('application/zip')).toBe('card');
+ });
+
+ it('falls back to kind when the mime is missing or octet-stream', () => {
+ expect(bytesPreviewModeForMime(null, 'html')).toBe('iframe-html');
+ expect(bytesPreviewModeForMime(undefined, 'svg')).toBe('iframe-svg');
+ expect(bytesPreviewModeForMime('application/octet-stream', 'image')).toBe(
+ 'image',
+ );
+ expect(bytesPreviewModeForMime('', 'code')).toBe('text-plain');
+ expect(bytesPreviewModeForMime('', 'data')).toBe('text-plain');
+ });
+
+ it('cards an unknown kind with no usable mime', () => {
+ expect(bytesPreviewModeForMime(null, 'presentation')).toBe('card');
+ expect(bytesPreviewModeForMime(null, null)).toBe('card');
+ });
+
+ it('exposes a sane inline text bound', () => {
+ expect(MAX_INLINE_TEXT_BYTES).toBeGreaterThan(0);
+ expect(MAX_INLINE_TEXT_BYTES).toBeLessThanOrEqual(1024 * 1024);
+ });
+});
+
describe('sortVersionsDesc', () => {
it('orders newest-first without mutating the input', () => {
const input = [version(1), version(3), version(2)];
diff --git a/frontend/src/components/artifactViewUtils.ts b/frontend/src/components/artifactViewUtils.ts
index d4c96069..b5b12ee6 100644
--- a/frontend/src/components/artifactViewUtils.ts
+++ b/frontend/src/components/artifactViewUtils.ts
@@ -40,6 +40,65 @@ export type DocumentArtifact = {
/** How the sidebar should render an artifact's current version. */
export type PreviewMode = 'frame' | 'image' | 'card' | 'text';
+/**
+ * How fetched artifact BYTES should be rendered inline when the version has no
+ * usable `spec`. Finer-grained than {@link PreviewMode}: it distinguishes the
+ * markup family (html vs svg) and the text family (markdown vs plain) so the
+ * renderer can pick the sandboxed iframe, a blob-URL `
`, the markdown
+ * renderer, or an escaped ``. `card` means "not previewable inline".
+ */
+export type BytesPreviewMode =
+ | 'iframe-html'
+ | 'iframe-svg'
+ | 'image'
+ | 'text-markdown'
+ | 'text-plain'
+ | 'card';
+
+/** Upper bound on fetched text rendered inline; larger bytes fall back to the card. */
+export const MAX_INLINE_TEXT_BYTES = 512 * 1024;
+
+const TEXT_PLAIN_MIME = new Set([
+ 'text/plain',
+ 'text/csv',
+ 'application/json',
+ 'text/xml',
+ 'application/xml',
+]);
+
+/**
+ * Decide how to render downloadable artifact bytes from the version's
+ * `mime_type` (falling back to `kind` when the mime is missing/coarse). Office
+ * documents, pdf, and anything unrecognized map to `card` (download only).
+ * Mermaid is intentionally NOT handled here — mermaid lives in `spec`, not as
+ * stored bytes, so it stays on the spec-based frame path.
+ */
+export function bytesPreviewModeForMime(
+ mimeType?: string | null,
+ kind?: string | null,
+): BytesPreviewMode {
+ const mime = (mimeType ?? '').toLowerCase().split(';')[0].trim();
+ const k = (kind ?? '').toLowerCase();
+
+ if (mime === 'image/svg+xml') return 'iframe-svg';
+ if (mime === 'text/html') return 'iframe-html';
+ if (mime === 'text/markdown' || mime === 'text/x-markdown')
+ return 'text-markdown';
+ if (mime.startsWith('image/')) return 'image';
+ if (TEXT_PLAIN_MIME.has(mime)) return 'text-plain';
+ if (mime.startsWith('text/')) return 'text-plain';
+
+ // Mime missing or coarse (e.g. application/octet-stream): fall back to kind.
+ if (!mime || mime === 'application/octet-stream') {
+ if (k === 'svg') return 'iframe-svg';
+ if (k === 'html') return 'iframe-html';
+ if (k === 'image') return 'image';
+ if (k === 'code' || k === 'data') return 'text-plain';
+ }
+
+ return 'card';
+}
+
/**
* Discriminates the legacy notes/todo response (`artifact_type`) from the
* generalized document/file response (`kind`).
diff --git a/frontend/src/components/useArtifactBytes.ts b/frontend/src/components/useArtifactBytes.ts
new file mode 100644
index 00000000..afa62f06
--- /dev/null
+++ b/frontend/src/components/useArtifactBytes.ts
@@ -0,0 +1,86 @@
+import { useEffect, useRef, useState } from 'react';
+
+import userService from '../api/services/userService';
+import {
+ type BytesPreviewMode,
+ MAX_INLINE_TEXT_BYTES,
+} from './artifactViewUtils';
+
+type BytesState =
+ | { status: 'loading' }
+ | { status: 'error' }
+ | { status: 'text'; text: string }
+ | { status: 'image'; url: string };
+
+/**
+ * Fetch a version's downloadable bytes via the authed download endpoint and
+ * expose them for inline preview. Version-aware: refetches whenever the
+ * artifact id, version, or mode changes, and ignores stale responses. Images
+ * are exposed as a blob object URL that is revoked on cleanup/version change;
+ * text/markup is read as a string and bounded by {@link MAX_INLINE_TEXT_BYTES}.
+ *
+ * On any fetch/size failure the state becomes `error` so the caller can fall
+ * back to the download card. The returned object URL must NOT be revoked by the
+ * caller; this hook owns its lifecycle.
+ */
+export function useArtifactBytes(
+ artifactId: string,
+ version: number,
+ mode: BytesPreviewMode,
+ token: string | null,
+): BytesState {
+ const [state, setState] = useState({ status: 'loading' });
+ const objectUrlRef = useRef(null);
+
+ useEffect(() => {
+ if (mode === 'card') {
+ setState({ status: 'error' });
+ return;
+ }
+
+ let cancelled = false;
+ setState({ status: 'loading' });
+
+ const revoke = () => {
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current);
+ objectUrlRef.current = null;
+ }
+ };
+
+ userService
+ .downloadArtifact(artifactId, token, version)
+ .then(async (response: Response) => {
+ if (!response.ok) throw new Error('download failed');
+
+ if (mode === 'image') {
+ const blob = await response.blob();
+ if (cancelled) return;
+ revoke();
+ const url = URL.createObjectURL(blob);
+ objectUrlRef.current = url;
+ setState({ status: 'image', url });
+ return;
+ }
+
+ // html / svg / text families render as a (bounded) string.
+ const blob = await response.blob();
+ if (cancelled) return;
+ if (blob.size > MAX_INLINE_TEXT_BYTES) throw new Error('too large');
+ const text = await blob.text();
+ if (cancelled) return;
+ setState({ status: 'text', text });
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setState({ status: 'error' });
+ });
+
+ return () => {
+ cancelled = true;
+ revoke();
+ };
+ }, [artifactId, version, mode, token]);
+
+ return state;
+}