import { act, cleanup, fireEvent, render } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { PropsWithChildren } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' vi.mock('@/hooks/queries/useMachines', () => ({ useMachines: () => ({ machines: [] }) })) vi.mock('@assistant-ui/react', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useAuiState: (selector: (state: unknown) => unknown) => selector({ thread: { extras: undefined } }), unstable_useThreadMessageIds: () => [], ThreadPrimitive: { ...actual.ThreadPrimitive, Root: ({ children, className }: PropsWithChildren<{ className?: string }>) => (
{children}
), Viewport: ({ children }: PropsWithChildren) => children, Messages: () => null } } }) import { HappyThread } from '@/components/AssistantChat/HappyThread' import type { ApiClient } from '@/api/client' import type { Session } from '@/types/api' const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTo') const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView') const originalResizeObserver = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver') let resizeCallbacks: Array<() => void> = [] class TestResizeObserver { constructor(callback: ResizeObserverCallback) { resizeCallbacks.push(() => callback([], this as unknown as ResizeObserver)) } observe() {} disconnect() {} } function renderThread(onViewModeChange = vi.fn(), unseenCount = 0) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const renderHappyThread = (forceScrollToken: number) => ( ) const result = render(renderHappyThread(0)) const viewport = result.container.querySelector('.chat-scroll-y') if (!viewport) { throw new Error('Chat viewport was not rendered') } Object.defineProperties(viewport, { scrollHeight: { configurable: true, value: 1_232 }, clientHeight: { configurable: true, value: 530 } }) act(() => { vi.advanceTimersByTime(0) }) return { ...result, viewport, onViewModeChange, rerenderThread: (forceScrollToken: number) => result.rerender(renderHappyThread(forceScrollToken)) } } beforeEach(() => { vi.useFakeTimers() resizeCallbacks = [] Object.defineProperty(globalThis, 'ResizeObserver', { configurable: true, value: TestResizeObserver }) Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, writable: true, value(this: HTMLElement, options: ScrollToOptions | number, y?: number) { const requestedTop = typeof options === 'number' ? y ?? 0 : options.top ?? 0 const maxScrollTop = Math.max(0, this.scrollHeight - this.clientHeight) this.scrollTop = Math.min(Math.max(0, requestedTop), maxScrollTop) } }) }) afterEach(() => { cleanup() vi.clearAllTimers() vi.useRealTimers() if (originalScrollTo) { Object.defineProperty(HTMLElement.prototype, 'scrollTo', originalScrollTo) } else { Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo') } if (originalScrollIntoView) { Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView) } else { Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') } if (originalResizeObserver) { Object.defineProperty(globalThis, 'ResizeObserver', originalResizeObserver) } else { Reflect.deleteProperty(globalThis, 'ResizeObserver') } }) describe('mobile initial scroll settling', () => { it('does not snap back after pointer cancellation ends a touch swipe', () => { const { viewport, onViewModeChange } = renderThread() expect(viewport.scrollTop).toBe(702) const pointerDown = new Event('pointerdown', { bubbles: true }) Object.defineProperties(pointerDown, { button: { value: 0 }, pointerType: { value: 'touch' } }) fireEvent(viewport, pointerDown) const pointerCancel = new Event('pointercancel', { bubbles: true }) Object.defineProperty(pointerCancel, 'pointerType', { value: 'touch' }) fireEvent(viewport, pointerCancel) viewport.scrollTop = 520 fireEvent.scroll(viewport) act(() => { vi.advanceTimersByTime(1_800) }) expect(viewport.scrollTop).toBe(520) expect(onViewModeChange).toHaveBeenLastCalledWith('history') }) it('keeps settling for non-explicit non-zero layout movement', () => { const { viewport, onViewModeChange } = renderThread() viewport.scrollTop = 520 fireEvent.scroll(viewport) act(() => { vi.advanceTimersByTime(1_800) }) expect(viewport.scrollTop).toBe(702) expect(onViewModeChange).not.toHaveBeenCalledWith('history') }) it('does not snap back after a window-captured native scrollbar drag', () => { const { viewport, onViewModeChange } = renderThread() vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, right: 320, bottom: 600 } as DOMRect) fireEvent.mouseDown(window, { button: 0, clientX: 319, clientY: 200 }) viewport.scrollTop = 520 fireEvent.scroll(viewport) fireEvent.mouseUp(window) act(() => { vi.advanceTimersByTime(1_800) }) expect(viewport.scrollTop).toBe(520) expect(onViewModeChange).toHaveBeenLastCalledWith('history') }) it('ignores captured mouse input outside the chat viewport', () => { const { viewport, onViewModeChange } = renderThread() vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, right: 320, bottom: 600 } as DOMRect) fireEvent.mouseDown(window, { button: 0, clientX: 400, clientY: 200 }) viewport.scrollTop = 520 fireEvent.scroll(viewport) fireEvent.mouseUp(window) act(() => { vi.advanceTimersByTime(1_800) }) expect(viewport.scrollTop).toBe(702) expect(onViewModeChange).not.toHaveBeenCalledWith('history') }) it('keeps settling after the runtime resets the viewport to the exact top', () => { const { viewport, onViewModeChange } = renderThread() viewport.scrollTop = 0 fireEvent.scroll(viewport) act(() => { vi.advanceTimersByTime(1_800) }) expect(viewport.scrollTop).toBe(702) expect(onViewModeChange).not.toHaveBeenCalledWith('history') }) }) describe('explicit tail scrolling', () => { it('renders the unread count inside the compact bottom control', () => { const { container } = renderThread(vi.fn(), 7) const button = container.querySelector('button[aria-label*="7"]') expect(button).not.toBeNull() expect(button).toHaveClass('rounded-full', 'h-6', 'w-6') expect(button).toHaveClass('bg-[var(--app-button)]', 'text-[var(--app-button-text)]') expect(button?.querySelector('span')).toHaveClass('translate-y-px') expect(button?.textContent).toContain('7') }) it('uses the same smooth end-alignment scroll as outline navigation', () => { const scrollIntoView = vi.fn() Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, writable: true, value: scrollIntoView }) const { rerenderThread } = renderThread() rerenderThread(1) expect(scrollIntoView).toHaveBeenCalledWith({ block: 'end', behavior: 'smooth' }) }) it('retargets the smooth tail jump when content grows during the animation', () => { const scrollIntoView = vi.fn() Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, writable: true, value: scrollIntoView }) const { rerenderThread } = renderThread() rerenderThread(1) expect(scrollIntoView).toHaveBeenCalledTimes(1) act(() => { resizeCallbacks.at(-1)?.() }) expect(scrollIntoView).toHaveBeenCalledTimes(2) expect(scrollIntoView).toHaveBeenLastCalledWith({ block: 'end', behavior: 'smooth' }) }) it('stays in tail mode through smooth-scroll progress and content growth', () => { const { viewport, onViewModeChange, rerenderThread } = renderThread() act(() => { vi.advanceTimersByTime(1_800) }) viewport.scrollTop = 400 fireEvent.scroll(viewport) expect(onViewModeChange).toHaveBeenLastCalledWith('history') Object.defineProperty(viewport, 'scrollTo', { configurable: true, value: vi.fn() }) onViewModeChange.mockClear() rerenderThread(1) expect(onViewModeChange).toHaveBeenLastCalledWith('tail') viewport.scrollTop = 500 fireEvent.scroll(viewport) Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: 1_400 }) viewport.scrollTop = 650 fireEvent.scroll(viewport) expect(onViewModeChange).not.toHaveBeenCalledWith('history') viewport.scrollTop = 870 fireEvent.scroll(viewport) expect(onViewModeChange).not.toHaveBeenCalledWith('history') }) })