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; } 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( } />, ); 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("[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 }; } 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("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( composer} 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('[data-user-prompt-id="u2"]'); expect(prompt).not.toBeNull(); Object.defineProperty(prompt, "offsetTop", { configurable: true, value: 420, }); scrollTo.mockClear(); await act(async () => { rerender( composer} 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( composer} 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('[data-user-prompt-id="u2"]'); expect(prompt).not.toBeNull(); Object.defineProperty(prompt, "offsetTop", { configurable: true, value: 420, }); await act(async () => { rerender( composer} 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( composer} 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( composer} 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('[data-user-prompt-id="u2"]'); expect(prompt).not.toBeNull(); Object.defineProperty(prompt, "offsetTop", { configurable: true, value: 420, }); await act(async () => { rerender( composer} scrollToLatestUserPromptSignal={1} />, ); }); await act(async () => { await new Promise((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( composer} scrollToLatestUserPromptSignal={1} />, ); }); expect(scrollTo).not.toHaveBeenCalled(); }); it("keeps the scroll-to-bottom button above a growing composer", () => { 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(() => { 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( composer} 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('[data-user-prompt-id="u2"]'); expect(prompt).not.toBeNull(); Object.defineProperty(prompt, "offsetTop", { configurable: true, value: 420, }); await act(async () => { rerender( composer} 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( } />, ); 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( } 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("separates the mobile welcome copy and composer into responsive rows", () => { render( composer} emptyState={
welcome
} />, ); 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( 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")); }); 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(() => { 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( } />, ); 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( 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(() => { 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( } />, ); 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(() => { 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( } 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(); 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(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(); 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 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(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( } 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( } 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( } 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( } conversationKey="chat-a" />, ); expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: "auto", }); Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 2400, }); scrollTo.mockClear(); rerender( } 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( } 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(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: "auto", }), ); }); });