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:
7
.changeset/fn-050-chat-transcript-revalidation.md
Normal file
7
.changeset/fn-050-chat-transcript-revalidation.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user