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',
});
});
});