feat(FN-3884): anchor QuickChatFAB and chat messages to latest on layout sh
Merges FN-3884 chat scroll anchoring — ChatView now re-scrolls to the latest message when reopened or when activation transitions occur, with a bounded bottom-anchor helper preventing layout-shift overshoots. QuickChatFAB was updated to wire the new anchoring behavior, and both components gained com Fusion-Task-Id: FN-3884
This commit is contained in:
7
.changeset/fn-3884-chat-scroll-latest.md
Normal file
7
.changeset/fn-3884-chat-scroll-latest.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix chat thread bottom anchoring when reopening sessions.
|
||||||
|
|
||||||
|
Quick Chat and Chat now scroll to the latest message every time they are reopened, even when markdown/images/tool details render after the initial paint.
|
||||||
@@ -785,7 +785,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const isUserScrollingRef = useRef(false);
|
const isUserScrollingRef = useRef(false);
|
||||||
const lastScrolledSessionIdRef = useRef<string | null>(null);
|
const lastAnchoredSessionStateRef = useRef<{ sessionId: string; loaded: boolean; hasMessages: boolean } | null>(null);
|
||||||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -895,31 +895,76 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
isUserScrollingRef.current = !atBottom;
|
isUserScrollingRef.current = !atBottom;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const anchorToBottom = useCallback((container: HTMLElement) => {
|
||||||
|
if (!container.isConnected) return;
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
let stableFrames = 0;
|
||||||
|
let lastScrollHeight = -1;
|
||||||
|
const maxFrames = 6;
|
||||||
|
|
||||||
|
const writeBottom = () => {
|
||||||
|
if (!container.isConnected) return;
|
||||||
|
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
if (container.scrollHeight === lastScrollHeight) {
|
||||||
|
stableFrames += 1;
|
||||||
|
} else {
|
||||||
|
stableFrames = 0;
|
||||||
|
lastScrollHeight = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame += 1;
|
||||||
|
if (frame >= maxFrames || stableFrames >= 2) {
|
||||||
|
setIsUserScrolling(false);
|
||||||
|
isUserScrollingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(writeBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
writeBottom();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
const messagesContainer = messagesContainerRef.current;
|
const messagesContainer = messagesContainerRef.current;
|
||||||
if (!messagesContainer) return;
|
if (!messagesContainer) return;
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
anchorToBottom(messagesContainer);
|
||||||
setIsUserScrolling(false);
|
}, [anchorToBottom]);
|
||||||
isUserScrollingRef.current = false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const sessionId = activeSession?.id ?? null;
|
const sessionId = activeSession?.id ?? null;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
lastScrolledSessionIdRef.current = null;
|
lastAnchoredSessionStateRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = {
|
||||||
|
sessionId,
|
||||||
|
loaded: !messagesLoading,
|
||||||
|
hasMessages: messages.length > 0,
|
||||||
|
};
|
||||||
|
const previousState = lastAnchoredSessionStateRef.current;
|
||||||
|
const isSessionChanged = previousState?.sessionId !== sessionId;
|
||||||
|
const finishedLoading =
|
||||||
|
previousState?.sessionId === sessionId && !previousState.loaded && nextState.loaded;
|
||||||
|
const firstMessagesArrived =
|
||||||
|
previousState?.sessionId === sessionId && !previousState.hasMessages && nextState.hasMessages;
|
||||||
|
|
||||||
|
const shouldAnchor = previousState === null || isSessionChanged || finishedLoading || firstMessagesArrived;
|
||||||
|
if (!shouldAnchor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (messages.length === 0) return;
|
|
||||||
if (lastScrolledSessionIdRef.current === sessionId) return;
|
|
||||||
|
|
||||||
const messagesContainer = messagesContainerRef.current;
|
const messagesContainer = messagesContainerRef.current;
|
||||||
if (!messagesContainer) return;
|
if (!messagesContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
anchorToBottom(messagesContainer);
|
||||||
setIsUserScrolling(false);
|
lastAnchoredSessionStateRef.current = nextState;
|
||||||
isUserScrollingRef.current = false;
|
}, [activeSession?.id, messages.length, messagesLoading, anchorToBottom]);
|
||||||
lastScrolledSessionIdRef.current = sessionId;
|
|
||||||
}, [activeSession?.id, messages.length]);
|
|
||||||
|
|
||||||
// Scroll thread container to bottom on new messages or streaming when user is near live tail.
|
// 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
|
// Avoid Element.scrollIntoView() here because on mobile Safari it can
|
||||||
|
|||||||
@@ -981,7 +981,7 @@ export function QuickChatFAB({
|
|||||||
// slides down on top of it.
|
// slides down on top of it.
|
||||||
const suppressVvShrinkRef = useRef(false);
|
const suppressVvShrinkRef = useRef(false);
|
||||||
const isUserScrollingRef = useRef(false);
|
const isUserScrollingRef = useRef(false);
|
||||||
const lastScrolledOpenSessionKeyRef = useRef<string | null>(null);
|
const previousOpenStateRef = useRef<{ isOpen: boolean; sessionId: string | null }>({ isOpen: false, sessionId: null });
|
||||||
|
|
||||||
// Pin the document at the top while the panel is open on mobile.
|
// Pin the document at the top while the panel is open on mobile.
|
||||||
// Otherwise iOS can leave window.scrollY > 0 (e.g. after the keyboard
|
// Otherwise iOS can leave window.scrollY > 0 (e.g. after the keyboard
|
||||||
@@ -1417,33 +1417,64 @@ export function QuickChatFAB({
|
|||||||
isUserScrollingRef.current = !atBottom;
|
isUserScrollingRef.current = !atBottom;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const anchorToBottom = useCallback((container: HTMLElement) => {
|
||||||
|
if (!container.isConnected) return;
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
let stableFrames = 0;
|
||||||
|
let lastScrollHeight = -1;
|
||||||
|
const maxFrames = 6;
|
||||||
|
|
||||||
|
const writeBottom = () => {
|
||||||
|
if (!container.isConnected) return;
|
||||||
|
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
if (container.scrollHeight === lastScrollHeight) {
|
||||||
|
stableFrames += 1;
|
||||||
|
} else {
|
||||||
|
stableFrames = 0;
|
||||||
|
lastScrollHeight = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame += 1;
|
||||||
|
if (frame >= maxFrames || stableFrames >= 2) {
|
||||||
|
setIsUserScrolling(false);
|
||||||
|
isUserScrollingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(writeBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
writeBottom();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
const messagesEl = messagesRef.current;
|
const messagesEl = messagesRef.current;
|
||||||
if (!messagesEl) return;
|
if (!messagesEl) return;
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
anchorToBottom(messagesEl);
|
||||||
setIsUserScrolling(false);
|
}, [anchorToBottom]);
|
||||||
isUserScrollingRef.current = false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const sessionId = activeSession?.id ?? null;
|
const sessionId = activeSession?.id ?? null;
|
||||||
|
const previousState = previousOpenStateRef.current;
|
||||||
|
previousOpenStateRef.current = { isOpen, sessionId };
|
||||||
|
|
||||||
if (!isOpen || !sessionId) {
|
if (!isOpen || !sessionId) {
|
||||||
lastScrolledOpenSessionKeyRef.current = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (messages.length === 0) return;
|
|
||||||
|
|
||||||
const openSessionKey = `${isOpen}:${sessionId}`;
|
const openingNow = !previousState.isOpen && isOpen;
|
||||||
if (lastScrolledOpenSessionKeyRef.current === openSessionKey) return;
|
const sessionChangedWhileOpen = previousState.isOpen && previousState.sessionId !== sessionId;
|
||||||
|
if (!openingNow && !sessionChangedWhileOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const messagesEl = messagesRef.current;
|
const messagesEl = messagesRef.current;
|
||||||
if (!messagesEl) return;
|
if (!messagesEl) return;
|
||||||
|
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
anchorToBottom(messagesEl);
|
||||||
setIsUserScrolling(false);
|
}, [isOpen, activeSession?.id, anchorToBottom]);
|
||||||
isUserScrollingRef.current = false;
|
|
||||||
lastScrolledOpenSessionKeyRef.current = openSessionKey;
|
|
||||||
}, [isOpen, activeSession?.id, messages.length]);
|
|
||||||
|
|
||||||
// Auto-scroll messages when user is near the live tail.
|
// Auto-scroll messages when user is near the live tail.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3212,7 +3212,7 @@ describe("ChatView mobile behavior", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("snaps to bottom when opening a session with loaded messages", async () => {
|
it("FN-3884: snaps to bottom when opening a session with loaded messages", async () => {
|
||||||
const restoreMatchMedia = mockDesktopViewport();
|
const restoreMatchMedia = mockDesktopViewport();
|
||||||
try {
|
try {
|
||||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||||
@@ -3243,7 +3243,81 @@ describe("ChatView mobile behavior", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("snaps to bottom when switching active session id", async () => {
|
it("FN-3884: re-anchors when messagesLoading transitions to loaded with messages", async () => {
|
||||||
|
const restoreMatchMedia = mockDesktopViewport();
|
||||||
|
try {
|
||||||
|
setupMockChat({ activeSession: activeSessionFixture, messages: [], messagesLoading: true });
|
||||||
|
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||||
|
let scrollTopValue = 0;
|
||||||
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 980 });
|
||||||
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTopValue,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTopValue = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: activeSessionFixture,
|
||||||
|
messagesLoading: false,
|
||||||
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Loaded", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||||
|
});
|
||||||
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollTopValue).toBe(980);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreMatchMedia.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-3884: retries bottom anchor while container height keeps growing", async () => {
|
||||||
|
const restoreMatchMedia = mockDesktopViewport();
|
||||||
|
const originalRaf = window.requestAnimationFrame;
|
||||||
|
const rafQueue: FrameRequestCallback[] = [];
|
||||||
|
window.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
|
||||||
|
rafQueue.push(cb);
|
||||||
|
return rafQueue.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: activeSessionFixture,
|
||||||
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||||
|
let scrollTopValue = 0;
|
||||||
|
let scrollHeightValue = 600;
|
||||||
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
||||||
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTopValue,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTopValue = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollHeightValue = 900;
|
||||||
|
while (rafQueue.length > 0) {
|
||||||
|
const cb = rafQueue.shift();
|
||||||
|
cb?.(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scrollTopValue).toBe(900);
|
||||||
|
} finally {
|
||||||
|
window.requestAnimationFrame = originalRaf;
|
||||||
|
restoreMatchMedia.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-3884: snaps to bottom when switching active session id", async () => {
|
||||||
const restoreMatchMedia = mockDesktopViewport();
|
const restoreMatchMedia = mockDesktopViewport();
|
||||||
try {
|
try {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
@@ -3279,7 +3353,7 @@ describe("ChatView mobile behavior", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not clobber scroll position on same-session history pagination", async () => {
|
it("FN-3884: does not yank when user scrolled up on same-session updates", async () => {
|
||||||
const restoreMatchMedia = mockDesktopViewport();
|
const restoreMatchMedia = mockDesktopViewport();
|
||||||
try {
|
try {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
|
|||||||
@@ -470,10 +470,12 @@ describe("QuickChatFAB session-first UX", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByTestId("quick-chat-jump-to-latest"));
|
fireEvent.click(screen.getByTestId("quick-chat-jump-to-latest"));
|
||||||
expect(scrollTopValue).toBe(1200);
|
expect(scrollTopValue).toBe(1200);
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.queryByTestId("quick-chat-jump-to-latest")).toBeNull();
|
expect(screen.queryByTestId("quick-chat-jump-to-latest")).toBeNull();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("snaps to bottom when first opened", async () => {
|
it("FN-3884: snaps to bottom when first opened", async () => {
|
||||||
const deferredMessages = createDeferredPromise<{
|
const deferredMessages = createDeferredPromise<{
|
||||||
messages: Array<{ id: string; sessionId: string; role: "assistant"; content: string; createdAt: string }>;
|
messages: Array<{ id: string; sessionId: string; role: "assistant"; content: string; createdAt: string }>;
|
||||||
}>();
|
}>();
|
||||||
@@ -512,7 +514,82 @@ describe("QuickChatFAB session-first UX", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("snaps to bottom when switching sessions while open", async () => {
|
it("FN-3884: reopens same session and scrolls to latest again", async () => {
|
||||||
|
mockFetchChatMessages.mockResolvedValue({
|
||||||
|
messages: [{ id: "msg-1", sessionId: "session-model", role: "assistant", content: "hello", createdAt: new Date().toISOString() }],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||||
|
const fab = screen.getByTestId("quick-chat-fab");
|
||||||
|
fireEvent.click(fab);
|
||||||
|
|
||||||
|
let messages = await screen.findByTestId("quick-chat-messages");
|
||||||
|
let scrollTopValue = 0;
|
||||||
|
const installScrollDescriptors = (target: HTMLElement) => {
|
||||||
|
Object.defineProperty(target, "scrollHeight", { configurable: true, get: () => 1000 });
|
||||||
|
Object.defineProperty(target, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTopValue,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTopValue = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
installScrollDescriptors(messages);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-close"));
|
||||||
|
scrollTopValue = 0;
|
||||||
|
fireEvent.click(fab);
|
||||||
|
|
||||||
|
messages = await screen.findByTestId("quick-chat-messages");
|
||||||
|
installScrollDescriptors(messages);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollTopValue).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-3884: retries anchor when quick chat thread height grows after open", async () => {
|
||||||
|
const originalRaf = window.requestAnimationFrame;
|
||||||
|
const rafQueue: FrameRequestCallback[] = [];
|
||||||
|
window.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
|
||||||
|
rafQueue.push(cb);
|
||||||
|
return rafQueue.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockFetchChatMessages.mockResolvedValue({
|
||||||
|
messages: [{ id: "msg-1", sessionId: "session-model", role: "assistant", content: "hello", createdAt: new Date().toISOString() }],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||||
|
|
||||||
|
const messages = await screen.findByTestId("quick-chat-messages");
|
||||||
|
let scrollTopValue = 0;
|
||||||
|
let scrollHeightValue = 500;
|
||||||
|
Object.defineProperty(messages, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
||||||
|
Object.defineProperty(messages, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTopValue,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTopValue = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollHeightValue = 900;
|
||||||
|
while (rafQueue.length > 0) {
|
||||||
|
const cb = rafQueue.shift();
|
||||||
|
cb?.(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scrollTopValue).toBe(900);
|
||||||
|
} finally {
|
||||||
|
window.requestAnimationFrame = originalRaf;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-3884: snaps to bottom when switching sessions while open", async () => {
|
||||||
mockFetchChatMessages
|
mockFetchChatMessages
|
||||||
.mockResolvedValueOnce({ messages: [{ id: "msg-1", sessionId: "session-model", role: "assistant", content: "A", createdAt: new Date().toISOString() }] })
|
.mockResolvedValueOnce({ messages: [{ id: "msg-1", sessionId: "session-model", role: "assistant", content: "A", createdAt: new Date().toISOString() }] })
|
||||||
.mockResolvedValueOnce({ messages: [{ id: "msg-2", sessionId: "session-agent", role: "assistant", content: "B", createdAt: new Date().toISOString() }] });
|
.mockResolvedValueOnce({ messages: [{ id: "msg-2", sessionId: "session-agent", role: "assistant", content: "B", createdAt: new Date().toISOString() }] });
|
||||||
|
|||||||
Reference in New Issue
Block a user