From a76ea5862a165dbf387e695e0fcd353fa9c86eb0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 01:29:28 -0700 Subject: [PATCH] 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 --- .../dashboard/app/components/QuickChatFAB.tsx | 21 ++- .../__tests__/QuickChatFAB.test.tsx | 176 ++++++++++++++++++ 2 files changed, 193 insertions(+), 4 deletions(-) diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index ff63c2b538..e3eaa9d026 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -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) { diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index ae36508da6..8a85ba9cee 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -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(); + 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(); + 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); + 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(); + 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 }>;