You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/04 14:18:43 UTC

(maka) 01/01: fix(ui): let the pin own scroll anchoring while it follows the tail

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch fix/transcript-pin-owns-anchoring
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 49bf05969320ce8c613c6c08637591e25117eed3
Author: AstroHan <le...@outlook.com>
AuthorDate: Fri Sep 4 22:18:27 2026 +0800

    fix(ui): let the pin own scroll anchoring while it follows the tail
    
    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
---
 apps/desktop/src/renderer/styles/chat-message.css  | 10 ----
 .../__tests__/transcript-scroll-authority.test.ts  | 48 ++++++++++++++-----
 packages/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();
     },


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org