FN-6513: keep quick chat anchored after loading

Quick chat now re-anchors to the latest message when opened content finishes loading.

- Track message-loading state with the previous open thread state.
- Re-run bottom anchoring when direct or room-thread messages transition from loading to loaded.
- Cover streaming, direct thread, and mobile room-thread tail anchoring with regression tests.

Files changed:
 packages/dashboard/app/components/QuickChatFAB.tsx |  21 ++-
 .../app/components/__tests__/QuickChatFAB.test.tsx | 176 +++++++++++++++++++++
 2 files changed, 193 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-6513

Fusion-Task-Lineage: 2cf3e110-b447-4182-a7ff-886a2b5cb815
This commit is contained in:
gsxdsm
2026-06-17 01:29:28 -07:00
parent 4e47c87c43
commit a76ea5862a
2 changed files with 193 additions and 4 deletions

View File

@@ -1154,7 +1154,11 @@ export function QuickChatFAB({
// slides down on top of it.
const suppressVvShrinkRef = useRef(false);
const isUserScrollingRef = useRef(false);
const previousOpenStateRef = useRef<{ isOpen: boolean; sessionId: string | null }>({ isOpen: false, sessionId: null });
const previousOpenStateRef = useRef<{ isOpen: boolean; sessionId: string | null; messagesLoading: boolean }>({
isOpen: false,
sessionId: null,
messagesLoading: false,
});
// 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
@@ -1814,8 +1818,9 @@ export function QuickChatFAB({
useLayoutEffect(() => {
const threadId = roomThreadActive ? (roomsState.activeRoom?.id ?? null) : (activeSession?.id ?? null);
const threadMessagesLoading = roomThreadActive ? roomsState.messagesLoading : messagesLoading;
const previousState = previousOpenStateRef.current;
previousOpenStateRef.current = { isOpen, sessionId: threadId };
previousOpenStateRef.current = { isOpen, sessionId: threadId, messagesLoading: threadMessagesLoading };
if (!isOpen || !threadId) {
return;
@@ -1823,15 +1828,23 @@ export function QuickChatFAB({
const openingNow = !previousState.isOpen && isOpen;
const sessionChangedWhileOpen = previousState.isOpen && previousState.sessionId !== threadId;
if (!openingNow && !sessionChangedWhileOpen) {
const messagesSettledAfterOpen = previousState.isOpen
&& previousState.sessionId === threadId
&& previousState.messagesLoading
&& !threadMessagesLoading;
if (!openingNow && !sessionChangedWhileOpen && !messagesSettledAfterOpen) {
return;
}
const messagesEl = messagesRef.current;
if (!messagesEl) return;
/*
FNXC:QuickChatScroll 2026-06-17-01:06:
FN-6513 requires quick chat opens to land on the live tail after asynchronous messages settle across direct sessions and room threads, on desktop and mobile. Re-run the same anchor path on loading-to-loaded transitions so a bounded initial-open frame loop cannot finish against the loading placeholder and leave isUserScrolling suppressing tail auto-scroll.
*/
anchorToBottom(messagesEl);
}, [isOpen, activeSession?.id, anchorToBottom, roomThreadActive, roomsState.activeRoom?.id]);
}, [isOpen, activeSession?.id, anchorToBottom, messagesLoading, roomThreadActive, roomsState.activeRoom?.id, roomsState.messagesLoading]);
useEffect(() => {
if (!isMobile || !isOpen || !activeSession) {

View File

@@ -1587,6 +1587,49 @@ describe("QuickChatFAB session-first UX", () => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
});
it("FN-6513: keeps the live tail anchored while a response is streaming", async () => {
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{
id: "msg-before-stream",
sessionId: "session-model",
role: "assistant",
content: "Before streaming",
createdAt: "2026-06-16T00:00:00.000Z",
},
],
});
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onChunk?.("streaming answer");
return { close: vi.fn(), isConnected: () => true };
});
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;
const scrollHeightValue = 1400;
Object.defineProperty(messages, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messages, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
const input = await screen.findByTestId("quick-chat-input");
await waitFor(() => expect(input).not.toBeDisabled());
fireEvent.change(input, { target: { value: "Stream a reply" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
expect(await screen.findByTestId("quick-chat-streaming-message")).toBeInTheDocument();
await waitFor(() => {
expect(scrollTopValue).toBe(scrollHeightValue);
});
});
it("shows the streaming indicator instead of the loading placeholder while waiting for a long reply", async () => {
const deferredMessages = createDeferredPromise<{ messages: never[] }>();
mockFetchChatMessages.mockImplementation(() => deferredMessages.promise);
@@ -1776,6 +1819,139 @@ describe("QuickChatFAB session-first UX", () => {
});
});
it("FN-6513: re-anchors a direct thread after async loading settles", async () => {
const deferredMessages = createDeferredPromise<{
messages: Array<{ id: string; sessionId: string; role: "assistant"; content: string; createdAt: string }>;
}>();
mockFetchChatMessages.mockImplementation(() => deferredMessages.promise);
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => expect(mockFetchChatMessages).toHaveBeenCalled());
const messages = await screen.findByTestId("quick-chat-messages");
let scrollTopValue = 0;
let scrollHeightValue = 120;
Object.defineProperty(messages, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messages, "clientHeight", { configurable: true, get: () => 20 });
Object.defineProperty(messages, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
expect(screen.getByText("Loading conversation…")).toBeInTheDocument();
fireEvent.scroll(messages);
expect(screen.getByTestId("quick-chat-jump-to-latest")).toBeInTheDocument();
scrollHeightValue = 1400;
deferredMessages.resolve({
messages: Array.from({ length: 12 }, (_, index) => ({
id: `direct-msg-${index}`,
sessionId: "session-model",
role: "assistant" as const,
content: `Loaded direct message ${index}`,
createdAt: `2026-06-16T00:00:${String(index).padStart(2, "0")}.000Z`,
})),
});
await waitFor(() => {
expect(scrollTopValue).toBe(scrollHeightValue);
});
});
it("FN-6513: re-anchors a mobile room thread after async loading settles", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
window.dispatchEvent(new Event("resize"));
mockUseViewportMode.mockReturnValue("mobile");
const originalRaf = window.requestAnimationFrame;
const originalCancelRaf = window.cancelAnimationFrame;
const rafQueue: FrameRequestCallback[] = [];
window.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
rafQueue.push(cb);
return rafQueue.length;
});
window.cancelAnimationFrame = vi.fn();
const room = {
id: "room-6513",
name: "engineering",
slug: "engineering",
memberCount: 2,
createdAt: "2026-06-16T00:00:00.000Z",
updatedAt: "2026-06-16T00:00:10.000Z",
};
const roomMessages = Array.from({ length: 12 }, (_, index) => ({
id: `room-msg-${index}`,
roomId: room.id,
role: index % 2 === 0 ? "assistant" as const : "user" as const,
content: `Loaded room message ${index}`,
createdAt: `2026-06-16T00:00:${String(index).padStart(2, "0")}.000Z`,
}));
let finishRoomLoad: (() => void) | null = null;
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
mockUseChatRooms.mockImplementation(() => {
const [messagesLoading, setMessagesLoading] = useState(true);
finishRoomLoad = () => setMessagesLoading(false);
return {
rooms: [room],
roomsLoading: false,
roomsError: null,
activeRoom: room,
activeRoomMembers: [],
messages: roomMessages,
messagesLoading,
selectRoom: vi.fn(),
createRoom: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn(),
clearRoom: vi.fn(),
refreshRooms: vi.fn(),
};
});
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 = 120;
Object.defineProperty(messages, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messages, "clientHeight", { configurable: true, get: () => 20 });
Object.defineProperty(messages, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
expect(screen.getByText("Loading conversation…")).toBeInTheDocument();
while (rafQueue.length > 0) {
const cb = rafQueue.shift();
cb?.(performance.now());
}
expect(scrollTopValue).toBe(scrollHeightValue);
scrollTopValue = 0;
fireEvent.scroll(messages);
expect(screen.getByTestId("quick-chat-jump-to-latest")).toBeInTheDocument();
scrollHeightValue = 1400;
await act(async () => {
finishRoomLoad?.();
});
await waitFor(() => {
expect(scrollTopValue).toBe(scrollHeightValue);
});
} finally {
window.requestAnimationFrame = originalRaf;
window.cancelAnimationFrame = originalCancelRaf;
}
});
it("FN-3910: anchors to live tail on initial controlled open", async () => {
const deferredMessages = createDeferredPromise<{
messages: Array<{ id: string; sessionId: string; role: "assistant"; content: string; createdAt: string }>;