Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions apps/desktop/e2e/scroll-geometry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,158 @@ test('the empty-chat hero centres in the reading column', async ({ window: page
});
expect(offset).toBeLessThanOrEqual(1);
});

// #2052: switching back mounts only the transcript tail; earlier turns arrive
// in idle chunks. The compensation contract: a historical turn the reader is
// anchored on must not move in the viewport while chunks mount above it. The
// unit tests pin the arithmetic; this pins the real Chromium layout and
// timing behind it. The bottom-lock path (re-pin instead of preserve) is
// already covered by the pinned settles above.
test('progressive fill preserves the reading anchor while earlier turns mount', async ({ longTranscriptWindow: page }) => {
await expect(page.locator('.maka-turn')).toHaveCount(24);
await settleGeometry(page, { pinned: true });

// Widen the fill window so the anchor is sampled while chunks are still
// mounting; unthrottled, an M-series host can finish the whole fill before
// the first sample.
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 20 });

await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click');
await page
.getByRole('navigation', { name: '对话列表' })
.getByRole('button', { name: '扩展', exact: true })
.dispatchEvent('click');
await expect(page.locator('.maka-turn')).toHaveCount(0);
// Release the bottom follower the way any upward scroll does (Astryx
// unlocks on scroll direction, any source), then PROVE the reading state
// holds before measuring anything: an in-flight spring finishes its
// current animation regardless of the unlock, and fixture windows swallow
// real wheel input, so a held position is the only trustworthy signal
// that the follower is out of the picture. Once held, the anchor is
// sampled after every fill step and the watch settles the moment the fill
// completes, so turn-size warm-up inflation (out of scope here, #827
// machinery) never enters the measurement. A completion observed with
// zero fill steps proves nothing and fails loudly instead of passing
// empty.
const watchPromise = page.evaluate(
() =>
new Promise<{ maxDrift: number; fillSteps: number }>((resolveWatch, rejectWatch) => {
const root = document.querySelector('[data-chat-scroll-container="true"]') as HTMLElement;
const guard = window.setTimeout(() => {
rejectWatch(new Error('Fill did not complete while watching the anchor'));
}, 45_000);
const fail = (why: string) => {
window.clearTimeout(guard);
rejectWatch(new Error(why));
};
const beginWatch = () => {
// The turn being read: the first whose box still reaches below
// the topbar.
const anchor = [...root.querySelectorAll('[data-turn-id]')].find(
(el) => el.getBoundingClientRect().bottom > 120,
);
if (!anchor) {
fail('Expected a visible turn to anchor on');
return;
}
const baseTop = anchor.getBoundingClientRect().top;
let turnCount = root.querySelectorAll('[data-turn-id]').length;
let fillSteps = 0;
let drift = 0;
const observer = new MutationObserver(() => {
if (root.dataset.progressiveFill === 'complete') {
observer.disconnect();
window.clearTimeout(guard);
if (fillSteps === 0) {
rejectWatch(new Error('Fill completed with no step observed; raise the throttle'));
return;
}
resolveWatch({ maxDrift: drift, fillSteps });
return;
}
const count = root.querySelectorAll('[data-turn-id]').length;
if (count > turnCount) {
turnCount = count;
fillSteps += 1;
drift = Math.max(drift, Math.abs(anchor.getBoundingClientRect().top - baseTop));
}
});
observer.observe(root, {
attributes: true,
attributeFilter: ['data-progressive-fill'],
childList: true,
subtree: true,
});
};
// Armed before the session switch is dispatched: the hold begins in
// the same task that sets data-progressive-fill, with no protocol
// round-trip inside the fill window.
const armed = () => {
if (root.dataset.progressiveFill === 'filling') {
tryHold();
return;
}
const armObserver = new MutationObserver(() => {
if (root.dataset.progressiveFill === 'filling') {
armObserver.disconnect();
tryHold();
}
});
armObserver.observe(root, { attributes: true, attributeFilter: ['data-progressive-fill'] });
};
let attempts = 0;
const tryHold = () => {
if (root.dataset.progressiveFill === 'complete') {
fail('Fill completed before the anchor held; raise the throttle');
return;
}
attempts += 1;
if (attempts > 20) {
fail('The scroller never held an unpinned position');
return;
}
// Astryx's scroll handler skips direction detection whenever
// scrollHeight changed in the same event, and the fill grows the
// document every step, so an upward write alone rarely unlocks.
// The wheel fast path unlocks unconditionally while the spring is
// animating, and a dispatched WheelEvent reaches that listener
// even though fixture windows swallow real wheel input.
root.dispatchEvent(
new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }),
);
root.scrollTop = Math.max(0, (root.scrollHeight - root.clientHeight) / 2);
let held = 0;
let last = root.scrollTop;
const check = () => {
if (root.dataset.progressiveFill === 'complete') {
fail('Fill completed before the anchor held; raise the throttle');
return;
}
if (root.scrollHeight - root.scrollTop - root.clientHeight < 200) {
tryHold();
return;
}
if (Math.abs(root.scrollTop - last) < 1) held += 1;
else {
held = 0;
last = root.scrollTop;
}
if (held >= 4) {
beginWatch();
return;
}
requestAnimationFrame(check);
};
requestAnimationFrame(check);
};
armed();
}),
);
await page.getByText('超长会话滚动几何').first().dispatchEvent('click');
const watch = await watchPromise;
expect(watch.fillSteps).toBeGreaterThan(0);
expect(watch.maxDrift).toBeLessThanOrEqual(2);

await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
});
78 changes: 78 additions & 0 deletions packages/ui/src/__tests__/chat-view-progressive-mount.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { ComponentProps } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { StoredMessage } from '@maka/core';
import { ChatSurfaceLayout } from '../chat-surface-layout.js';
import { LocaleProvider } from '../locale-context.js';
import { ChatView } from '../chat-view.js';
import { DEFAULT_MOUNT_WINDOW } from '../progressive-turn-mount.js';

const activeSession = {
id: 'session-1',
name: 'Test',
isFlagged: false,
isArchived: false,
labels: [],
hasUnread: false,
status: 'done' as const,
backend: 'fake' as const,
llmConnectionSlug: 'fake',
connectionLocked: false,
model: 'fake',
permissionMode: 'ask' as const,
};

function OwnedChatView(props: ComponentProps<typeof ChatView>) {
return (
<LocaleProvider locale="en">
<ChatSurfaceLayout composer={null}>
<ChatView {...props} />
</ChatSurfaceLayout>
</LocaleProvider>
);
}

function transcriptOf(turnCount: number): StoredMessage[] {
return Array.from({ length: turnCount }, (_ignored, index): StoredMessage => ({
type: 'user',
id: `user-${index}`,
turnId: `turn-${index}`,
ts: index + 1,
text: `prompt ${index}`,
}));
}

function mountedTurnIds(markup: string): string[] {
return [...markup.matchAll(/data-turn-id="(turn-\d+)"/g)].map((match) => match[1]!);
}

// #2052: the first commit after opening a long session renders only the tail
// window; the idle fill that completes the transcript is behavior of the
// client runtime and is covered by the pure window tests.
describe('ChatView progressive mount', () => {
it('renders only the tail window of a long transcript in the first commit', () => {
const markup = renderToStaticMarkup(
<OwnedChatView messages={transcriptOf(30)} activeSession={activeSession} onNew={() => undefined} />,
);
const mounted = mountedTurnIds(markup);
assert.equal(mounted.length, DEFAULT_MOUNT_WINDOW.initialWindow);
assert.equal(mounted[0], `turn-${30 - DEFAULT_MOUNT_WINDOW.initialWindow}`);
assert.equal(mounted[mounted.length - 1], 'turn-29');
});

it('renders a short transcript completely', () => {
const markup = renderToStaticMarkup(
<OwnedChatView messages={transcriptOf(4)} activeSession={activeSession} onNew={() => undefined} />,
);
assert.deepEqual(mountedTurnIds(markup), ['turn-0', 'turn-1', 'turn-2', 'turn-3']);
});

it('keeps one prompt rail tick per turn while the transcript is windowed', () => {
const markup = renderToStaticMarkup(
<OwnedChatView messages={transcriptOf(30)} activeSession={activeSession} onNew={() => undefined} />,
);
const railTicks = markup.match(/maka-prompt-rail-tick/g) ?? [];
assert.ok(railTicks.length >= 30, `expected 30 rail ticks, saw ${railTicks.length}`);
});
});
117 changes: 117 additions & 0 deletions packages/ui/src/__tests__/progressive-turn-mount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
compensateFillScroll,
DEFAULT_MOUNT_WINDOW,
fillMountWindow,
initialMountWindow,
reconcileMountWindow,
type MountWindowState,
} from '../progressive-turn-mount.js';

const config = DEFAULT_MOUNT_WINDOW;

function state(key: string | undefined, length: number, start: number): MountWindowState {
return { key, length, start };
}

describe('initialMountWindow', () => {
it('starts a long transcript at its tail window', () => {
assert.equal(initialMountWindow('a', 30, config).start, 30 - config.initialWindow);
});

it('starts a short transcript fully mounted', () => {
assert.equal(initialMountWindow('a', 4, config).start, 0);
assert.equal(initialMountWindow(undefined, 0, config).start, 0);
});
});

describe('reconcileMountWindow', () => {
it('re-windows to the tail on a session switch', () => {
const next = reconcileMountWindow(state('a', 30, 0), { key: 'b', length: 30 }, config);
assert.equal(next.start, 30 - config.initialWindow);
assert.equal(next.key, 'b');
});

it('re-windows when the turn count jumps by more than one window at once', () => {
const next = reconcileMountWindow(state('a', 0, 0), { key: 'a', length: 30 }, config);
assert.equal(next.start, 30 - config.initialWindow);
});

it('keeps the window while streaming appends single turns', () => {
const current = state('a', 30, 20);
const next = reconcileMountWindow(current, { key: 'a', length: 31 }, config);
assert.equal(next.start, 20);
});

it('re-windows when the transcript shrinks below the window start', () => {
const next = reconcileMountWindow(state('a', 30, 20), { key: 'a', length: 5 }, config);
assert.equal(next.start, 0);
});

it('re-windows when the transcript shrinks exactly to the window start', () => {
// start === length slices to an empty transcript, just as invalid as
// start past the end (#2191 review).
const next = reconcileMountWindow(state('a', 30, 20), { key: 'a', length: 20 }, config);
assert.equal(next.start, 20 - config.initialWindow);
});

it('keeps an empty transcript at start zero', () => {
const current = state('a', 0, 0);
assert.equal(reconcileMountWindow(current, { key: 'a', length: 0 }, config), current);
});

it('widens to include an ensured index', () => {
const next = reconcileMountWindow(state('a', 30, 20), { key: 'a', length: 30 }, config, 3);
assert.equal(next.start, 3);
});

it('ignores an ensured index that is already mounted or unknown', () => {
const current = state('a', 30, 20);
assert.equal(reconcileMountWindow(current, { key: 'a', length: 30 }, config, 25), current);
assert.equal(reconcileMountWindow(current, { key: 'a', length: 30 }, config, -1), current);
});

it('returns the same reference when nothing changes', () => {
const current = state('a', 30, 20);
assert.equal(reconcileMountWindow(current, { key: 'a', length: 30 }, config), current);
});

it('mounts a short transcript completely from the first commit', () => {
const next = reconcileMountWindow(state(undefined, 0, 0), { key: 'a', length: 4 }, config);
assert.equal(next.start, 0);
});
});

describe('fillMountWindow', () => {
it('steps the window up by one chunk until it reaches zero', () => {
let current = state('a', 30, 20);
const starts: number[] = [];
while (current.start > 0) {
current = fillMountWindow(current, config);
starts.push(current.start);
}
assert.deepEqual(starts, [16, 12, 8, 4, 0]);
assert.equal(fillMountWindow(current, config), current);
});
});

describe('compensateFillScroll', () => {
it('preserves the reading position when the user has scrolled up', () => {
const result = compensateFillScroll(
{ scrollTop: 1000, scrollHeight: 5000, clientHeight: 800 },
6000,
);
assert.equal(result.pin, false);
assert.equal(result.scrollTop, 2000);
});

it('re-pins to the bottom inside the 10px lock threshold', () => {
const result = compensateFillScroll(
{ scrollTop: 4195, scrollHeight: 5000, clientHeight: 800 },
6000,
);
assert.equal(result.pin, true);
assert.equal(result.scrollTop, 6000);
});
});
Loading
Loading