diff --git a/.changeset/fn-6496-chat-stream-prior-thread.md b/.changeset/fn-6496-chat-stream-prior-thread.md new file mode 100644 index 0000000000..5355dc7ffb --- /dev/null +++ b/.changeset/fn-6496-chat-stream-prior-thread.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Keep prior chat thread messages visible while reconnecting to an in-flight streamed assistant response. diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index 674811c653..11f43e2723 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -1225,8 +1225,14 @@ describe("useChat", () => { }); }); - it("re-attaches from fetchChatSession replay id when tab becomes visible", async () => { + it("FN-6496 loads prior thread when visibility resume reattaches", async () => { const session = makeSession({ id: "session-001", agentId: "agent-001" }); + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }), + makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }), + makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }), + ]; const generatingSession = { ...session, isGenerating: true, @@ -1241,6 +1247,9 @@ describe("useChat", () => { }; mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); + mockFetchChatMessages + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ messages: priorThreadNewestFirst }); const addToast = vi.fn(); const { result } = renderHook(() => useChat(undefined, addToast)); @@ -1270,6 +1279,13 @@ describe("useChat", () => { undefined, { lastEventId: 77 }, ); + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages.map((message) => message.id)).toEqual([ + "msg-001", + "msg-002", + "msg-003", + "msg-004", + ]); expect(addToast).not.toHaveBeenCalled(); }); }); @@ -1292,10 +1308,18 @@ describe("useChat", () => { updatedAt: "2026-04-08T00:00:00.000Z", }, }; + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-004", sessionId: staleSession.id, role: "assistant", content: "Second answer" }), + makeMessage({ id: "msg-003", sessionId: staleSession.id, role: "user", content: "Second question" }), + makeMessage({ id: "msg-002", sessionId: staleSession.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: staleSession.id, role: "user", content: "First question" }), + ]; mockFetchChatSessions.mockResolvedValueOnce({ sessions: [staleSession] }); mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); - mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockFetchChatMessages + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ messages: priorThreadNewestFirst }); const { result } = renderHook(() => useChat()); @@ -1318,6 +1342,12 @@ describe("useChat", () => { expect(result.current.streamingText).toBe("partial text"); expect(result.current.streamingThinking).toBe("thinking"); expect(result.current.streamingToolCalls).toHaveLength(1); + expect(result.current.messages.map((message) => message.id)).toEqual([ + "msg-001", + "msg-002", + "msg-003", + "msg-004", + ]); }); }); @@ -2835,6 +2865,258 @@ describe("useChat", () => { }); }); + it("FN-6496 loads prior thread during chat:session:updated in-flight attach", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Existing" }); + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }), + makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }), + makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }), + ]; + const generatingSession = { + ...session, + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "live partial", + streamingThinking: "thinking", + toolCalls: [], + replayFromEventId: 88, + updatedAt: "2026-04-08T00:00:00.000Z", + }, + }; + + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ messages: priorThreadNewestFirst }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession(session.id); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe(session.id); + expect(mockFetchChatMessages).toHaveBeenCalledWith(session.id, { limit: 50, order: "desc" }, "proj-123"); + }); + expect(result.current.messages).toEqual([]); + + act(() => { + subscribeHandler["chat:session:updated"]?.({ + data: JSON.stringify(generatingSession), + } as MessageEvent); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + expect(result.current.streamingText).toBe("live partial"); + expect(mockAttachChatStream).toHaveBeenCalledWith( + session.id, + expect.any(Object), + "proj-123", + { lastEventId: 88 }, + ); + expect(mockFetchChatMessages).toHaveBeenCalledTimes(2); + expect(result.current.messages.map((message) => message.id)).toEqual([ + "msg-001", + "msg-002", + "msg-003", + "msg-004", + ]); + }); + }); + + it("FN-6496 loads prior thread when auto-reattach effect observes refreshed generation", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Stale" }); + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }), + makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }), + makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }), + ]; + const generatingSession = { + ...session, + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "refreshed partial", + streamingThinking: "thinking", + toolCalls: [], + replayFromEventId: 90, + updatedAt: "2026-04-08T00:00:00.000Z", + }, + }; + + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); + mockFetchChatMessages + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ messages: priorThreadNewestFirst }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession(session.id); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + expect(result.current.streamingText).toBe("refreshed partial"); + expect(mockAttachChatStream).toHaveBeenCalledWith( + session.id, + expect.any(Object), + "proj-123", + { lastEventId: 90 }, + ); + expect(mockFetchChatMessages).toHaveBeenCalledTimes(2); + expect(result.current.messages.map((message) => message.id)).toEqual([ + "msg-001", + "msg-002", + "msg-003", + "msg-004", + ]); + }); + }); + + it("FN-6496 loads prior thread when reconnectSessionSilently reattaches after send suspension", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Reconnect" }); + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }), + makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }), + makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }), + ]; + const generatingSession = { + ...session, + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "reconnected partial", + streamingThinking: "thinking", + toolCalls: [], + replayFromEventId: 91, + updatedAt: "2026-04-08T00:00:00.000Z", + }, + }; + let onError: ((data: string | apiModule.ChatFailureInfo, tempUserMessageId: string) => void) | undefined; + + mockFetchChatSessions + .mockResolvedValueOnce({ sessions: [session] }) + .mockResolvedValueOnce({ sessions: [generatingSession] }); + mockFetchChatSession + .mockResolvedValueOnce({ session }) + .mockResolvedValueOnce({ session: generatingSession }); + mockFetchChatMessages + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ messages: priorThreadNewestFirst }); + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + onError = handlers.onError; + return { close: vi.fn(), isConnected: () => true }; + }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession(session.id); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe(session.id); + }); + + act(() => { + result.current.sendMessage("Continue"); + onError?.("Failed to fetch", "temp-reconnect"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + expect(result.current.streamingText).toBe("reconnected partial"); + expect(mockAttachChatStream).toHaveBeenCalledWith( + session.id, + expect.any(Object), + "proj-123", + { lastEventId: 91 }, + ); + expect(result.current.messages.map((message) => message.id)).toEqual([ + "msg-001", + "msg-002", + "msg-003", + "msg-004", + ]); + }); + }); + + it("FN-6496 does not refetch or duplicate when prior thread is already loaded", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Loaded" }); + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }), + ]; + const generatingSession = { + ...session, + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "live partial", + streamingThinking: "", + toolCalls: [], + replayFromEventId: 89, + updatedAt: "2026-04-08T00:00:00.000Z", + }, + }; + + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValueOnce({ messages: priorThreadNewestFirst }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession(session.id); + }); + + await waitFor(() => { + expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]); + }); + mockFetchChatMessages.mockClear(); + + act(() => { + subscribeHandler["chat:session:updated"]?.({ + data: JSON.stringify(generatingSession), + } as MessageEvent); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + expect(mockAttachChatStream).toHaveBeenCalledWith( + session.id, + expect.any(Object), + "proj-123", + { lastEventId: 89 }, + ); + }); + expect(mockFetchChatMessages).not.toHaveBeenCalled(); + expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]); + }); + it("FN-5104 ignores replay checkpoint bumps while attach stream is already active", async () => { const generating = { ...makeSession({ id: "session-001", agentId: "agent-001", title: "Gen" }), diff --git a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts index a685712928..9ae125b839 100644 --- a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts @@ -1,6 +1,6 @@ import { act, fireEvent, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatSession } from "@fusion/core"; +import type { ChatMessage, ChatSession } from "@fusion/core"; import * as apiModule from "../../api"; import { getChatPendingMessageKey } from "../chatPendingMessageStorage"; import { getPersistedLastQuickChatSessionId } from "../quickChatLastSessionStorage"; @@ -40,6 +40,18 @@ function makeSession(overrides: Partial & Pick & Pick): ChatMessage { + return { + id: overrides.id, + sessionId: overrides.sessionId, + role: overrides.role, + content: overrides.content, + thinkingOutput: overrides.thinkingOutput ?? null, + metadata: overrides.metadata ?? null, + createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z", + }; +} + const setDocumentVisibilityState = (state: DocumentVisibilityState) => { Object.defineProperty(document, "visibilityState", { configurable: true, @@ -1712,8 +1724,14 @@ describe("useQuickChat", () => { }); }); - it("reattaches with replayFromEventId when tab becomes visible and server is generating", async () => { + it("FN-6496 loads prior thread when QuickChat visibility resume reattaches", async () => { const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" }); + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-004", sessionId: existingSession.id, role: "assistant", content: "Second answer" }), + makeMessage({ id: "msg-003", sessionId: existingSession.id, role: "user", content: "Second question" }), + makeMessage({ id: "msg-002", sessionId: existingSession.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: existingSession.id, role: "user", content: "First question" }), + ]; const generatingSession = { ...existingSession, isGenerating: true, @@ -1730,7 +1748,9 @@ describe("useQuickChat", () => { mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession }); mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); - mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockFetchChatMessages + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ messages: priorThreadNewestFirst }); const { result } = renderHook(() => useQuickChat("proj-123", addToast)); @@ -1750,6 +1770,13 @@ describe("useQuickChat", () => { "proj-123", { lastEventId: 17 }, ); + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages.map((message) => message.id)).toEqual([ + "msg-001", + "msg-002", + "msg-003", + "msg-004", + ]); expect(addToast).not.toHaveBeenCalled(); }); }); @@ -2153,10 +2180,16 @@ describe("useQuickChat", () => { }); }); - it("sets isStreaming=true when initializing a session with isGenerating=true", async () => { + it("FN-6496 loads prior thread when initializing a generating QuickChat session", async () => { const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true }; + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-004", sessionId: session.id, role: "assistant", content: "Second answer" }), + makeMessage({ id: "msg-003", sessionId: session.id, role: "user", content: "Second question" }), + makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }), + ]; mockFetchResumeChatSession.mockResolvedValue({ session }); - mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockFetchChatMessages.mockResolvedValue({ messages: priorThreadNewestFirst }); const { result } = renderHook(() => useQuickChat("proj-123")); @@ -2167,9 +2200,113 @@ describe("useQuickChat", () => { await waitFor(() => { expect(result.current.isStreaming).toBe(true); expect(result.current.streamingText).toBe(""); + expect(result.current.messages.map((message) => message.id)).toEqual([ + "msg-001", + "msg-002", + "msg-003", + "msg-004", + ]); }); }); + it("FN-6496 loads prior thread when QuickChat auto-reattach effect observes refreshed generation", async () => { + const staleSession = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false }; + const generatingSession = { + ...staleSession, + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "refreshed partial", + streamingThinking: "thinking", + toolCalls: [], + replayFromEventId: 18, + updatedAt: "2026-04-08T00:00:00.000Z", + }, + }; + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-004", sessionId: staleSession.id, role: "assistant", content: "Second answer" }), + makeMessage({ id: "msg-003", sessionId: staleSession.id, role: "user", content: "Second question" }), + makeMessage({ id: "msg-002", sessionId: staleSession.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: staleSession.id, role: "user", content: "First question" }), + ]; + mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); + mockFetchChatMessages + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ messages: priorThreadNewestFirst }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.selectSession(staleSession); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + expect(result.current.streamingText).toBe("refreshed partial"); + expect(mockAttachChatStream).toHaveBeenCalledWith( + "session-001", + expect.any(Object), + "proj-123", + { lastEventId: 18 }, + ); + expect(mockFetchChatMessages).toHaveBeenCalledTimes(2); + expect(result.current.messages.map((message) => message.id)).toEqual([ + "msg-001", + "msg-002", + "msg-003", + "msg-004", + ]); + }); + }); + + it("FN-6496 does not refetch or duplicate QuickChat thread when already loaded", async () => { + const session = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "live partial", + streamingThinking: "", + toolCalls: [], + replayFromEventId: 19, + updatedAt: "2026-04-08T00:00:00.000Z", + }, + }; + const priorThreadNewestFirst = [ + makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "First answer" }), + makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "First question" }), + ]; + mockFetchResumeChatSession.mockResolvedValue({ session }); + mockFetchChatMessages.mockResolvedValue({ messages: priorThreadNewestFirst }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.switchSession("agent-001"); + }); + + await waitFor(() => { + expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]); + expect(result.current.isStreaming).toBe(true); + }); + mockFetchChatMessages.mockClear(); + + act(() => { + result.current.selectSession(session); + }); + + await waitFor(() => { + expect(mockAttachChatStream).toHaveBeenCalledWith( + "session-001", + expect.any(Object), + "proj-123", + { lastEventId: 19 }, + ); + }); + expect(mockFetchChatMessages).not.toHaveBeenCalled(); + expect(result.current.messages.map((message) => message.id)).toEqual(["msg-001", "msg-002"]); + }); + it("does not set isStreaming when isGenerating is false", async () => { const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false }; mockFetchResumeChatSession.mockResolvedValue({ session }); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 434bb35048..27902a8d72 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -527,13 +527,24 @@ export function useChat( const attachIfGenerating = useCallback(( sessionId: string, inFlightGeneration?: ChatInFlightGenerationState | null, - options?: { silent?: boolean }, + options?: { silent?: boolean; priorThreadLoadAlreadyStarted?: boolean }, ) => { if (streamRef.current || !sessionId) { return true; } cancelledByUserRef.current = false; + const currentMessages = messagesRef.current; + const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId; + if (needsPriorThreadLoad && !options?.priorThreadLoadAlreadyStarted) { + /* + FNXC:ChatStreaming 2026-06-16-18:10: + In-flight attach must keep the persisted prior thread visible while the assistant bubble streams. + The chat:message:added SSE echo is suppressed during streaming to avoid duplicate local bubbles, so attach has to hydrate cached history and start a thread load itself when messages are empty or from another session. + */ + hydrateMessagesFromCache(sessionId); + void loadMessages(sessionId); + } if (inFlightGeneration) { setStreamingText(inFlightGeneration.streamingText); setStreamingThinking(inFlightGeneration.streamingThinking); @@ -605,7 +616,7 @@ export function useChat( : null, }; return true; - }, [addToast, loadMessages, projectId, flushPendingMessage]); + }, [addToast, hydrateMessagesFromCache, loadMessages, projectId, flushPendingMessage]); // Select a session const selectSession = useCallback( @@ -663,7 +674,7 @@ export function useChat( // all streaming state. Showing "Connecting…" immediately tells the // user the AI is still working. if (session?.isGenerating) { - attachIfGenerating(session.id, session.inFlightGeneration); + attachIfGenerating(session.id, session.inFlightGeneration, { priorThreadLoadAlreadyStarted: true }); } // Persist active session to localStorage diff --git a/packages/dashboard/app/hooks/useQuickChat.ts b/packages/dashboard/app/hooks/useQuickChat.ts index 3808eca0c6..8a151d5c77 100644 --- a/packages/dashboard/app/hooks/useQuickChat.ts +++ b/packages/dashboard/app/hooks/useQuickChat.ts @@ -241,6 +241,8 @@ export function useQuickChat( // component's useEffect that depends on switchSession. const activeSessionRef = useRef(activeSession); activeSessionRef.current = activeSession; + const messagesRef = useRef(messages); + messagesRef.current = messages; // Max retries for session init to prevent infinite toast loops const initRetryCountRef = useRef(0); @@ -321,6 +323,20 @@ export function useQuickChat( } }, []); + const loadMessagesForSession = useCallback(async (sessionId: string) => { + setMessagesLoading(true); + try { + const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId); + if (activeSessionRef.current?.id === sessionId) { + setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo)); + } + } catch (err) { + console.error("[useQuickChat] Failed to load messages:", err); + } finally { + setMessagesLoading(false); + } + }, [projectId]); + const attachIfGenerating = useCallback(( sessionId: string, inFlightGeneration?: ChatInFlightGenerationState | null, @@ -331,6 +347,16 @@ export function useQuickChat( } cancelledByUserRef.current = false; + const currentMessages = messagesRef.current; + const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId; + if (needsPriorThreadLoad) { + /* + FNXC:ChatStreaming 2026-06-16-18:16: + QuickChat has the same streaming visibility contract as the full chat view: a resumed in-flight assistant bubble must not hide prior user turns or assistant responses. + Because QuickChat has no message cache and streaming suppresses persisted echo handling, attach fetches the session thread directly by id instead of relying on activeSession-bound loaders that may see stale state. + */ + void loadMessagesForSession(sessionId); + } if (inFlightGeneration) { setStreamingText(inFlightGeneration.streamingText); setStreamingThinking(inFlightGeneration.streamingThinking); @@ -361,9 +387,7 @@ export function useQuickChat( isStreamingRef.current = false; streamRef.current = null; lastAttachedGenerationRef.current = null; - void fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId).then((data) => { - if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo)); - }).catch(() => {}); + void loadMessagesForSession(sessionId); flushPendingMessage(); }, onError: (data) => { @@ -378,9 +402,7 @@ export function useQuickChat( if (!options?.silent) { addToast?.(errorMessage, "error"); } - void fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId).then((data) => { - if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo)); - }).catch(() => {}); + void loadMessagesForSession(sessionId); flushPendingMessage(); }, }); @@ -397,7 +419,7 @@ export function useQuickChat( : null, }; return true; - }, [addToast, projectId, flushPendingMessage]); + }, [addToast, loadMessagesForSession, flushPendingMessage, t, projectId]); // Fetch existing sessions and find/create one for the given target const initializeSession = useCallback( @@ -456,17 +478,8 @@ export function useQuickChat( const loadMessages = useCallback(async () => { if (!activeSession) return; - setMessagesLoading(true); - try { - const sessionId = activeSession.id; - const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId); - if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo)); - } catch (err) { - console.error("[useQuickChat] Failed to load messages:", err); - } finally { - setMessagesLoading(false); - } - }, [activeSession, projectId]); + await loadMessagesForSession(activeSession.id); + }, [activeSession, loadMessagesForSession]); // Load messages when session changes useEffect(() => { @@ -524,17 +537,8 @@ export function useQuickChat( // Reload messages from server (for same-session revisit) const reloadMessages = useCallback(async () => { if (!activeSession) return; - setMessagesLoading(true); - try { - const sessionId = activeSession.id; - const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId); - if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo)); - } catch (err) { - console.error("[useQuickChat] Failed to reload messages:", err); - } finally { - setMessagesLoading(false); - } - }, [activeSession, projectId]); + await loadMessagesForSession(activeSession.id); + }, [activeSession, loadMessagesForSession]); const resetTransientComposerState = useCallback(() => { cancelStreamingFlushesRef.current?.(); diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 465e412966..32d82c2088 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -235,8 +235,19 @@ const qualityAppSettingsOnlyTests = ["app/components/__tests__/SettingsModal.tes FNXC:DashboardTestQuarantine 2026-06-14-17:01: FN-6454 applied the quarantine deletion ratchet to every dashboard test quarantined on 2026-06-14. Keep this list empty until a new flaky dashboard test is quarantined with a matching ledger entry. + +FNXC:DashboardTestQuarantine 2026-06-16-18:59: +FN-6496 verification observed QuickEntryBox expanded-mode assertions fail only in the workspace gate while an isolated file rerun passed. +Quarantine the file under the deletion ratchet instead of appeasing timing/state leakage with retries or widened waits. + +FNXC:DashboardTestQuarantine 2026-06-16-19:21: +FN-6496 merge verification observed github-tracking-hook fail during the changed-test backfill shard with temp-directory cleanup ENOTEMPTY, then pass on isolated rerun. +Quarantine the cleanup-flaky file under the deletion ratchet rather than changing production or test timing outside the chat-streaming scope. */ -const quarantinedDashboardTests: string[] = []; +const quarantinedDashboardTests: string[] = [ + "app/components/__tests__/QuickEntryBox.test.tsx", + "src/__tests__/github-tracking-hook.test.ts", +]; const qualityApiTests = [ // Critical HTTP/server behavior: auth, task/project/settings mutation, diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index 39eac9c428..29733cf1e9 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -1,4 +1,15 @@ { "$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.", - "entries": [] + "entries": [ + { + "file": "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx", + "reason": "FN-6496 verification: pnpm test failed in QuickEntryBox expanded-mode tests (expected aria-expanded=false, received true) while FN-6496 only changed chat streaming hooks; direct isolated rerun of this file passed, so classify as unrelated flaky state leakage. Failing command: pnpm test; confirming command: pnpm --filter @fusion/dashboard exec vitest run app/components/__tests__/QuickEntryBox.test.tsx --reporter=dot --silent=passed-only.", + "quarantinedAt": "2026-06-16" + }, + { + "file": "packages/dashboard/src/__tests__/github-tracking-hook.test.ts", + "reason": "FN-6496 merge verification: pnpm test failed in dashboard-api-quality-backfill with ENOTEMPTY while removing a temp task directory; isolated rerun of the file passed, so classify as unrelated cleanup flake. Failing command: pnpm test; confirming command: pnpm --filter @fusion/dashboard exec vitest run src/__tests__/github-tracking-hook.test.ts --reporter=dot --silent=passed-only.", + "quarantinedAt": "2026-06-16" + } + ] }