feat(ui): conversation-wide image gallery, layout fix, and session state restore

- 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
This commit is contained in:
viettranx committed 2026-03-25 19:41:53 +07:00
1 parent f793bba0e0
commit f94fc1528e
12 files changed
+273 -71

No files matched your search

+28
View File
@@ -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, &params); 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.
+5 -4
View File
@@ -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"
+1
View File
@@ -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",
@@ -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<ChatImageGalleryCtx>({ 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<number | null>(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 (
<Ctx.Provider value={value}>
{children}
{currentIndex !== null && images[currentIndex] && (
<ImageLightbox
src={images[currentIndex]!.src}
alt={images[currentIndex]!.alt}
fileName={images[currentIndex]!.fileName}
size={images[currentIndex]!.size}
onClose={() => setCurrentIndex(null)}
images={images}
currentIndex={currentIndex}
onNavigate={setCurrentIndex}
/>
)}
</Ctx.Provider>
);
}
+3 -13
View File
@@ -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<number | null>(null);
const { openImage } = useChatImageGallery();
const [preview, setPreview] = useState<{
name: string;
href: string;
@@ -85,7 +85,7 @@ export function MediaGallery({ items }: MediaGalleryProps) {
<div key={i} className="group relative overflow-hidden rounded-lg border">
<button
type="button"
onClick={() => setLightboxIdx(i)}
onClick={() => openImage(item.path)}
className="block w-full cursor-pointer"
>
<CachedImage
@@ -146,16 +146,6 @@ export function MediaGallery({ items }: MediaGalleryProps) {
</div>
)}
{lightboxIdx !== null && images[lightboxIdx] && (
<ImageLightbox
src={images[lightboxIdx]!.path}
alt={images[lightboxIdx]!.fileName ?? ""}
fileName={images[lightboxIdx]!.fileName}
size={images[lightboxIdx]!.size}
onClose={() => setLightboxIdx(null)}
/>
)}
{loading && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/50">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
+1 -1
View File
@@ -19,7 +19,7 @@ export function TaskPanel({ tasks, open, onClose }: TaskPanelProps) {
{/* Header */}
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">
Tasks ({tasks.length})
Running Tasks ({tasks.length})
</span>
<button
type="button"
+1 -1
View File
@@ -61,7 +61,7 @@ export function AppLayout() {
<span>{t("disconnectedGateway")}</span>
</div>
)}
<main className="flex-1 overflow-y-auto">
<main className="min-w-0 flex-1 overflow-y-auto">
<ErrorBoundary key={stableErrorBoundaryKey(location.pathname)}>
<Outlet />
</ErrorBoundary>
+82 -17
View File
@@ -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 (
<div
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
{/* Toolbar */}
<div className="absolute top-4 right-4 flex items-center gap-2">
<a
href={toDownloadUrl(src)}
href={toDownloadUrl(current.src)}
download={displayName}
onClick={(e) => e.stopPropagation()}
className="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"
className={toolbarBtnClass}
title="Download"
>
<Download className="h-5 w-5" />
</a>
<button
type="button"
onClick={onClose}
className="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"
>
<button type="button" onClick={onClose} className={toolbarBtnClass}>
<X className="h-5 w-5" />
</button>
</div>
{/* Prev / Next arrows */}
{canPrev && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onNavigate(currentIndex - 1); }}
className={`${navBtnClass} left-4`}
title="Previous"
>
<ChevronLeft className="h-6 w-6" />
</button>
)}
{canNext && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onNavigate(currentIndex + 1); }}
className={`${navBtnClass} right-4`}
title="Next"
>
<ChevronRight className="h-6 w-6" />
</button>
)}
{/* Image */}
<img
src={cachedSrc}
alt={alt ?? "image"}
alt={current.alt ?? "image"}
className="max-h-[85vh] max-w-[90vw] rounded-lg object-contain"
onClick={(e) => e.stopPropagation()}
/>
{(fileName || (size != null && size > 0)) && (
{/* Info bar: counter + filename + size */}
{(isGallery || current.fileName || (current.size != null && current.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>}
{isGallery && (
<>
<span className="tabular-nums">{currentIndex + 1} / {images.length}</span>
{(current.fileName || (current.size != null && current.size > 0)) && <span className="text-white/50">·</span>}
</>
)}
{current.fileName && <span className="max-w-[300px] truncate">{current.fileName}</span>}
{current.fileName && current.size != null && current.size > 0 && <span className="text-white/50">·</span>}
{current.size != null && current.size > 0 && <span className="text-white/60">{formatSize(current.size)}</span>}
</div>
)}
</div>
@@ -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);
+1 -1
View File
@@ -216,7 +216,7 @@ export function ChatPage() {
)}
{/* Main chat area */}
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex min-w-0 flex-1 min-h-0 flex-col">
{isMobile && (
<div className="flex shrink-0 items-center border-b px-3 py-2 landscape-compact">
<button
+70 -30
View File
@@ -7,8 +7,10 @@ import { SystemNotification } from "@/components/chat/system-notification";
import { TeamActivityPanel } from "@/components/chat/team-activity-panel";
import { ToolCallCard } from "@/components/chat/tool-call-card";
import { ThinkingBlock } from "@/components/chat/thinking-block";
import { ChatImageGalleryProvider } from "@/components/chat/chat-image-gallery-context";
import { useAutoScroll } from "@/hooks/use-auto-scroll";
import type { ChatMessage, ToolStreamEntry, RunActivity, ActiveTeamTask } from "@/types/chat";
import type { LightboxImage } from "@/components/shared/image-lightbox";
interface ChatThreadProps {
messages: ChatMessage[];
@@ -85,6 +87,42 @@ export const ChatThread = memo(function ChatThread({
const displayItems = useMemo(() => buildDisplayItems(messages), [messages]);
// Collect all images from all messages for conversation-wide gallery.
// Sources: mediaItems (attached files) + markdown inline images ![alt](src).
const allImages = useMemo<LightboxImage[]>(() => {
const seen = new Set<string>();
const imgs: LightboxImage[] = [];
const add = (src: string, alt?: string, fileName?: string, size?: number) => {
const key = src.split("?")[0] ?? src; // dedupe by path without query params
if (seen.has(key)) return;
seen.add(key);
imgs.push({ src, alt, fileName, size });
};
for (const msg of messages) {
// From mediaItems (attached files)
if (msg.mediaItems) {
for (const item of msg.mediaItems) {
if (item.kind === "image") add(item.path, item.fileName ?? "", item.fileName, item.size);
}
}
// From markdown inline images: ![alt](src)
if (msg.content) {
const re = /!\[([^\]]*)\]\(([^)]+)\)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(msg.content)) !== null) {
const alt = match[1] ?? "";
let src = match[2] ?? "";
// Resolve relative file paths to API URL
if (src && !src.startsWith("http") && !src.startsWith("/v1/files/")) {
src = `/v1/files/${encodeURIComponent(src)}`;
}
if (src) add(src, alt, alt || (src.split("/").pop() ?? "image"));
}
}
}
return imgs;
}, [messages]);
if (messages.length === 0 && !isBusy) {
if (loading) {
return (
@@ -102,39 +140,41 @@ export const ChatThread = memo(function ChatThread({
}
return (
<div
ref={ref}
onScroll={onScroll}
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4"
style={{
backgroundImage: "radial-gradient(circle, var(--color-border) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
>
<div className="mx-auto max-w-3xl space-y-3">
{displayItems.map((item) => {
switch (item.kind) {
case "notification":
return <SystemNotification key={`notif-${item.idx}`} message={item.msg} />;
case "message":
return <MessageBubble key={`msg-${item.idx}`} message={item.msg} />;
case "merged-tools":
return <MergedToolGroup key={`tools-${item.idx}`} msgs={item.msgs} />;
}
})}
<ChatImageGalleryProvider images={allImages}>
<div
ref={ref}
onScroll={onScroll}
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4"
style={{
backgroundImage: "radial-gradient(circle, var(--color-border) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
>
<div className="mx-auto max-w-3xl space-y-3">
{displayItems.map((item) => {
switch (item.kind) {
case "notification":
return <SystemNotification key={`notif-${item.idx}`} message={item.msg} />;
case "message":
return <MessageBubble key={`msg-${item.idx}`} message={item.msg} />;
case "merged-tools":
return <MergedToolGroup key={`tools-${item.idx}`} msgs={item.msgs} />;
}
})}
{teamTasks.length > 0 && <TeamActivityPanel tasks={teamTasks} onTogglePanel={onToggleTaskPanel} />}
{teamTasks.length > 0 && <TeamActivityPanel tasks={teamTasks} onTogglePanel={onToggleTaskPanel} />}
<ActiveRunZone
isRunning={isRunning}
activity={activity}
thinkingText={thinkingText}
streamText={streamText}
toolStream={toolStream}
blockReplies={blockReplies}
/>
<ActiveRunZone
isRunning={isRunning}
activity={activity}
thinkingText={thinkingText}
streamText={streamText}
toolStream={toolStream}
blockReplies={blockReplies}
/>
</div>
</div>
</div>
</ChatImageGalleryProvider>
);
});
@@ -139,17 +139,32 @@ export function useChatMessages(sessionKey: string, agentId: string) {
}
}, [ws, agentId, sessionKey]);
// Load history and restore active tasks when session changes
// Load history, restore running state and active tasks when session changes.
// Cancelled flag prevents stale RPC responses from overwriting state on rapid switches.
useEffect(() => {
let cancelled = false;
if (sessionKey) {
loadHistory();
// Restore active team tasks (fire-and-forget — teams module may not be configured)
// Restore session running state
ws.call<{ isRunning?: boolean; activity?: RunActivity }>(Methods.CHAT_SESSION_STATUS, { sessionKey })
.then((res) => {
if (cancelled) return;
if (res.isRunning) setIsRunning(true);
if (res.activity) {
setActivity(res.activity);
activityRef.current = res.activity;
}
})
.catch(() => {});
// Restore active team tasks (teams module may not be configured)
ws.call<{ tasks?: ActiveTeamTask[] }>(Methods.TEAMS_TASK_ACTIVE_BY_SESSION, { sessionKey })
.then((res) => {
if (cancelled) return;
if (res.tasks && res.tasks.length > 0) setTeamTasks(res.tasks);
})
.catch(() => {}); // teams not configured or RPC not registered
.catch(() => {});
}
return () => { cancelled = true; };
}, [sessionKey, loadHistory, ws]);
// Called before sending a message so the event handler knows to capture run.started