feat(FN-5380): preserve ChatView scroll position on navigation
Preserve chat scroll state across view transitions in ChatView, with comprehensive test coverage. The feature adds scroll position persistence logic gated behind a debug trace flag, and a full test suite covering the scroll state behavior. Fusion-Task-Id: FN-5380
This commit is contained in:
committed by
gsxdsm
parent
dcb6774db3
commit
12582414da
@@ -862,6 +862,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
<div
|
||||
className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}`}
|
||||
data-testid={`chat-message-${message.id}`}
|
||||
data-message-id={message.id}
|
||||
>
|
||||
{showAssistantIdentity && (
|
||||
<div className="chat-message-avatar">
|
||||
@@ -995,8 +996,18 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const isUserScrollingRef = useRef(false);
|
||||
const lastAnchoredThreadStateRef = useRef<{ threadId: string; loaded: boolean; hasMessages: boolean } | null>(null);
|
||||
const previousChatScopeRef = useRef<"direct" | "rooms" | null>(null);
|
||||
const visibilityReanchorTimeoutRef = useRef<number | null>(null);
|
||||
const directThreadDeferredAnchorTimeoutRef = useRef<number | null>(null);
|
||||
const lastMessageCountRef = useRef(0);
|
||||
const lastThreadIdRef = useRef<string | null>(null);
|
||||
const scrollRestoreSnapshotRef = useRef<{
|
||||
threadId: string;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
anchorMessageId: string | null;
|
||||
anchorOffset: number;
|
||||
wasPinnedBefore: boolean;
|
||||
} | null>(null);
|
||||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chatThreadRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -1182,6 +1193,37 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getActiveThreadId = useCallback(() => {
|
||||
return roomThreadActive ? (rooms.activeRoom?.id ?? null) : (activeSession?.id ?? null);
|
||||
}, [roomThreadActive, rooms.activeRoom?.id, activeSession?.id]);
|
||||
|
||||
const getMessageElement = useCallback((container: HTMLElement, messageId: string) => {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return container.querySelector<HTMLElement>(`.chat-message[data-message-id="${CSS.escape(messageId)}"]`);
|
||||
}
|
||||
return container.querySelector<HTMLElement>(`.chat-message[data-message-id="${messageId.replace(/"/g, "\\\"")}"]`);
|
||||
}, []);
|
||||
|
||||
const captureScrollSnapshot = useCallback(() => {
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
const threadId = getActiveThreadId();
|
||||
if (!messagesContainer || !threadId) return;
|
||||
|
||||
const anchorMessage = messagesContainer.querySelector<HTMLElement>(".chat-message[data-message-id]");
|
||||
const anchorMessageId = anchorMessage?.getAttribute("data-message-id") ?? null;
|
||||
const anchorOffset = anchorMessage ? anchorMessage.offsetTop - messagesContainer.scrollTop : 0;
|
||||
|
||||
scrollRestoreSnapshotRef.current = {
|
||||
threadId,
|
||||
scrollTop: messagesContainer.scrollTop,
|
||||
scrollHeight: messagesContainer.scrollHeight,
|
||||
clientHeight: messagesContainer.clientHeight,
|
||||
anchorMessageId,
|
||||
anchorOffset,
|
||||
wasPinnedBefore: !isUserScrollingRef.current,
|
||||
};
|
||||
}, [getActiveThreadId]);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
if (!messagesContainer) return;
|
||||
@@ -1190,7 +1232,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const atBottom = messagesContainer.scrollTop + messagesContainer.clientHeight >= messagesContainer.scrollHeight - threshold;
|
||||
setIsUserScrolling(!atBottom);
|
||||
isUserScrollingRef.current = !atBottom;
|
||||
}, []);
|
||||
captureScrollSnapshot();
|
||||
}, [captureScrollSnapshot]);
|
||||
|
||||
const anchorToBottom = useCallback((container: HTMLElement) => {
|
||||
if (!container.isConnected) return;
|
||||
@@ -1224,11 +1267,61 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
writeBottom();
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const activeThreadMessages = roomThreadActive ? rooms.messages : messages;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
const threadId = getActiveThreadId();
|
||||
const snapshot = scrollRestoreSnapshotRef.current;
|
||||
if (!messagesContainer || !threadId || !snapshot || snapshot.threadId !== threadId || snapshot.wasPinnedBefore) {
|
||||
return;
|
||||
}
|
||||
|
||||
let restoredScrollTop = snapshot.scrollTop;
|
||||
if (snapshot.anchorMessageId) {
|
||||
const anchorElement = getMessageElement(messagesContainer, snapshot.anchorMessageId);
|
||||
if (anchorElement) {
|
||||
restoredScrollTop = anchorElement.offsetTop - snapshot.anchorOffset;
|
||||
} else {
|
||||
restoredScrollTop = snapshot.scrollTop + (messagesContainer.scrollHeight - snapshot.scrollHeight);
|
||||
}
|
||||
} else {
|
||||
restoredScrollTop = snapshot.scrollTop + (messagesContainer.scrollHeight - snapshot.scrollHeight);
|
||||
}
|
||||
|
||||
messagesContainer.scrollTop = Math.max(0, restoredScrollTop);
|
||||
isUserScrollingRef.current = true;
|
||||
setIsUserScrolling(true);
|
||||
scrollRestoreSnapshotRef.current = null;
|
||||
}, [activeThreadMessages, getActiveThreadId, getMessageElement]);
|
||||
|
||||
const logScrollDebug = useCallback((cause: string) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (process.env.NODE_ENV === "production" || !(window as unknown as { FN_5380_DEBUG?: boolean }).FN_5380_DEBUG) {
|
||||
return;
|
||||
}
|
||||
const container = messagesContainerRef.current;
|
||||
const threshold = 50;
|
||||
const atBottom = container
|
||||
? container.scrollTop + container.clientHeight >= container.scrollHeight - threshold
|
||||
: true;
|
||||
console.debug("[chat-scroll]", {
|
||||
cause,
|
||||
wasPinnedBefore: !isUserScrollingRef.current,
|
||||
atBottomNow: atBottom,
|
||||
messageCount: activeThreadMessages.length,
|
||||
roomThreadActive,
|
||||
});
|
||||
}, [activeThreadMessages.length, roomThreadActive]);
|
||||
|
||||
const scrollToBottom = useCallback((cause: string) => {
|
||||
logScrollDebug(cause);
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
if (!messagesContainer) return;
|
||||
anchorToBottom(messagesContainer);
|
||||
}, [anchorToBottom]);
|
||||
}, [anchorToBottom, logScrollDebug]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (directThreadDeferredAnchorTimeoutRef.current !== null) {
|
||||
@@ -1263,6 +1356,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
return;
|
||||
}
|
||||
|
||||
logScrollDebug(isThreadChanged ? "thread-change" : finishedLoading ? "finished-loading" : firstMessagesArrived ? "first-messages" : "mount");
|
||||
anchorToBottom(messagesContainer);
|
||||
if (!roomThreadActive) {
|
||||
directThreadDeferredAnchorTimeoutRef.current = window.setTimeout(() => {
|
||||
@@ -1296,16 +1390,40 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
anchorToBottom,
|
||||
]);
|
||||
|
||||
const activeThreadMessages = roomThreadActive ? rooms.messages : messages;
|
||||
|
||||
// Scroll thread container to bottom on new messages or streaming when user is near live tail.
|
||||
// Avoid Element.scrollIntoView() here because on mobile Safari it can
|
||||
// scroll the page viewport instead of only the chat thread.
|
||||
// Scroll thread container to bottom during streaming only when already pinned.
|
||||
useEffect(() => {
|
||||
if (!isUserScrollingRef.current) {
|
||||
scrollToBottom();
|
||||
if (!isStreaming || isUserScrollingRef.current) {
|
||||
return;
|
||||
}
|
||||
}, [activeThreadMessages, streamingText, streamingThinking, isStreaming, scrollToBottom]);
|
||||
scrollToBottom("streaming");
|
||||
}, [isStreaming, streamingText, streamingThinking, scrollToBottom]);
|
||||
|
||||
// Snap to latest on new messages only when the user was pinned before growth.
|
||||
useEffect(() => {
|
||||
const threadId = getActiveThreadId();
|
||||
if (!threadId) {
|
||||
lastMessageCountRef.current = 0;
|
||||
lastThreadIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastThreadIdRef.current !== threadId) {
|
||||
lastThreadIdRef.current = threadId;
|
||||
lastMessageCountRef.current = activeThreadMessages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousCount = lastMessageCountRef.current;
|
||||
const nextCount = activeThreadMessages.length;
|
||||
const didGrow = nextCount > previousCount;
|
||||
const wasPinnedBefore = !isUserScrollingRef.current;
|
||||
|
||||
lastMessageCountRef.current = nextCount;
|
||||
|
||||
if (didGrow && wasPinnedBefore) {
|
||||
scrollToBottom("new-message");
|
||||
}
|
||||
}, [activeThreadMessages, getActiveThreadId, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyboardOverlap <= 0) {
|
||||
@@ -1317,7 +1435,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
scrollToBottom("keyboard");
|
||||
}, [keyboardOverlap, scrollToBottom]);
|
||||
|
||||
// Lock body scroll on mobile while the keyboard is up so iOS can't shift
|
||||
@@ -1463,52 +1581,25 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
return;
|
||||
}
|
||||
|
||||
const reAnchorToLatest = () => {
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
if (!messagesContainer) {
|
||||
return;
|
||||
}
|
||||
anchorToBottom(messagesContainer);
|
||||
isUserScrollingRef.current = false;
|
||||
setIsUserScrolling(false);
|
||||
|
||||
if (visibilityReanchorTimeoutRef.current !== null) {
|
||||
window.clearTimeout(visibilityReanchorTimeoutRef.current);
|
||||
visibilityReanchorTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
visibilityReanchorTimeoutRef.current = window.setTimeout(() => {
|
||||
visibilityReanchorTimeoutRef.current = null;
|
||||
if (isUserScrollingRef.current) {
|
||||
return;
|
||||
}
|
||||
const connectedContainer = messagesContainerRef.current;
|
||||
if (!connectedContainer) {
|
||||
return;
|
||||
}
|
||||
anchorToBottom(connectedContainer);
|
||||
}, 250);
|
||||
const captureForRefetch = () => {
|
||||
captureScrollSnapshot();
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
reAnchorToLatest();
|
||||
captureForRefetch();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("pageshow", reAnchorToLatest);
|
||||
window.addEventListener("pageshow", captureForRefetch);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("pageshow", reAnchorToLatest);
|
||||
if (visibilityReanchorTimeoutRef.current !== null) {
|
||||
window.clearTimeout(visibilityReanchorTimeoutRef.current);
|
||||
visibilityReanchorTimeoutRef.current = null;
|
||||
}
|
||||
window.removeEventListener("pageshow", captureForRefetch);
|
||||
};
|
||||
}, [isMobile, activeSession, roomThreadActive, anchorToBottom]);
|
||||
}, [isMobile, activeSession, roomThreadActive, captureScrollSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (roomThreadActive) {
|
||||
@@ -2815,7 +2906,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
type="button"
|
||||
className="btn btn-sm chat-jump-to-latest"
|
||||
data-testid="chat-jump-to-latest"
|
||||
onClick={scrollToBottom}
|
||||
onClick={() => scrollToBottom("fab-click")}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
Latest
|
||||
@@ -3053,7 +3144,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
type="button"
|
||||
className="btn btn-sm chat-jump-to-latest"
|
||||
data-testid="chat-jump-to-latest"
|
||||
onClick={scrollToBottom}
|
||||
onClick={() => scrollToBottom("fab-click")}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
Latest
|
||||
|
||||
@@ -3112,6 +3112,128 @@ describe("Direct/Rooms scope toggle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-5380 scroll preservation", () => {
|
||||
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;
|
||||
};
|
||||
|
||||
it("preserves scroll across silent reconnect-style refetch for direct chats", async () => {
|
||||
const baseMessages = makeMessages(30);
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
|
||||
|
||||
const view = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
const container = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
const readScrollTop = attachScrollGeometry(container, 760);
|
||||
|
||||
fireEvent.scroll(container);
|
||||
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages] });
|
||||
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readScrollTop()).toBe(760);
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-scrolls on new message only when previously pinned", async () => {
|
||||
const baseMessages = makeMessages(4);
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
|
||||
|
||||
const view = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
const container = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
const readScrollTop = attachScrollGeometry(container, 1700);
|
||||
|
||||
fireEvent.scroll(container);
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages, ...makeMessages(1).map((message) => ({ ...message, id: "msg-5" }))] });
|
||||
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readScrollTop()).toBe(2000);
|
||||
});
|
||||
|
||||
container.scrollTop = 500;
|
||||
fireEvent.scroll(container);
|
||||
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: makeMessages(6) });
|
||||
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readScrollTop()).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves scroll through visibility reconnect path", async () => {
|
||||
const baseMessages = makeMessages(20);
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
|
||||
|
||||
const view = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
const container = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
const readScrollTop = attachScrollGeometry(container, 640);
|
||||
|
||||
fireEvent.scroll(container);
|
||||
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages, ...makeMessages(1).map((message) => ({ ...message, id: "msg-21" }))] });
|
||||
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readScrollTop()).toBe(640);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves room transcript scroll on message refresh", async () => {
|
||||
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: [],
|
||||
}));
|
||||
|
||||
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, 420);
|
||||
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(420);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizable sidebar", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -4484,7 +4606,7 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4327: desktop visibility restore re-anchors direct chat and resets jump-to-latest state", async () => {
|
||||
it("FN-5380: desktop visibility restore preserves manual direct-thread scroll", async () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
try {
|
||||
setupMockChat({
|
||||
@@ -4514,11 +4636,9 @@ describe("ChatView mobile behavior", () => {
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollTopValue).toBe(1200);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
|
||||
expect(scrollTopValue).toBe(600);
|
||||
});
|
||||
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
|
||||
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
@@ -4540,7 +4660,7 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4327: desktop pageshow re-anchors direct chat thread", async () => {
|
||||
it("FN-5380: desktop pageshow preserves direct chat scroll position", async () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
try {
|
||||
setupMockChat({
|
||||
@@ -4564,7 +4684,7 @@ describe("ChatView mobile behavior", () => {
|
||||
fireEvent(window, new Event("pageshow"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollTopValue).toBe(1280);
|
||||
expect(scrollTopValue).toBe(420);
|
||||
});
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
|
||||
Reference in New Issue
Block a user