diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index ebe039fd44..a81add591c 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -42,16 +42,6 @@ width: 100%; } -/* Inserting earlier turns above the reader must not move what they are - reading. The browser's scroll anchoring does exactly that, so state the - dependency on the scroller that runs it rather than inheriting the `auto` - default: Maka reads no geometry and restores no position of its own. The - one case anchoring declines is a scroller sitting at zero, compensated in - useChatScroll after the turns land. */ -[data-chat-scroll-container='true'] { - overflow-anchor: auto; -} - .maka-transcript-turn { display: flex; width: 100%; diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 9901780cb0..e7fc390c89 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -38,6 +38,8 @@ interface FakeRoot { clientHeight: number; /** The boxes `scrollHeight` is made of, which is what the authority watches. */ children: readonly unknown[]; + /** `overflow-anchor` is the authority's to set, so the fake carries it. */ + style: { overflowAnchor: string }; addEventListener(type: string, listener: () => void): void; removeEventListener(type: string, listener: () => void): void; /** Dispatch the scroll event the browser would, one frame later. */ @@ -54,6 +56,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, children: [{}], + style: { overflowAnchor: '' }, addEventListener(type, listener) { if (type === 'scroll') listeners.add(listener); }, @@ -231,26 +234,47 @@ test('a scroll event that arrives late is still this authority\'s own write', () }); }); -test('growth that outruns the write does not read as the reader scrolling up', () => { +test('a reader who scrolls up mid-stream is not swallowed by the growth', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); assert.equal(root.scrollTop, 2_400); - // The transcript grew, and the scroll event for it arrives before this - // authority has been told to follow it. The offset is 302px from a tail - // that moved — identical, as a position, to a reader who scrolled up. + // Streaming: content lands, and the reader's gesture reaches this authority + // in the same event as the growth it landed with. Geometry changed, so the + // released rule would swallow it — and swallowing it is how a reader gets + // written back to the tail for as long as the answer keeps coming. root.grow(302); - root.scrollTop = 2_402; + root.scrollTop = 900; root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, true); - - // The affordance still knows how far the tail now is, and the next growth - // signal takes the reader back to it. + assert.equal(authority.getSnapshot().pinned, false); assert.equal(authority.getSnapshot().awayFromTail, true); + + // And released means released: the next growth leaves them where they are. resize(); - assert.equal(root.scrollTop, 2_702); + assert.equal(root.scrollTop, 900); + }); +}); + +test('the pin owns anchoring, and hands it back on release', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + + // Pinned, this authority writes the tail every frame, so anchoring can only + // produce events for adjustments that were never drawn. + assert.equal(root.style.overflowAnchor, 'none'); + + authority.releasePin(); + assert.equal(root.style.overflowAnchor, ''); + + authority.pinToTail(); + assert.equal(root.style.overflowAnchor, 'none'); + + detach(); + assert.equal(root.style.overflowAnchor, ''); }); }); @@ -292,9 +316,9 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { root.emitScroll(); assert.equal(heard, 0); - // Content arriving, with anchoring moving the offset to hold the reader. + // Content arriving. Anchoring is off while pinned and growth below moves no + // offset, so the event carries the offset this authority wrote: its echo. root.grow(500); - root.scrollTop = 2_900; root.emitScroll(); assert.equal(heard, 0); @@ -309,3 +333,29 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { assert.equal(heard, 1); }); }); + +test('a clamp that leaves the reader on the tail is not the reader', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + let heard = 0; + authority.subscribeToReaderScroll(() => { + heard += 1; + }); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + + // Content shrinks under a reader who is already at the end — a notice that + // retires, a turn that settles shorter than it first laid out. The browser + // clamps the offset itself and the event carries an offset this authority + // never wrote, but the reader is still on the tail: they did not move up, + // so there is nobody to report. Telling anyone otherwise is how a + // transcript that nobody scrolled goes and asks for history. + root.scrollHeight -= 500; + root.scrollTop = root.scrollHeight - root.clientHeight; + root.emitScroll(); + assert.equal(heard, 0); + assert.equal(authority.getSnapshot().pinned, true); + assert.equal(root.style.overflowAnchor, 'none'); + }); +}); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index e2a20e5b0a..9a77f80283 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -28,10 +28,17 @@ * 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 released, "keep the reader where they were reading" is the definition + * of `overflow-anchor: auto`, so that case is just don't touch it: stay out of + * the way and let the browser hold their place. + * + * While pinned it is the opposite instruction. Anchoring holds an anchor node + * still; the pin holds the tail. Both cannot be obeyed, and the pin wins every + * time — this authority overwrites anchoring's adjustment on the very next + * frame, so it was never drawn. What survives is its `scroll` event, which + * arrives with the offset moved and no reader behind it, and that is a lie this + * file cannot see through. So the pin turns anchoring off, and turns it back on + * when it releases. * * 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 @@ -123,6 +130,16 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const distanceToTail = (): number => root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0; + /** + * Anchoring is a writer, and while pinned it is a writer whose every write is + * about to be overwritten. Turning it off there leaves the reader and this + * authority as the only two, which is what lets a non-echo event be read as + * the reader without inferring anything. + */ + const applyAnchoring = (): void => { + if (root) root.style.overflowAnchor = pinned ? 'none' : ''; + }; + const writeToTail = (): void => { if (!root) return; root.scrollTop = root.scrollHeight; @@ -152,26 +169,36 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { lastClientHeight = target.clientHeight; return; } - // An event that arrives with the scroll geometry changed is the content - // or the viewport moving under the reader, not the reader moving: - // anchoring holding them still as turns land above, growth that outran - // this authority's own write, or a resize the browser answered by - // clamping the offset. Their offset changed and their intent did not, - // so the pin — which is that intent — must not be re-derived from where - // they now are, and nobody may be told the reader asked for anything. - // The affordance still follows the new distance, because that is a fact - // about the viewport rather than about them. + // While released, an event that arrives with the scroll geometry changed + // is the content or the viewport moving under the reader, not the reader + // moving: anchoring holding them still as turns land above, or a resize + // the browser answered by clamping the offset. Their offset changed and + // their intent did not, so the pin — which is that intent — must not be + // re-derived from where they now are, and nobody may be told the reader + // asked for anything. The affordance still follows the new distance, + // because that is a fact about the viewport rather than about them. + // + // While pinned, swallowing an event because content grew in the same + // frame is exactly how a reader who scrolls up during streaming gets + // written back to the tail. Anchoring is off there, so growth does not + // move `scrollTop` and the event that finds them away from the tail is + // theirs. An event that leaves them still on it is not: they did not + // move up, so there is nobody to report and nothing to release. That is + // where the browser's own clamp lands when the content shrinks under a + // reader who is already at the end. const moved = target.scrollHeight !== lastScrollHeight || target.clientHeight !== lastClientHeight; lastScrollHeight = target.scrollHeight; lastClientHeight = target.clientHeight; const distance = distanceToTail(); awayFromTail = distance > BUTTON_THRESHOLD_PX; - if (moved) { + const atTail = distance <= PIN_THRESHOLD_PX; + if (moved && (!pinned || atTail)) { publish(); return; } - pinned = distance <= PIN_THRESHOLD_PX; + pinned = atTail; + applyAnchoring(); publish(); for (const listener of [...readerListeners]) listener(); }; @@ -207,22 +234,26 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const childList = new MutationObserver(observeBox); childList.observe(target, { childList: true }); observeBox(); + applyAnchoring(); if (pinned) writeToTail(); return () => { childList.disconnect(); box.disconnect(); target.removeEventListener('scroll', onScroll); + target.style.overflowAnchor = ''; lastWrittenTop = undefined; if (root === target) root = null; }; }, pinToTail() { pinned = true; + applyAnchoring(); writeToTail(); publish(); }, releasePin() { pinned = false; + applyAnchoring(); awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); },