1199 lines
36 KiB
TypeScript
1199 lines
36 KiB
TypeScript
import { useRef } from "react";
|
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
|
import {
|
|
HISTORY_WINDOW_INCREMENT,
|
|
INITIAL_HISTORY_WINDOW,
|
|
ThreadViewport,
|
|
type ThreadViewportHandle,
|
|
windowMessages,
|
|
} from "@/components/thread/ThreadViewport";
|
|
import type { UIMessage } from "@/lib/types";
|
|
|
|
const messages: UIMessage[] = [
|
|
{
|
|
id: "u1",
|
|
role: "user",
|
|
content: "hello",
|
|
createdAt: Date.now(),
|
|
},
|
|
];
|
|
|
|
const emptyMessages: UIMessage[] = [];
|
|
|
|
interface ResizeObserverInstance {
|
|
element?: Element;
|
|
callback: ResizeObserverCallback;
|
|
disconnect: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function stubVisualViewport({
|
|
height,
|
|
innerHeight,
|
|
offsetTop = 0,
|
|
}: {
|
|
height: number;
|
|
innerHeight: number;
|
|
offsetTop?: number;
|
|
}) {
|
|
const originalInnerHeight = window.innerHeight;
|
|
const originalVisualViewport = window.visualViewport;
|
|
const target = new EventTarget();
|
|
const viewport = {
|
|
width: 390,
|
|
height,
|
|
offsetTop,
|
|
offsetLeft: 0,
|
|
pageTop: offsetTop,
|
|
pageLeft: 0,
|
|
scale: 1,
|
|
addEventListener: target.addEventListener.bind(target),
|
|
removeEventListener: target.removeEventListener.bind(target),
|
|
dispatchEvent: target.dispatchEvent.bind(target),
|
|
} as unknown as VisualViewport;
|
|
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: innerHeight,
|
|
});
|
|
Object.defineProperty(window, "visualViewport", {
|
|
configurable: true,
|
|
value: viewport,
|
|
});
|
|
|
|
return {
|
|
viewport,
|
|
restore: () => {
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: originalInnerHeight,
|
|
});
|
|
if (originalVisualViewport) {
|
|
Object.defineProperty(window, "visualViewport", {
|
|
configurable: true,
|
|
value: originalVisualViewport,
|
|
});
|
|
} else {
|
|
Reflect.deleteProperty(window, "visualViewport");
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function stubResizeObserver() {
|
|
const original = globalThis.ResizeObserver;
|
|
const observers: ResizeObserverInstance[] = [];
|
|
class MockResizeObserver {
|
|
element?: Element;
|
|
callback: ResizeObserverCallback;
|
|
disconnect = vi.fn();
|
|
|
|
constructor(callback: ResizeObserverCallback) {
|
|
this.callback = callback;
|
|
observers.push(this);
|
|
}
|
|
|
|
observe(element: Element) {
|
|
this.element = element;
|
|
}
|
|
}
|
|
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
|
return {
|
|
observers,
|
|
restore: () => vi.stubGlobal("ResizeObserver", original),
|
|
};
|
|
}
|
|
|
|
function makeLongMessages(count: number): UIMessage[] {
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
id: `m${index}`,
|
|
role: "user" as const,
|
|
content: `message ${index}`,
|
|
createdAt: index,
|
|
}));
|
|
}
|
|
|
|
function makePromptExchangeMessages(count: number): UIMessage[] {
|
|
return Array.from({ length: count }, (_, index) => ([
|
|
{
|
|
id: `m${index}`,
|
|
role: "user" as const,
|
|
content: `message ${index}`,
|
|
createdAt: index * 2,
|
|
},
|
|
{
|
|
id: `a${index}`,
|
|
role: "assistant" as const,
|
|
content: `answer ${index}`,
|
|
createdAt: index * 2 + 1,
|
|
},
|
|
])).flat();
|
|
}
|
|
|
|
async function renderPromptRailViewport({
|
|
scrollTo,
|
|
}: {
|
|
scrollTo?: (options?: ScrollToOptions) => void;
|
|
} = {}) {
|
|
const promptMessages = makePromptExchangeMessages(5);
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={promptMessages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1800 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, value: 0 },
|
|
...(scrollTo ? { scrollTo: { configurable: true, value: scrollTo } } : {}),
|
|
});
|
|
|
|
const promptEls = Array.from(
|
|
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
|
);
|
|
promptEls.forEach((el, index) => {
|
|
Object.defineProperty(el, "offsetTop", {
|
|
configurable: true,
|
|
value: index * 360,
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
window.dispatchEvent(new Event("resize"));
|
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
|
});
|
|
|
|
return { promptEls };
|
|
}
|
|
|
|
function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
|
|
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
|
return (
|
|
<div>
|
|
<PromptNavigator
|
|
messages={messages}
|
|
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
|
|
/>
|
|
<ThreadViewport
|
|
ref={viewportRef}
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("ThreadViewport", () => {
|
|
it("top-aligns short threads in the message rendering area", () => {
|
|
render(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
|
|
const messageRegion = screen.getByTestId("thread-message-region");
|
|
expect(messageRegion).toHaveClass("justify-start");
|
|
expect(messageRegion).not.toHaveClass("justify-end");
|
|
expect(messageRegion).toHaveClass("pb-4");
|
|
expect(messageRegion.className).not.toContain("5rem");
|
|
});
|
|
|
|
it("top-aligns a short active turn while the agent is responding", () => {
|
|
render(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
|
|
const messageRegion = screen.getByTestId("thread-message-region");
|
|
expect(messageRegion).toHaveClass("justify-start");
|
|
expect(messageRegion).not.toHaveClass("justify-end");
|
|
expect(screen.getByTestId("thread-composer-dock")).not.toHaveClass("mt-auto");
|
|
});
|
|
|
|
it("anchors the latest user prompt after sending instead of scrolling to the bottom", async () => {
|
|
const threaded: UIMessage[] = [
|
|
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
|
{ id: "a1", role: "assistant", content: "old answer", createdAt: 2 },
|
|
{ id: "u2", role: "user", content: "new question", createdAt: 3 },
|
|
];
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={0}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1200 },
|
|
clientHeight: { configurable: true, value: 500 },
|
|
scrollTop: { configurable: true, writable: true, value: 700 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
|
expect(prompt).not.toBeNull();
|
|
Object.defineProperty(prompt, "offsetTop", {
|
|
configurable: true,
|
|
value: 420,
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={1}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 404,
|
|
behavior: "auto",
|
|
});
|
|
expect(screen.getByTestId("thread-message-region")).toHaveClass("justify-start");
|
|
});
|
|
|
|
it("keeps following active agent output after anchoring the sent prompt", async () => {
|
|
const threaded: UIMessage[] = [
|
|
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
|
{ id: "a1", role: "assistant", content: "old answer", createdAt: 2 },
|
|
{ id: "u2", role: "user", content: "new question", createdAt: 3 },
|
|
];
|
|
const answer: UIMessage = {
|
|
id: "a2",
|
|
role: "assistant",
|
|
content: "streaming answer",
|
|
createdAt: 4,
|
|
};
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={0}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1200 },
|
|
clientHeight: { configurable: true, value: 500 },
|
|
scrollTop: { configurable: true, writable: true, value: 700 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
|
expect(prompt).not.toBeNull();
|
|
Object.defineProperty(prompt, "offsetTop", {
|
|
configurable: true,
|
|
value: 420,
|
|
});
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={1}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 404,
|
|
behavior: "auto",
|
|
});
|
|
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 1800,
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={[...threaded, answer]}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={1}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 1300,
|
|
behavior: "auto",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not follow active agent output after the user manually scrolls away", async () => {
|
|
const threaded: UIMessage[] = [
|
|
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
|
{ id: "a1", role: "assistant", content: "old answer", createdAt: 2 },
|
|
{ id: "u2", role: "user", content: "new question", createdAt: 3 },
|
|
];
|
|
const answer: UIMessage = {
|
|
id: "a2",
|
|
role: "assistant",
|
|
content: "streaming answer",
|
|
createdAt: 4,
|
|
};
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={0}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1200 },
|
|
clientHeight: { configurable: true, value: 500 },
|
|
scrollTop: { configurable: true, writable: true, value: 700 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
|
expect(prompt).not.toBeNull();
|
|
Object.defineProperty(prompt, "offsetTop", {
|
|
configurable: true,
|
|
value: 420,
|
|
});
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={1}
|
|
/>,
|
|
);
|
|
});
|
|
await act(async () => {
|
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
|
});
|
|
|
|
scroller.scrollTop = 100;
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 1800,
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={[...threaded, answer]}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={1}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
|
const resizeObserver = stubResizeObserver();
|
|
|
|
try {
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 2400 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, value: 0 },
|
|
});
|
|
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
const button = screen.getByRole("button", { name: "Scroll to bottom" });
|
|
const buttonPositioner = button.parentElement as HTMLElement;
|
|
expect(button).not.toHaveClass("-translate-x-1/2");
|
|
expect(buttonPositioner).toHaveStyle({ bottom: "192px" });
|
|
|
|
const composerDock = screen.getByTestId("thread-composer-dock");
|
|
composerDock.getBoundingClientRect = () =>
|
|
({
|
|
height: 240,
|
|
width: 800,
|
|
top: 0,
|
|
right: 800,
|
|
bottom: 240,
|
|
left: 0,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
}) as DOMRect;
|
|
|
|
const composerObserver = resizeObserver.observers.find(
|
|
(observer) => observer.element === composerDock,
|
|
);
|
|
expect(composerObserver).toBeDefined();
|
|
|
|
act(() => {
|
|
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
|
});
|
|
|
|
expect(buttonPositioner).toHaveStyle({ bottom: "256px" });
|
|
} finally {
|
|
resizeObserver.restore();
|
|
}
|
|
});
|
|
|
|
it("keeps the active prompt visible when the composer grows", async () => {
|
|
const resizeObserver = stubResizeObserver();
|
|
|
|
try {
|
|
const threaded: UIMessage[] = [
|
|
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
|
{ id: "a1", role: "assistant", content: "old answer", createdAt: 2 },
|
|
{ id: "u2", role: "user", content: "new question", createdAt: 3 },
|
|
];
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={0}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1200 },
|
|
clientHeight: { configurable: true, value: 500 },
|
|
scrollTop: { configurable: true, writable: true, value: 700 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
|
expect(prompt).not.toBeNull();
|
|
Object.defineProperty(prompt, "offsetTop", {
|
|
configurable: true,
|
|
value: 420,
|
|
});
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
scrollToLatestUserPromptSignal={1}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
scrollTo.mockClear();
|
|
const composerDock = screen.getByTestId("thread-composer-dock");
|
|
composerDock.getBoundingClientRect = () =>
|
|
({
|
|
height: 240,
|
|
width: 800,
|
|
top: 0,
|
|
right: 800,
|
|
bottom: 240,
|
|
left: 0,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
}) as DOMRect;
|
|
const composerObserver = resizeObserver.observers.find(
|
|
(observer) => observer.element === composerDock,
|
|
);
|
|
expect(composerObserver).toBeDefined();
|
|
|
|
act(() => {
|
|
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 404,
|
|
behavior: "auto",
|
|
}),
|
|
);
|
|
} finally {
|
|
resizeObserver.restore();
|
|
}
|
|
});
|
|
|
|
it("keeps the thread scrollport above a mobile soft keyboard", async () => {
|
|
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
|
|
try {
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<textarea aria-label="Message input" />}
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 2400 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, value: 0 },
|
|
});
|
|
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
act(() => {
|
|
input.focus();
|
|
fireEvent.focusIn(input);
|
|
});
|
|
|
|
await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" }));
|
|
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
|
|
|
|
act(() => {
|
|
visualViewport.viewport.dispatchEvent(new Event("resize"));
|
|
});
|
|
expect(scroller).toHaveStyle({ bottom: "320px" });
|
|
} finally {
|
|
visualViewport.restore();
|
|
}
|
|
});
|
|
|
|
it("keeps the welcome composer above a mobile soft keyboard", async () => {
|
|
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
|
|
try {
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={emptyMessages}
|
|
isStreaming={false}
|
|
composer={<textarea aria-label="Message input" />}
|
|
emptyState={<div>welcome</div>}
|
|
showScrollToBottomButton={false}
|
|
/>,
|
|
);
|
|
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
|
|
const input = screen.getByLabelText("Message input");
|
|
|
|
act(() => {
|
|
input.focus();
|
|
fireEvent.focusIn(input);
|
|
});
|
|
|
|
await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" }));
|
|
} finally {
|
|
visualViewport.restore();
|
|
}
|
|
});
|
|
|
|
it("separates the mobile welcome copy and composer into responsive rows", () => {
|
|
render(
|
|
<ThreadViewport
|
|
messages={emptyMessages}
|
|
isStreaming={false}
|
|
composer={<div>composer</div>}
|
|
emptyState={<div>welcome</div>}
|
|
/>,
|
|
);
|
|
|
|
const layout = screen.getByTestId("thread-welcome-layout");
|
|
expect(layout).toHaveClass(
|
|
"grid",
|
|
"gap-8",
|
|
"grid-rows-[minmax(min-content,1fr)_auto]",
|
|
);
|
|
expect(screen.getByText("welcome").parentElement).toHaveClass("min-h-0");
|
|
expect(screen.getByText("welcome").parentElement?.className).toContain(
|
|
"sm:bottom-[calc(100%+2rem)]",
|
|
);
|
|
});
|
|
|
|
it("allows the welcome view to scroll when a short viewport overflows", async () => {
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={emptyMessages}
|
|
isStreaming={false}
|
|
composer={<div>composer</div>}
|
|
emptyState={<div>welcome</div>}
|
|
showScrollToBottomButton={false}
|
|
/>,
|
|
);
|
|
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 620 },
|
|
clientHeight: { configurable: true, value: 320 },
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event("resize"));
|
|
});
|
|
|
|
await waitFor(() => expect(scroller).toHaveClass("overflow-y-auto"));
|
|
});
|
|
|
|
it("scrolls recent messages into view when the composer receives focus", async () => {
|
|
const scrollTo = vi.fn();
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<textarea aria-label="Message input" />}
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 2400 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
act(() => {
|
|
input.focus();
|
|
fireEvent.focusIn(input);
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 1800,
|
|
behavior: "auto",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("scrolls recent messages into view when the focused composer resizes the visual viewport without an inset", async () => {
|
|
const visualViewport = stubVisualViewport({ innerHeight: 500, height: 500 });
|
|
const scrollTo = vi.fn();
|
|
|
|
try {
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<textarea aria-label="Message input" />}
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 2400 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
Object.defineProperty(document, "activeElement", {
|
|
configurable: true,
|
|
get: () => input,
|
|
});
|
|
|
|
act(() => {
|
|
visualViewport.viewport.dispatchEvent(new Event("resize"));
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 1800,
|
|
behavior: "auto",
|
|
}),
|
|
);
|
|
expect(scroller).not.toHaveStyle({ bottom: "320px" });
|
|
} finally {
|
|
Reflect.deleteProperty(document, "activeElement");
|
|
visualViewport.restore();
|
|
}
|
|
});
|
|
|
|
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={emptyMessages}
|
|
isStreaming={false}
|
|
composer={<div>composer</div>}
|
|
emptyState={<div>welcome</div>}
|
|
showScrollToBottomButton={false}
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 2400 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
});
|
|
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders only the tail window for long history by default", () => {
|
|
const longMessages = makeLongMessages(300);
|
|
|
|
render(
|
|
<ThreadViewport
|
|
messages={longMessages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
|
|
expect(screen.getByText("message 140")).toBeInTheDocument();
|
|
expect(screen.getByText("message 299")).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Load earlier messages" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("automatically expands earlier local history near the top", () => {
|
|
const longMessages = makeLongMessages(300);
|
|
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={longMessages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 2400 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
});
|
|
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
const firstVisible =
|
|
300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT;
|
|
|
|
expect(
|
|
screen.queryByText(`message ${firstVisible - 1}`),
|
|
).not.toBeInTheDocument();
|
|
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
|
|
expect(screen.getAllByText("message 299").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("automatically requests older transcript pages near the top", () => {
|
|
const onLoadOlder = vi.fn();
|
|
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={makeLongMessages(20)}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
hasMoreBefore
|
|
onLoadOlder={onLoadOlder}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1800 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
});
|
|
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
expect(onLoadOlder).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renders a prompt rail that jumps to user messages", async () => {
|
|
const scrollTo = vi.fn();
|
|
const { promptEls } = await renderPromptRailViewport({ scrollTo });
|
|
|
|
expect(promptEls).toHaveLength(5);
|
|
|
|
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
|
|
const promptMarkers = screen.getAllByRole("button", { name: /Jump to prompt:/ });
|
|
const markerTops = promptMarkers.map((marker) => Number.parseFloat(marker.style.top));
|
|
expect(markerTops[2]).toBeCloseTo(50);
|
|
expect(markerTops[1] - markerTops[0]).toBeCloseTo(16 / 3);
|
|
expect(markerTops[4] - markerTops[0]).toBeCloseTo(64 / 3);
|
|
|
|
const railMarkers = screen.getAllByTestId("prompt-rail-marker");
|
|
expect(railMarkers).toHaveLength(promptMarkers.length);
|
|
expect(railMarkers.every((marker) => marker.style.width === "9px")).toBe(true);
|
|
|
|
fireEvent.pointerEnter(promptMarkers[2]);
|
|
expect(railMarkers.map((marker) => marker.style.width)).toEqual([
|
|
"16px",
|
|
"22px",
|
|
"28px",
|
|
"22px",
|
|
"16px",
|
|
]);
|
|
|
|
fireEvent.pointerLeave(promptMarkers[2]);
|
|
expect(railMarkers.every((marker) => marker.style.width === "9px")).toBe(true);
|
|
|
|
const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" });
|
|
expect(within(targetPrompt).getByText("message 3")).toBeInTheDocument();
|
|
expect(within(targetPrompt).getByText("answer 3")).toBeInTheDocument();
|
|
|
|
fireEvent.click(targetPrompt);
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 1064,
|
|
behavior: "smooth",
|
|
});
|
|
});
|
|
|
|
it("opens a prompt navigator list and jumps to a selected prompt", async () => {
|
|
const promptMessages = makeLongMessages(5);
|
|
const { container } = render(<ViewportWithPromptNavigator messages={promptMessages} />);
|
|
|
|
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
|
|
const scrollTo = vi.fn();
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1800 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, value: 0 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
|
|
const promptEls = Array.from(
|
|
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
|
);
|
|
promptEls.forEach((el, index) => {
|
|
Object.defineProperty(el, "offsetTop", {
|
|
configurable: true,
|
|
value: index * 360,
|
|
});
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
|
|
const dialog = screen.getByRole("dialog");
|
|
expect(within(dialog).getByText("Prompts")).toBeInTheDocument();
|
|
expect(within(dialog).getByText("message 4")).toBeInTheDocument();
|
|
|
|
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search prompts" }), {
|
|
target: { value: "message 4" },
|
|
});
|
|
expect(within(dialog).queryByText("message 1")).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 4" }));
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 1424,
|
|
behavior: "smooth",
|
|
});
|
|
});
|
|
|
|
it("expands the history window before jumping to an older prompt from the navigator", async () => {
|
|
const longMessages = makeLongMessages(300);
|
|
render(<ViewportWithPromptNavigator messages={longMessages} />);
|
|
|
|
expect(screen.queryByText("message 20")).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
|
|
const dialog = screen.getByRole("dialog");
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 20" }));
|
|
|
|
await waitFor(() => expect(screen.getByText("message 20")).toBeInTheDocument());
|
|
});
|
|
|
|
it("renders the prompt rail for compact scroll ranges", async () => {
|
|
const promptMessages = makeLongMessages(3);
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={promptMessages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 700 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, value: 0 },
|
|
});
|
|
|
|
const promptEls = Array.from(
|
|
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
|
);
|
|
expect(promptEls).toHaveLength(3);
|
|
promptEls.forEach((el, index) => {
|
|
Object.defineProperty(el, "offsetTop", {
|
|
configurable: true,
|
|
value: index * 50,
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
window.dispatchEvent(new Event("resize"));
|
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
|
});
|
|
|
|
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
|
|
});
|
|
|
|
it("buckets dense prompt rails without rendering every prompt as a marker", async () => {
|
|
const promptMessages = makeLongMessages(100);
|
|
const { container } = render(
|
|
<ThreadViewport
|
|
messages={promptMessages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
const scrollTo = vi.fn();
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 10000 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, value: 0 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
|
|
const promptEls = Array.from(
|
|
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
|
);
|
|
expect(promptEls).toHaveLength(100);
|
|
promptEls.forEach((el, index) => {
|
|
Object.defineProperty(el, "offsetTop", {
|
|
configurable: true,
|
|
value: index * 90,
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
window.dispatchEvent(new Event("resize"));
|
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
|
});
|
|
|
|
const promptMarkers = screen.getAllByRole("button", { name: /Jump to prompt:/ });
|
|
expect(promptMarkers.length).toBeGreaterThan(3);
|
|
expect(promptMarkers.length).toBeLessThan(100);
|
|
expect(
|
|
promptMarkers.some((marker) =>
|
|
marker.getAttribute("aria-label")?.includes("prompts, latest"),
|
|
),
|
|
).toBe(true);
|
|
|
|
fireEvent.click(promptMarkers[promptMarkers.length - 1]);
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 8894,
|
|
behavior: "smooth",
|
|
});
|
|
});
|
|
|
|
it("expands the window start to avoid cutting an agent activity cluster", () => {
|
|
const clustered = makeLongMessages(200);
|
|
clustered.splice(
|
|
38,
|
|
3,
|
|
{
|
|
id: "r0",
|
|
role: "assistant",
|
|
content: "",
|
|
reasoning: "first reasoning",
|
|
createdAt: 38,
|
|
},
|
|
{
|
|
id: "t0",
|
|
role: "tool",
|
|
kind: "trace",
|
|
content: "tool()",
|
|
traces: ["tool()"],
|
|
createdAt: 39,
|
|
},
|
|
{
|
|
id: "r1",
|
|
role: "assistant",
|
|
content: "",
|
|
reasoning: "second reasoning",
|
|
createdAt: 40,
|
|
},
|
|
);
|
|
|
|
const visible = windowMessages(clustered, INITIAL_HISTORY_WINDOW);
|
|
|
|
expect(visible[0].id).toBe("r0");
|
|
expect(visible).toHaveLength(INITIAL_HISTORY_WINDOW + 2);
|
|
});
|
|
|
|
it("resets to the bottom when opening a different conversation", async () => {
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
conversationKey="chat-a"
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 2400 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
conversationKey="chat-b"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 1800,
|
|
behavior: "auto",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={emptyMessages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
conversationKey={null}
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 0 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={emptyMessages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
conversationKey="chat-a"
|
|
/>,
|
|
);
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 0,
|
|
behavior: "auto",
|
|
});
|
|
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 2400,
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
conversationKey="chat-a"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 1800,
|
|
behavior: "auto",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("scrolls to the bottom when explicitly signalled after send", async () => {
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
scrollToBottomSignal={0}
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 2400 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
scrollTo: { configurable: true, value: scrollTo },
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
scrollToBottomSignal={1}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
top: 1800,
|
|
behavior: "auto",
|
|
}),
|
|
);
|
|
});
|
|
});
|