From e2c131c57d9e4947c32aa6757a0a80690cc6651c Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 21 Mar 2026 12:01:04 +0700 Subject: [PATCH] feat(ui): add image download overlay, improve agent selector defaults - Add hover download button with filename/size on chat image thumbnails - Add download button and file info bar to image lightbox overlay - Add hover download button on inline markdown images - Increase agent selector dropdown height on desktop (sm:max-h-80) - Select default agent (is_default) instead of first in list - Increase session title generation timeout to 120s --- internal/agent/title_generate.go | 2 +- ui/web/src/components/chat/agent-selector.tsx | 5 +- ui/web/src/components/chat/media-gallery.tsx | 47 +++++++++++++----- .../src/components/shared/image-lightbox.tsx | 48 ++++++++++++++----- .../components/shared/markdown-renderer.tsx | 36 +++++++++----- 5 files changed, 100 insertions(+), 38 deletions(-) diff --git a/internal/agent/title_generate.go b/internal/agent/title_generate.go index 92ef2da3..627a4534 100644 --- a/internal/agent/title_generate.go +++ b/internal/agent/title_generate.go @@ -9,7 +9,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/providers" ) -const titleGenerateTimeout = 30 * time.Second +const titleGenerateTimeout = 120 * time.Second const titleSystemPrompt = `Generate a short title (max 15 words) for this conversation based on the user's message. Reply with only the title, no quotes or punctuation wrapping.` diff --git a/ui/web/src/components/chat/agent-selector.tsx b/ui/web/src/components/chat/agent-selector.tsx index 8429929a..25cb9b78 100644 --- a/ui/web/src/components/chat/agent-selector.tsx +++ b/ui/web/src/components/chat/agent-selector.tsx @@ -34,7 +34,8 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) { const active = (res.agents ?? []).filter((a) => a.status === "active"); setAgents(active); if (active.length > 0 && !active.some((a) => a.agent_key === value)) { - onChange(active[0]!.agent_key); + const defaultAgent = active.find((a) => a.is_default) ?? active[0]!; + onChange(defaultAgent.agent_key); } }) .catch(() => {}); @@ -92,7 +93,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
{agents.length === 0 && (
diff --git a/ui/web/src/components/chat/media-gallery.tsx b/ui/web/src/components/chat/media-gallery.tsx index 084984b5..216ce7f0 100644 --- a/ui/web/src/components/chat/media-gallery.tsx +++ b/ui/web/src/components/chat/media-gallery.tsx @@ -72,19 +72,38 @@ export function MediaGallery({ items }: MediaGalleryProps) { {images.length > 0 && (
{images.map((item, i) => ( - +
+ +
+
+
+ {item.fileName && {item.fileName}} + {item.size != null && item.size > 0 && ( + {formatSize(item.size)} + )} +
+ e.stopPropagation()} + className="shrink-0 rounded-md bg-black/60 p-1 text-white shadow-md hover:bg-black/80 transition-colors cursor-pointer" + title="Download" + > + + +
+
))}
)} @@ -121,6 +140,8 @@ export function MediaGallery({ items }: MediaGalleryProps) { setLightboxIdx(null)} /> )} diff --git a/ui/web/src/components/shared/image-lightbox.tsx b/ui/web/src/components/shared/image-lightbox.tsx index 5441cc0e..a5ea6899 100644 --- a/ui/web/src/components/shared/image-lightbox.tsx +++ b/ui/web/src/components/shared/image-lightbox.tsx @@ -1,13 +1,16 @@ import { useEffect, useCallback } from "react"; -import { X } from "lucide-react"; +import { X, Download } from "lucide-react"; +import { formatSize } from "@/lib/file-helpers"; interface ImageLightboxProps { src: string; alt?: string; + fileName?: string; + size?: number; onClose: () => void; } -export function ImageLightbox({ src, alt, onClose }: ImageLightboxProps) { +export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightboxProps) { const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); @@ -20,24 +23,47 @@ export function ImageLightbox({ src, alt, onClose }: ImageLightboxProps) { return () => document.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); + const displayName = fileName || alt || "image"; + return (
- + {alt e.stopPropagation()} /> + {(fileName || (size != null && size > 0)) && ( +
e.stopPropagation()} + > + {fileName && {fileName}} + {fileName && size != null && size > 0 && ยท} + {size != null && size > 0 && {formatSize(size)}} +
+ )}
); } diff --git a/ui/web/src/components/shared/markdown-renderer.tsx b/ui/web/src/components/shared/markdown-renderer.tsx index 857eab5a..222b4379 100644 --- a/ui/web/src/components/shared/markdown-renderer.tsx +++ b/ui/web/src/components/shared/markdown-renderer.tsx @@ -166,18 +166,32 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) }, img({ src, alt, ...props }) { const resolvedSrc = isFileLink(src) ? toFileUrl(src!, token) : src; + const displayName = alt || fileNameFromHref(src ?? ""); return ( - {alt { - e.preventDefault(); - if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image"); - }} - {...props} - /> + + {alt { + e.preventDefault(); + if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image"); + }} + {...props} + /> + {resolvedSrc && ( + e.stopPropagation()} + className="absolute top-1.5 right-1.5 flex items-center justify-center rounded-md bg-black/60 p-1 text-white shadow-md opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-black/80 cursor-pointer" + title="Download" + > + + + )} + ); }, table({ children, ...props }) {