Files
hapi/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx
T
AnanovoandGitHub ceb9314350 feat(web): add scroll-to-bottom button (#1694)
* feat(web): add scroll-to-bottom button

* fix(web): type counted scroll button props

* fix(web): retarget smooth tail scroll

* chore: retrigger pull request checks
2026-09-09 09:32:09 +08:00

313 lines
11 KiB
TypeScript

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<typeof import('@assistant-ui/react')>()
return {
...actual,
useAuiState: (selector: (state: unknown) => unknown) => selector({
thread: { extras: undefined }
}),
unstable_useThreadMessageIds: () => [],
ThreadPrimitive: {
...actual.ThreadPrimitive,
Root: ({ children, className }: PropsWithChildren<{ className?: string }>) => (
<div className={className}>{children}</div>
),
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) => (
<QueryClientProvider client={queryClient}>
<I18nProvider>
<HappyThread
api={{ getHubSettings: vi.fn().mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false }) } as unknown as ApiClient}
session={{ metadata: {} } as Session}
sessionId="mobile-scroll-session"
metadata={null}
disabled={false}
onRefresh={vi.fn()}
onViewModeChange={onViewModeChange}
isSyncingTail={false}
messagesWarning={null}
hasMoreMessages={false}
isLoadingMoreMessages={false}
onLoadMore={vi.fn().mockResolvedValue({ status: 'exhausted' })}
onCancelLoadMore={vi.fn()}
unseenCount={unseenCount}
rawMessagesCount={1}
normalizedMessagesCount={1}
messagesVersion={1}
historyVersion={0}
forceScrollToken={forceScrollToken}
outlineOpen={false}
outlineItems={[]}
onOutlineOpenChange={vi.fn()}
/>
</I18nProvider>
</QueryClientProvider>
)
const result = render(renderHappyThread(0))
const viewport = result.container.querySelector<HTMLElement>('.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<HTMLButtonElement>('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')
})
})