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:
Fusion
2026-05-03 16:51:01 -07:00
committed by gsxdsm
parent 63bb62fc80
commit ba893b82bf
10 changed files with 410 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix chat progress indicator on reload: show "Connecting…" indicator when dashboard reloads during active AI generation

View File

@@ -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 */

View File

@@ -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 {

View File

@@ -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);
});
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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)

View File

@@ -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;

View File

@@ -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([]);
});
});

View File

@@ -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 ────────────────────────────────────────────────────────────

View File

@@ -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;