fix(workspace): scroll/diff-race/recovery/editor/mobile polish
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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.
|
||||
<AgentThread {...baseProps} messages={messages as never} />,
|
||||
);
|
||||
|
||||
/** 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<HTMLElement>('.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(
|
||||
<AgentThread
|
||||
{...baseProps}
|
||||
messages={[message('m1', 'first'), message('m2', 'second')] as never}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentThread
|
||||
{...baseProps}
|
||||
messages={[message('m1', 'first'), message('m2', 'second')] as never}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1000,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user