[object Object]

← 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

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 →