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
28 changes: 16 additions & 12 deletions apps/desktop/stories/app-shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1776,24 +1776,28 @@ export const ReaderScrolledUpIsNotPulledBack: Story = {
expect(before, JSON.stringify(tailMetrics())).toBeGreaterThan(100);
await waitFor(() => expect(dockOffered()).toBe(true));

const anchorTurnId = document.querySelector<HTMLElement>('[data-turn-id]')?.dataset.turnId;
if (!anchorTurnId) throw new Error('the transcript has no mounted turn');
const viewport = root.getBoundingClientRect();
const anchorTurnId = [...root.querySelectorAll<HTMLElement>('[data-transcript-turn-id]')]
.find((turn) => {
const bounds = turn.getBoundingClientRect();
return bounds.bottom > viewport.top && bounds.top < viewport.bottom;
})?.dataset.transcriptTurnId;
if (!anchorTurnId) throw new Error('the transcript has no visible turn');
const anchorTop = turnTop(anchorTurnId);

appendTurn?.();

// The turn the reader was on is still where it was. Everything that
// arrived, arrived below them.
//
// Both conditions retry together, because `distance` crosses `before`
// while the arriving Turn is still laid out at its `content-visibility`
// estimate — reading the anchor on that frame reads an intermediate
// layout, and on a slow renderer it is still 16px out. Retrying cannot
// launder a real failure: a reader who was pulled back is at the tail, so
// `distance` never gets above `before` again.
// Track the first visible turn. The first mounted turn can be thousands
// of pixels above the reader; native anchoring correctly moves that turn
// when intervening content-visibility estimates resolve while holding the
// reader still. Keep both checks together as the arriving turn settles.
await waitFor(() => {
expect(tailMetrics().distance).toBeGreaterThan(before);
expect(Math.abs(turnTop(anchorTurnId) - anchorTop)).toBeLessThanOrEqual(4);
const afterTop = turnTop(anchorTurnId);
expect(
Math.abs(afterTop - anchorTop),
JSON.stringify({ anchorTurnId, anchorTop, afterTop, ...tailMetrics() }),
).toBeLessThanOrEqual(4);
});
},
};
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/__tests__/transcript-scroll-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { test } from 'node:test';
import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js';

interface FakeRoot {
style: { overflowAnchor: string };
scrollTop: number;
scrollHeight: number;
clientHeight: number;
Expand All @@ -50,6 +51,7 @@ interface FakeRoot {
function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): FakeRoot {
const listeners = new Set<() => void>();
const root: FakeRoot = {
style: { overflowAnchor: '' },
scrollTop: 0,
scrollHeight: options?.scrollHeight ?? 3_000,
clientHeight: options?.clientHeight ?? 600,
Expand Down
22 changes: 12 additions & 10 deletions packages/ui/src/transcript-scroll-authority.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,13 @@
* pinned → content that grows writes `scrollTop = scrollHeight`
* !pinned → nothing here writes `scrollTop`, ever
*
* "Keep the reader where they were reading" is the definition of
* `overflow-anchor: auto`, which is already the initial value and costs nothing,
* and "the reader is dragging" is also just don't touch it — so both of those
* are the same instruction to this code: stay out of the way.
* While pinned, disable native anchoring so content cannot move the viewport
* behind this authority's own write. Once released, restore native anchoring
* to keep the reader on the same content without application writes.
*
* Being the only writer is what makes the state exact rather than guessed. It
* remembers the offset it wrote, so a scroll event that finds the scroller
* still on that offset is its own echo and any other offset is the reader — by
* construction, and with no dependence on when the event arrives. Astryx had to
* infer that from scroll direction, height deltas and wheel events, and every
* one of those signals has more than one cause.
* The last written offset identifies our asynchronous scroll echoes. When
* released, geometry also accounts for native anchoring and browser clamping
* before an unexplained movement is reported as reader input.
*/

import {
Expand Down Expand Up @@ -137,6 +133,9 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
const readerListeners = new Set<() => void>();

const publish = (): void => {
// Net height cannot explain anchoring when content shrinks above the
// viewport while growing below it. Give each mode just one scroll writer.
if (root) root.style.overflowAnchor = pinned ? 'none' : 'auto';
if (snapshot.pinned === pinned && snapshot.awayFromTail === awayFromTail) return;
snapshot = { pinned, awayFromTail };
for (const listener of listeners) listener();
Expand All @@ -163,6 +162,8 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
root = next;
const target = root;
if (!target) return () => undefined;
const previousOverflowAnchor = target.style.overflowAnchor;
publish();
const onScroll = (): void => {
// An event that finds the scroller still on the offset this authority
// put it on is the echo of that write, however late it arrives; any
Expand Down Expand Up @@ -254,6 +255,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
childList.disconnect();
box.disconnect();
target.removeEventListener('scroll', onScroll);
target.style.overflowAnchor = previousOverflowAnchor;
lastWrittenTop = undefined;
if (root === target) root = null;
};
Expand Down
48 changes: 48 additions & 0 deletions packages/ui/stories/transcript-scroll-rounding.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,51 @@ export const ContentThatOnlyRoundsIsNotTheReader: Story = {
}
},
};

// Growth can replace intrinsic-size estimates above the viewport while adding
// content below it. A queued tail-write event must not turn that layout into
// reader intent just because its net height change has the opposite sign.
export const OpposingResizesKeepFollowingTheTail: Story = {
play: async () => {
const root = scroller();
const above = root.querySelector<HTMLElement>('[data-probe="above"]');
const below = root.querySelector<HTMLElement>('[data-probe="below"]');
if (!above || !below) throw new Error('the probe spacers are missing');
const anchor = root.querySelector<HTMLElement>('[data-probe="anchor"]');
if (!anchor) throw new Error('the probe anchor is missing');
// Keep the anchor visible above the tail spacer so native anchoring has
// a candidate whose position changes when the upper box shrinks.
root.style.height = '860px';
above.style.height = '2000px';
anchor.style.height = '1000px';
below.style.height = '600px';
const authority = createTranscriptScrollAuthority();
const detach = authority.attach(root);
try {
await settled();
// Leave the rAF callback: mutations made inside it are observed by RO
// in that same rendering step, before a pending scroll can be delivered.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
let readerMoves = 0;
authority.subscribeToReaderScroll(() => { readerMoves += 1; });

const previousHeight = root.scrollHeight;
// Queue a real scroll event from a tail write. Before it arrives, layout
// shrinks above the reader and grows below them in the same task.
below.style.height = '601px';
authority.pinToTail();
above.style.height = '1909px';
below.style.height = '1200px';
// Commit layout before the queued scroll event is delivered. This is
// also what a consumer reading scrollHeight during streaming does.
expect(root.scrollHeight).toBeGreaterThan(previousHeight);
await settled();

expect(readerMoves).toBe(0);
expect(authority.getSnapshot().pinned).toBe(true);
expect(root.scrollHeight - root.clientHeight - root.scrollTop).toBeLessThanOrEqual(4);
} finally {
detach();
}
},
};