293 lines
8.0 KiB
TypeScript
293 lines
8.0 KiB
TypeScript
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { UiMessage } from "@/stores/chat/ui-message";
|
|
|
|
import { ChatArea } from "../chat-area";
|
|
|
|
vi.mock("../message-bubble", () => ({
|
|
MessageBubble: ({ content }: { content: string }) => <div>{content}</div>,
|
|
}));
|
|
|
|
let scrollHeight = 0;
|
|
let clientHeight = 0;
|
|
let resizeObservers: MockResizeObserver[] = [];
|
|
|
|
class MockResizeObserver {
|
|
readonly callback: ResizeObserverCallback;
|
|
|
|
constructor(callback: ResizeObserverCallback) {
|
|
this.callback = callback;
|
|
resizeObservers.push(this);
|
|
}
|
|
|
|
observe(): void {}
|
|
disconnect(): void {}
|
|
|
|
trigger(): void {
|
|
this.callback([], this as unknown as ResizeObserver);
|
|
}
|
|
}
|
|
|
|
describe("ChatArea scrolling", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
|
scrollHeight = 0;
|
|
clientHeight = 0;
|
|
resizeObservers = [];
|
|
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
|
vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1));
|
|
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(
|
|
function (this: HTMLElement) {
|
|
return this.getAttribute("aria-label") === "Chat messages"
|
|
? scrollHeight
|
|
: 0;
|
|
},
|
|
);
|
|
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(
|
|
function (this: HTMLElement) {
|
|
return this.getAttribute("aria-label") === "Chat messages"
|
|
? clientHeight
|
|
: 0;
|
|
},
|
|
);
|
|
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("lands at the bottom on a normal entry", () => {
|
|
scrollHeight = 900;
|
|
clientHeight = 300;
|
|
|
|
renderChatArea(root, [createMessage("history-1")], true);
|
|
|
|
expect(getScrollNode(container).scrollTop).toBe(600);
|
|
});
|
|
|
|
it("waits for promotion bootstrap before settling at the final bottom", () => {
|
|
scrollHeight = 800;
|
|
clientHeight = 300;
|
|
const history = [createMessage("history-1")];
|
|
|
|
renderChatArea(root, history, false);
|
|
expect(getScrollNode(container).scrollTop).toBe(0);
|
|
|
|
scrollHeight = 1_200;
|
|
renderChatArea(
|
|
root,
|
|
[...history, createMessage("promotion-1")],
|
|
true,
|
|
);
|
|
|
|
expect(getScrollNode(container).scrollTop).toBe(900);
|
|
});
|
|
|
|
it("keeps the list bottom-anchored when rendered content grows", () => {
|
|
scrollHeight = 1_000;
|
|
clientHeight = 300;
|
|
renderChatArea(root, [createMessage("history-1")], true);
|
|
expect(getScrollNode(container).scrollTop).toBe(700);
|
|
|
|
scrollHeight = 1_400;
|
|
act(() => resizeObservers.forEach((observer) => observer.trigger()));
|
|
|
|
expect(getScrollNode(container).scrollTop).toBe(1_100);
|
|
});
|
|
|
|
it("follows new messages when the user remains near the bottom", () => {
|
|
scrollHeight = 1_000;
|
|
clientHeight = 300;
|
|
const history = [createMessage("history-1")];
|
|
renderChatArea(root, history, true);
|
|
const scrollNode = getScrollNode(container);
|
|
const scrollTo = vi.fn();
|
|
Object.defineProperty(scrollNode, "scrollTo", { value: scrollTo });
|
|
|
|
scrollNode.scrollTop = 650;
|
|
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
|
|
|
|
scrollHeight = 1_400;
|
|
renderChatArea(root, [...history, createMessage("reply-1")], true);
|
|
|
|
expect(scrollNode.scrollTop).toBe(1_100);
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves position after the user intentionally scrolls upward", () => {
|
|
scrollHeight = 1_000;
|
|
clientHeight = 300;
|
|
const history = [createMessage("history-1")];
|
|
renderChatArea(root, history, true);
|
|
const scrollNode = getScrollNode(container);
|
|
|
|
scrollNode.scrollTop = 100;
|
|
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
|
|
|
|
scrollHeight = 1_400;
|
|
act(() => resizeObservers.forEach((observer) => observer.trigger()));
|
|
renderChatArea(root, [...history, createMessage("reply-1")], true);
|
|
|
|
expect(scrollNode.scrollTop).toBe(100);
|
|
});
|
|
|
|
it("forces the latest user message into view after the keyboard resizes", () => {
|
|
scrollHeight = 1_000;
|
|
clientHeight = 300;
|
|
const history = [createMessage("history-1")];
|
|
renderChatArea(root, history, true, { scrollToBottomSignal: 0 });
|
|
const scrollNode = getScrollNode(container);
|
|
|
|
clientHeight = 200;
|
|
scrollNode.scrollTop = 700;
|
|
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
|
|
|
|
scrollHeight = 1_200;
|
|
renderChatArea(
|
|
root,
|
|
[...history, createUserMessage("sent-1")],
|
|
true,
|
|
{ scrollToBottomSignal: 1 },
|
|
);
|
|
|
|
expect(scrollNode.scrollTop).toBe(1_000);
|
|
});
|
|
|
|
it("scrolls below a promotion after the user sends a message", () => {
|
|
scrollHeight = 1_000;
|
|
clientHeight = 300;
|
|
const history = [createMessage("history-1")];
|
|
const promotion = createMessage("promotion-1");
|
|
renderChatArea(root, [...history, promotion], true, {
|
|
scrollToBottomSignal: 0,
|
|
});
|
|
const scrollNode = getScrollNode(container);
|
|
scrollNode.scrollTop = 100;
|
|
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
|
|
|
|
scrollHeight = 1_500;
|
|
renderChatArea(
|
|
root,
|
|
[...history, createUserMessage("sent-1"), promotion],
|
|
true,
|
|
{ scrollToBottomSignal: 1 },
|
|
);
|
|
|
|
expect(scrollNode.scrollTop).toBe(1_200);
|
|
});
|
|
|
|
it("preserves the visible message after older history is prepended", () => {
|
|
scrollHeight = 1_000;
|
|
clientHeight = 300;
|
|
const onLoadMoreHistory = vi.fn();
|
|
const history = [createMessage("history-1")];
|
|
renderChatArea(root, history, true, {
|
|
canLoadMoreHistory: true,
|
|
onLoadMoreHistory,
|
|
});
|
|
const scrollNode = getScrollNode(container);
|
|
scrollNode.scrollTop = 0;
|
|
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
|
|
|
|
pull(scrollNode, 180);
|
|
expect(onLoadMoreHistory).toHaveBeenCalledOnce();
|
|
|
|
scrollHeight = 1_400;
|
|
renderChatArea(
|
|
root,
|
|
[createMessage("older-1"), ...history],
|
|
true,
|
|
{
|
|
canLoadMoreHistory: true,
|
|
onLoadMoreHistory,
|
|
},
|
|
);
|
|
|
|
expect(scrollNode.scrollTop).toBe(400);
|
|
});
|
|
});
|
|
|
|
function renderChatArea(
|
|
root: Root,
|
|
messages: readonly UiMessage[],
|
|
initialScrollReady: boolean,
|
|
historyOptions: {
|
|
canLoadMoreHistory?: boolean;
|
|
isLoadingMoreHistory?: boolean;
|
|
onLoadMoreHistory?: () => void;
|
|
scrollToBottomSignal?: number;
|
|
} = {},
|
|
): void {
|
|
act(() => {
|
|
root.render(
|
|
<ChatArea
|
|
characterId="elio"
|
|
messages={messages}
|
|
isReplyingAI={false}
|
|
initialScrollReady={initialScrollReady}
|
|
{...historyOptions}
|
|
/>,
|
|
);
|
|
});
|
|
}
|
|
|
|
function pull(target: HTMLElement, distance: number): void {
|
|
act(() => {
|
|
target.dispatchEvent(createTouchEvent("touchstart", 0));
|
|
target.dispatchEvent(createTouchEvent("touchmove", distance));
|
|
target.dispatchEvent(createTouchEvent("touchend", distance, true));
|
|
});
|
|
}
|
|
|
|
function createTouchEvent(
|
|
type: string,
|
|
clientY: number,
|
|
ended = false,
|
|
): Event {
|
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
Object.defineProperty(event, "touches", {
|
|
value: ended ? [] : [{ identifier: 1, clientY }],
|
|
});
|
|
return event;
|
|
}
|
|
|
|
function createMessage(id: string): UiMessage {
|
|
return {
|
|
id,
|
|
content: `Message ${id}`,
|
|
isFromAI: true,
|
|
date: "2026-07-15",
|
|
};
|
|
}
|
|
|
|
function createUserMessage(id: string): UiMessage {
|
|
return {
|
|
id,
|
|
content: `Message ${id}`,
|
|
isFromAI: false,
|
|
date: "2026-07-15",
|
|
};
|
|
}
|
|
|
|
function getScrollNode(container: HTMLElement): HTMLElement {
|
|
const node = container.querySelector<HTMLElement>(
|
|
'[aria-label="Chat messages"]',
|
|
);
|
|
if (!node) throw new Error("Chat scroll container was not rendered");
|
|
return node;
|
|
}
|