mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
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:
1 parent
f793bba0e0
commit
f94fc1528e
12 files changed
+273
-71
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 .
|
||||
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: 
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user