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 <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 18:26:10 +00:00
parent c3aeff5ff1
commit 67becd9fc2
6 changed files with 110 additions and 12 deletions

View File

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

View File

@@ -2959,7 +2959,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
onQuestionSubmit={handleQuestionSubmit}
/>
</>
) : messagesLoading ? (
) : messagesLoading && messages.length === 0 ? (
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
) : messages.length === 0 && !activeSession ? (
renderEmptyState()
@@ -4007,7 +4007,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div>
)}
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
{rooms.messagesLoading ? (
{rooms.messagesLoading && rooms.messages.length === 0 ? (
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
) : rooms.messages.filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL).length === 0 ? (
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>

View File

@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
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 });

View File

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

View File

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

View File

@@ -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<string | undefined>(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<ChatRoomMember[]>(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]);