feat(FN-3771): add chat bottom-snap behavior and roadmap plugin extraction
Chat UX improvements land first: bottom-snap scrolling behavior is wired into both `ChatView` and `QuickChatFAB`, with comprehensive test coverage added for the components and their underlying hooks; stale-closure bugs in the `useChat` and `useQuickChat` queue-flush handlers are also patched. Roadna Fusion-Task-Id: FN-3771
This commit is contained in:
5
.changeset/fn-3771-chat-scroll-on-open.md
Normal file
5
.changeset/fn-3771-chat-scroll-on-open.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix dashboard chat surfaces so ChatView and Quick Chat snap to the latest message when opened or when switching sessions, while preserving scroll-up reading state during streaming/history loads.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
||||||
// unstyled content when the lazy chunk loads. Do not re-import here.
|
// unstyled content when the lazy chunk loads. Do not re-import here.
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
@@ -777,6 +777,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 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);
|
||||||
@@ -878,6 +879,24 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
isUserScrollingRef.current = false;
|
isUserScrollingRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const sessionId = activeSession?.id ?? null;
|
||||||
|
if (!sessionId) {
|
||||||
|
lastScrolledSessionIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
if (lastScrolledSessionIdRef.current === sessionId) return;
|
||||||
|
|
||||||
|
const messagesContainer = messagesContainerRef.current;
|
||||||
|
if (!messagesContainer) return;
|
||||||
|
|
||||||
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
setIsUserScrolling(false);
|
||||||
|
isUserScrollingRef.current = false;
|
||||||
|
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
|
||||||
// scroll the page viewport instead of only the chat thread.
|
// scroll the page viewport instead of only the chat thread.
|
||||||
|
|||||||
@@ -980,6 +980,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);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1427,6 +1428,26 @@ export function QuickChatFAB({
|
|||||||
isUserScrollingRef.current = false;
|
isUserScrollingRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const sessionId = activeSession?.id ?? null;
|
||||||
|
if (!isOpen || !sessionId) {
|
||||||
|
lastScrolledOpenSessionKeyRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
const openSessionKey = `${isOpen}:${sessionId}`;
|
||||||
|
if (lastScrolledOpenSessionKeyRef.current === openSessionKey) return;
|
||||||
|
|
||||||
|
const messagesEl = messagesRef.current;
|
||||||
|
if (!messagesEl) return;
|
||||||
|
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
setIsUserScrolling(false);
|
||||||
|
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(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|||||||
@@ -3142,6 +3142,113 @@ describe("ChatView mobile behavior", () => {
|
|||||||
restoreMatchMedia.mockRestore();
|
restoreMatchMedia.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("snaps to bottom when opening a session with loaded messages", async () => {
|
||||||
|
const restoreMatchMedia = mockDesktopViewport();
|
||||||
|
try {
|
||||||
|
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||||
|
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: () => 950 });
|
||||||
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTopValue,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTopValue = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: activeSessionFixture,
|
||||||
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||||
|
});
|
||||||
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollTopValue).toBe(950);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreMatchMedia.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snaps to bottom when switching active session id", async () => {
|
||||||
|
const restoreMatchMedia = mockDesktopViewport();
|
||||||
|
try {
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: activeSessionFixture,
|
||||||
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||||
|
});
|
||||||
|
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||||
|
let scrollTopValue = 0;
|
||||||
|
let scrollHeightValue = 900;
|
||||||
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
||||||
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTopValue,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTopValue = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: { ...activeSessionFixture, id: "session-002" },
|
||||||
|
messages: [{ id: "msg-101", sessionId: "session-002", role: "assistant", content: "Two", createdAt: "2026-04-08T00:01:00.000Z" }],
|
||||||
|
});
|
||||||
|
scrollHeightValue = 1300;
|
||||||
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollTopValue).toBe(1300);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreMatchMedia.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clobber scroll position on same-session history pagination", async () => {
|
||||||
|
const restoreMatchMedia = mockDesktopViewport();
|
||||||
|
try {
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: activeSessionFixture,
|
||||||
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||||
|
let scrollTopValue = 700;
|
||||||
|
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1200 });
|
||||||
|
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
||||||
|
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTopValue,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTopValue = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.scroll(messagesContainer);
|
||||||
|
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: activeSessionFixture,
|
||||||
|
messages: [
|
||||||
|
{ id: "msg-000", sessionId: "session-001", role: "assistant", content: "Older", createdAt: "2026-04-07T23:59:00.000Z" },
|
||||||
|
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollTopValue).toBe(700);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreMatchMedia.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ChatView mobile CSS contract", () => {
|
describe("ChatView mobile CSS contract", () => {
|
||||||
|
|||||||
@@ -472,4 +472,32 @@ describe("QuickChatFAB session-first UX", () => {
|
|||||||
expect(scrollTopValue).toBe(1200);
|
expect(scrollTopValue).toBe(1200);
|
||||||
expect(screen.queryByTestId("quick-chat-jump-to-latest")).toBeNull();
|
expect(screen.queryByTestId("quick-chat-jump-to-latest")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("snaps to bottom when switching sessions while open", async () => {
|
||||||
|
mockFetchChatMessages
|
||||||
|
.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() }] });
|
||||||
|
|
||||||
|
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 = 1100;
|
||||||
|
Object.defineProperty(messages, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
|
||||||
|
Object.defineProperty(messages, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => scrollTopValue,
|
||||||
|
set: (value: number) => {
|
||||||
|
scrollTopValue = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByTestId("quick-chat-session-dropdown"), { target: { value: "session-agent" } });
|
||||||
|
scrollHeightValue = 1700;
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollTopValue).toBe(1700);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user