diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7e7b58a5b..32fc4b4f6 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -12,6 +12,7 @@ import { FullScreenLoader } from "./components/Preloader";
import { ThemeProvider } from "./ThemeContext";
import { PWAModeProvider } from "./PWAContext";
import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp";
+import ImageLightbox from "@/components/ImageLightbox";
import { ErrorBoundary } from "react-error-boundary";
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
@@ -33,6 +34,7 @@ export default function App() {
+
diff --git a/frontend/src/components/ImageLightbox/index.jsx b/frontend/src/components/ImageLightbox/index.jsx
new file mode 100644
index 000000000..bd91cd076
--- /dev/null
+++ b/frontend/src/components/ImageLightbox/index.jsx
@@ -0,0 +1,115 @@
+import { useState, useEffect } from "react";
+import { createPortal } from "react-dom";
+import { X, CaretLeft, CaretRight } from "@phosphor-icons/react";
+
+const OPEN_EVENT = "open-image-lightbox";
+
+/**
+ * Opens the image lightbox from anywhere in the app.
+ * @param {{contentString: string, name: string}[]} images
+ * @param {number} initialIndex
+ */
+export function openImageLightbox(images, initialIndex = 0) {
+ window.dispatchEvent(
+ new CustomEvent(OPEN_EVENT, { detail: { images, initialIndex } })
+ );
+}
+
+export default function ImageLightbox() {
+ const [images, setImages] = useState(null);
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ useEffect(() => {
+ function handleOpen(e) {
+ setImages(e.detail.images);
+ setCurrentIndex(e.detail.initialIndex);
+ }
+ window.addEventListener(OPEN_EVENT, handleOpen);
+ return () => window.removeEventListener(OPEN_EVENT, handleOpen);
+ }, []);
+
+ function close() {
+ setImages(null);
+ }
+
+ function handlePrevious() {
+ setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
+ }
+
+ function handleNext() {
+ setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
+ }
+
+ useEffect(() => {
+ if (!images) return;
+ function handleKeyDown(e) {
+ if (e.key === "Escape") close();
+ else if (e.key === "ArrowLeft") handlePrevious();
+ else if (e.key === "ArrowRight") handleNext();
+ }
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [images]);
+
+ if (!images || images.length === 0) return null;
+ const safeIndex = Math.min(currentIndex, images.length - 1);
+ const currentImage = images[safeIndex];
+ if (!currentImage) return null;
+
+ return createPortal(
+
+
+
+
+
+ {images.length > 1 && (
+ <>
+
{
+ e.stopPropagation();
+ handlePrevious();
+ }}
+ className="absolute left-4 p-3 text-white light:text-white hover:text-white/70 transition-colors rounded-full bg-white/10 hover:bg-white/20 border-none cursor-pointer"
+ aria-label="Previous image"
+ >
+
+
+
{
+ e.stopPropagation();
+ handleNext();
+ }}
+ className="absolute right-4 p-3 text-white light:text-white hover:text-white/70 transition-colors rounded-full bg-white/10 hover:bg-white/20 border-none cursor-pointer"
+ aria-label="Next image"
+ >
+
+
+ >
+ )}
+
+
e.stopPropagation()}
+ />
+
+ {images.length > 1 && (
+
+ {safeIndex + 1} / {images.length}
+
+ )}
+
,
+ document.getElementById("root")
+ );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx
index c6c581753..53a0d751f 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx
@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { chatQueryRefusalResponse } from "@/utils/chat";
import HistoricalOutputs from "./HistoricalOutputs";
+import { openImageLightbox } from "@/components/ImageLightbox";
const HistoricalMessage = ({
uuid = v4(),
@@ -205,17 +206,27 @@ export default memo(
}
);
+/**
+ * Currently only renders image attachments as clickable thumbnails that open in the lightbox.
+ * Other attachment types may be supported here in the future.
+ */
function ChatAttachments({ attachments = [] }) {
if (!attachments.length) return null;
return (
- {attachments.map((item) => (
-
(
+
+ onClick={() => openImageLightbox(attachments, index)}
+ className="p-0 border-none bg-transparent cursor-pointer hover:opacity-80 transition-opacity"
+ >
+
+
))}
);
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx
index 6b1e2d1b3..d4e6f5bad 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx
@@ -11,6 +11,7 @@ import {
X,
} from "@phosphor-icons/react";
import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper";
+import { openImageLightbox } from "@/components/ImageLightbox";
/**
* @param {{attachments: import("../../DnDWrapper").Attachment[]}}
@@ -18,10 +19,25 @@ import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper";
*/
export default function AttachmentManager({ attachments }) {
if (attachments.length === 0) return null;
+
+ function handleImageClick(attachment) {
+ const imageAttachments = attachments
+ .filter((a) => a.type === "attachment" && a.contentString)
+ .map((a) => ({ contentString: a.contentString, name: a.file.name }));
+ const idx = imageAttachments.findIndex(
+ (img) => img.name === attachment.file?.name
+ );
+ if (idx !== -1) openImageLightbox(imageAttachments, idx);
+ }
+
return (
{attachments.map((attachment) => (
-
+
handleImageClick(attachment)}
+ />
))}
);
@@ -30,7 +46,7 @@ export default function AttachmentManager({ attachments }) {
/**
* @param {{attachment: import("../../DnDWrapper").Attachment}}
*/
-function AttachmentItem({ attachment }) {
+function AttachmentItem({ attachment, onImageClick }) {
const { uid, file, status, error, document, type, contentString } =
attachment;
const { iconBgColor, Icon } = displayFromFile(file);
@@ -115,12 +131,18 @@ function AttachmentItem({ attachment }) {
-
+
+
+
);
}