feat(FN-3336): recover chat isGenerating state on session load to prevent U
Merges FN-3336 to recover chat streaming state on page reload — adds `isGenerating()` and `getGeneratingSessionIds()` to `ChatManager`, enriches the session API to surface active-streaming sessions, and makes `useChat` and `useQuickChat` hooks restore the `isGenerating` flag when loading existing se Fusion-Task-Id: FN-3336
This commit is contained in:
5
.changeset/fix-chat-progress-indicator-reload.md
Normal file
5
.changeset/fix-chat-progress-indicator-reload.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix chat progress indicator on reload: show "Connecting…" indicator when dashboard reloads during active AI generation
|
||||
@@ -63,6 +63,8 @@ export type EnrichedChatSession = ChatSession & {
|
||||
lastMessagePreview?: string;
|
||||
/** Timestamp of the last message in the session */
|
||||
lastMessageAt?: string;
|
||||
/** Whether a generation is currently in progress for this session */
|
||||
isGenerating?: boolean;
|
||||
};
|
||||
|
||||
/** A parsed @ mention of an agent in a chat message */
|
||||
|
||||
@@ -39,7 +39,6 @@ import type {
|
||||
AgentRating,
|
||||
AgentRatingSummary,
|
||||
AgentRatingInput,
|
||||
ChatSession,
|
||||
ChatMessage,
|
||||
EnrichedChatSession,
|
||||
Roadmap,
|
||||
@@ -7673,7 +7672,7 @@ export interface ChatSessionListResponse {
|
||||
}
|
||||
|
||||
export interface ChatSessionResponse {
|
||||
session: ChatSession;
|
||||
session: EnrichedChatSession;
|
||||
}
|
||||
|
||||
export interface ChatMessageListResponse {
|
||||
|
||||
@@ -1410,4 +1410,95 @@ describe("useChat", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-3336: streaming state recovery on reload", () => {
|
||||
it("sets isStreaming=true when selecting a session with isGenerating=true", async () => {
|
||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.streamingText).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set isStreaming when isGenerating is false", async () => {
|
||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false };
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears recovery streaming state when SSE delivers assistant message", async () => {
|
||||
let subscribeHandler: Record<string, (event: MessageEvent) => void> = {};
|
||||
mockSubscribeSse.mockImplementation((_url, options) => {
|
||||
if (options?.events) {
|
||||
subscribeHandler = options.events as typeof subscribeHandler;
|
||||
}
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
// Simulate SSE delivering the completed assistant message
|
||||
const assistantMessage = makeMessage({
|
||||
id: "msg-assistant-001",
|
||||
sessionId: "session-001",
|
||||
role: "assistant",
|
||||
content: "Generated response",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
subscribeHandler["chat:message:added"](
|
||||
new MessageEvent("chat:message:added", { data: JSON.stringify(assistantMessage) }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.streamingText).toBe("");
|
||||
expect(result.current.messages.some((m) => m.id === "msg-assistant-001")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FN_AGENT_ID, useQuickChat } from "../useQuickChat";
|
||||
vi.mock("../../api", () => ({
|
||||
fetchResumeChatSession: vi.fn(),
|
||||
fetchChatSessions: vi.fn(),
|
||||
fetchChatSession: vi.fn(),
|
||||
createChatSession: vi.fn(),
|
||||
fetchChatMessages: vi.fn(),
|
||||
streamChatResponse: vi.fn(),
|
||||
@@ -15,6 +16,7 @@ vi.mock("../../api", () => ({
|
||||
|
||||
const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession);
|
||||
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
||||
const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession);
|
||||
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
||||
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
||||
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
||||
@@ -43,6 +45,9 @@ describe("useQuickChat", () => {
|
||||
session: makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||
});
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockFetchChatSession.mockResolvedValue({
|
||||
session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false },
|
||||
});
|
||||
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
mockCancelChatResponse.mockResolvedValue({ success: true });
|
||||
});
|
||||
@@ -703,4 +708,80 @@ describe("useQuickChat", () => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to get response", "error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-3336: streaming state recovery on reload", () => {
|
||||
it("sets isStreaming=true when initializing a session with isGenerating=true", async () => {
|
||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.streamingText).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set isStreaming when isGenerating is false", async () => {
|
||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false };
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears recovery streaming state when polling detects generation complete", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
// After first poll, server reports generation is done and has a new assistant message
|
||||
mockFetchChatSession.mockResolvedValue({
|
||||
session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false },
|
||||
});
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ id: "msg-1", sessionId: "session-001", role: "assistant", content: "Done", thinkingOutput: null, metadata: null, createdAt: new Date().toISOString() },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
// Advance time to trigger the polling interval (3s)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3500);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.streamingText).toBe("");
|
||||
expect(result.current.messages.some((m) => m.id === "msg-1")).toBe(true);
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ChatSessionInfo {
|
||||
updatedAt: string;
|
||||
lastMessagePreview?: string;
|
||||
lastMessageAt?: string;
|
||||
isGenerating?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolCallInfo {
|
||||
@@ -321,6 +322,15 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
// Recover streaming state if the server reports an active generation.
|
||||
// After a reload/HMR, the server keeps generating but the UI loses
|
||||
// all streaming state. Showing "Connecting…" immediately tells the
|
||||
// user the AI is still working.
|
||||
if (session?.isGenerating) {
|
||||
setIsStreaming(true);
|
||||
setStreamingText("");
|
||||
}
|
||||
|
||||
// Persist active session to localStorage
|
||||
if (id) {
|
||||
setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId);
|
||||
@@ -685,6 +695,26 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recovery mode: isStreaming is true but there's no active stream (streamRef is null).
|
||||
// This happens after a page reload/HMR when the server is still generating.
|
||||
// When the assistant message arrives via SSE, add it and clear the recovery state.
|
||||
if (
|
||||
activeSessionRef.current?.id === message.sessionId &&
|
||||
isStreamingRef.current &&
|
||||
!streamRef.current &&
|
||||
message.role === "assistant"
|
||||
) {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === message.id)) return prev;
|
||||
return [...prev, message];
|
||||
});
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only add if this is the active session AND we're not streaming
|
||||
// (during streaming, messages are managed locally to avoid duplicates)
|
||||
// Use ref to get the current value (state may not be updated yet when handler runs)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChatMessage, ChatSession } from "@fusion/core";
|
||||
import {
|
||||
fetchResumeChatSession,
|
||||
fetchChatSessions,
|
||||
fetchChatSession,
|
||||
createChatSession,
|
||||
fetchChatMessages,
|
||||
streamChatResponse,
|
||||
@@ -173,6 +174,8 @@ export function useQuickChat(
|
||||
const cancelledByUserRef = useRef(false);
|
||||
const cancelStreamingFlushesRef = useRef<(() => void) | null>(null);
|
||||
const pendingMessageRef = useRef("");
|
||||
const isStreamingRef = useRef(isStreaming);
|
||||
isStreamingRef.current = isStreaming;
|
||||
const sendCompletionRef = useRef<{ resolve: () => void; reject: (error?: unknown) => void } | null>(null);
|
||||
|
||||
// Track the current selected chat target for session management
|
||||
@@ -234,6 +237,14 @@ export function useQuickChat(
|
||||
if (existingSession) {
|
||||
setActiveSession(existingSession);
|
||||
currentSessionKeyRef.current = sessionKey;
|
||||
|
||||
// Recover streaming state if server is still generating for this session.
|
||||
// After a reload/HMR, the server keeps generating but the UI loses
|
||||
// all streaming state. Show the "Connecting…" indicator immediately.
|
||||
if (existingSession.isGenerating) {
|
||||
setIsStreaming(true);
|
||||
setStreamingText("");
|
||||
}
|
||||
} else {
|
||||
const newSession = await createSessionForTarget(target);
|
||||
setActiveSession(newSession);
|
||||
@@ -273,6 +284,41 @@ export function useQuickChat(
|
||||
}
|
||||
}, [activeSession, loadMessages]);
|
||||
|
||||
// Poll for generation completion during recovery mode.
|
||||
// Recovery mode: isStreaming=true but streamRef.current is null (no local stream).
|
||||
// This happens after a reload/HMR when the server is still generating.
|
||||
// Poll every 3s until the server reports isGenerating=false, then reload messages
|
||||
// and clear streaming state.
|
||||
useEffect(() => {
|
||||
if (!isStreaming || streamRef.current || !activeSession) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
// Re-check conditions inside the callback (state may have changed)
|
||||
if (!isStreamingRef.current || streamRef.current || !activeSession) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchChatSession(activeSession.id, projectId);
|
||||
if (!data.session.isGenerating) {
|
||||
clearInterval(interval);
|
||||
// Reload messages to pick up the completed assistant message
|
||||
const msgData = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId);
|
||||
setMessages(msgData.messages.map(mapChatMessageToInfo));
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - will retry on next interval
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreaming, activeSession, projectId]);
|
||||
|
||||
// Reload messages from server (for same-session revisit)
|
||||
const reloadMessages = useCallback(async () => {
|
||||
if (!activeSession) return;
|
||||
|
||||
@@ -1256,3 +1256,132 @@ describe("ChatManager diagnostics", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatManager.isGenerating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetChatState();
|
||||
mockChatStore.getSession.mockReturnValue({
|
||||
id: "chat-001",
|
||||
agentId: "agent-1",
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
mockChatStore.addMessage.mockReturnValue({
|
||||
id: "msg-1",
|
||||
sessionId: "chat-001",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
mockSummarizeTitle.mockResolvedValue("Test Title");
|
||||
});
|
||||
|
||||
it("returns false when no generation is active", () => {
|
||||
const chatManager = createChatManager();
|
||||
expect(chatManager.isGenerating("chat-001")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true during an active generation", async () => {
|
||||
let resolvePrompt: () => void;
|
||||
const promptPromise = new Promise<void>((resolve) => {
|
||||
resolvePrompt = resolve;
|
||||
});
|
||||
|
||||
__setCreateFnAgent(async () => {
|
||||
await promptPromise;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: { messages: [{ role: "assistant", content: "Done" }] },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
|
||||
// Start the generation (don't await it — it blocks until resolvePrompt is called)
|
||||
const sendPromise = chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
// The generation should be active now
|
||||
expect(chatManager.isGenerating("chat-001")).toBe(true);
|
||||
expect(chatManager.isGenerating("chat-999")).toBe(false); // different session
|
||||
|
||||
// Complete the generation
|
||||
resolvePrompt!();
|
||||
await sendPromise;
|
||||
|
||||
// Generation should be cleared
|
||||
expect(chatManager.isGenerating("chat-001")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatManager.getGeneratingSessionIds", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetChatState();
|
||||
mockSummarizeTitle.mockResolvedValue("Test Title");
|
||||
});
|
||||
|
||||
it("returns empty array when no generations are active", () => {
|
||||
const chatManager = createChatManager();
|
||||
expect(chatManager.getGeneratingSessionIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all session IDs with active generations", async () => {
|
||||
let resolvePrompt1: () => void;
|
||||
let resolvePrompt2: () => void;
|
||||
const promptPromise1 = new Promise<void>((resolve) => { resolvePrompt1 = resolve; });
|
||||
const promptPromise2 = new Promise<void>((resolve) => { resolvePrompt2 = resolve; });
|
||||
|
||||
let callCount = 0;
|
||||
__setCreateFnAgent(async () => {
|
||||
callCount++;
|
||||
const promise = callCount === 1 ? promptPromise1 : promptPromise2;
|
||||
await promise;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: { messages: [{ role: "assistant", content: "Done" }] },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
mockChatStore.getSession.mockImplementation((id: string) => ({
|
||||
id,
|
||||
agentId: "agent-1",
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
mockChatStore.addMessage.mockReturnValue({
|
||||
id: "msg-1",
|
||||
sessionId: "chat-001",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
|
||||
// Start two generations
|
||||
const send1 = chatManager.sendMessage("chat-001", "Hello");
|
||||
const send2 = chatManager.sendMessage("chat-002", "World");
|
||||
|
||||
// Both should show as generating
|
||||
const ids = chatManager.getGeneratingSessionIds();
|
||||
expect(ids).toContain("chat-001");
|
||||
expect(ids).toContain("chat-002");
|
||||
expect(ids).toHaveLength(2);
|
||||
|
||||
// Complete both
|
||||
resolvePrompt1!();
|
||||
resolvePrompt2!();
|
||||
await Promise.all([send1, send2]);
|
||||
|
||||
expect(chatManager.getGeneratingSessionIds()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -922,6 +922,21 @@ export class ChatManager {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a generation is currently in progress for the given session.
|
||||
*/
|
||||
isGenerating(sessionId: string): boolean {
|
||||
return this.activeGenerations.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all session IDs that currently have an active generation.
|
||||
* Useful for batch-enriching session lists without N+1 lookups.
|
||||
*/
|
||||
getGeneratingSessionIds(): string[] {
|
||||
return [...this.activeGenerations.keys()];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -120,16 +120,21 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
const sessionIds = sessions.map((s) => s.id);
|
||||
const lastMessages = chatStore.getLastMessageForSessions(sessionIds);
|
||||
|
||||
// Batch-gather generating session IDs to avoid N+1 calls
|
||||
const generatingIds = options?.chatManager?.getGeneratingSessionIds?.() ?? [];
|
||||
const generatingSet = new Set(generatingIds);
|
||||
|
||||
for (const session of sessions) {
|
||||
const lastMessage = lastMessages.get(session.id);
|
||||
const enriched: EnrichedChatSession = session;
|
||||
if (lastMessage) {
|
||||
// Truncate content to 100 chars for preview
|
||||
const content = lastMessage.content || "";
|
||||
const enriched: EnrichedChatSession = session;
|
||||
enriched.lastMessagePreview =
|
||||
content.length > 100 ? content.slice(0, 100) + "…" : content;
|
||||
enriched.lastMessageAt = lastMessage.createdAt;
|
||||
}
|
||||
enriched.isGenerating = generatingSet.has(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +244,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
throw notFound(`Chat session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
res.json({ session });
|
||||
const enriched: EnrichedChatSession = session;
|
||||
enriched.isGenerating = options?.chatManager?.isGenerating?.(sessionId) ?? false;
|
||||
|
||||
res.json({ session: enriched });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
Reference in New Issue
Block a user