From 67becd9fc2570dc398c3aa003c6b577b6ee12f80 Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Wed, 19 Aug 2026 18:26:10 +0000 Subject: [PATCH] FN-050: preserve chat transcripts during revalidation Keep populated chat transcripts and reader position stable while background requests refresh data. - Retain in-memory session and room messages during same-context revalidation. - Show loading placeholders only for cold or empty transcript loads. - Add regression coverage for cache behavior and transcript rendering. - Add the published-package changeset. Files changed: .changeset/fn-050-chat-transcript-revalidation.md | 7 ++++ packages/dashboard/app/components/ChatView.tsx | 4 +- .../__tests__/ChatView.sessions-rooms.test.tsx | 44 ++++++++++++++++++++++ .../app/hooks/__tests__/useChatRooms.cache.test.ts | 27 +++++++++++++ packages/dashboard/app/hooks/useChat.ts | 16 ++++++-- packages/dashboard/app/hooks/useChatRooms.ts | 26 +++++++++---- 6 files changed, 111 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-050 Fusion-Task-Lineage: 688cf159-3ebd-4c40-b56f-21319863de23 Co-authored-by: Fusion --- .../fn-050-chat-transcript-revalidation.md | 7 +++ .../dashboard/app/components/ChatView.tsx | 4 +- .../ChatView.sessions-rooms.test.tsx | 44 +++++++++++++++++++ .../__tests__/useChatRooms.cache.test.ts | 27 ++++++++++++ packages/dashboard/app/hooks/useChat.ts | 16 +++++-- packages/dashboard/app/hooks/useChatRooms.ts | 24 +++++++--- 6 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 .changeset/fn-050-chat-transcript-revalidation.md diff --git a/.changeset/fn-050-chat-transcript-revalidation.md b/.changeset/fn-050-chat-transcript-revalidation.md new file mode 100644 index 0000000000..41f8d75dc9 --- /dev/null +++ b/.changeset/fn-050-chat-transcript-revalidation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep direct and room chat transcripts visible during background refresh. +category: fix +dev: Preserves populated transcript rows and reader anchors through same-thread revalidation. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 3c4105f5b5..c48ecc30ea 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -2959,7 +2959,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout onQuestionSubmit={handleQuestionSubmit} /> - ) : messagesLoading ? ( + ) : messagesLoading && messages.length === 0 ? (
{t("chat.loadingMessages", "Loading messages...")}
) : messages.length === 0 && !activeSession ? ( renderEmptyState() @@ -4007,7 +4007,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout )}
- {rooms.messagesLoading ? ( + {rooms.messagesLoading && rooms.messages.length === 0 ? (
{t("chat.loadingMessages", "Loading messages...")}
) : rooms.messages.filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL).length === 0 ? (
{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}
diff --git a/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx index acd4fed699..71893b439e 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.sessions-rooms.test.tsx @@ -571,6 +571,50 @@ describe("FN-5380 scroll preservation", () => { }); }); + it("keeps populated direct and room viewports mounted during revalidation", async () => { + const directMessages = makeMessages(12); + setupMockChat({ activeSession: activeSessionFixture, messages: directMessages, messagesLoading: false }); + const directView = rtlRender(); + const directContainer = document.querySelector(".chat-messages") as HTMLDivElement; + const readDirectScrollTop = attachScrollGeometry(directContainer, 520); + fireEvent.scroll(directContainer); + + setupMockChat({ activeSession: activeSessionFixture, messages: directMessages, messagesLoading: true }); + directView.rerender(); + + expect(screen.getByText("Message 1")).toBeInTheDocument(); + expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument(); + expect(readDirectScrollTop()).toBe(520); + directView.unmount(); + + const room = createRoomFixture("ops"); + const roomMessages = makeMessages(12, room.id).map((message) => ({ + id: message.id, + roomId: room.id, + role: message.role, + content: message.content, + createdAt: message.createdAt, + senderAgentId: null, + thinkingOutput: null, + metadata: null, + mentions: [], + })); + localStorage.setItem("fusion:chat-scope", "rooms"); + setupMockChat({ sessions: [], filteredSessions: [] }); + setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false }); + const roomView = rtlRender(); + const roomContainer = document.querySelector(".chat-messages") as HTMLDivElement; + const readRoomScrollTop = attachScrollGeometry(roomContainer, 460); + fireEvent.scroll(roomContainer); + + setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: true }); + roomView.rerender(); + + expect(screen.getByText("Message 1")).toBeInTheDocument(); + expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument(); + expect(readRoomScrollTop()).toBe(460); + }); + it("auto-scrolls on new message only when previously pinned", async () => { const baseMessages = makeMessages(4); setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages }); diff --git a/packages/dashboard/app/hooks/__tests__/useChatRooms.cache.test.ts b/packages/dashboard/app/hooks/__tests__/useChatRooms.cache.test.ts index 001028f533..db5b4e73bd 100644 --- a/packages/dashboard/app/hooks/__tests__/useChatRooms.cache.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChatRooms.cache.test.ts @@ -92,6 +92,33 @@ describe("useChatRooms cache behavior", () => { await waitFor(() => expect(result.current.messages[0]?.id).toBe("m9")); }); + it("retains active room rows during an uncached refresh", async () => { + mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [message("m3", "room-a", "initial")] }); + const { result } = renderHook(() => useChatRooms("proj-1")); + await waitFor(() => expect(result.current.rooms.length).toBeGreaterThan(0)); + + act(() => result.current.selectRoom("room-a")); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + + localStorage.removeItem(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`); + const refresh = deferred<{ messages: ChatRoomMessage[] }>(); + mockFetchChatRoomMessages.mockReturnValueOnce(refresh.promise); + + act(() => { + void result.current.refreshRooms(); + }); + + expect(result.current.messages[0]).toMatchObject({ roomId: "room-a" }); + expect(result.current.messagesLoading).toBe(false); + + await act(async () => { + refresh.resolve({ messages: [message("m4", "room-a", "fresh")] }); + await refresh.promise; + }); + + await waitFor(() => expect(result.current.messages[0]).toMatchObject({ id: "m4", content: "fresh" })); + }); + it("cold open keeps loading true until fetch resolves", async () => { const messagesDef = deferred<{ messages: ChatRoomMessage[] }>(); mockFetchChatRoomMessages.mockReturnValueOnce(messagesDef.promise); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 52be933877..5ad49991cf 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -692,13 +692,21 @@ export function useChat( const cacheKey = getChatMessagesCacheKey(projectId, sessionId); const cachedMessages = !isPaginationRequest ? readCachedMessages(projectId, sessionId) : []; const hasCachedMessages = cachedMessages.length > 0; + const hasRetainedMessages = !isPaginationRequest + && activeSessionRef.current?.id === sessionId + && messagesRef.current.length > 0 + && messagesRef.current.every((message) => message.sessionId === sessionId); - if (!isPaginationRequest && hasCachedMessages) { + /* + FNXC:ChatTranscriptRevalidation 2026-08-19-18:09: + A same-session background revalidation must not blank a populated selected transcript or + invalidate its reader anchor. Reserve messagesLoading for cold loads (and pagination), while + retaining in-memory rows until the fenced authoritative response replaces them. + */ + if (!isPaginationRequest && hasCachedMessages && !hasRetainedMessages) { setMessages(sortChatMessagesChronologically(cachedMessages)); - setMessagesLoading(false); - } else { - setMessagesLoading(true); } + setMessagesLoading(isPaginationRequest || (!hasCachedMessages && !hasRetainedMessages)); try { const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc", ...opts }, projectId); diff --git a/packages/dashboard/app/hooks/useChatRooms.ts b/packages/dashboard/app/hooks/useChatRooms.ts index 437effdd4d..8aaf9f757d 100644 --- a/packages/dashboard/app/hooks/useChatRooms.ts +++ b/packages/dashboard/app/hooks/useChatRooms.ts @@ -167,10 +167,12 @@ export function useChatRooms( const roomsRef = useRef(rooms); const activeRoomRef = useRef(activeRoom); + const messagesRef = useRef(messages); const projectContextVersionRef = useRef(0); const previousProjectIdRef = useRef(projectId); roomsRef.current = rooms; activeRoomRef.current = activeRoom; + messagesRef.current = messages; if (previousProjectIdRef.current !== projectId) { previousProjectIdRef.current = projectId; @@ -192,21 +194,29 @@ export function useChatRooms( const cachedMembers = readCache(membersCacheKey(room.id), { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); const hasCachedMessages = Array.isArray(cachedMessages) && cachedMessages.length > 0; const hasCachedMembers = Array.isArray(cachedMembers) && cachedMembers.length > 0; + const hasRetainedMessages = activeRoomRef.current?.id === room.id + && messagesRef.current.length > 0 + && messagesRef.current.every((message) => message.roomId === room.id); + /* + FNXC:ChatTranscriptRevalidation 2026-08-19-18:09: + A same-room background revalidation must not blank a populated selected transcript or + invalidate its reader anchor. Only a new room with neither cache nor in-memory rows owns the + blocking loader; the active room keeps its rows until its fenced response arrives. + */ if (hasCachedMessages || hasCachedMembers) { timer.mark("cache-hit"); - if (hasCachedMessages) { + if (hasCachedMessages && !hasRetainedMessages) { setMessages(cachedMessages); timer.mark("hydrate"); } if (hasCachedMembers) { setActiveRoomMembers(cachedMembers); } - setMessagesLoading(false); - } else { + } else if (!hasRetainedMessages) { setMessages([]); - setMessagesLoading(true); } + setMessagesLoading(!hasCachedMessages && !hasRetainedMessages); try { const [membersData, messagesData] = await Promise.all([ @@ -231,8 +241,10 @@ export function useChatRooms( setMessages([]); } } finally { - setMessagesLoading(false); - timer.complete({ warm: hasCachedMessages, membersCached: hasCachedMembers }); + if (activeRoomRef.current?.id === room.id) { + setMessagesLoading(false); + } + timer.complete({ warm: hasCachedMessages || hasRetainedMessages, membersCached: hasCachedMembers }); } }, [membersCacheKey, messagesCacheKey, projectId]);