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 ? (
- {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]);