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:
gsxdsm
2026-05-30 16:48:13 -07:00
parent 0e0b17f3c4
commit eba6a72594
3 changed files with 78 additions and 7 deletions

View File

@@ -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;

View File

@@ -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]);

View File

@@ -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);
});
});