diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 66c84b7cdf..bc49fb088d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -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('[data-turn-id]')?.dataset.turnId; - if (!anchorTurnId) throw new Error('the transcript has no mounted turn'); + const viewport = root.getBoundingClientRect(); + const anchorTurnId = [...root.querySelectorAll('[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); }); }, }; diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 4debc43eaf..c0d5649342 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -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; @@ -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, diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index f4de10b70c..51bbb09bfa 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -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 { @@ -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(); @@ -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 @@ -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; }; diff --git a/packages/ui/stories/transcript-scroll-rounding.stories.tsx b/packages/ui/stories/transcript-scroll-rounding.stories.tsx index 2192ff88df..239e977d61 100644 --- a/packages/ui/stories/transcript-scroll-rounding.stories.tsx +++ b/packages/ui/stories/transcript-scroll-rounding.stories.tsx @@ -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('[data-probe="above"]'); + const below = root.querySelector('[data-probe="below"]'); + if (!above || !below) throw new Error('the probe spacers are missing'); + const anchor = root.querySelector('[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((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(); + } + }, +};