From f94fc1528e0046d6b7c30789d4a3117dc938cdcd Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 25 Mar 2026 19:41:53 +0700 Subject: [PATCH] feat(ui): conversation-wide image gallery, layout fix, and session state restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix chat layout overflow when TaskPanel sidebar is open by adding min-w-0 to flex containers in AppLayout and ChatPage - Add conversation-wide image gallery with prev/next navigation (ArrowKeys) and image counter — collects images from both mediaItems and markdown - Rename task panel header from "Tasks" to "Running Tasks" - Add chat.session.status RPC to restore isRunning and activity state on session switch, preventing lost running indicators - Add cancelled flag to session restore effect to prevent race conditions on rapid session switching --- internal/gateway/methods/chat.go | 28 +++++ pkg/protocol/methods.go | 9 +- ui/web/src/api/protocol.ts | 1 + .../chat/chat-image-gallery-context.tsx | 53 ++++++++++ ui/web/src/components/chat/media-gallery.tsx | 16 +-- ui/web/src/components/chat/task-panel.tsx | 2 +- ui/web/src/components/layout/app-layout.tsx | 2 +- .../src/components/shared/image-lightbox.tsx | 99 ++++++++++++++--- .../components/shared/markdown-renderer.tsx | 11 +- ui/web/src/pages/chat/chat-page.tsx | 2 +- ui/web/src/pages/chat/chat-thread.tsx | 100 ++++++++++++------ .../src/pages/chat/hooks/use-chat-messages.ts | 21 +++- 12 files changed, 273 insertions(+), 71 deletions(-) create mode 100644 ui/web/src/components/chat/chat-image-gallery-context.tsx diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index bc981df4..a4c05688 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -45,6 +45,34 @@ func (m *ChatMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodChatHistory, m.handleHistory) router.Register(protocol.MethodChatAbort, m.handleAbort) router.Register(protocol.MethodChatInject, m.handleInject) + router.Register(protocol.MethodChatSessionStatus, m.handleSessionStatus) +} + +// handleSessionStatus returns the running state and activity for a session. +// Used by the frontend to restore UI state after switching between sessions. +func (m *ChatMethods) handleSessionStatus(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params struct { + SessionKey string `json:"sessionKey"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.SessionKey == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sessionKey required")) + return + } + + isRunning := m.agents.IsSessionBusy(params.SessionKey) + var activity map[string]any + if status := m.agents.GetActivity(params.SessionKey); status != nil { + activity = map[string]any{ + "phase": status.Phase, + "tool": status.Tool, + "iteration": status.Iteration, + } + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ + "isRunning": isRunning, + "activity": activity, + })) } // chatMediaItem represents a media file attached to a chat message. diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 6ddb7388..2d26490a 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -11,10 +11,11 @@ const ( MethodAgentIdentityGet = "agent.identity.get" // Chat - MethodChatSend = "chat.send" - MethodChatHistory = "chat.history" - MethodChatAbort = "chat.abort" - MethodChatInject = "chat.inject" + MethodChatSend = "chat.send" + MethodChatHistory = "chat.history" + MethodChatAbort = "chat.abort" + MethodChatInject = "chat.inject" + MethodChatSessionStatus = "chat.session.status" // Agents management MethodAgentsList = "agents.list" diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 34bb536b..e23bce40 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -54,6 +54,7 @@ export const Methods = { CHAT_HISTORY: "chat.history", CHAT_ABORT: "chat.abort", CHAT_INJECT: "chat.inject", + CHAT_SESSION_STATUS: "chat.session.status", // Agents management AGENTS_LIST: "agents.list", diff --git a/ui/web/src/components/chat/chat-image-gallery-context.tsx b/ui/web/src/components/chat/chat-image-gallery-context.tsx new file mode 100644 index 00000000..12388642 --- /dev/null +++ b/ui/web/src/components/chat/chat-image-gallery-context.tsx @@ -0,0 +1,53 @@ +import { createContext, useContext, useState, useMemo, useCallback } from "react"; +import { ImageLightbox, type LightboxImage } from "@/components/shared/image-lightbox"; + +interface ChatImageGalleryCtx { + /** Open the lightbox at the image matching this src URL. */ + openImage: (src: string) => void; + /** Register images so they appear in the gallery. Called by child components. */ + allImages: LightboxImage[]; +} + +const Ctx = createContext({ openImage: () => {}, allImages: [] }); + +export const useChatImageGallery = () => useContext(Ctx); + +interface Props { + images: LightboxImage[]; + children: React.ReactNode; +} + +/** Wraps chat thread content; provides a conversation-wide image lightbox. */ +export function ChatImageGalleryProvider({ images, children }: Props) { + const [currentIndex, setCurrentIndex] = useState(null); + + const openImage = useCallback( + (src: string) => { + // Normalize: strip query params for matching + const clean = (s: string) => s.split("?")[0] ?? s; + const idx = images.findIndex((img) => clean(img.src) === clean(src)); + setCurrentIndex(idx >= 0 ? idx : null); + }, + [images], + ); + + const value = useMemo(() => ({ openImage, allImages: images }), [openImage, images]); + + return ( + + {children} + {currentIndex !== null && images[currentIndex] && ( + setCurrentIndex(null)} + images={images} + currentIndex={currentIndex} + onNavigate={setCurrentIndex} + /> + )} + + ); +} diff --git a/ui/web/src/components/chat/media-gallery.tsx b/ui/web/src/components/chat/media-gallery.tsx index ae6ade69..7a21bde1 100644 --- a/ui/web/src/components/chat/media-gallery.tsx +++ b/ui/web/src/components/chat/media-gallery.tsx @@ -1,9 +1,9 @@ import { useState, useCallback } from "react"; import { Download, FileText, FileCode, Music, Film, File } from "lucide-react"; -import { ImageLightbox } from "@/components/shared/image-lightbox"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; import { formatSize, toDownloadUrl } from "@/lib/file-helpers"; import { useMediaUrl } from "@/hooks/use-media-url"; +import { useChatImageGallery } from "./chat-image-gallery-context"; import { Dialog, DialogContent, @@ -45,7 +45,7 @@ function CachedImage({ src, alt, className, loading, onClick }: { } export function MediaGallery({ items }: MediaGalleryProps) { - const [lightboxIdx, setLightboxIdx] = useState(null); + const { openImage } = useChatImageGallery(); const [preview, setPreview] = useState<{ name: string; href: string; @@ -85,7 +85,7 @@ export function MediaGallery({ items }: MediaGalleryProps) {
)} -
+
diff --git a/ui/web/src/components/shared/image-lightbox.tsx b/ui/web/src/components/shared/image-lightbox.tsx index cbdbeead..4e4fe727 100644 --- a/ui/web/src/components/shared/image-lightbox.tsx +++ b/ui/web/src/components/shared/image-lightbox.tsx @@ -1,22 +1,58 @@ import { useEffect, useCallback } from "react"; -import { X, Download } from "lucide-react"; +import { X, Download, ChevronLeft, ChevronRight } from "lucide-react"; import { formatSize, toDownloadUrl } from "@/lib/file-helpers"; import { useMediaUrl } from "@/hooks/use-media-url"; +export interface LightboxImage { + src: string; + alt?: string; + fileName?: string; + size?: number; +} + interface ImageLightboxProps { src: string; alt?: string; fileName?: string; size?: number; onClose: () => void; + /** Gallery mode: provide images array + currentIndex + onNavigate */ + images?: LightboxImage[]; + currentIndex?: number; + onNavigate?: (index: number) => void; } -export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightboxProps) { +const navBtnClass = + "absolute top-1/2 -translate-y-1/2 rounded-full bg-white/90 dark:bg-neutral-800/90 p-2.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer"; + +const toolbarBtnClass = + "rounded-full bg-white/90 dark:bg-neutral-800/90 p-2.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer"; + +/** Resolve current image — gallery mode uses images[currentIndex], single mode uses props directly. */ +function resolveCurrentImage(props: ImageLightboxProps): LightboxImage { + const { images, currentIndex, src, alt, fileName, size } = props; + if (images && currentIndex != null && images[currentIndex]) { + return images[currentIndex]!; + } + return { src, alt, fileName, size }; +} + +export function ImageLightbox(props: ImageLightboxProps) { + const { onClose, images, currentIndex, onNavigate } = props; + const current = resolveCurrentImage(props); + const isGallery = images && images.length > 1 && onNavigate && currentIndex != null; + const canPrev = isGallery && currentIndex > 0; + const canNext = isGallery && currentIndex < images.length - 1; + const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); + if (isGallery) { + if (e.key === "ArrowLeft" && canPrev) onNavigate(currentIndex - 1); + if (e.key === "ArrowRight" && canNext) onNavigate(currentIndex + 1); + } }, - [onClose], + [onClose, isGallery, canPrev, canNext, onNavigate, currentIndex], ); useEffect(() => { @@ -24,46 +60,75 @@ export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightb return () => document.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); - const displayName = fileName || alt || "image"; - const cachedSrc = useMediaUrl(src); + const displayName = current.fileName || current.alt || "image"; + const cachedSrc = useMediaUrl(current.src); return (
+ {/* Toolbar */} + + {/* Prev / Next arrows */} + {canPrev && ( + + )} + {canNext && ( + + )} + + {/* Image */} {alt e.stopPropagation()} /> - {(fileName || (size != null && size > 0)) && ( + + {/* Info bar: counter + filename + size */} + {(isGallery || current.fileName || (current.size != null && current.size > 0)) && (
e.stopPropagation()} > - {fileName && {fileName}} - {fileName && size != null && size > 0 && ·} - {size != null && size > 0 && {formatSize(size)}} + {isGallery && ( + <> + {currentIndex + 1} / {images.length} + {(current.fileName || (current.size != null && current.size > 0)) && ·} + + )} + {current.fileName && {current.fileName}} + {current.fileName && current.size != null && current.size > 0 && ·} + {current.size != null && current.size > 0 && {formatSize(current.size)}}
)}
diff --git a/ui/web/src/components/shared/markdown-renderer.tsx b/ui/web/src/components/shared/markdown-renderer.tsx index 1f619454..1fe705f6 100644 --- a/ui/web/src/components/shared/markdown-renderer.tsx +++ b/ui/web/src/components/shared/markdown-renderer.tsx @@ -12,6 +12,7 @@ import { toFileUrl, toDownloadUrl } from "@/lib/file-helpers"; import { useMediaUrl } from "@/hooks/use-media-url"; import { Check, Copy, Download, FileText } from "lucide-react"; import { ImageLightbox } from "./image-lightbox"; +import { useChatImageGallery } from "@/components/chat/chat-image-gallery-context"; import { Dialog, DialogContent, @@ -124,8 +125,16 @@ function CachedMarkdownImg({ src, alt, openLightbox, ...props }: { } export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) { + const gallery = useChatImageGallery(); const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); - const openLightbox = useCallback((src: string, alt: string) => setLightbox({ src, alt }), []); + // Use conversation-wide gallery if available (has images), else fall back to local lightbox + const openLightbox = useCallback((src: string, alt: string) => { + if (gallery.allImages.length > 0) { + gallery.openImage(src); + } else { + setLightbox({ src, alt }); + } + }, [gallery]); const [filePreview, setFilePreview] = useState<{ name: string; href: string; content: string; mediaType?: "image" | "audio" | "video" } | null>(null); const [fileLoading, setFileLoading] = useState(false); diff --git a/ui/web/src/pages/chat/chat-page.tsx b/ui/web/src/pages/chat/chat-page.tsx index 6e5a5cdd..9a9c80b5 100644 --- a/ui/web/src/pages/chat/chat-page.tsx +++ b/ui/web/src/pages/chat/chat-page.tsx @@ -216,7 +216,7 @@ export function ChatPage() { )} {/* Main chat area */} -
+
{isMobile && (