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}
|
onQuestionSubmit={handleQuestionSubmit}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : messagesLoading ? (
|
) : messagesLoading && messages.length === 0 ? (
|
||||||
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||||
) : messages.length === 0 && !activeSession ? (
|
) : messages.length === 0 && !activeSession ? (
|
||||||
renderEmptyState()
|
renderEmptyState()
|
||||||
@@ -4007,7 +4007,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
<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>
|
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||||
) : rooms.messages.filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL).length === 0 ? (
|
) : 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>
|
<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 () => {
|
it("auto-scrolls on new message only when previously pinned", async () => {
|
||||||
const baseMessages = makeMessages(4);
|
const baseMessages = makeMessages(4);
|
||||||
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
|
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
|
||||||
|
|||||||
@@ -92,6 +92,33 @@ describe("useChatRooms cache behavior", () => {
|
|||||||
await waitFor(() => expect(result.current.messages[0]?.id).toBe("m9"));
|
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 () => {
|
it("cold open keeps loading true until fetch resolves", async () => {
|
||||||
const messagesDef = deferred<{ messages: ChatRoomMessage[] }>();
|
const messagesDef = deferred<{ messages: ChatRoomMessage[] }>();
|
||||||
mockFetchChatRoomMessages.mockReturnValueOnce(messagesDef.promise);
|
mockFetchChatRoomMessages.mockReturnValueOnce(messagesDef.promise);
|
||||||
|
|||||||
@@ -692,13 +692,21 @@ export function useChat(
|
|||||||
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
|
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
|
||||||
const cachedMessages = !isPaginationRequest ? readCachedMessages(projectId, sessionId) : [];
|
const cachedMessages = !isPaginationRequest ? readCachedMessages(projectId, sessionId) : [];
|
||||||
const hasCachedMessages = cachedMessages.length > 0;
|
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));
|
setMessages(sortChatMessagesChronologically(cachedMessages));
|
||||||
setMessagesLoading(false);
|
|
||||||
} else {
|
|
||||||
setMessagesLoading(true);
|
|
||||||
}
|
}
|
||||||
|
setMessagesLoading(isPaginationRequest || (!hasCachedMessages && !hasRetainedMessages));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc", ...opts }, projectId);
|
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc", ...opts }, projectId);
|
||||||
|
|||||||
@@ -167,10 +167,12 @@ export function useChatRooms(
|
|||||||
|
|
||||||
const roomsRef = useRef(rooms);
|
const roomsRef = useRef(rooms);
|
||||||
const activeRoomRef = useRef(activeRoom);
|
const activeRoomRef = useRef(activeRoom);
|
||||||
|
const messagesRef = useRef(messages);
|
||||||
const projectContextVersionRef = useRef(0);
|
const projectContextVersionRef = useRef(0);
|
||||||
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
||||||
roomsRef.current = rooms;
|
roomsRef.current = rooms;
|
||||||
activeRoomRef.current = activeRoom;
|
activeRoomRef.current = activeRoom;
|
||||||
|
messagesRef.current = messages;
|
||||||
|
|
||||||
if (previousProjectIdRef.current !== projectId) {
|
if (previousProjectIdRef.current !== projectId) {
|
||||||
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 cachedMembers = readCache<ChatRoomMember[]>(membersCacheKey(room.id), { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
|
||||||
const hasCachedMessages = Array.isArray(cachedMessages) && cachedMessages.length > 0;
|
const hasCachedMessages = Array.isArray(cachedMessages) && cachedMessages.length > 0;
|
||||||
const hasCachedMembers = Array.isArray(cachedMembers) && cachedMembers.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) {
|
if (hasCachedMessages || hasCachedMembers) {
|
||||||
timer.mark("cache-hit");
|
timer.mark("cache-hit");
|
||||||
if (hasCachedMessages) {
|
if (hasCachedMessages && !hasRetainedMessages) {
|
||||||
setMessages(cachedMessages);
|
setMessages(cachedMessages);
|
||||||
timer.mark("hydrate");
|
timer.mark("hydrate");
|
||||||
}
|
}
|
||||||
if (hasCachedMembers) {
|
if (hasCachedMembers) {
|
||||||
setActiveRoomMembers(cachedMembers);
|
setActiveRoomMembers(cachedMembers);
|
||||||
}
|
}
|
||||||
setMessagesLoading(false);
|
} else if (!hasRetainedMessages) {
|
||||||
} else {
|
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setMessagesLoading(true);
|
|
||||||
}
|
}
|
||||||
|
setMessagesLoading(!hasCachedMessages && !hasRetainedMessages);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [membersData, messagesData] = await Promise.all([
|
const [membersData, messagesData] = await Promise.all([
|
||||||
@@ -231,8 +241,10 @@ export function useChatRooms(
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setMessagesLoading(false);
|
if (activeRoomRef.current?.id === room.id) {
|
||||||
timer.complete({ warm: hasCachedMessages, membersCached: hasCachedMembers });
|
setMessagesLoading(false);
|
||||||
|
}
|
||||||
|
timer.complete({ warm: hasCachedMessages || hasRetainedMessages, membersCached: hasCachedMembers });
|
||||||
}
|
}
|
||||||
}, [membersCacheKey, messagesCacheKey, projectId]);
|
}, [membersCacheKey, messagesCacheKey, projectId]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user