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 { await act(async () => { await new Promise((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; } 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( } />, ); 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("[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((resolve) => window.requestAnimationFrame(() => resolve())); }); return { promptEls, scroller }; } function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) { const viewportRef = useRef(null); return (
viewportRef.current?.jumpToUserPrompt(promptId)} /> } />
); } describe("ThreadViewport", () => { it("top-aligns short threads in the message rendering area", () => { render( composer} />, ); 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( composer} />, ); 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( composer} />, ); 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('[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( composer} 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( composer} />, ); 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('[data-user-prompt-id="u2"]'); expect(prompt).not.toBeNull(); Object.defineProperty(prompt, "offsetTop", { configurable: true, value: 420, }); jumpTo.mockClear(); await act(async () => { rerender( composer} 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( composer} 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( composer} 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(); const scrollTo = vi.fn(); const { container, rerender } = render( composer} />, ); 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('[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( composer} 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( composer} />, ); 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( '[data-user-prompt-id="optimistic-user"]', ); expect(optimisticPrompt).not.toBeNull(); Object.defineProperty(optimisticPrompt, "offsetTop", { configurable: true, value: 1_420, }); await act(async () => { rerender( composer} activeTurnId="turn-stable" />, ); }); followTo.mockClear(); await act(async () => { rerender( composer} 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( composer} />, ); 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('[data-user-prompt-id="u2"]'); expect(prompt).not.toBeNull(); Object.defineProperty(prompt, "offsetTop", { configurable: true, value: 1420, }); await act(async () => { rerender( composer} activeTurnId="turn-2" activeTurnStartedHere />, ); }); await flushAnimationFrame(); followTo.mockClear(); Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 1932, }); await act(async () => { rerender( composer} 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( composer} />, ); }); 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( composer} />, ); 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( composer} />, ); 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('[data-user-prompt-id="u2"]'); expect(prompt).not.toBeNull(); Object.defineProperty(prompt, "offsetTop", { configurable: true, value: 420, }); await act(async () => { rerender( composer} 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( } />, ); 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( } emptyState={
welcome
} 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( )} emptyState={
welcome
} />, ); const initialComposer = screen.getByTestId("persistent-composer"); fireEvent.change(initialComposer, { target: { value: "kept draft" } }); rerender( )} emptyState={
welcome
} />, ); 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( composer} emptyState={
welcome
} />, ); 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( composer} emptyState={
welcome
} 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( } />, ); 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( } />, ); 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( composer} emptyState={
welcome
} 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( } />, ); 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( } />, ); 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( } 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(); 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("[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(); 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( } />, ); 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("[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((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( } />, ); 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("[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((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( } 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( } 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( } 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( } 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( } 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( } 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( } conversationKey="chat-a" />, ); expect(jumpTo).toHaveBeenCalledWith(0); Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 2400, }); scrollTo.mockClear(); jumpTo.mockClear(); rerender( } 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( } 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( } scrollToBottomSignal={1} />, ); await waitFor(() => expect(scroller.scrollTop).toBe(1800)); }); });