1645 lines
51 KiB
TypeScript
1645 lines
51 KiB
TypeScript
import { createRef, useRef } from "react";
|
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, 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 { ThreadCameraController } from "@/components/thread/thread-camera";
|
|
import type { UIMessage } from "@/lib/types";
|
|
|
|
const messages: UIMessage[] = [
|
|
{
|
|
id: "u1",
|
|
role: "user",
|
|
content: "hello",
|
|
createdAt: Date.now(),
|
|
},
|
|
];
|
|
|
|
const emptyMessages: UIMessage[] = [];
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function flushAnimationFrame(): Promise<void> {
|
|
await act(async () => {
|
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
|
});
|
|
}
|
|
|
|
function dispatchUserScroll(scroller: HTMLElement): void {
|
|
fireEvent.pointerDown(scroller, { button: 0 });
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
}
|
|
|
|
interface ResizeObserverInstance {
|
|
elements: 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 {
|
|
elements: Element[] = [];
|
|
callback: ResizeObserverCallback;
|
|
disconnect = vi.fn();
|
|
|
|
constructor(callback: ResizeObserverCallback) {
|
|
this.callback = callback;
|
|
observers.push(this);
|
|
}
|
|
|
|
observe(element: Element) {
|
|
this.elements.push(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, writable: 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, scroller };
|
|
}
|
|
|
|
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("allows long messages to shrink within the shared mobile grid column", () => {
|
|
render(
|
|
<ThreadViewport
|
|
messages={[
|
|
...messages,
|
|
{
|
|
id: "a-long-link",
|
|
role: "assistant",
|
|
content:
|
|
"https://github.com/HKUDS/nanobot/discussions/17788077"
|
|
+ "/a-very-long-unbroken-segment-that-must-not-widen-the-thread",
|
|
createdAt: Date.now(),
|
|
},
|
|
]}
|
|
isStreaming={false}
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId("thread-message-region")).toHaveClass("min-w-0");
|
|
});
|
|
|
|
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("pins a waiting prompt to the exact lower scroll boundary", async () => {
|
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
|
const threaded: UIMessage[] = [
|
|
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
|
|
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
|
|
{ id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 },
|
|
];
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
|
|
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();
|
|
jumpTo.mockClear();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
activeTurnId="turn-2"
|
|
activeTurnStartedHere
|
|
/>,
|
|
);
|
|
});
|
|
await flushAnimationFrame();
|
|
|
|
expect(jumpTo).toHaveBeenCalledWith(700);
|
|
expect(screen.getByTestId("thread-message-region")).toHaveClass("justify-start");
|
|
});
|
|
|
|
it("keeps automatic ownership when layout emits a scroll before its measurement frame", async () => {
|
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
|
const threaded: UIMessage[] = [
|
|
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
|
|
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
|
|
{ id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 },
|
|
];
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1_200 },
|
|
clientHeight: { configurable: true, value: 500 },
|
|
scrollTop: { configurable: true, writable: true, value: 700 },
|
|
});
|
|
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
|
expect(prompt).not.toBeNull();
|
|
Object.defineProperty(prompt, "offsetTop", {
|
|
configurable: true,
|
|
value: 420,
|
|
});
|
|
jumpTo.mockClear();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
activeTurnId="turn-2"
|
|
activeTurnStartedHere
|
|
/>,
|
|
);
|
|
});
|
|
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 1_280,
|
|
});
|
|
act(() => {
|
|
scroller.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
expect(screen.queryByRole("button", { name: "Scroll to bottom" }))
|
|
.not.toBeInTheDocument();
|
|
await flushAnimationFrame();
|
|
expect(jumpTo).toHaveBeenCalledWith(780);
|
|
expect(scroller.scrollTop).toBe(780);
|
|
});
|
|
|
|
it("lets the first prompt supersede a pending empty-conversation camera command", async () => {
|
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={emptyMessages}
|
|
isStreaming={false}
|
|
composer={<div>composer</div>}
|
|
conversationKey={null}
|
|
/>,
|
|
);
|
|
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 },
|
|
});
|
|
scrollTo.mockClear();
|
|
jumpTo.mockClear();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={[
|
|
{
|
|
id: "u-first",
|
|
role: "user",
|
|
content: "first question",
|
|
turnId: "turn-first",
|
|
createdAt: 1,
|
|
},
|
|
]}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
conversationKey="chat-a"
|
|
activeTurnId="turn-first"
|
|
activeTurnStartedHere
|
|
/>,
|
|
);
|
|
});
|
|
await flushAnimationFrame();
|
|
|
|
expect(jumpTo).toHaveBeenCalledTimes(1);
|
|
expect(jumpTo).toHaveBeenCalledWith(700);
|
|
});
|
|
|
|
it("anchors the latest user prompt even when assistant output is already present", async () => {
|
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
|
const threaded: UIMessage[] = [
|
|
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
|
|
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
|
|
{ id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 },
|
|
{ id: "a2", role: "assistant", content: "fast answer", turnId: "turn-2", createdAt: 4 },
|
|
];
|
|
const viewportRef = createRef<ThreadViewportHandle>();
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
ref={viewportRef}
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1904 },
|
|
clientHeight: { configurable: true, value: 500 },
|
|
scrollTop: { configurable: true, writable: true, value: 1404 },
|
|
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: 1420,
|
|
});
|
|
viewportRef.current?.cancelAutoScroll();
|
|
scrollTo.mockClear();
|
|
jumpTo.mockClear();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
ref={viewportRef}
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
activeTurnId="turn-2"
|
|
activeTurnStartedHere
|
|
/>,
|
|
);
|
|
});
|
|
await flushAnimationFrame();
|
|
|
|
expect(jumpTo).toHaveBeenCalledWith(1404);
|
|
});
|
|
|
|
it("drives the camera from a message commit when canonical replay replaces the prompt DOM id", async () => {
|
|
const resizeObserver = stubResizeObserver();
|
|
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo")
|
|
.mockReturnValue("started");
|
|
|
|
try {
|
|
const optimistic: UIMessage[] = [
|
|
{
|
|
id: "optimistic-user",
|
|
role: "user",
|
|
content: "new question",
|
|
turnId: "turn-stable",
|
|
createdAt: 1,
|
|
},
|
|
{
|
|
id: "optimistic-answer",
|
|
role: "assistant",
|
|
content: "answer",
|
|
turnId: "turn-stable",
|
|
isStreaming: true,
|
|
createdAt: 2,
|
|
},
|
|
];
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={optimistic}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 2_000 },
|
|
clientHeight: { configurable: true, value: 500 },
|
|
scrollTop: { configurable: true, writable: true, value: 1_500 },
|
|
scrollTo: {
|
|
configurable: true,
|
|
value: ({ top }: ScrollToOptions) => {
|
|
if (typeof top === "number") scroller.scrollTop = top;
|
|
},
|
|
},
|
|
});
|
|
const optimisticPrompt = container.querySelector<HTMLElement>(
|
|
'[data-user-prompt-id="optimistic-user"]',
|
|
);
|
|
expect(optimisticPrompt).not.toBeNull();
|
|
Object.defineProperty(optimisticPrompt, "offsetTop", {
|
|
configurable: true,
|
|
value: 1_420,
|
|
});
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={optimistic}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
activeTurnId="turn-stable"
|
|
/>,
|
|
);
|
|
});
|
|
followTo.mockClear();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={[
|
|
{
|
|
...optimistic[0],
|
|
id: "canonical-user",
|
|
},
|
|
{
|
|
...optimistic[1],
|
|
id: "canonical-answer",
|
|
content: "canonical answer",
|
|
},
|
|
]}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
activeTurnId="turn-stable"
|
|
/>,
|
|
);
|
|
});
|
|
await flushAnimationFrame();
|
|
// Streaming React commits only invalidate geometry. The next display
|
|
// frame measures once and issues the follow command.
|
|
expect(followTo).toHaveBeenCalledWith(1_500);
|
|
|
|
followTo.mockClear();
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 2_120,
|
|
});
|
|
const messageContent = screen.getByTestId("thread-message-region").firstElementChild;
|
|
const contentObserver = resizeObserver.observers.find(
|
|
(observer) => observer.elements.includes(messageContent!),
|
|
);
|
|
expect(contentObserver).toBeDefined();
|
|
|
|
act(() => {
|
|
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
|
});
|
|
await flushAnimationFrame();
|
|
|
|
// ResizeObserver remains the asynchronous reflow fallback.
|
|
expect(followTo).toHaveBeenCalledWith(1_620);
|
|
} finally {
|
|
followTo.mockRestore();
|
|
resizeObserver.restore();
|
|
}
|
|
});
|
|
|
|
it("coalesces streamed layout growth into frame-driven camera targets", async () => {
|
|
const resizeObserver = stubResizeObserver();
|
|
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo")
|
|
.mockReturnValue("started");
|
|
|
|
try {
|
|
const threaded: UIMessage[] = [
|
|
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
|
|
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
|
|
{ id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 },
|
|
];
|
|
const answer: UIMessage = {
|
|
id: "a2",
|
|
role: "assistant",
|
|
content: "streaming answer",
|
|
turnId: "turn-2",
|
|
isStreaming: true,
|
|
createdAt: 4,
|
|
};
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
|
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
|
Object.defineProperties(scroller, {
|
|
scrollHeight: { configurable: true, value: 1904 },
|
|
clientHeight: { configurable: true, value: 500 },
|
|
scrollTop: { configurable: true, writable: true, value: 1404 },
|
|
});
|
|
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
|
expect(prompt).not.toBeNull();
|
|
Object.defineProperty(prompt, "offsetTop", {
|
|
configurable: true,
|
|
value: 1420,
|
|
});
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
activeTurnId="turn-2"
|
|
activeTurnStartedHere
|
|
/>,
|
|
);
|
|
});
|
|
await flushAnimationFrame();
|
|
followTo.mockClear();
|
|
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 1932,
|
|
});
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={[...threaded, answer]}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
activeTurnId="turn-2"
|
|
/>,
|
|
);
|
|
});
|
|
await flushAnimationFrame();
|
|
expect(followTo).toHaveBeenCalledTimes(1);
|
|
expect(followTo).toHaveBeenLastCalledWith(1432);
|
|
followTo.mockClear();
|
|
|
|
const messageRegion = screen.getByTestId("thread-message-region");
|
|
const messageContent = messageRegion.firstElementChild;
|
|
expect(messageContent).not.toBeNull();
|
|
const contentObserver = resizeObserver.observers.find(
|
|
(observer) => observer.elements.includes(messageContent!),
|
|
);
|
|
expect(contentObserver).toBeDefined();
|
|
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 1948,
|
|
});
|
|
act(() => {
|
|
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
|
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
|
});
|
|
expect(followTo).not.toHaveBeenCalled();
|
|
await flushAnimationFrame();
|
|
expect(followTo).toHaveBeenCalledTimes(1);
|
|
expect(followTo).toHaveBeenLastCalledWith(1448);
|
|
followTo.mockClear();
|
|
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 2548,
|
|
});
|
|
await act(async () => {
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={[
|
|
...threaded,
|
|
{
|
|
...answer,
|
|
content: "final answer received as one large batch",
|
|
isStreaming: false,
|
|
},
|
|
]}
|
|
isStreaming={false}
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
});
|
|
await flushAnimationFrame();
|
|
expect(followTo).toHaveBeenLastCalledWith(2048);
|
|
followTo.mockClear();
|
|
|
|
scroller.scrollTop = 100;
|
|
act(() => {
|
|
dispatchUserScroll(scroller);
|
|
});
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 2560,
|
|
});
|
|
act(() => {
|
|
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
|
|
});
|
|
await flushAnimationFrame();
|
|
expect(followTo).not.toHaveBeenCalled();
|
|
expect(scroller.scrollTop).toBe(100);
|
|
} finally {
|
|
followTo.mockRestore();
|
|
resizeObserver.restore();
|
|
}
|
|
});
|
|
|
|
it("keeps the scroll-to-bottom button above a growing composer", async () => {
|
|
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(() => {
|
|
dispatchUserScroll(scroller);
|
|
});
|
|
|
|
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.elements.includes(composerDock),
|
|
);
|
|
expect(composerObserver).toBeDefined();
|
|
|
|
act(() => {
|
|
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
|
});
|
|
await flushAnimationFrame();
|
|
|
|
expect(buttonPositioner).toHaveStyle({ bottom: "256px" });
|
|
} finally {
|
|
resizeObserver.restore();
|
|
}
|
|
});
|
|
|
|
it("pins the waiting boundary across composer and grid-track growth", async () => {
|
|
const resizeObserver = stubResizeObserver();
|
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
|
|
|
try {
|
|
const threaded: UIMessage[] = [
|
|
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
|
|
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
|
|
{ id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 },
|
|
];
|
|
const scrollTo = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={threaded}
|
|
isStreaming
|
|
composer={<div>composer</div>}
|
|
/>,
|
|
);
|
|
|
|
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>}
|
|
activeTurnId="turn-2"
|
|
/>,
|
|
);
|
|
});
|
|
await flushAnimationFrame();
|
|
|
|
scrollTo.mockClear();
|
|
jumpTo.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.elements.includes(composerDock),
|
|
);
|
|
expect(composerObserver).toBeDefined();
|
|
expect(composerObserver!.elements).toContain(
|
|
screen.getByTestId("thread-message-region"),
|
|
);
|
|
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 1228,
|
|
});
|
|
act(() => {
|
|
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
|
});
|
|
await flushAnimationFrame();
|
|
|
|
expect(jumpTo).toHaveBeenCalledWith(728);
|
|
|
|
jumpTo.mockClear();
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 1268,
|
|
});
|
|
act(() => {
|
|
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
|
});
|
|
await flushAnimationFrame();
|
|
|
|
expect(jumpTo).toHaveBeenCalledWith(768);
|
|
} 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(() => {
|
|
dispatchUserScroll(scroller);
|
|
});
|
|
|
|
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("keeps one composer instance while the landing layout docks into a thread", () => {
|
|
const { rerender } = render(
|
|
<ThreadViewport
|
|
messages={emptyMessages}
|
|
isStreaming={false}
|
|
composer={(
|
|
<input
|
|
data-testid="persistent-composer"
|
|
defaultValue="draft"
|
|
/>
|
|
)}
|
|
emptyState={<div>welcome</div>}
|
|
/>,
|
|
);
|
|
|
|
const initialComposer = screen.getByTestId("persistent-composer");
|
|
fireEvent.change(initialComposer, { target: { value: "kept draft" } });
|
|
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming
|
|
composer={(
|
|
<input
|
|
data-testid="persistent-composer"
|
|
defaultValue="draft"
|
|
/>
|
|
)}
|
|
emptyState={<div>welcome</div>}
|
|
/>,
|
|
);
|
|
|
|
const dockedComposer = screen.getByTestId("persistent-composer");
|
|
expect(dockedComposer).toBe(initialComposer);
|
|
expect(dockedComposer).toHaveValue("kept draft");
|
|
expect(dockedComposer.closest(".thread-layout")).toHaveAttribute(
|
|
"data-layout",
|
|
"thread",
|
|
);
|
|
});
|
|
|
|
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("thread-layout", "grid");
|
|
expect(layout).toHaveAttribute("data-layout", "hero");
|
|
expect(screen.getByText("welcome").parentElement).toHaveClass(
|
|
"min-h-0",
|
|
"items-center",
|
|
"sm:items-end",
|
|
"sm:pb-8",
|
|
);
|
|
expect(screen.getByTestId("thread-composer-motion")).toContainElement(
|
|
screen.getByText("composer"),
|
|
);
|
|
});
|
|
|
|
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"));
|
|
expect(scroller).toHaveClass(
|
|
"[scrollbar-width:none]",
|
|
"[&::-webkit-scrollbar]:hidden",
|
|
);
|
|
});
|
|
|
|
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(() => {
|
|
dispatchUserScroll(scroller);
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
act(() => {
|
|
input.focus();
|
|
fireEvent.focusIn(input);
|
|
});
|
|
|
|
await waitFor(() => expect(scroller.scrollTop).toBe(1800));
|
|
});
|
|
|
|
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(scroller.scrollTop).toBe(1800));
|
|
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(() => {
|
|
dispatchUserScroll(scroller);
|
|
});
|
|
|
|
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(() => {
|
|
dispatchUserScroll(scroller);
|
|
});
|
|
|
|
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(() => {
|
|
dispatchUserScroll(scroller);
|
|
});
|
|
|
|
expect(onLoadOlder).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renders a prompt rail that jumps to user messages", async () => {
|
|
const navigateTo = vi.spyOn(ThreadCameraController.prototype, "navigateTo")
|
|
.mockReturnValue("started");
|
|
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(navigateTo).toHaveBeenCalledWith(1064);
|
|
});
|
|
|
|
it("lets direct paging input interrupt prompt rail navigation", async () => {
|
|
vi.spyOn(ThreadCameraController.prototype, "navigateTo")
|
|
.mockReturnValue("started");
|
|
const cancel = vi.spyOn(ThreadCameraController.prototype, "cancel");
|
|
const { scroller } = await renderPromptRailViewport();
|
|
const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" });
|
|
const restartNavigation = () => {
|
|
fireEvent.click(targetPrompt);
|
|
cancel.mockClear();
|
|
};
|
|
|
|
scroller.scrollTop = 500;
|
|
restartNavigation();
|
|
fireEvent.wheel(scroller, { deltaY: 120 });
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
|
|
restartNavigation();
|
|
fireEvent.wheel(scroller, { deltaY: -120 });
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
|
|
restartNavigation();
|
|
fireEvent.keyDown(scroller, { key: "PageDown" });
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
|
|
restartNavigation();
|
|
fireEvent.touchStart(scroller, { touches: [{ clientY: 300 }] });
|
|
fireEvent.touchMove(scroller, { touches: [{ clientY: 200 }] });
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
|
|
restartNavigation();
|
|
scroller.scrollTop = 1200;
|
|
fireEvent.wheel(scroller, { deltaY: 120 });
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
|
|
cancel.mockClear();
|
|
fireEvent.wheel(scroller, { deltaY: 120 });
|
|
expect(cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens a prompt navigator list and jumps to a selected prompt", async () => {
|
|
const navigateTo = vi.spyOn(ThreadCameraController.prototype, "navigateTo")
|
|
.mockReturnValue("started");
|
|
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(navigateTo).toHaveBeenCalledWith(1200);
|
|
});
|
|
|
|
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 navigateTo = vi.spyOn(ThreadCameraController.prototype, "navigateTo")
|
|
.mockReturnValue("started");
|
|
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(navigateTo).toHaveBeenCalledWith(8894);
|
|
});
|
|
|
|
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(() => {
|
|
dispatchUserScroll(scroller);
|
|
});
|
|
scrollTo.mockClear();
|
|
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
conversationKey="chat-b"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(scroller.scrollTop).toBe(1800));
|
|
});
|
|
|
|
it("waits for the next conversation's transcript before restoring its bottom", async () => {
|
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
|
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
|
|
const oldMessages: UIMessage[] = [
|
|
{
|
|
id: "old-user",
|
|
role: "user",
|
|
content: "old question",
|
|
turnId: "old-turn",
|
|
createdAt: 1,
|
|
},
|
|
];
|
|
const nextMessages: UIMessage[] = [
|
|
{
|
|
id: "next-user",
|
|
role: "user",
|
|
content: "next question",
|
|
turnId: "next-turn",
|
|
createdAt: 2,
|
|
},
|
|
{
|
|
id: "next-answer",
|
|
role: "assistant",
|
|
content: "next answer",
|
|
turnId: "next-turn",
|
|
createdAt: 3,
|
|
},
|
|
];
|
|
const { container, rerender } = render(
|
|
<ThreadViewport
|
|
messages={oldMessages}
|
|
isStreaming
|
|
composer={<div />}
|
|
conversationKey="chat-a"
|
|
activeTurnId="old-turn"
|
|
/>,
|
|
);
|
|
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: 300 },
|
|
});
|
|
jumpTo.mockClear();
|
|
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={oldMessages}
|
|
isStreaming
|
|
composer={<div />}
|
|
conversationKey="chat-b"
|
|
activeTurnId="old-turn"
|
|
conversationReady={false}
|
|
/>,
|
|
);
|
|
expect(scroller.scrollTop).toBe(300);
|
|
expect(jumpTo).not.toHaveBeenCalled();
|
|
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 3000,
|
|
});
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={nextMessages}
|
|
isStreaming
|
|
composer={<div />}
|
|
conversationKey="chat-b"
|
|
activeTurnId="next-turn"
|
|
conversationReady
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(scroller.scrollTop).toBe(2400));
|
|
await flushAnimationFrame();
|
|
expect(jumpTo.mock.calls).toEqual([[2400]]);
|
|
expect(followTo).toHaveBeenCalledWith(2400);
|
|
});
|
|
|
|
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
|
|
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
|
|
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();
|
|
jumpTo.mockClear();
|
|
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={emptyMessages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
conversationKey="chat-a"
|
|
/>,
|
|
);
|
|
expect(jumpTo).toHaveBeenCalledWith(0);
|
|
|
|
Object.defineProperty(scroller, "scrollHeight", {
|
|
configurable: true,
|
|
value: 2400,
|
|
});
|
|
scrollTo.mockClear();
|
|
jumpTo.mockClear();
|
|
|
|
rerender(
|
|
<ThreadViewport
|
|
messages={messages}
|
|
isStreaming={false}
|
|
composer={<div />}
|
|
conversationKey="chat-a"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(scroller.scrollTop).toBe(1800));
|
|
expect(jumpTo).toHaveBeenCalledWith(1800);
|
|
});
|
|
|
|
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(scroller.scrollTop).toBe(1800));
|
|
});
|
|
});
|