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
258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { ChatView } from "../ChatView";
|
|
import * as useChatModule from "../../hooks/useChat";
|
|
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
|
import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat";
|
|
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
|
|
|
vi.mock("../../hooks/useChat");
|
|
vi.mock("../../hooks/useChatRooms", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../hooks/useChatRooms")>();
|
|
return {
|
|
...actual,
|
|
useChatRooms: vi.fn(),
|
|
};
|
|
});
|
|
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
|
return {
|
|
...actual,
|
|
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
|
};
|
|
});
|
|
vi.mock("../../api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../api")>();
|
|
return {
|
|
...actual,
|
|
fetchAgents: vi.fn().mockResolvedValue([{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} }]),
|
|
};
|
|
});
|
|
vi.mock("lucide-react", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("lucide-react")>();
|
|
return {
|
|
...actual,
|
|
ArrowUpToLine: (props: any) => <svg data-testid="icon-arrow-up-to-line" {...props} />,
|
|
};
|
|
});
|
|
|
|
const mockUseChat = vi.mocked(useChatModule.useChat);
|
|
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
|
|
|
const activeSession: ChatSessionInfo = {
|
|
id: "session-001",
|
|
agentId: "agent-001",
|
|
status: "active",
|
|
title: "Test Chat",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
};
|
|
|
|
const defaultChatState: UseChatReturn = {
|
|
sessions: [activeSession],
|
|
activeSession,
|
|
sessionsLoading: false,
|
|
messages: [],
|
|
messagesLoading: false,
|
|
isStreaming: false,
|
|
streamingText: "",
|
|
streamingThinking: "",
|
|
streamingToolCalls: [],
|
|
selectSession: vi.fn(),
|
|
createSession: vi.fn(),
|
|
archiveSession: vi.fn(),
|
|
deleteSession: vi.fn(),
|
|
sendMessage: vi.fn(),
|
|
stopStreaming: vi.fn(),
|
|
pendingMessage: "",
|
|
clearPendingMessage: vi.fn(),
|
|
loadMoreMessages: vi.fn(),
|
|
hasMoreMessages: false,
|
|
searchQuery: "",
|
|
setSearchQuery: vi.fn(),
|
|
filteredSessions: [activeSession],
|
|
refreshSessions: vi.fn(),
|
|
agentsMap: new Map(),
|
|
};
|
|
|
|
const roomA = {
|
|
id: "room-a",
|
|
name: "Room A",
|
|
slug: "room-a",
|
|
description: null,
|
|
projectId: "proj-123",
|
|
createdBy: "agent-1",
|
|
status: "active" as const,
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
};
|
|
|
|
const defaultRoomsState: UseChatRoomsResult = {
|
|
rooms: [roomA],
|
|
roomsLoading: false,
|
|
roomsError: null,
|
|
activeRoom: roomA,
|
|
activeRoomMembers: [],
|
|
messages: [],
|
|
messagesLoading: false,
|
|
selectRoom: vi.fn(),
|
|
createRoom: vi.fn(),
|
|
deleteRoom: vi.fn(),
|
|
sendRoomMessage: vi.fn(),
|
|
clearRoom: vi.fn(),
|
|
refreshRooms: vi.fn(),
|
|
};
|
|
|
|
function setup(chatOverrides: Partial<UseChatReturn> = {}, roomsOverrides: Partial<UseChatRoomsResult> = {}, experimentalFeatures?: Record<string, boolean>) {
|
|
mockUseChat.mockReturnValue({ ...defaultChatState, ...chatOverrides });
|
|
mockUseChatRooms.mockReturnValue({ ...defaultRoomsState, ...roomsOverrides });
|
|
return render(<ChatView addToast={vi.fn()} experimentalFeatures={experimentalFeatures} />);
|
|
}
|
|
|
|
describe("ChatView scroll-to-top message affordance", () => {
|
|
beforeEach(() => {
|
|
Object.defineProperty(window, "matchMedia", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
|
|
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
});
|
|
|
|
it("renders on assistant messages and not on user or failed assistant messages", () => {
|
|
setup({
|
|
messages: [
|
|
{ id: "assistant-ok", sessionId: activeSession.id, role: "assistant", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
{ id: "assistant-failed", sessionId: activeSession.id, role: "assistant", content: "failed", createdAt: "2026-04-08T00:00:01.000Z", failureInfo: { summary: "oops" } },
|
|
{ id: "user-1", sessionId: activeSession.id, role: "user", content: "hey", createdAt: "2026-04-08T00:00:02.000Z" },
|
|
],
|
|
});
|
|
|
|
expect(screen.getByTestId("chat-message-scroll-to-top-assistant-ok")).toHaveAttribute("aria-label", "Scroll message to top");
|
|
expect(screen.queryByTestId("chat-message-scroll-to-top-assistant-failed")).toBeNull();
|
|
expect(screen.queryByTestId("chat-message-scroll-to-top-user-1")).toBeNull();
|
|
});
|
|
|
|
it("scrolls container to message top with smooth behavior", () => {
|
|
setup({
|
|
messages: [
|
|
{ id: "assistant-ok", sessionId: activeSession.id, role: "assistant", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
});
|
|
|
|
const container = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
const target = screen.getByTestId("chat-message-assistant-ok") as HTMLDivElement;
|
|
Object.defineProperty(container, "scrollTop", { configurable: true, writable: true, value: 20 });
|
|
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ top: 100 } as DOMRect);
|
|
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 260 } as DOMRect);
|
|
|
|
fireEvent.click(screen.getByTestId("chat-message-scroll-to-top-assistant-ok"));
|
|
|
|
expect(container.scrollTo).toHaveBeenCalledWith({ top: 180, behavior: "smooth" });
|
|
});
|
|
|
|
it("uses auto behavior when reduced motion is preferred", () => {
|
|
vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
|
|
matches: query === "(prefers-reduced-motion: reduce)",
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}));
|
|
|
|
setup({
|
|
messages: [
|
|
{ id: "assistant-ok", sessionId: activeSession.id, role: "assistant", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
|
],
|
|
});
|
|
|
|
const container = document.querySelector(".chat-messages") as HTMLDivElement;
|
|
const target = screen.getByTestId("chat-message-assistant-ok") as HTMLDivElement;
|
|
Object.defineProperty(container, "scrollTop", { configurable: true, writable: true, value: 0 });
|
|
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ top: 0 } as DOMRect);
|
|
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 120 } as DOMRect);
|
|
|
|
const button = screen.getByTestId("chat-message-scroll-to-top-assistant-ok");
|
|
button.focus();
|
|
fireEvent.click(button);
|
|
|
|
expect(container.scrollTo).toHaveBeenCalledWith({ top: 120, behavior: "auto" });
|
|
});
|
|
|
|
it("renders the affordance for room assistant messages", () => {
|
|
setup(
|
|
{
|
|
sessions: [activeSession],
|
|
activeSession,
|
|
},
|
|
{
|
|
messages: [
|
|
{ id: "room-assistant-1", roomId: roomA.id, role: "assistant", content: "Room response", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
|
|
],
|
|
},
|
|
{ chatRooms: true },
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
|
|
|
|
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);
|
|
});
|
|
});
|