From f317b07f3b8e9ab6048eb71176568a6bf6427bc6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 22:18:27 +0800 Subject: [PATCH 1/2] fix(ui): let the pin own scroll anchoring while it follows the tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reader who scrolls up while an answer streams is written straight back to the tail, and stays there for as long as the output keeps coming (#4269). The transcript's scroll authority reads a `scroll` event as the reader whenever the offset is not the one it wrote. Native anchoring breaks that: it is a second writer, and while pinned it is a writer whose every adjustment this authority overwrites on the next frame. The adjustment is never drawn, but its event is real, and it arrives with the geometry changed — so the guard that exists for the released case swallows the reader's own tick along with it, and the ResizeObserver puts them back. Anchoring and the pin are opposite instructions: one holds an anchor node still, the other holds the tail. So the pin turns anchoring off while it is following, and hands it back on release, where holding the reader's place is exactly what it is for. The released path — the geometry guard, history paging, re-pinning — is unchanged. That leaves `overflow-anchor` with one owner, so the CSS rule that declared it goes with the header comment that claimed the two policies were the same instruction. Measured on the real Desktop build with CDP wheel input: 40 ticks upward while the transcript grows from a macrotask, six rounds each. Growth every 4ms, main escapes 0/6 (stuck 193px from the tail after 4800px of wheel); with this change, 6/6. Every 8ms, 1/6 -> 6/6, and seven snap-backs to zero. History paging is byte-identical: 7 loads, 6 evictions, 0 visible jumps, 0 snap-to-tail on both. Generated-by: Claude Code --- .../src/renderer/styles/chat-message.css | 10 ---- .../transcript-scroll-authority.test.ts | 48 ++++++++++++---- .../ui/src/transcript-scroll-authority.tsx | 55 ++++++++++++++----- 3 files changed, 77 insertions(+), 36 deletions(-) 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..39360fa604 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); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index e2a20e5b0a..16bade9d22 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,32 @@ 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 there is nothing left for this to catch. Anchoring is off + // and growth alone does not move `scrollTop`, so a non-echo event there + // is the reader — and swallowing it because content grew in the same + // frame is exactly how a reader who scrolls up during streaming gets + // written back to the tail. const moved = target.scrollHeight !== lastScrollHeight || target.clientHeight !== lastClientHeight; lastScrollHeight = target.scrollHeight; lastClientHeight = target.clientHeight; const distance = distanceToTail(); awayFromTail = distance > BUTTON_THRESHOLD_PX; - if (moved) { + if (moved && !pinned) { publish(); return; } pinned = distance <= PIN_THRESHOLD_PX; + applyAnchoring(); publish(); for (const listener of [...readerListeners]) listener(); }; @@ -207,22 +230,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(); }, From 98ec4f266652cb76f0d2b83e127094a4e4be9191 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 00:42:13 +0800 Subject: [PATCH 2/2] fix(ui): keep a clamp on the tail from reading as the reader While pinned, anchoring is off, so a scroll event that finds the reader away from the tail is theirs. One that leaves them still on it is not: content shrank under a reader who was already at the end and the browser clamped the offset itself. Reporting that as the reader made a settled transcript ask for earlier history on its own. Generated-by: Claude Code --- .../transcript-scroll-authority.test.ts | 26 +++++++++++++++++++ .../ui/src/transcript-scroll-authority.tsx | 16 +++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 39360fa604..e7fc390c89 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -333,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 16bade9d22..9a77f80283 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -178,22 +178,26 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // asked for anything. The affordance still follows the new distance, // because that is a fact about the viewport rather than about them. // - // While pinned there is nothing left for this to catch. Anchoring is off - // and growth alone does not move `scrollTop`, so a non-echo event there - // is the reader — and swallowing it because content grew in the same + // 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. + // 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 && !pinned) { + 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();