FN-5757: prevent chat message list from snapping to top
Keep chat scroll position stable when stale top snapshots are captured during mobile message updates. - Capture scroll anchors from the first visible message instead of always sampling the first DOM node - Ignore stale or likely-invalid zero-position snapshots before attempting scroll restoration - Guard bottom anchoring from interrupting user scroll unless explicitly forced and add regression coverage for stale snapshot behavior Files changed: packages/dashboard/app/components/ChatView.css | 11 +++++- packages/dashboard/app/components/ChatView.tsx | 33 +++++++++++++---- packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx | 41 ++++++++++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-5757 Fusion-Task-Lineage: 4e4f6843-ba70-40da-8d16-358bdfcc7b09
This commit is contained in:
@@ -642,13 +642,15 @@
|
||||
|
||||
/* Messages */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
/* Stop iOS rubber-band scroll from propagating to the parent /
|
||||
document when the user swipes on the edge of the messages list
|
||||
with the keyboard up. Without this, the bounce escapes the
|
||||
@@ -1743,6 +1745,13 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.chat-thread-header-identity {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1056,6 +1056,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
anchorMessageId: string | null;
|
||||
anchorOffset: number;
|
||||
wasPinnedBefore: boolean;
|
||||
capturedAtMs: number;
|
||||
} | null>(null);
|
||||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -1274,18 +1275,23 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const threadId = getActiveThreadId();
|
||||
if (!messagesContainer || !threadId) return;
|
||||
|
||||
const anchorMessage = messagesContainer.querySelector<HTMLElement>(".chat-message[data-message-id]");
|
||||
const scrollTop = messagesContainer.scrollTop;
|
||||
const messageElements = messagesContainer.querySelectorAll<HTMLElement>(".chat-message[data-message-id]");
|
||||
const anchorMessage = Array.from(messageElements).find((element) => element.offsetTop + element.offsetHeight >= scrollTop)
|
||||
?? messageElements[0]
|
||||
?? null;
|
||||
const anchorMessageId = anchorMessage?.getAttribute("data-message-id") ?? null;
|
||||
const anchorOffset = anchorMessage ? anchorMessage.offsetTop - messagesContainer.scrollTop : 0;
|
||||
const anchorOffset = anchorMessage ? anchorMessage.offsetTop - scrollTop : 0;
|
||||
|
||||
scrollRestoreSnapshotRef.current = {
|
||||
threadId,
|
||||
scrollTop: messagesContainer.scrollTop,
|
||||
scrollTop,
|
||||
scrollHeight: messagesContainer.scrollHeight,
|
||||
clientHeight: messagesContainer.clientHeight,
|
||||
anchorMessageId,
|
||||
anchorOffset,
|
||||
wasPinnedBefore: !isUserScrollingRef.current,
|
||||
capturedAtMs: typeof performance !== "undefined" ? performance.now() : Date.now(),
|
||||
};
|
||||
}, [getActiveThreadId]);
|
||||
|
||||
@@ -1300,8 +1306,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
captureScrollSnapshot();
|
||||
}, [captureScrollSnapshot]);
|
||||
|
||||
const anchorToBottom = useCallback((container: HTMLElement) => {
|
||||
const anchorToBottom = useCallback((container: HTMLElement, options?: { force?: boolean }) => {
|
||||
if (!container.isConnected) return;
|
||||
if (!options?.force && isUserScrollingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
let stableFrames = 0;
|
||||
@@ -1310,6 +1319,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
|
||||
const writeBottom = () => {
|
||||
if (!container.isConnected) return;
|
||||
if (!options?.force && isUserScrollingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
if (container.scrollHeight === lastScrollHeight) {
|
||||
@@ -1342,6 +1354,15 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshotAgeMs = (typeof performance !== "undefined" ? performance.now() : Date.now()) - snapshot.capturedAtMs;
|
||||
const hasScrollableOverflow = messagesContainer.scrollHeight > messagesContainer.clientHeight;
|
||||
const isStaleSnapshot = snapshotAgeMs > 3000;
|
||||
const isLikelyInvalidTopSample = snapshot.scrollTop <= 0 && snapshot.anchorOffset <= 0 && hasScrollableOverflow;
|
||||
if (!isUserScrollingRef.current || isStaleSnapshot || isLikelyInvalidTopSample) {
|
||||
scrollRestoreSnapshotRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let restoredScrollTop = snapshot.scrollTop;
|
||||
if (snapshot.anchorMessageId) {
|
||||
const anchorElement = getMessageElement(messagesContainer, snapshot.anchorMessageId);
|
||||
@@ -1425,7 +1446,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}
|
||||
|
||||
logScrollDebug(isThreadChanged ? "thread-change" : finishedLoading ? "finished-loading" : firstMessagesArrived ? "first-messages" : "mount");
|
||||
anchorToBottom(messagesContainer);
|
||||
anchorToBottom(messagesContainer, { force: true });
|
||||
if (!roomThreadActive) {
|
||||
directThreadDeferredAnchorTimeoutRef.current = window.setTimeout(() => {
|
||||
directThreadDeferredAnchorTimeoutRef.current = null;
|
||||
@@ -1644,7 +1665,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
return;
|
||||
}
|
||||
|
||||
anchorToBottom(messagesContainer);
|
||||
anchorToBottom(messagesContainer, { force: true });
|
||||
isUserScrollingRef.current = false;
|
||||
setIsUserScrolling(false);
|
||||
}, [chatScope, rooms.activeRoom, anchorToBottom]);
|
||||
|
||||
@@ -213,4 +213,45 @@ describe("ChatView scroll-to-top message affordance", () => {
|
||||
|
||||
expect(screen.getByTestId("chat-message-scroll-to-top-room-assistant-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not reset to top when a stale zero snapshot is captured while user is reading older messages", () => {
|
||||
const state: UseChatReturn = {
|
||||
...defaultChatState,
|
||||
messages: [
|
||||
{ id: "assistant-1", sessionId: activeSession.id, role: "assistant", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
{ id: "assistant-2", sessionId: activeSession.id, role: "assistant", content: "world", createdAt: "2026-04-08T00:00:01.000Z" },
|
||||
],
|
||||
};
|
||||
|
||||
mockUseChat.mockImplementation(() => state);
|
||||
mockUseChatRooms.mockReturnValue(defaultRoomsState);
|
||||
|
||||
const { rerender } = render(<ChatView addToast={vi.fn()} />);
|
||||
const container = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
|
||||
let scrollTopValue = 600;
|
||||
Object.defineProperty(container, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTopValue,
|
||||
set: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(container, "clientHeight", { configurable: true, value: 300 });
|
||||
Object.defineProperty(container, "scrollHeight", { configurable: true, value: 1200 });
|
||||
|
||||
fireEvent.scroll(container);
|
||||
|
||||
scrollTopValue = 0;
|
||||
fireEvent.scroll(container);
|
||||
|
||||
scrollTopValue = 600;
|
||||
state.messages = [
|
||||
...state.messages,
|
||||
{ id: "assistant-3", sessionId: activeSession.id, role: "assistant", content: "new", createdAt: "2026-04-08T00:00:02.000Z" },
|
||||
];
|
||||
rerender(<ChatView addToast={vi.fn()} />);
|
||||
|
||||
expect(scrollTopValue).toBe(600);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user