fix(workspace): scroll/diff-race/recovery/editor/mobile polish

This commit is contained in:
Gabriel Brown
2026-07-11 17:59:25 -04:00
parent b00141130c
commit f89dfce3b8
6 changed files with 477 additions and 18 deletions
@@ -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',
});
});
});
@@ -0,0 +1,72 @@
import { describe, expect, test } from 'vitest';
import {
createRequestSequencer,
isNearBottom,
NEAR_BOTTOM_THRESHOLD,
RECOVERY_THRESHOLD,
shouldShowRecovery,
} from '@/components/agent-workspace/workspace-helpers';
describe('createRequestSequencer', () => {
test('only the latest issued token is considered current', () => {
const sequencer = createRequestSequencer();
const first = sequencer.next();
const second = sequencer.next();
// Simulate the first (slower) request resolving after the second: it is
// stale and must be dropped.
expect(sequencer.isLatest(first)).toBe(false);
// The newest request wins.
expect(sequencer.isLatest(second)).toBe(true);
});
test('a fresh request supersedes a previously latest one', () => {
const sequencer = createRequestSequencer();
const token = sequencer.next();
expect(sequencer.isLatest(token)).toBe(true);
sequencer.next();
expect(sequencer.isLatest(token)).toBe(false);
});
});
describe('shouldShowRecovery', () => {
test('defaults to a threshold of 3 consecutive failures', () => {
expect(RECOVERY_THRESHOLD).toBe(3);
expect(shouldShowRecovery(0)).toBe(false);
expect(shouldShowRecovery(1)).toBe(false);
expect(shouldShowRecovery(2)).toBe(false);
expect(shouldShowRecovery(3)).toBe(true);
expect(shouldShowRecovery(4)).toBe(true);
});
test('honours a custom threshold', () => {
expect(shouldShowRecovery(1, 2)).toBe(false);
expect(shouldShowRecovery(2, 2)).toBe(true);
});
});
describe('isNearBottom', () => {
test('true when within the threshold band of the bottom', () => {
expect(
isNearBottom({ scrollHeight: 1000, scrollTop: 940, clientHeight: 100 }),
).toBe(true);
});
test('false when scrolled up beyond the threshold', () => {
expect(
isNearBottom({ scrollHeight: 1000, scrollTop: 200, clientHeight: 100 }),
).toBe(false);
});
test('uses NEAR_BOTTOM_THRESHOLD as the default band', () => {
expect(NEAR_BOTTOM_THRESHOLD).toBe(80);
// Exactly at the threshold is not "near" (strict less-than).
expect(
isNearBottom({ scrollHeight: 1000, scrollTop: 820, clientHeight: 100 }),
).toBe(false);
expect(
isNearBottom({ scrollHeight: 1000, scrollTop: 821, clientHeight: 100 }),
).toBe(true);
});
});