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
This commit is contained in:
viettranx committed 2026-03-21 12:01:04 +07:00
1 parent 394ded7836
commit e2c131c57d
5 files changed
+100 -38

No files matched your search

+1 -1
View File
@@ -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.`
@@ -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) {
<div
ref={dropdownRef}
style={dropdownStyle}
className="pointer-events-auto max-h-60 overflow-y-auto rounded-lg border bg-popover p-1 shadow-md"
className="pointer-events-auto max-h-60 sm:max-h-80 overflow-y-auto rounded-lg border bg-popover p-1 shadow-md"
>
{agents.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
+34 -13
View File
@@ -72,19 +72,38 @@ export function MediaGallery({ items }: MediaGalleryProps) {
{images.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{images.map((item, i) => (
<button
key={i}
type="button"
onClick={() => setLightboxIdx(i)}
className="relative overflow-hidden rounded-lg border cursor-pointer"
>
<img
src={item.path}
alt={item.fileName ?? ""}
className="h-40 w-full object-cover"
loading="lazy"
/>
</button>
<div key={i} className="group relative overflow-hidden rounded-lg border">
<button
type="button"
onClick={() => setLightboxIdx(i)}
className="block w-full cursor-pointer"
>
<img
src={item.path}
alt={item.fileName ?? ""}
className="h-40 w-full object-cover"
loading="lazy"
/>
</button>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between px-2 pb-1.5 opacity-0 transition-opacity group-hover:opacity-100">
<div className="flex min-w-0 flex-col text-xs text-white drop-shadow-sm">
{item.fileName && <span className="truncate">{item.fileName}</span>}
{item.size != null && item.size > 0 && (
<span className="text-white/70">{formatSize(item.size)}</span>
)}
</div>
<a
href={item.path}
download={item.fileName ?? "image"}
onClick={(e) => 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"
>
<Download className="h-4 w-4" />
</a>
</div>
</div>
))}
</div>
)}
@@ -121,6 +140,8 @@ export function MediaGallery({ items }: MediaGalleryProps) {
<ImageLightbox
src={images[lightboxIdx]!.path}
alt={images[lightboxIdx]!.fileName ?? ""}
fileName={images[lightboxIdx]!.fileName}
size={images[lightboxIdx]!.size}
onClose={() => setLightboxIdx(null)}
/>
)}
+37 -11
View File
@@ -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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition-colors cursor-pointer"
>
<X className="h-5 w-5" />
</button>
<div className="absolute top-4 right-4 flex items-center gap-2">
<a
href={src}
download={displayName}
onClick={(e) => e.stopPropagation()}
className="rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition-colors cursor-pointer"
title="Download"
>
<Download className="h-5 w-5" />
</a>
<button
type="button"
onClick={onClose}
className="rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition-colors cursor-pointer"
>
<X className="h-5 w-5" />
</button>
</div>
<img
src={src}
alt={alt ?? "image"}
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
className="max-h-[85vh] max-w-[90vw] rounded-lg object-contain"
onClick={(e) => e.stopPropagation()}
/>
{(fileName || (size != null && size > 0)) && (
<div
className="mt-3 flex items-center gap-2 rounded-full bg-black/60 px-4 py-1.5 text-sm text-white/90"
onClick={(e) => e.stopPropagation()}
>
{fileName && <span className="max-w-[300px] truncate">{fileName}</span>}
{fileName && size != null && size > 0 && <span className="text-white/50">·</span>}
{size != null && size > 0 && <span className="text-white/60">{formatSize(size)}</span>}
</div>
)}
</div>
);
}
@@ -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 (
<img
src={resolvedSrc}
alt={alt ?? "image"}
className="max-w-sm rounded-lg border shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
loading="lazy"
onClick={(e) => {
e.preventDefault();
if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image");
}}
{...props}
/>
<span className="group/img relative inline-block overflow-hidden rounded-lg border shadow-sm">
<img
src={resolvedSrc}
alt={alt ?? "image"}
className="block max-w-sm cursor-pointer hover:opacity-90 transition-opacity"
loading="lazy"
onClick={(e) => {
e.preventDefault();
if (resolvedSrc) openLightbox(resolvedSrc, alt ?? "image");
}}
{...props}
/>
{resolvedSrc && (
<a
href={resolvedSrc}
download={displayName}
onClick={(e) => 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"
>
<Download className="h-4 w-4" />
</a>
)}
</span>
);
},
table({ children, ...props }) {