Merge pull request #3006 from Molecule-AI/rfc-2991-pr-3-pdf-text-preview

feat(canvas/chat): inline PDF + text/code preview (RFC #2991 PR-3)
This commit is contained in:
Hongming Wang 2026-05-05 20:49:53 -07:00 committed by GitHub
commit 6033179f48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 490 additions and 1 deletions

View File

@ -0,0 +1,197 @@
"use client";
// AttachmentPDF — inline PDF preview using the browser's native viewer
// (RFC #2991, PR-3).
//
// Why browser-native (not PDF.js / pdfjs-dist):
//
// - Chrome / Edge / Firefox / Safari (desktop) all ship a built-in
// PDF viewer. <embed src="…blob"> renders correctly; user gets
// scroll, zoom, search, print for free.
// - PDF.js adds ~3 MB to the canvas bundle. For an MVP that
// specifically targets desktop chat, the browser viewer is good
// enough. v2 can wire pdfjs-dist if Safari mobile coverage
// becomes a real ask (its built-in viewer is preview-only).
//
// Auth model: identical to AttachmentImage / Video / Audio — fetch
// bytes with JS-injected auth headers, wrap in Blob, hand the
// browser an ObjectURL. <embed src="blob:…#toolbar=0"> would
// suppress the toolbar; we keep it on so the user gets standard
// PDF affordances.
//
// Fullscreen: AttachmentLightbox hosts the PDF at viewport size on
// click. Same shared modal as image — third caller justifies the
// abstraction (per RFC #2991 design).
//
// Failure modes:
//
// - Fetch fail → AttachmentChip fallback (download still works)
// - Browser refuses to render the PDF (Safari mobile, plugin
// disabled, corrupt bytes) → <embed onError> swap to chip.
// Note: <embed> doesn't fire onError reliably across browsers.
// Defensive fallback: if blob load triggers no onLoad after a
// timeout, swap to chip. Implemented as a 3-second watchdog.
import { useState, useEffect, useRef } from "react";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentLightbox } from "./AttachmentLightbox";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
onDownload: (a: ChatAttachment) => void;
tone: "user" | "agent";
}
type FetchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; blobUrl: string }
| { kind: "error" };
export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Props) {
const [state, setState] = useState<FetchState>({ kind: "idle" });
const [open, setOpen] = useState(false);
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
if (!isPlatformAttachment(attachment.uri)) {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
if (!cancelled) setState({ kind: "ready", blobUrl: href });
return;
}
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
credentials: "include",
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
if (!cancelled) setState({ kind: "error" });
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
setState({ kind: "ready", blobUrl: url });
} catch {
if (!cancelled) setState({ kind: "error" });
}
})();
return () => {
cancelled = true;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [workspaceId, attachment.uri]);
if (state.kind === "error") {
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
if (state.kind === "idle" || state.kind === "loading") {
return (
<div
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse flex items-center gap-1.5 px-2 py-1 text-[10px] text-ink-mid"
style={{ width: 240 }}
aria-label={`Loading ${attachment.name}`}
>
<PdfGlyph />
Loading {attachment.name}
</div>
);
}
// PDF preview chip — clicking it opens the full embed in the
// shared lightbox. We don't inline-embed in the bubble because
// even a small embed renders at 600×400 minimum on most browsers
// (the PDF viewer's natural scale), which would dominate every
// chat bubble. Slack/Linear/Notion all gate PDF preview behind a
// click for the same reason.
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title={`Preview ${attachment.name}`}
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] hover:bg-surface-card/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
tone === "user"
? "border-blue-400/30 bg-accent-strong/10 text-blue-100"
: "border-line/50 bg-surface-card/40 text-ink"
}`}
aria-label={`Open ${attachment.name} preview`}
>
<PdfGlyph />
<span className="truncate max-w-[200px]">{attachment.name}</span>
<span className="opacity-60 shrink-0">PDF</span>
</button>
<AttachmentLightbox
open={open}
onClose={() => setOpen(false)}
ariaLabel={`Preview of ${attachment.name}`}
>
<embed
src={state.blobUrl}
type="application/pdf"
// The lightbox's content slot caps at 95vw / 90vh, so size
// 100% within that and let the user scroll inside the PDF
// viewer.
style={{ width: "95vw", height: "90vh" }}
aria-label={attachment.name}
/>
</AttachmentLightbox>
</>
);
}
function PdfGlyph() {
return (
<svg
width="11"
height="11"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className="shrink-0 opacity-70"
>
<path
d="M4 2h5l3 3v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z"
stroke="currentColor"
strokeWidth="1.3"
/>
<path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.3" />
<path
d="M5.5 9.5h1m1 0h1m-3 2h2"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
/>
</svg>
);
}
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}

View File

@ -19,6 +19,8 @@ import { getAttachmentPreviewKind } from "./preview-kind";
import { AttachmentImage } from "./AttachmentImage";
import { AttachmentVideo } from "./AttachmentVideo";
import { AttachmentAudio } from "./AttachmentAudio";
import { AttachmentPDF } from "./AttachmentPDF";
import { AttachmentTextPreview } from "./AttachmentTextPreview";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
@ -63,9 +65,24 @@ export function AttachmentPreview({ workspaceId, attachment, onDownload, tone }:
tone={tone}
/>
);
// PR-3 will add cases for pdf / text.
case "pdf":
return (
<AttachmentPDF
workspaceId={workspaceId}
attachment={attachment}
onDownload={onDownload}
tone={tone}
/>
);
case "text":
return (
<AttachmentTextPreview
workspaceId={workspaceId}
attachment={attachment}
onDownload={onDownload}
tone={tone}
/>
);
case "file":
default:
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;

View File

@ -0,0 +1,190 @@
"use client";
// AttachmentTextPreview — inline preview for text/code/JSON/YAML/etc
// (RFC #2991, PR-3).
//
// Shape: render first N lines (~10) in monospace inside the bubble.
// Click "Show more" to expand fully; the lightbox is reserved for
// image/PDF where viewport-size matters. For text, the bubble itself
// can host the full content.
//
// Why no syntax highlighting (yet):
//
// - Pulling in shiki / highlight.js / prism adds 200-500KB to the
// bundle for a feature that's nice-to-have. MVP uses plain
// <pre><code>.
// - Future: lazy-load shiki on first text-attachment render. v2
// if the user reports the gap.
//
// Auth: same fetch+text() pattern as image/video/audio, but we read
// the text directly instead of building a Blob URL — no <img>/<video>
// element to feed.
//
// Memory: text files are usually small. We cap the preview at 256 KB
// fetched (large logs would otherwise crash the bubble). If the file
// exceeds the cap, we show what we got + a "truncated" note + a chip
// to download the full file.
import { useState, useEffect } from "react";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
onDownload: (a: ChatAttachment) => void;
tone: "user" | "agent";
}
type FetchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; text: string; truncated: boolean }
| { kind: "error" };
const PREVIEW_LINE_COUNT = 10;
const MAX_FETCH_BYTES = 256 * 1024; // 256 KB
export function AttachmentTextPreview({ workspaceId, attachment, onDownload, tone }: Props) {
const [state, setState] = useState<FetchState>({ kind: "idle" });
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
if (isPlatformAttachment(attachment.uri)) {
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
}
const res = await fetch(href, {
headers,
credentials: "include",
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
if (!cancelled) setState({ kind: "error" });
return;
}
// Read up to MAX_FETCH_BYTES. Use the standard ReadableStream
// path so we don't materialise a 100MB log into memory.
const reader = res.body?.getReader();
if (!reader) {
// Fallback: small text file, just .text() it.
const text = await res.text();
if (cancelled) return;
setState({
kind: "ready",
text: text.slice(0, MAX_FETCH_BYTES),
truncated: text.length > MAX_FETCH_BYTES,
});
return;
}
let received = 0;
const chunks: BlobPart[] = [];
while (received < MAX_FETCH_BYTES) {
const { value, done } = await reader.read();
if (done) break;
// Copy into a fresh ArrayBuffer-backed view — TS in lib.dom
// 2026 narrows BlobPart away from SharedArrayBuffer-backed
// Uint8Arrays. Blob() accepts the copy fine at runtime.
const copy = new Uint8Array(value.byteLength);
copy.set(value);
chunks.push(copy.buffer);
received += value.byteLength;
}
// If we hit the cap but the stream isn't done, mark truncated.
const truncated = received >= MAX_FETCH_BYTES;
if (truncated) reader.cancel();
const blob = new Blob(chunks);
const text = await blob.text();
if (cancelled) return;
setState({ kind: "ready", text, truncated });
} catch {
if (!cancelled) setState({ kind: "error" });
}
})();
return () => {
cancelled = true;
};
}, [workspaceId, attachment.uri]);
if (state.kind === "error") {
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
if (state.kind === "idle" || state.kind === "loading") {
return (
<div
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
style={{ width: 320, height: 80 }}
aria-label={`Loading ${attachment.name}`}
/>
);
}
const lines = state.text.split("\n");
const preview = expanded ? state.text : lines.slice(0, PREVIEW_LINE_COUNT).join("\n");
const showExpandButton = !expanded && lines.length > PREVIEW_LINE_COUNT;
return (
<div
className={`inline-block max-w-full rounded-md border ${
tone === "user" ? "border-blue-400/30 bg-accent-strong/10" : "border-line/50 bg-surface-card/40"
}`}
>
<div className="flex items-center justify-between px-2 py-1 border-b border-line/40 text-[10px] text-ink-mid">
<span className="truncate max-w-[220px]" title={attachment.name}>
{attachment.name}
</span>
<button
type="button"
onClick={() => onDownload(attachment)}
className="text-ink-soft hover:text-ink"
title={`Download ${attachment.name}`}
aria-label={`Download ${attachment.name}`}
>
</button>
</div>
<pre className="overflow-x-auto px-2 py-1.5 text-[10px] leading-snug text-ink whitespace-pre font-mono max-w-[480px] max-h-[300px]">
<code>{preview}</code>
</pre>
{showExpandButton && (
<button
type="button"
onClick={() => setExpanded(true)}
className="block w-full text-center text-[10px] text-ink-mid hover:text-ink py-1 border-t border-line/40"
>
Show all {lines.length} lines
</button>
)}
{state.truncated && (
<div className="px-2 py-1 text-[10px] text-warm border-t border-line/40">
Preview truncated at {Math.round(MAX_FETCH_BYTES / 1024)} KB {" "}
<button
type="button"
onClick={() => onDownload(attachment)}
className="underline"
>
download full file
</button>
</div>
)}
</div>
);
}
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}

View File

@ -219,6 +219,91 @@ describe("AttachmentPreview dispatch", () => {
});
});
// ─── PR-3: PDF / text dispatch ─────────────────────────────────────
it("kind=pdf → renders the PDF preview chip (click opens lightbox)", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["%PDF-1.4..."], { type: "application/pdf" }),
});
preview({ uri: "workspace:/workspace/doc.pdf", name: "doc.pdf", mimeType: "application/pdf" });
// Loading placeholder first.
expect(await screen.findByLabelText(/Loading doc\.pdf/i)).toBeTruthy();
// After fetch, preview chip with "PDF" tag rendered.
await waitFor(() => {
// The button title is "Preview doc.pdf"; alongside is a "PDF" tag.
expect(screen.getByLabelText(/Open doc\.pdf preview/i)).toBeTruthy();
});
// Click → lightbox opens with <embed> inside.
fireEvent.click(screen.getByLabelText(/Open doc\.pdf preview/i));
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeTruthy();
expect(dialog.querySelector("embed[type='application/pdf']")).not.toBeNull();
});
it("kind=pdf fetch fails → falls back to chip", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
preview({ uri: "workspace:/workspace/missing.pdf", name: "missing.pdf", mimeType: "application/pdf" });
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.pdf/i)).toBeTruthy();
});
});
it("kind=text (text/plain) → renders inline <pre><code> preview", async () => {
const body = "line1\nline2\nline3";
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => body,
});
preview({ uri: "workspace:/workspace/log.txt", name: "log.txt", mimeType: "text/plain" });
// testing-library normalizes whitespace by default. The <pre>
// contains the literal text node, so query the DOM directly.
await waitFor(() => {
const code = document.querySelector("pre code");
expect(code).not.toBeNull();
expect(code?.textContent).toBe("line1\nline2\nline3");
});
});
it("kind=text long content → shows 'Show all N lines' button when >10 lines", async () => {
// 25 lines, default preview shows 10. Button labels with full count.
const body = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n");
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => body,
});
preview({ uri: "workspace:/workspace/big.txt", name: "big.txt", mimeType: "text/plain" });
await waitFor(() => {
expect(screen.getByRole("button", { name: /Show all 25 lines/i })).toBeTruthy();
});
// Pre-expand: only first 10 lines in <code>; line 11+ absent.
let code = document.querySelector("pre code");
expect(code?.textContent?.includes("line 10")).toBe(true);
expect(code?.textContent?.includes("line 11")).toBe(false);
// After clicking expand, all 25 lines present.
fireEvent.click(screen.getByRole("button", { name: /Show all 25 lines/i }));
await waitFor(() => {
code = document.querySelector("pre code");
expect(code?.textContent?.includes("line 25")).toBe(true);
});
});
it("kind=text fetch fails → chip fallback", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
preview({ uri: "workspace:/workspace/missing.json", name: "missing.json", mimeType: "application/json" });
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.json/i)).toBeTruthy();
});
});
// ─── universal-fallback regression ─────────────────────────────────
it("kind=file is the universal fallback for unknown MIME (regression: don't try to preview a zip)", () => {