Uh oh!
There was an error while loading. Please reload this page.
fix(ui): let the pin own scroll anchoring while it follows the tail - #4793
fix(ui): let the pin own scroll anchoring while it follows the tail#4793Astro-Han wants to merge 2 commits into
Conversation
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
49bf059 to
f317b07CompareWhile 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
jackwener
left a comment
There was a problem hiding this comment.
Approving at exact head 98ec4f266652cb76f0d2b83e127094a4e4be9191. No P0 or P1. One P2, which is about how this lands rather than what it does. Four independent reviews, each sealing before reading the others.
This PR is CONFLICTING. The approval covers the change as written; it is not a statement that the merged result is reviewed — see the P2.
The diagnosis is right, and the interception is now provable
The root cause is stated precisely: overflow-anchor: auto makes the browser a second writer of scrollTop, and while pinned it is a writer whose every adjustment this authority overwrites on the next frame. The adjustment is never drawn, but its scroll event is real and arrives with the geometry changed — so the moved guard, which exists to stop content landing above a released reader from re-deriving their pin, swallows the reader's own wheel tick along with it.
Separating the two writers rather than trying to tell their events apart is the stronger fix, and the framing earns it: anchoring holds an anchor node still, the pin holds the tail, and both cannot be obeyed at once.
The historical replay confirms it catches #4269. The base's transcript-scroll-authority.tsx is byte-identical to the blob introduced with the authority in #4105 (e2a20e5b), so the base is the historical implementation. Keeping the head's tests and restoring the two production files to it takes the suite from 11/11 to 8 pass / 3 fail — the #4269 case yields pinned = true where the test expects false, which is exactly the reader being swallowed. A second test independently rejects a pinned state that did not set overflowAnchor: 'none', so this is not one condition branch pinned twice. Restoring the head returns 11/11.
Worth noting how the two layers divide: the unit fakes do not model Chromium anchoring, and they are not trying to. They pin removing the second writer and a combined event must release, and later growth must not re-tail. The browser mechanism is what the Desktop/CDP measurements cover. Neither substitutes for the other, and together they are not redundant.
Turning anchoring off costs nothing measurable
Ordinary history prepend still runs released, with overflow-anchor: auto intact. Both production entries order it that way: a reader scroll sets pinned = false and clears the inline override at transcript-scroll-authority.tsx:189-203 before notifying subscribers, and only then does use-chat-scroll.ts:162-164 request earlier history; the scrollTop === 0 wheel path explicitly calls releasePin() before requestEarlier() (:170-180).
The deleted CSS rule was restating the UA default. Computed value while released is still auto, and no other production owner of overflow-anchor / overflowAnchor exists anywhere in the tree.
The one crossing state is safe and semantically right. It is reachable only when history was requested while released and the user re-pins via dock before the async response lands. Constructed with a temporary review-only seam in real Chromium: the earlier range lands, tail distance settles at 0px, computed anchoring stays none. That is the correct precedence — the re-pin is the reader's most recent intent, and returning history should not drag the viewport off the tail.
Existing transcript and prompt-rail stories run 15/15 on a clean head, with tail-pinned stories showing none inline and computed while released, history and navigation stories showing an empty inline value and computed auto — including both history stories and all eight prompt-rail stories. UI built suite 368/368.
[P2] The rebase is a design decision, not a text conflict
This branch's merge base predates #4786, which landed a different answer to the same problem: it keeps anchoring and distinguishes content movement from reader movement with a band plus an accumulated unexplained residue. Neither mechanism is present on this branch, and both act on the same path once merged.
The specific hazard, if the merge keeps only the boolean moved:
a reader who scrolls in the same event as content landing above them is
moved && !pinnedhere and is swallowed — while onmain,unexplainedcan still hear them.
And the band cannot simply be dropped: clamp-on-shrink and viewport resize still exist after anchoring is off, and those are the other half of what it covers.
One consequence for the evidence: the six-round CDP comparison — main 0/6 escapes, 193px, 3 snap-backs; this branch 6/6, 10513px, 0 — was measured on a tree without #4786. It is not invalidated, but the behaviour of both mechanisms acting together is not covered by it, and re-running after the rebase would be worth the time.
On atTail
The added conjunct is sound for the clamp it names: content shrinks, the browser sets scrollTop to the new maximum, that offset is not lastWrittenTop, and treating it as the reader would fire the reader subscription on a transcript nobody scrolled. Test :337-360 is that case. It also swallows any geometry-changed event within PIN_THRESHOLD_PX of the tail while already pinned — but a real scroll-up past 10px with growth in the same event is !atTail && pinned and is still heard.
This is a UI fix, so the merge decision remains a human's — and on this one the merge itself needs a decision before it is mechanical.
简体中文
在 98ec4f266652cb76f0d2b83e127094a4e4be9191 上批准。没有 P0/P1。 一条 P2,关乎它如何落地,而不是它做了什么。四次独立评审,各自封存后才互看。
这个 PR 目前是 CONFLICTING。本批准覆盖的是「这份改动本身」,不构成「合并后的结果已被评审」——见下面那条 P2。
诊断是对的,而且拦截力现在是可证明的
根因说得很准确:overflow-anchor: auto让浏览器成为 scrollTop 的第二个写者;而在 pinned 期间,它是一个「每次调整都会被本 authority 在下一帧覆盖」的写者。那次调整从未被绘制,但它的 scroll 事件是真的,而且带着改变了的几何——于是 moved 守卫(它的存在本是为了阻止落在已释放读者上方的内容重新推导 pin)把读者自己的滚轮 tick 也一起吞掉了。
把两个写者分开,而不是试图分辨它们的事件,是更强的修法,而这个表述本身撑得起它:anchoring 让锚节点不动,pin 让尾部不动,两者不可能同时被遵守。
历史回放确认它抓得住 #4269。 base 的 transcript-scroll-authority.tsx 与随该 authority 一同引入的 #4105 那个 blob 逐字节相同(e2a20e5b),所以 base 本身就是那个历史实现。 保留 head 的测试、把两个生产文件恢复为它,套件从 11/11 变成 8 通过 / 3 失败——#4269 那个用例得到 pinned = true,而测试期望 false,正是读者被吞掉的那一幕。 另有一条测试独立地拒绝「pinned 却没有设置 overflowAnchor: 'none'」的状态,所以这不是把同一个条件分支钉了两遍。 恢复 head 后回到 11/11。
值得写下这两层是怎么分工的:单元 fake 不模拟 Chromium 的 anchoring,它们也不打算模拟。它们钉住的是**「移除第二个写者」以及「组合事件必须释放,且随后的增长不得把人写回尾部」**。浏览器机制由 Desktop/CDP 实测负责。 两者互不替代,合起来也不冗余。
关掉 anchoring 没有可测量的代价
普通的 history prepend 仍然运行在 released 状态下,overflow-anchor: auto 完好。 两条生产入口的顺序都是如此:读者滚动会在 transcript-scroll-authority.tsx:189-203 先把 pinned 置为 false 并清掉 inline override,然后才通知订阅者,而 use-chat-scroll.ts:162-164 更在其后才请求更早的历史;scrollTop === 0 的 wheel 路径则在 :170-180明确先调用 releasePin() 再 requestEarlier()。
被删掉的那条 CSS 规则只是在重述 UA 默认值。 released 时实测 computed 值仍是 auto,而且整棵树里不存在第二个overflow-anchor / overflowAnchor 的生产 owner。
唯一那个交叉状态是安全且语义正确的。 它只在「历史是在 released 状态下请求的、而用户在异步响应落地前通过 dock 重新 pin」时可达。用一个临时的、仅供评审的 seam 在真实 Chromium 中构造出来:更早的范围成功落下,尾部距离最终稳定在 0px,computed anchoring 保持 none。 这是正确的优先级——re-pin 是读者最新的意图,返回的历史不应把视口从尾部拽走。
现有的 transcript 与 prompt-rail stories 在干净 head 上跑 15/15,尾部 pinned 的 stories 的 inline 与 computed 都是 none,history 与 navigation stories 则是 inline 为空、computed 为 auto——包含两条 history story 和全部八条 prompt-rail story。 UI 构建套件 368/368。
[P2] 这次 rebase 是一个设计决定,不是文本冲突
这个分支的 merge base 早于 #4786,而后者对同一个问题给出了另一个答案:它保留 anchoring,用一条带 + 累积的 unexplained 残差来区分「内容移动」与「读者移动」。两套机制在本分支上都不存在,而合并之后它们会作用在同一条路径上。
如果合并只保留布尔 moved,具体的危险是:
一个读者在「内容落在他上方」的同一个事件里滚动,在这里是
moved && !pinned,会被吞掉——而在main上,unexplained仍然听得见他。
而且这条带不能就这么丢掉:收缩时的 clamp 与 viewport resize 在 anchoring 关闭之后依然存在,那正是它覆盖的另一半。
对证据的一个推论:那份六轮 CDP 对照——main 0/6 逃脱、193px、3 次 snap-back;本分支 6/6、10513px、0 次——是在一棵没有 #4786 的树上测出来的。 它没有因此失效,但「两套机制同时作用」的行为并不在它的覆盖范围内,rebase 之后重跑一遍是值得的。
关于 atTail
这个新增的合取项对它所命名的那个 clamp 是成立的:内容收缩,浏览器把 scrollTop 设为新的最大值,该偏移不等于 lastWrittenTop,而把它当作读者就会在没有人滚动过的 transcript 上触发读者订阅。测试 :337-360 正是这个用例。它同时也会吞掉「已经 pinned 时,距尾部 PIN_THRESHOLD_PX 以内的任何几何变化事件」——但真实的、超过 10px 的向上滚动,即使同一事件里有增长,是 !atTail && pinned,仍然会被听见。
这是一个 UI 修复,所以合并与否仍由人决定——而这一单,合并动作本身在成为机械操作之前,需要先有一个决定。
Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.
Astro-Han
commented
Sep 4, 2026
Closing this: measured against current #4786 landed a different fix for #4269 while this was open — it keeps anchoring on and asks instead how much of the offset the content can account for, treating anything outside that band as the reader. I rebased this branch onto it, resolved the two into a single rule (the band, with the pin owning anchoring so that growth accounts for nothing while pinned), and re-ran the Electron A/B against both builds. Real Desktop build, CDP wheel input, production motion, 6 rounds each:
The second shape is the one this branch's argument predicted it would win: a gesture smaller than the arrival landing under it, where leaving anchoring on widens the band enough to hide the reader. It does not win there. The distributions overlap at n=6, so I read it as no difference rather than worse — either way there is nothing to justify the change. The numbers in the description above are void: their baseline was What is left of this branch is one assignment and one deleted CSS rule, and it has already shown it can introduce a regression (two history stories went red on the first revision). That is risk without a measured benefit, so it goes. Two real things came out of it, and both will be picked up separately:
|
Summary
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.
overflow-anchor: automakes the browser a second writer ofscrollTop. While the transcript is pinned, it is a writer whose every adjustment this authority overwrites on the next frame — the adjustment is never drawn, but itsscrollevent is real, and it arrives with the geometry changed. Themovedguard, which exists so that content landing above a released reader does not re-derive their pin, then swallows the reader's own wheel tick along with it, and the ResizeObserver writes them back to the tail.Anchoring and the pin are opposite instructions: anchoring holds an anchor node still, the pin 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 anchoring is for.
With anchoring off, growth moves no offset, so while pinned an event that finds the reader away from the tail is theirs. One that leaves them on it is not — that is where the browser's own clamp lands when content shrinks under a reader who is already at the end.
That leaves
overflow-anchorwith a single owner, so the CSS rule inchat-message.cssthat declared it goes too, along with the file-header sentence claiming the two policies were the same instruction. The released path — the geometry guard, history paging, re-pinning — is unchanged.Fixes#4269
Verification
Real Desktop build, CDP wheel input, production motion. Pinned at the tail, the transcript grows from a macrotask (an IPC-delivered React commit, which lands before the frame's scroll events are dispatched), the reader wheels up 40 ticks = 4800px. Six rounds each.
mainmainWith PR #4259's sub-turn containment CSS also applied (growth every 4ms):
main0/6 escaped, this branch 6/6.History paging is unchanged: 7 loads, 6 evictions, 0 visible jumps over 130px, 0 snap-to-tail, on both builds.
@maka/ui368 pass / 0 fail. Storybook smoke: 277 stories, 303 theme renders.npm run format,npm run lintclean.Review focus
The first revision of this branch claimed that with anchoring off, any non-echo event while pinned is the reader. That was wrong, and CI caught it:
TailFollowDoesNotAskForHistoryandNestedScrollerNearHistoryBoundaryAsksForNothingboth started asking for earlier history on a transcript nobody had scrolled. The reachable input is the browser clampingscrollTopwhen content shrinks under a reader already at the end — it carries an offset this authority never wrote, with the geometry changed, and it is not a reader. The second commit narrows the rule to what the design can actually stand behind, and adds a test for that case.Two pre-existing tests changed here. Both construct "while pinned, something moved
scrollTopthat this authority did not write" and assert the reader is not reported. The shrink-clamp is that input, and it is now covered by a test that names it; the two originals asserted it through growth, which anchoring-off has made unreachable. They are replaced by a #4269 regression test (a reader who scrolls up mid-stream is not swallowed) and one asserting the pin owns anchoring and hands it back.Still the part to attack: another way for
scrollTopto move while pinned that is neither this authority, nor the reader, nor a clamp back onto the tail.AI use
Select exactly one:
Tool(s) and scope: Claude Code investigated the mechanism, wrote the change and the tests, and ran the Electron measurements above.
Checklist
Does this PR entail a change in behavior?