FN-5720: fix room chat scroll anchoring on scope re-entry
Ensure Rooms scope reliably re-anchors to bottom on re-entry without breaking scroll preservation during refetch. - reset stale thread anchor state when entering Rooms without an active room - treat direct↔rooms transitions as entry events and anchor messages to bottom on entry - keep existing scroll position when room messages are refetched in-place - add regression tests covering room re-entry anchoring and refetch scroll preservation Files changed: packages/dashboard/app/components/ChatView.tsx | 14 +++- .../app/components/__tests__/ChatView.test.tsx | 88 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-5720 Fusion-Task-Lineage: dde8818b-f2e5-4c55-8a4c-2c54241c2e38
This commit is contained in:
@@ -1623,11 +1623,19 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const previousScope = previousChatScopeRef.current;
|
||||
previousChatScopeRef.current = chatScope;
|
||||
|
||||
if (chatScope !== "direct") {
|
||||
if (chatScope === "rooms" && !rooms.activeRoom) {
|
||||
lastAnchoredThreadStateRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousScope !== null && previousScope !== "rooms") {
|
||||
const enteredDirect =
|
||||
chatScope === "direct" &&
|
||||
(previousScope === null || previousScope === "rooms");
|
||||
const enteredRooms =
|
||||
chatScope === "rooms" &&
|
||||
(previousScope === null || previousScope === "direct");
|
||||
|
||||
if (!enteredDirect && !enteredRooms) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1639,7 +1647,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
anchorToBottom(messagesContainer);
|
||||
isUserScrollingRef.current = false;
|
||||
setIsUserScrolling(false);
|
||||
}, [chatScope, anchorToBottom]);
|
||||
}, [chatScope, rooms.activeRoom, anchorToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession && !roomThreadActive) {
|
||||
|
||||
@@ -3234,6 +3234,94 @@ describe("FN-5380 scroll preservation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-5720 room re-entry anchoring", () => {
|
||||
const makeMessages = (count: number, sessionId = "session-001") =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
id: `msg-${index + 1}`,
|
||||
sessionId,
|
||||
role: index % 2 === 0 ? "assistant" : "user",
|
||||
content: `Message ${index + 1}`,
|
||||
createdAt: `2026-04-08T00:00:${String(index).padStart(2, "0")}.000Z`,
|
||||
} satisfies ChatMessageInfo));
|
||||
|
||||
const attachScrollGeometry = (container: HTMLDivElement, initialTop: number, height = 2000) => {
|
||||
let scrollTopValue = initialTop;
|
||||
Object.defineProperty(container, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(container, "clientHeight", { configurable: true, get: () => 300 });
|
||||
Object.defineProperty(container, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTopValue,
|
||||
set: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
});
|
||||
return () => scrollTopValue;
|
||||
};
|
||||
|
||||
const makeRoomMessages = (roomId: string, count: number) =>
|
||||
makeMessages(count, roomId).map((message) => ({
|
||||
id: message.id,
|
||||
roomId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
createdAt: message.createdAt,
|
||||
senderAgentId: null,
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
mentions: [],
|
||||
}));
|
||||
|
||||
it("anchors to bottom when re-entering Rooms scope", async () => {
|
||||
const room = createRoomFixture("ops");
|
||||
const roomMessages = makeRoomMessages(room.id, 12);
|
||||
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
sessions: [activeSessionFixture],
|
||||
filteredSessions: [activeSessionFixture],
|
||||
messages: [{ id: "dm-1", sessionId: activeSessionFixture.id, role: "assistant", content: "Direct", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false });
|
||||
localStorage.setItem("fusion:chat-scope", "rooms");
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const container = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
const readScrollTop = attachScrollGeometry(container, 420);
|
||||
|
||||
container.scrollTop = 420;
|
||||
fireEvent.scroll(container);
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
|
||||
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readScrollTop()).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves scrolled-up room position on message refetch", async () => {
|
||||
const room = createRoomFixture("ops");
|
||||
const roomMessages = makeRoomMessages(room.id, 10);
|
||||
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false });
|
||||
|
||||
const view = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const container = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
const readScrollTop = attachScrollGeometry(container, 380);
|
||||
fireEvent.scroll(container);
|
||||
|
||||
setupMockRooms({ rooms: [room], activeRoom: room, messages: [...roomMessages], messagesLoading: false });
|
||||
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readScrollTop()).toBe(380);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizable sidebar", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
Reference in New Issue
Block a user