← back to Exo
Add image lightbox (#1414)
3f57416dbfaa79076a90ece0bc96f971ad444998 · 2026-02-07 01:30:03 +0000 · ciaranbor
## Motivation
No way to view generated or attached images at full resolution in the
dashboard
## Changes
- New ImageLightbox.svelte — fullscreen overlay with download, close
(click-outside/Escape), and transitions
- ChatMessages.svelte — all images (input attachments + generated) are
now clickable to open in lightbox; added expand button to generated
image hover overlay
## Why It Works
Single expandedImageSrc state variable drives the lightbox — set it to
show, null to hide.
## Test Plan
### Manual Testing
- Click any image (attachment thumbnail or generated) → lightbox opens
- Close via Escape, click-outside, or close button
- Download button saves with correct extension
Files touched
M dashboard/src/lib/components/ChatMessages.svelteA dashboard/src/lib/components/ImageLightbox.svelte
Diff
commit 3f57416dbfaa79076a90ece0bc96f971ad444998
Author: ciaranbor <81697641+ciaranbor@users.noreply.github.com>
Date: Sat Feb 7 01:30:03 2026 +0000
Add image lightbox (#1414)
## Motivation
No way to view generated or attached images at full resolution in the
dashboard
## Changes
- New ImageLightbox.svelte — fullscreen overlay with download, close
(click-outside/Escape), and transitions
- ChatMessages.svelte — all images (input attachments + generated) are
now clickable to open in lightbox; added expand button to generated
image hover overlay
## Why It Works
Single expandedImageSrc state variable drives the lightbox — set it to
show, null to hide.
## Test Plan
### Manual Testing
- Click any image (attachment thumbnail or generated) → lightbox opens
- Close via Escape, click-outside, or close button
- Download button saves with correct extension
---
dashboard/src/lib/components/ChatMessages.svelte | 47 ++++++++++-
dashboard/src/lib/components/ImageLightbox.svelte | 96 +++++++++++++++++++++++
2 files changed, 141 insertions(+), 2 deletions(-)
diff --git a/dashboard/src/lib/components/ChatMessages.svelte b/dashboard/src/lib/components/ChatMessages.svelte
index 44b9ec0d..46dfe46b 100644
--- a/dashboard/src/lib/components/ChatMessages.svelte
+++ b/dashboard/src/lib/components/ChatMessages.svelte
@@ -13,6 +13,7 @@
import type { MessageAttachment } from "$lib/stores/app.svelte";
import MarkdownContent from "./MarkdownContent.svelte";
import TokenHeatmap from "./TokenHeatmap.svelte";
+ import ImageLightbox from "./ImageLightbox.svelte";
interface Props {
class?: string;
@@ -101,6 +102,9 @@
let copiedMessageId = $state<string | null>(null);
let expandedThinkingMessageIds = $state<Set<string>>(new Set());
+ // Lightbox state
+ let expandedImageSrc = $state<string | null>(null);
+
// Uncertainty heatmap toggle
let heatmapMessageIds = $state<Set<string>>(new Set());
@@ -389,10 +393,15 @@
class="flex items-center gap-2 bg-exo-dark-gray/60 border border-exo-yellow/20 rounded px-2 py-1 text-xs font-mono"
>
{#if attachment.type === "image" && attachment.preview}
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
<img
src={attachment.preview}
alt={attachment.name}
- class="w-12 h-12 object-cover rounded border border-exo-yellow/20"
+ class="w-12 h-12 object-cover rounded border border-exo-yellow/20 cursor-pointer hover:border-exo-yellow/50 transition-colors"
+ onclick={() => {
+ if (attachment.preview)
+ expandedImageSrc = attachment.preview;
+ }}
/>
{:else}
<span>{getAttachmentIcon(attachment)}</span>
@@ -466,15 +475,44 @@
<div class="mb-3">
{#each message.attachments.filter((a) => a.type === "generated-image") as attachment}
<div class="relative group/img inline-block">
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
<img
src={attachment.preview}
alt=""
- class="max-w-full max-h-[512px] rounded-lg border border-exo-yellow/20 shadow-lg shadow-black/20"
+ class="max-w-full max-h-[512px] rounded-lg border border-exo-yellow/20 shadow-lg shadow-black/20 cursor-pointer"
+ onclick={() => {
+ if (attachment.preview)
+ expandedImageSrc = attachment.preview;
+ }}
/>
<!-- Button overlay -->
<div
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity"
>
+ <!-- Expand button -->
+ <button
+ type="button"
+ class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
+ onclick={() => {
+ if (attachment.preview)
+ expandedImageSrc = attachment.preview;
+ }}
+ title="Expand image"
+ >
+ <svg
+ class="w-4 h-4"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
+ />
+ </svg>
+ </button>
<!-- Edit button -->
<button
type="button"
@@ -789,3 +827,8 @@
</button>
{/if}
</div>
+
+<ImageLightbox
+ src={expandedImageSrc}
+ onclose={() => (expandedImageSrc = null)}
+/>
diff --git a/dashboard/src/lib/components/ImageLightbox.svelte b/dashboard/src/lib/components/ImageLightbox.svelte
new file mode 100644
index 00000000..427e49c4
--- /dev/null
+++ b/dashboard/src/lib/components/ImageLightbox.svelte
@@ -0,0 +1,96 @@
+<script lang="ts">
+ import { fade, fly } from "svelte/transition";
+ import { cubicOut } from "svelte/easing";
+
+ interface Props {
+ src: string | null;
+ onclose: () => void;
+ }
+
+ let { src, onclose }: Props = $props();
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === "Escape") {
+ onclose();
+ }
+ }
+
+ function extensionFromSrc(dataSrc: string): string {
+ const match = dataSrc.match(/^data:image\/(\w+)/);
+ if (match) return match[1] === "jpeg" ? "jpg" : match[1];
+ const urlMatch = dataSrc.match(/\.(\w+)(?:\?|$)/);
+ if (urlMatch) return urlMatch[1];
+ return "png";
+ }
+
+ function handleDownload(e: MouseEvent) {
+ e.stopPropagation();
+ if (!src) return;
+ const link = document.createElement("a");
+ link.href = src;
+ link.download = `image-${Date.now()}.${extensionFromSrc(src)}`;
+ link.click();
+ }
+
+ function handleClose(e: MouseEvent) {
+ e.stopPropagation();
+ onclose();
+ }
+</script>
+
+<svelte:window onkeydown={src ? handleKeydown : undefined} />
+
+{#if src}
+ <div
+ class="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center"
+ transition:fade={{ duration: 200 }}
+ onclick={onclose}
+ role="presentation"
+ onintrostart={() => (document.body.style.overflow = "hidden")}
+ onoutroend={() => (document.body.style.overflow = "")}
+ >
+ <div class="absolute top-4 right-4 flex gap-2 z-10">
+ <button
+ type="button"
+ class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer transition-colors"
+ onclick={handleDownload}
+ title="Download image"
+ >
+ <svg
+ class="w-5 h-5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
+ />
+ </svg>
+ </button>
+ <button
+ type="button"
+ class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer transition-colors"
+ onclick={handleClose}
+ title="Close"
+ >
+ <svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
+ <path
+ d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
+ />
+ </svg>
+ </button>
+ </div>
+
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
+ <img
+ {src}
+ alt=""
+ class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
+ transition:fly={{ y: 20, duration: 300, easing: cubicOut }}
+ onclick={(e) => e.stopPropagation()}
+ />
+ </div>
+{/if}
← 8f3681cf Synchronize before warmup (#1419)
·
back to Exo
·
Handle config.json not found (image models) (#1408) 2fbdb27b →