From dcece2762b65c270d3f5cde357f5cdb668aae1c2 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 20:43:46 -0700 Subject: [PATCH] feat(canvas/chat): inline PDF + text/code preview (RFC #2991 PR-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new arms to the AttachmentPreview kind dispatcher: * PDF — chip in the bubble, click opens the shared AttachmentLightbox with a browser-native at 95vw/90vh. Fetch+Blob+ObjectURL auth path matches AttachmentImage / Video. PDF.js not pulled in; browser viewer is good enough for the desktop chat MVP (Slack/Linear/Notion all gate full-page PDF behind a click for the same reason). Falls back to AttachmentChip on fetch error. * Text/code/JSON/YAML — first 10 lines in monospace
 right
  in the bubble, "Show all N lines" expands to full content, with a
  filename + ⬇ download header. Streams up to 256 KB then marks
  truncated and offers a download chip; large logs don't crash the
  bubble. No syntax highlighting in v1 — shiki adds 200-500 KB and is
  pure polish.

Coverage: 5 new dispatch tests (PDF success → embed in lightbox,
PDF fetch fail → chip fallback, text inline render, text long content
→ Show-all-N-lines expand button, text fetch fail → chip fallback).
All 19 AttachmentPreview tests pass; tsc --noEmit clean.

Stacked on rfc-2991-pr-1-image-preview-lightbox (PR-2 already merged
into PR-1's branch). PR-1 ships first; this rebases onto staging
once it lands.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 .../components/tabs/chat/AttachmentPDF.tsx    | 197 ++++++++++++++++++
 .../tabs/chat/AttachmentPreview.tsx           |  19 +-
 .../tabs/chat/AttachmentTextPreview.tsx       | 190 +++++++++++++++++
 .../chat/__tests__/AttachmentPreview.test.tsx |  85 ++++++++
 4 files changed, 490 insertions(+), 1 deletion(-)
 create mode 100644 canvas/src/components/tabs/chat/AttachmentPDF.tsx
 create mode 100644 canvas/src/components/tabs/chat/AttachmentTextPreview.tsx

diff --git a/canvas/src/components/tabs/chat/AttachmentPDF.tsx b/canvas/src/components/tabs/chat/AttachmentPDF.tsx
new file mode 100644
index 00000000..efe5fc90
--- /dev/null
+++ b/canvas/src/components/tabs/chat/AttachmentPDF.tsx
@@ -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.  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.  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) →  swap to chip.
+//     Note:  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({ kind: "idle" });
+  const [open, setOpen] = useState(false);
+  const blobUrlRef = useRef(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 = {};
+        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 ;
+  }
+  if (state.kind === "idle" || state.kind === "loading") {
+    return (
+      
+ + Loading {attachment.name}… +
+ ); + } + + // 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 ( + <> + + setOpen(false)} + ariaLabel={`Preview of ${attachment.name}`} + > + + + + ); +} + +function PdfGlyph() { + return ( + + ); +} + +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; +} diff --git a/canvas/src/components/tabs/chat/AttachmentPreview.tsx b/canvas/src/components/tabs/chat/AttachmentPreview.tsx index 794d23e2..d794fa70 100644 --- a/canvas/src/components/tabs/chat/AttachmentPreview.tsx +++ b/canvas/src/components/tabs/chat/AttachmentPreview.tsx @@ -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 ( + + ); case "text": + return ( + + ); case "file": default: return ; diff --git a/canvas/src/components/tabs/chat/AttachmentTextPreview.tsx b/canvas/src/components/tabs/chat/AttachmentTextPreview.tsx new file mode 100644 index 00000000..41a92a45 --- /dev/null +++ b/canvas/src/components/tabs/chat/AttachmentTextPreview.tsx @@ -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 +//
.
+//   - 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 /