diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx
index 2bc3939f..f343b63c 100644
--- a/canvas/src/components/tabs/ChatTab.tsx
+++ b/canvas/src/components/tabs/ChatTab.tsx
@@ -8,7 +8,8 @@ import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads";
-import { AttachmentChip, PendingAttachmentPill } from "./chat/AttachmentViews";
+import { PendingAttachmentPill } from "./chat/AttachmentViews";
+import { AttachmentPreview } from "./chat/AttachmentPreview";
import { extractFilesFromTask } from "./chat/message-parser";
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { appendActivityLine } from "./chat/activityLog";
@@ -1137,8 +1138,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
{msg.attachments && msg.attachments.length > 0 && (
{msg.attachments.map((att, i) => (
- WILL NOT
+// include our cookie + Origin headers when the browser loads it —
+// even for same-origin canvas-server, the auth chain (cookie + token
+// + X-Molecule-Org-Slug header) is JS-injected, not browser-default.
+//
+// Solution: same auth path the chip download uses. Fetch the bytes
+// with the JS auth headers, wrap in a Blob, hand the browser an
+// ObjectURL. The image renders from local memory; no second request,
+// no auth leakage, no CORS pain.
+//
+// That same blob URL is what the lightbox shows on click — single
+// fetch, cached for the lifetime of the message bubble.
+//
+// Failure modes
+// -------------
+//
+// - Fetch fails (404, 403, network) → fall back to AttachmentChip
+// (the existing file-pill download flow). The user still gets a
+// working download; we just lose the inline preview.
+// - Decoded as non-image (server returned wrong Content-Type, or
+// bytes are corrupt) → onError handler swaps to AttachmentChip.
+// - Bytes too large — no enforcement here; the server caps at 25MB
+// per file (chat_files.go), which is too big for a thumbnail but
+// acceptable for a chat-attached image. If we hit pain we can
+// downscale via canvas, but defer that to v2.
+
+import { useState, useEffect, useRef } from "react";
+import type { ChatAttachment } from "./types";
+import { downloadChatFile, 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 AttachmentImage({ workspaceId, attachment, onDownload, tone }: Props) {
+ const [state, setState] = useState({ kind: "idle" });
+ const [open, setOpen] = useState(false);
+ // Track whether we created the ObjectURL so cleanup runs on the
+ // exact value we minted (state could change between effect setup
+ // and effect cleanup if a new fetch fires).
+ const blobUrlRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setState({ kind: "loading" });
+
+ // For non-platform URIs (http/https external image hosts) we can
+ // skip the auth fetch — browser loads them directly. We bail out
+ // of the auth-fetch flow and use the raw URL via resolveAttachmentHref.
+ if (!isPlatformAttachment(attachment.uri)) {
+ const href = resolveAttachmentHref(workspaceId, attachment.uri);
+ if (!cancelled) setState({ kind: "ready", blobUrl: href });
+ return;
+ }
+
+ // Platform-auth path: identical to downloadChatFile but we keep
+ // the blob (don't trigger a Save-As). Use the same headers it does
+ // by going through it indirectly — no, downloadChatFile triggers a
+ // Save-As. Need a separate fetch.
+ void (async () => {
+ try {
+ const href = resolveAttachmentHref(workspaceId, attachment.uri);
+ const headers: Record = {};
+ // Read the same env var downloadChatFile reads — single source
+ // of truth would be cleaner; refactor opportunity for PR-2 if
+ // we add the same path to AttachmentVideo.
+ 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;
+ }
+ 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;
+ // Free the ObjectURL when the bubble unmounts — keeps memory
+ // bounded across long chat histories.
+ if (blobUrlRef.current) {
+ URL.revokeObjectURL(blobUrlRef.current);
+ blobUrlRef.current = null;
+ }
+ };
+ }, [workspaceId, attachment.uri]);
+
+ // Failure → render the existing file chip. Maintains the download
+ // affordance even if preview fails; the user never gets stuck.
+ if (state.kind === "error") {
+ return ;
+ }
+
+ // Loading → small placeholder pill so the bubble doesn't reflow
+ // when the image lands. Sized to roughly the thumbnail's aspect
+ // ratio guess (a 240x180 box) so the layout is stable.
+ if (state.kind === "loading" || state.kind === "idle") {
+ return (
+
+ );
+ }
+
+ // Ready → inline thumbnail with click handler. The img has its
+ // own onError so a corrupt blob (server returned the right size
+ // but invalid bytes) falls through to the chip too.
+ return (
+ <>
+
+ setOpen(false)}
+ ariaLabel={`Preview of ${attachment.name}`}
+ >
+
+
+ >
+ );
+}
+
+// Internal helper — duplicated from uploads.ts (it's not exported
+// there). Kept local so this component doesn't reach into private
+// surface; if AttachmentVideo / AttachmentPDF in PR-2/PR-3 also need
+// it, lift to an exported helper at that point (the third-caller
+// rule).
+function getTenantSlug(): string | null {
+ if (typeof window === "undefined") return null;
+ const host = window.location.hostname;
+ // Tenant subdomain shape: .moleculesai.app
+ const m = host.match(/^([^.]+)\.moleculesai\.app$/);
+ return m ? m[1] : null;
+}
diff --git a/canvas/src/components/tabs/chat/AttachmentLightbox.tsx b/canvas/src/components/tabs/chat/AttachmentLightbox.tsx
new file mode 100644
index 00000000..09f4cb01
--- /dev/null
+++ b/canvas/src/components/tabs/chat/AttachmentLightbox.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+// AttachmentLightbox — shared fullscreen modal for image / PDF /
+// (future) any-fullscreen-renderable kind. Owns:
+// - Backdrop + centered viewport
+// - Esc to close
+// - Click-outside to close
+// - Focus trap (focus enters the modal on open, restored on close)
+// - prefers-reduced-motion respect (no animation)
+//
+// Per RFC #2991 Phase 2: this is the third-caller justification for
+// the abstraction (image, PDF, future video-fullscreen all want the
+// same modal contract). Not invented for a single caller.
+//
+// Design choices:
+//
+// 1. Portals — we don't use ReactDOM.createPortal because the canvas
+// chat surface already renders at a high z-index and the modal's
+// fixed-position layout reaches the viewport regardless. Saves a
+// portal mount in the common case + avoids the SSR warning (canvas
+// is "use client" but the parent shell is server-rendered).
+//
+// 2. Focus trap — inline implementation (not a 3rd-party dep). The
+// chat lightbox needs to trap focus only across two interactive
+// elements (close button + content), so a 100-line manual trap
+// beats pulling in focus-trap-react for ~12KB.
+//
+// 3. Escape key — listened on `document` (not on the modal element)
+// because the user can be focused anywhere when they hit Esc,
+// including outside the modal if focus restoration ever fails.
+// The cleanup runs on unmount so leaked listeners don't persist.
+
+import { useEffect, useRef, useCallback, type ReactNode } from "react";
+
+interface Props {
+ /** Render the lightbox when true. Caller controls open state. */
+ open: boolean;
+ /** Caller's handler for "close" — Esc, click-outside, X button. */
+ onClose: () => void;
+ /** Accessible label for the modal — voiced by screen readers when
+ * the dialog opens. The caller knows what's inside (image alt
+ * text, PDF filename) and supplies it. */
+ ariaLabel: string;
+ /** The thing being shown in fullscreen — ,