test(FN-4336): add mobile direct-chat anchor regression coverage

Fusion-Task-Id: FN-4336
Fusion-Task-Lineage: d96c9da9-e355-4dc0-bf8c-451a917b0270
This commit is contained in:
Fusion
2026-05-13 09:41:51 -07:00
committed by gsxdsm
parent c55be45125
commit 954ccba0a8
2 changed files with 281 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Direct chat ("main chat") now stays pinned to the latest message on mobile when content lays out asynchronously (markdown, code highlighting, attachments, iOS keyboard offset) or when returning to the tab. Rooms and QuickChat behavior unchanged.

View File

@@ -4040,6 +4040,282 @@ describe("ChatView mobile behavior", () => {
} }
}); });
it("FN-4336: direct chat re-anchors on ResizeObserver growth in mobile", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
const originalResizeObserver = globalThis.ResizeObserver;
let resizeCallback: ResizeObserverCallback | null = null;
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
},
);
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 = 1000;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
await waitFor(() => {
expect(scrollTopValue).toBe(1000);
});
scrollHeightValue = 1300;
await act(async () => {
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
});
await waitFor(() => {
expect(scrollTopValue).toBe(1300);
});
} finally {
restoreMatchMedia.mockRestore();
if (originalResizeObserver) {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
}
});
it("FN-4336: direct chat ResizeObserver growth keeps thread pinned", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
const originalResizeObserver = globalThis.ResizeObserver;
let resizeCallback: ResizeObserverCallback | null = null;
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
},
);
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 = 2000;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
fireEvent.scroll(messagesContainer);
scrollHeightValue = 2400;
await act(async () => {
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
});
await waitFor(() => {
expect(scrollTopValue).toBe(2400);
});
} finally {
restoreMatchMedia.mockRestore();
if (originalResizeObserver) {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
}
});
it("FN-4336: visibility restore performs deferred direct chat settle pass", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
vi.useFakeTimers();
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 = 1000;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
fireEvent(document, new Event("visibilitychange"));
await act(async () => {
vi.runOnlyPendingTimers();
});
scrollHeightValue = 1500;
await act(async () => {
vi.advanceTimersByTime(260);
vi.runOnlyPendingTimers();
});
expect(scrollTopValue).toBe(1500);
} finally {
restoreMatchMedia.mockRestore();
vi.useRealTimers();
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
}
});
it("FN-4336: rooms scope does not attach direct ResizeObserver follower", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
const originalResizeObserver = globalThis.ResizeObserver;
localStorage.setItem("fusion:chat-scope", "rooms");
const resizeObserverCtor = vi.fn();
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
resizeObserverCtor(callback);
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
},
);
try {
setupMockChat({ activeSession: null, messages: [] });
setupMockRooms({
activeRoom: createRoomFixture("general"),
rooms: [createRoomFixture("general")],
messages: [
{
id: "room-msg-001",
roomId: "room-general",
role: "assistant",
content: "Room message",
thinkingOutput: null,
metadata: null,
senderAgentId: null,
mentions: [],
createdAt: "2026-05-12T00:00:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(screen.getByTestId("chat-sidebar-rooms")).toBeInTheDocument();
});
expect(resizeObserverCtor).not.toHaveBeenCalled();
} finally {
restoreMatchMedia.mockRestore();
if (originalResizeObserver) {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
}
});
it("FN-4336: desktop direct chat still re-anchors on ResizeObserver growth", async () => {
const restoreMatchMedia = mockViewportMode("desktop");
const originalResizeObserver = globalThis.ResizeObserver;
let resizeCallback: ResizeObserverCallback | null = null;
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
},
);
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 = 1200;
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeightValue });
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 300 });
Object.defineProperty(messagesContainer, "scrollTop", {
configurable: true,
get: () => scrollTopValue,
set: (value: number) => {
scrollTopValue = value;
},
});
await waitFor(() => {
expect(scrollTopValue).toBe(1200);
});
scrollHeightValue = 1700;
await act(async () => {
resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver);
});
await waitFor(() => {
expect(scrollTopValue).toBe(1700);
});
} finally {
restoreMatchMedia.mockRestore();
if (originalResizeObserver) {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
}
});
it("FN-4327: desktop visibility restore re-anchors direct chat and resets jump-to-latest state", async () => { it("FN-4327: desktop visibility restore re-anchors direct chat and resets jump-to-latest state", async () => {
const restoreMatchMedia = mockDesktopViewport(); const restoreMatchMedia = mockDesktopViewport();
try { try {