import { render } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AgentThread } from '../../src/components/agent-workspace/agent-thread'; vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, })); type Message = { _id: string; _creationTime: number; role: 'user' | 'assistant'; content: string; status?: string; }; const message = (id: string, content: string): Message => ({ _id: id, _creationTime: Date.now(), role: 'assistant', content, status: 'complete', }); const baseProps = { jobId: 'job-1', events: [], interactions: [], workspaceChanges: [], disabled: false, agentTurnActive: true, onOpenFile: vi.fn(), onOpenDiff: vi.fn(), }; const renderThread = (messages: Message[]) => render( // Types are exercised at runtime; cast the fixture shapes to satisfy the // Doc<> generics without pulling the full backend types into the test. , ); /** Stubs the scroll geometry of the messages list so we control near-bottom. */ const stubScrollGeometry = ( container: HTMLElement, geometry: { scrollHeight: number; scrollTop: number; clientHeight: number }, ) => { const node = container.querySelector('.overflow-y-auto'); if (!node) throw new Error('scroll container not found'); const scrollTo = vi.fn(); Object.defineProperty(node, 'scrollTo', { configurable: true, value: scrollTo, }); Object.defineProperty(node, 'scrollHeight', { configurable: true, get: () => geometry.scrollHeight, }); Object.defineProperty(node, 'clientHeight', { configurable: true, get: () => geometry.clientHeight, }); let scrollTop = geometry.scrollTop; Object.defineProperty(node, 'scrollTop', { configurable: true, get: () => scrollTop, set: (value: number) => { scrollTop = value; }, }); return { node, scrollTo }; }; afterEach(() => { vi.clearAllMocks(); }); describe('AgentThread auto-scroll', () => { it('does not yank to the bottom while the user is scrolled up', () => { const { container, rerender } = renderThread([message('m1', 'first')]); const { scrollTo } = stubScrollGeometry(container, { scrollHeight: 1000, scrollTop: 100, clientHeight: 200, }); rerender( , ); expect(scrollTo).not.toHaveBeenCalled(); }); it('auto-scrolls to newest when already near the bottom', () => { const { container, rerender } = renderThread([message('m1', 'first')]); const { scrollTo } = stubScrollGeometry(container, { scrollHeight: 1000, scrollTop: 780, clientHeight: 200, }); rerender( , ); expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth', }); }); });