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 /