refactor(ui): make the transcript the only writer of its scroll position - #4105
Conversation
jackwener
left a comment
There was a problem hiding this comment.
I found no P0–P3 issues on exact head e7baf0d75847edf0d2f953a83980e1d6ab186457.
The session-switch jump was a real path: after arrival, useChatScroll scheduled an idle callback that called requestEarlier() whenever the scroller was still near the top, which is exactly where a newly filled transcript sits. That inserted history the reader had not asked for, and those turns then grew under them. The upward-scroll and wheel handlers already load earlier history on intent, so the prefetch had no job.
What remains of Maka's scroll work is the pair Astryx cannot see: jump-to-turn and earlier-history load both call unlockAutoFollow, and "return to latest" calls scrollToBottom({ behavior: 'instant' }). @astryxdesign/core@0.5.0 already has that method; the patch only exposes it and uses an instant jump on conversationKey change instead of lock(), which re-entered the spring. The one-pixel nudge at scrollTop === 0 matches the documented overflow-anchor hole. I did not treat issue #4099 as evidence.
I am not merging. This is a behavior-changing scroll-ownership change; merge is a human call. Hosted test was still queued when I posted. This review does not claim CI is green.
Posted by an automated review agent operated by @WAWQAQ. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.
简体中文
精确 head e7baf0d75847edf0d2f953a83980e1d6ab186457 上我没有发现 P0–P3。
会话切换后的跳动是真实路径:到达后 useChatScroll 会在空闲时无条件预取更早历史,而新填满的转录往往还停在顶部附近,于是读者没要的回合被插进来并在下方长高。向上滚动和滚轮已经会按意图加载,这段预取没有工作可做。
Maka 还留下 Astryx 看不见的两处:跳到指定回合、加载更早历史会 unlockAutoFollow;回到最新会 scrollToBottom({ behavior: 'instant' })。@astryxdesign/core@0.5.0 已有这个方法,补丁只是暴露它,并在 conversationKey 变化时立刻跳到底,而不是 lock() 再走弹簧。scrollTop === 0 时的 1px 挪动对得上 overflow-anchor 在最顶端不生效的缺口。我没有把 issue #4099 当证据。
我不合入。这是会改行为的滚动归属调整,合入由人类决定。发这条时 hosted test 还在排队,这次审查不表示 CI 已绿。
本条评论由 @WAWQAQ 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。
M4n5ter
left a comment
There was a problem hiding this comment.
I am approving exact head e7baf0d75847edf0d2f953a83980e1d6ab186457 because I found no P0 or P1 issues. I left one non-blocking P2 inline: when a session's transcript arrives asynchronously, the conversation-change jump consumes Astryx's pending first-fill state while the scroller is still empty, so the later content still enters through the spring instead of landing at the bottom in one step.
The simplification is otherwise real: the change removes the speculative prefetch, two product-owned scroll authorities, their gating state, and their dedicated tests instead of moving them elsewhere. The remaining host seams are limited to the two moves Astryx cannot infer itself: releasing auto-follow for reader-directed navigation and re-locking it when returning to the latest turn.
I verified all 237 UI tests, the Desktop production build, seven real Electron prompt-rail tests, Biome, and the changed diff. The exact-head hosted windows_recovery check is green; hosted test is still queued, so this approval does not claim CI is fully green.
Posted by an automated review agent operated by @M4n5ter. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.
简体中文
本条评论由 @M4n5ter 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。
The instant jump on conversation change assumed the incoming transcript was already mounted. It is not: setActiveId clears messages and marks the load pending in the same update that changes the key, so the scroller the swap sees holds only the loading placeholder and has nothing to scroll. scrollToBottom consumes the hook's pending first fill whether or not the jump could do anything, so the transcript arriving a few frames later took the spring path and flew down from the top — the exact motion the pin used to prevent, reintroduced for cold switches only. Arming the fill again after the jump covers both shapes: a transcript already on screen is positioned by the jump, and one that arrives later is positioned in a single frame by the first scrollIfLocked that sees scrollable content. This matches what the measurements showed and I misread at the time: cold first visits settled 677px from the bottom while warm switches settled at 5px, which I attributed to load cost rather than to this path. Reported by M4n5ter's review agent on apache#4105. Generated-by: Claude Code
The instant jump on conversation change assumed the incoming transcript was already mounted. It is not: setActiveId clears messages and marks the load pending in the same update that changes the key, so the scroller the swap sees holds only the loading placeholder and has nothing to scroll. scrollToBottom consumes the hook's pending first fill whether or not the jump could do anything, so the transcript arriving a few frames later took the spring path and flew down from the top — the exact motion the pin used to prevent, reintroduced for cold switches only. Arming the fill again after the jump covers both shapes: a transcript already on screen is positioned by the jump, and one that arrives later is positioned in a single frame by the first scrollIfLocked that sees scrollable content. This matches what the measurements showed and I misread at the time: cold first visits settled 677px from the bottom while warm switches settled at 5px, which I attributed to load cost rather than to this path. Reported by M4n5ter's review agent on #4105. Generated-by: Claude Code
fa97885 to
c0de634
Compare
ecd3290 to
782b5b7
Compare
jackwener
left a comment
There was a problem hiding this comment.
I am not approving exact head 9eb824f9a9225e615236fd0816d88a05d6c002c5. 1×P1, no P0, no P2/P3.
P1. Earlier-history load is no longer tied to reader intent, and a real ask at the top keeps the pin.
The single-writer pin is the right cut. These two handlers sit outside it. 9eb824f9a stopped unpinning on a history request because a short latest window was requesting while still following — that request should not fire while the reader is at the tail, and a wheel-up at scrollTop === 0 is a reader ask the authority cannot see (scroll does not fire there).
I did not treat issue #4099 as evidence. I am not merging: this is a behavior-changing scroll refactor, and merge is a human call. Hosted test was still queued when I posted; this review does not claim CI is green.
Posted by an automated review agent operated by @WAWQAQ. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.
简体中文
精确 head 9eb824f9a9225e615236fd0816d88a05d6c002c5 上我不 Approve。1×P1,没有 P0,没有 P2/P3。
P1. 更早历史的加载已经不再绑定读者意图;真正在顶端要历史时,钉住也不会松开。
单一写入者这个切法是对的。出问题的是钉住外面的两个 handler。9eb824f9a 不再在请求历史上松钉,是因为短窗口在跟随时也会发出请求——跟随时根本不该请求;而 scrollTop === 0 时向上滚轮是读者在要历史,authority 看不见(这里不会有 scroll)。
我没有把 issue #4099 当证据。我不合入:这是会改行为的滚动重构,合入由人类决定。发这条时 hosted test 还在排队,这次审查不表示 CI 已绿。
本条评论由 @WAWQAQ 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。
zhiiw
left a comment
There was a problem hiding this comment.
Reviewed at exact head 9eb824f9 (23 files, +1059/−994). The mechanism reads clean on inspection — one writer for scrollTop, navigation as a one-shot command while unpinned, the duplicated prefetch removed. I did not find correctness issues in the code itself worth a P-level.
However, this head cannot be approved: its own evidence spec is red in CI. Run 33237483941 (test, Linux/xvfb) fails two of the six new transcript-scroll assertions, both with the same signature — the transcript settles 302px off the tail where the budget is ≤4px:
a streaming answer keeps the viewport at the taila gesture a nested scroller consumed does not release the tail
The author verified them locally and they pass there; on the hosted runner they do not. Until that gap is reconciled (either the tests are environment-fragile or the behavior differs off the author's machine), the PR's own claims are unproven on CI. Inline notes on the two failing specs.
简体中文
代码本身读完没有发现值得定级的问题,但这个 head 不能批:PR 自己新增的证据测试在 CI(Linux/xvfb)上挂了 2/6,失败签名一致——收敛后离尾部 302px(预算是 ≤4px)。作者本地是过的。这个落差没 reconciled 之前,本 PR 自己的声明在 CI 上未被证明。
M4n5ter
left a comment
There was a problem hiding this comment.
I reviewed exact head 14f894da459463ead9a2bde47d327208f49b30ce. I found no P0 or P1 issue, so I am approving it with non-blocking P2 feedback.
The latest commit closes the event-ordering defect from the preceding head: the authority now matches its clamped written offset instead of owning a whole animation-frame window. The production-state probe now preserves a reader scroll across later growth, and the real Electron dock regression passed 5/5. The earlier delayed conversation-fill problem also remains closed.
I independently reproduced both active use-chat-scroll.ts history-loading threads and did not add duplicate inline comments. I classify their observed effects as P2 rather than P1: one path performs bounded earlier-history work without reader intent, and the already-at-zero wheel path causes a recoverable viewport jump after the first page loads. Neither loses data or creates an unrecoverable state. I added one separate P2 inline for a supported transcript-growth path that still does not reach the new authority.
The refactor's simplification is genuine: it removes the two previous scroll authorities, speculative prefetch machinery, duplicate DOM/state seams, and their dedicated suites. All 258 UI tests and the Desktop production build pass locally. Hosted checks for this exact head were still running at my last read, so this approval does not claim that CI is green.
Posted by an automated review agent operated by @M4n5ter. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.
简体中文
本条评论由 @M4n5ter 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。
Switching sessions scheduled a requestIdleCallback that fired roughly 150ms later and called requestEarlier() unconditionally, inserting earlier-history turns above the reader's scroll position. Those turns then inflated in waves as their content resolved, moving the transcript under the reader three times after it had already settled. Nothing asked for that history. The prefetch was speculative: it ran on arrival rather than on any reader intent, and the upward-scroll and wheel handlers already load earlier history when the reader actually approaches the top. Measured with alternating within-instance A/B (3 repetitions per configuration, sigma about 0.02): mean CLS across four sessions drops from 0.481 to 0.085. Removing the only reader of requestEarlierRef also removes the cross-effect mutable-callback coupling between the history loader and the arrival gate, so the arrival effect no longer depends on hasOlderHistory or canLoadEarlier. Capability given up: earlier history is no longer warmed during the switch, so the reader's first upward scroll pays one load. Generated-by: Claude Code
chat-scroll-anchor captured a turn id plus its offset before content landed above the reader and restored that position a frame later. This is what browser scroll anchoring already does, and does better: the browser compensates during layout, not a frame after it. Measured directly: inserting 1500px above the viewport mid-scroll moves the visible content 0px. Measured in place: with the arrival prefetch gone, removing the hand-rolled anchoring leaves mean CLS unchanged (0.085 to 0.090, byte-identical on three of four sessions). The anchoring was carrying no load once nothing inserted content the reader had not asked for, which is why it could only go after the prefetch. Removing it also removes the double compensation it sat behind — a scrollTop adjustment by the scrollHeight delta, applied whenever the restore reported failure — and the pendingAnchor round trip through the virtualizer's window installs and resize observer. Added in its place: overflow-anchor stated on the transcript column so the dependency is legible rather than inherited from the default, and a one-pixel nudge when the earlier-history request starts at the very top, where anchoring is suppressed and would otherwise let the incoming turns jump the reader. Generated-by: Claude Code
chat-surface-layout states that Astryx owns scrolling and new-message following. arrival-bottom-pin was a second implementation of exactly that, added in apache#2239 because ChatLayout exposed only scrollContainerRef and contentRef, so its controller could not be reached. apache#2923 opened that seam for unlockAutoFollow and the pin was never revisited. Reading Astryx's controller, it already covers what six review rounds put into the pin: resize-synthetic scroll events are excluded by comparing scrollHeight and offsetHeight, a horizontal wheel is excluded by requiring deltaY < 0, and gestures are scoped by binding to the scroller itself rather than by testing where the pointer was. Its initial fill positions in one frame instead of springing from the top, which is what the pin's clamp existed to produce. The one gap was reachability again: on a conversation change the patch called lock(), which re-enters through the spring because the hook's initial-fill flag was consumed at mount. Asking for the instant jump directly closes it, in the patch that was already there. Removing the pin leaves two moves Astryx cannot see, both now going through the context: navigating to a turn and loading earlier history release auto-follow, and "return to latest" resumes it. The second needed the other half of apache#2923's seam, so the patch also exposes scrollToBottom. Both are additive context fields to upstream. data-turn-window went with the pin it gated: its ready state existed to release the pin, and the fonts.ready wait plus fifty markdown polls plus double rAF existed to time that release. The two E2E tests that waited on it wait for a mounted turn instead, which is what they were after. latestNavigationNonce was left write-only and goes too. arrival-bottom-pin.test.ts is replaced by a test of what Maka still owns, the two release moments, rather than a test of Astryx's internals. Capability given up: a wheel or touch over the dock while the transcript is animating now releases following, where the pin discriminated by gesture origin; and returning to the bottom re-locks following, where the pin's release was permanent for that arrival. Generated-by: Claude Code
The instant jump on conversation change assumed the incoming transcript was already mounted. It is not: setActiveId clears messages and marks the load pending in the same update that changes the key, so the scroller the swap sees holds only the loading placeholder and has nothing to scroll. scrollToBottom consumes the hook's pending first fill whether or not the jump could do anything, so the transcript arriving a few frames later took the spring path and flew down from the top — the exact motion the pin used to prevent, reintroduced for cold switches only. Arming the fill again after the jump covers both shapes: a transcript already on screen is positioned by the jump, and one that arrives later is positioned in a single frame by the first scrollIfLocked that sees scrollable content. This matches what the measurements showed and I misread at the time: cold first visits settled 677px from the bottom while warm switches settled at 5px, which I attributed to load cost rather than to this path. Reported by M4n5ter's review agent on apache#4105. Generated-by: Claude Code
Adversarial review found three more defects, all one mistake. The mechanisms this branch deleted were continuous: the anchor restored a captured position whenever the content landed, and the pin clamped the bottom frame by frame for the whole arrival. Their replacements are one-shot — Astryx's controller acts on the frame it is called, and the browser anchors at the instant content is inserted. The call sites moved across unchanged, and each one now fires before the DOM reaches the state it assumes. Loading earlier history nudged the scroller off zero when the request was made, but anchoring is suppressed or not at the moment the turns land, a whole IPC round trip later — and the reader coasting upward is back at zero by then. The compensation moves to the frame after the turns are on screen, which is also the first moment their height is known. Return to latest jumped in a promise callback that resolves before React commits the newer range, so it jumped against the old geometry and consumed the pending first fill, leaving the range that arrived after it to spring down from the top. The jump is removed rather than repaired: nothing ever required this button to arrive instantly, and the transcript scrolling to the newest turn shows the reader what happened. With no host calling it, scrollToBottom comes back out of the layout context. Navigating to a turn released auto-follow on every transcript update, not once per chosen target: the effect re-runs on messages so a target that arrives before its turn still lands, and the release it used to make was an idempotent no-op. Astryx's unlock is persistent, and the search target is never cleared, so following stayed off for the rest of the session. Also here, because this change is what surfaced them: the gesture releases were gated on a spring being in flight, which never happens under prefers-reduced-motion, leaving those readers unable to leave the tail by wheel or touch — the predicate is following, not animating. And overflow-anchor moves to the scroller that actually runs it; on the content column it was inert. hasTurns had no reader left. Generated-by: Claude Code
Widening the wheel and touchmove release from "a spring is running" to "we are following" reaches every gesture that merely bubbles through the scroller. The transcript is self-scrolling, so the scroller is the whole ChatLayout root: a tool output body, the pty terminal, the composer, and the graph panel all sit inside it and all scroll on their own. A wheel they consume never moves the outer scroller, so no scroll and no scrollend follow, and the scrollend re-lock can never run. Following is off, the distance to the bottom is still zero so the scroll-to-bottom button stays hidden, and the reader has no way back. That widening was aimed at readers who prefer reduced motion, for whom the spring never runs and both releases are therefore dead code. It is a real gap, but scroll-direction detection still serves them correctly, so what they lose is a shortcut rather than the behaviour. Trading that for a way to silently stop following is the wrong exchange, and it is not what this branch set out to change. Reported upstream instead. Generated-by: Claude Code
Three writers moved `scrollTop` in the chat transcript — Astryx's auto-follow lock and spring, Maka's height-delta compensation and `scrollIntoView`, and the browser's own anchoring — and none of them held the answer to "where should the viewport be". They avoided each other through flags, effect ordering and heuristics reconstructed from DOM signals, and each heuristic had more than one cause. Collapse the policy to one boolean. `pinned` means growth writes `scrollTop = scrollHeight`; not pinned means nothing here writes it, ever, and `overflow-anchor: auto` — already the initial value — keeps the reader where they were reading for free. Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at `scrollTop === 0`, the one place native anchoring declines to help. Every command releases the pin first, so a command can never race the policy. Being the only writer is what makes the state exact. A write flags itself, so an unflagged `scroll` event is the reader by construction — and because `scroll` does not bubble, a gesture a nested scroller consumed never reaches the authority at all. Astryx's scroll layer is turned off per call site through a new `scrollOwner` prop rather than globally: the workhub surfaces render no `ChatView` and still want stock auto-follow. The growth signal reuses the turn virtualizer's existing `ResizeObserver`; no new observer is added. The patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks — `conversationKey`, `unlockAutoFollow` and `resetInitialFill` all disappear, replaced by forwarding one `autoScroll` flag into the hook's existing `enabled` switch. Generated-by: Claude Opus 5 via Claude Code
Five Playwright assertions over the real Electron window, one per property the single scroll authority is supposed to have: a streaming answer keeps the tail on screen, content arriving after the reader scrolls up does not pull them back, a gesture a nested scroller consumed does not release the tail, the dock affordance returns the reader to the tail, and earlier history lands above the turn the reader is on. They assert element positions rather than pixel deltas. Where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that frame's own growth, because following by `ResizeObserver` is one frame behind by construction and that frame is never painted. A fixed pixel budget would encode the machine it was written on. Generated-by: Claude Opus 5 via Claude Code
The transcript carried its own compensation for content landing above the reader: capture a turn's box before an earlier-history load, restore that box after it. It existed for one reason — the browser declines to anchor while a scroller sits at `scrollTop === 0`, which is where the wheel path asks for history. Measured in Chromium, that hole is one pixel deep. Inserting 501px above the reader moves `scrollTop` by 501 at an offset of 1, by 503 at 2, by 551 at 50, and by nothing at all at 0. So raise the offset to 1 before asking, and native anchoring covers the case its absence was being compensated for. Everywhere else the compensation was already redundant: the load fires at `scrollTop <= max(640, clientHeight * 2)`, and at every one of those offsets the browser was anchoring anyway, with the restore computing a delta of zero on top of it. A second authority for a fact the platform already owned, live on one boundary out of 641. Deletes `chat-scroll-anchor`, the `holdAnchor` command and the `anchor` field, leaving the authority with one boolean and two commands. The E2E covers the offset that made this possible: it fails, holding the scroller at zero, without the raise. Generated-by: Claude Code
Three leftovers from when the authority was optional, all of them second behaviours for states nothing can reach: The provider was installed only for `scrollOwner="host"`, so every consumer carried a null branch — a detached snapshot for the dock button, optional calls in `ChatView`, a ref mirror and a guard in `useChatScroll`. Install it unconditionally instead. An authority nobody hands a scroller to writes nothing and costs one object, and `useTranscriptScrollAuthority` can now throw on a missing provider the way `ChatView` already does for a missing layout, because a missing one means the tree is assembled wrong. `autoScroll` sat on the public props while `scrollOwner` decided it, and the internal value was spread last — a caller-supplied one was silently dropped. Omit it from the type; `scrollOwner` remains the only answer. Asking for earlier history was deduplicated twice: `app-shell` refuses a request while one is in flight, and the hook kept its own in-flight ref because the shell's guard read React state that had not updated yet within the same task. Move that guard to a ref at the shell, where the request actually originates, and the hook stops tracking a load it does not own — along with the `historyLoadPending` prop it only needed for the guard. Generated-by: Claude Code
…tent grows The pin had one growth signal, and it came from the transcript: a resize of the content the virtualizer mounts. Nothing told it about the box doing the looking. A window resize, a composer that gains a line, a dock that changes height — each takes pixels from `clientHeight` without touching `scrollHeight`, and a pinned reader is left exactly that far from the bottom with no further signal to correct it. CI caught the case on Linux, where the window settles into its size after the first turn has already streamed: the tail lag through the stream was within a frame's growth, and the transcript still came to rest 302px short of the end. Observe the scroller itself alongside the content. It is the same correction on the same policy — pinned writes the tail, released writes nothing — so it adds a signal rather than a second way to decide. Asking for earlier history no longer releases the pin either. That release assumed the reader had scrolled up to ask, but the request also fires on a transcript short enough that its tail is inside the load band, where the reader is following and must keep following. The authority already sees the scroll that means the reader moved; nothing else needs an opinion. The E2E assertions that failed now report `scrollTop`, `scrollHeight` and `clientHeight` alongside the distance, because a bare distance cannot say whether the content outgrew the reader or the viewport shrank under them. Generated-by: Claude Code
…ming The authority flagged itself while it wrote and cleared the flag on the next frame, so an event that took longer than a frame to arrive came back as a reader's gesture and released the tail. CI showed exactly that, twice, with the numbers to name it: `clientHeight` was a steady 660 — no viewport shrank — while the transcript rested at `scrollTop` 944 against a `scrollHeight` of 1906. 944 is a tail, just not the current one: the write landed, the answer kept streaming, and the late event found the scroller 302px from where the tail had moved to, which is indistinguishable from a reader who scrolled up by 302. Timing cannot make that distinction, so stop asking it to. Remember the offset written, clamped as the browser stored it, and treat an event that finds the scroller still on it as the echo of that write however late it is. A reader's gesture has by definition moved `scrollTop` somewhere else. The frame flag and its `requestAnimationFrame` go with it. The previous commit's viewport observer stays — following the tail through a window resize is right on its own terms — but this, not that, is what those two failures were. Generated-by: Claude Code
Two review findings, one cause: "the reader wants the history above them" was being re-derived from position, and position has more than one producer. `nearStart()` is true of the tail as well on any transcript shorter than about three viewports, so following it asked for earlier history on every write — the speculative insert this branch's first commit existed to delete. A wheel at `scrollTop === 0` had the opposite problem: the scroller cannot move, no scroll event follows, and the one gesture that unambiguously means "show me what is above" was the one nothing could see. So stop reading the raw event twice. The authority already watches the scroller to keep the pin, and it is the only thing that can tell a reader's movement from everything else that writes `scrollTop`: its own tail writes, native anchoring compensating for turns that landed above, growth that outran a write, and the browser clamping the offset when the viewport resizes. It now publishes that distinction — `subscribeToReaderScroll` fires for the reader and for nobody else — and the pin is only re-derived from position on those same events. A wheel up stays where it is, in the hook, because it is a gesture and not a movement: it releases the pin and asks. Told apart by scroll geometry: an event that arrives with `scrollHeight` or `clientHeight` changed since the last one is the content or the viewport moving under the reader, whose offset therefore changed while their intent did not. That is also what left the CI failures standing — growth outrunning the write read as a reader who had scrolled up, and `releasePin` was undone by the next anchoring correction. E2E covers the half that is observable: following the tail asks for nothing, and a wheel the scroller cannot act on still asks. The release itself has no assertion — the reader who asks is already at the tail, so anchoring leaves them the same distance from it either way — and the spec says so rather than asserting something that passes both ways. Generated-by: Claude Code
The pin's growth signal came from the turn virtualizer, which observes the scroller and every mounted turn wrapper to keep its height index. That set is not the transcript: an optimistic user message, the no-tail live fallback and an orphaned conversation item all render outside the wrappers, so growth from any of them reached nobody and a pinned reader was left however many pixels that content added away from the tail. Sending a follow-up is the ordinary way to hit it. Observing the scroller's own children instead makes that unrepresentable — `scrollHeight` is those boxes, whatever the transcript chose to put in them — and it is the same signal the viewport already needed, so `notifyContentResize` and the `onContentResize` wiring through the virtualizer go with it. The virtualizer is left observing turns for the one thing turns are: its height index. Covered by `a message the transcript renders outside a turn is followed too`, which asserts the optimistic message lands outside a turn wrapper and that the tail is still under the reader; it fails with the children unobserved. Generated-by: Claude Code
0d16c68 to
944f234
Compare
zhiiw
left a comment
There was a problem hiding this comment.
Re-reviewed at exact head 944f2342, after the three follow-up commits that answer my earlier inline comments.
The fix for the CI-red evidence is the right one. The old authority flagged its own scrollTop writes with a frame-windowed writing flag — on a loaded machine the echo of a write can arrive after the window closes, which is exactly how the hosted Linux runner failed the two settle assertions while the author's machine passed. The new authority identifies its own writes by position (lastWrittenTop read back after clamping) and discriminates reader movement from content/viewport movement by comparing scroll geometry (scrollHeight/clientHeight) — no timing anywhere. The ResizeObserver now watches the scroller's children rather than relying on the transcript's own idea of what grew. notifyContentResize is gone with no remaining callers.
My earlier comments are answered by this head. Nothing new to report.
Executed on a real Windows machine at this head: transcript-scroll-authority 9/9, including the new echo-late-arrival and growth-outruns-write cases. Hosted test and windows_recovery are both completed/success on the exact head.
简体中文
新 head 回答了我上一轮的行内评论:把"自己写的滚动"从时间窗识别改成位置回读(lastWrittenTop 读回 clamp 后的值)+ 几何比对(scrollHeight/clientHeight)区分读者手势与内容/视口移动——完全不依赖时序,这正是 hosted runner 上挂掉、作者机器上过的那个失败模式。本机真 Windows:transcript-scroll-authority 9/9(含新增的晚到 echo 与超增长的用例),hosted test 与 windows_recovery 在当前 head 全绿。
Summary
Switching sessions moved the transcript under the reader after it had already settled.
The immediate cause was one speculative prefetch: on arrival,
useChatScrollscheduled an idle callback that loaded earlier history nobody asked for and inserted it above the reader's position, where it then inflated in waves. The upward-scroll and wheel handlers already load that history on real intent, so the prefetch had no job.Removing it exposed the real one.
scrollTophad three writers — Astryx's lock and spring, this hook's compensation andscrollIntoView, and the browser's own anchoring — and nothing decided where the viewport should be. They coordinated through ref flags and effect ordering, so every defect in this area is two writers colliding in one commit.unlockAutoFollowwas the tell: a seam whose only purpose is to let one owner overrule another.So this PR ends with one writer.
transcript-scroll-authorityholds a single boolean:pinned→ content that grows writesscrollTop = scrollHeight!pinned→ nothing here writesscrollTop, ever"Keep the reader where they were reading" is the definition of
overflow-anchor: auto, already the initial value and free, and "the reader is dragging" is also don't touch it — both are the same instruction to this code. Navigation becomes a one-shot command that only runs while unpinned, so a command cannot race the policy.Being the only writer is what makes "the reader moved" exact rather than inferred. The authority remembers the offset it wrote, so an event that finds the scroller still on that offset is its own echo however late it arrives; and an event arriving with
scrollHeightorclientHeightchanged is the content or the viewport moving under the reader — anchoring, growth that outran a write, a resize the browser answered by clamping — which moves their offset without changing their intent. What is left is the reader, and the authority publishes it (subscribeToReaderScroll) so nothing else keeps a second reading of the raw event. Astryx had to infer this from scroll direction, height deltas and wheel events, and every one of those signals has more than one cause.It watches its own scroller, too: the scroller's box, because the tail moves when the viewport shrinks, and the scroller's children's boxes, because that is what
scrollHeightis made of. Not a list of nodes the transcript thinks matter — an observer that knows which nodes matter is an observer that can be wrong about it, and it was: the transcript renders the optimistic user message, the no-tail live fallback and orphaned conversation items outside the virtual turn wrappers.Removed along the way, because the authority makes them redundant:
arrival-bottom-pin, the idle prefetch,chat-scroll-anchor,data-turn-windowand thefonts.readywait, fifty markdown polls and double rAF that timed its release, thenotifyContentResizecommand and theonContentResizewiring throughuseTurnVirtualizer, and three of the four injection points in the vendored Astryx patch. The patch's scroll surface is now one seam —ChatLayoutforwardingautoScrollinto theenabledoptionuseChatStreamScrollalready published.The hand-rolled compensation for content landing above the reader went too. It existed because the browser declines to anchor at
scrollTop === 0, and that hole is exactly one pixel deep: measured in Chromium, a 501px insert above the reader movesscrollTopby 501 at an offset of 1 and by nothing at 0. Raising the offset to 1 before asking for history hands the case to native anchoring, which was already doing the work at every other offset the load can fire from.The authority is also unconditional: every
ChatSurfaceLayoutprovides one, so nothing below carries a second behaviour for its absence, andautoScroll— whichscrollOwnerdecides and the layout spread last — is off the public props. Deduplicating an earlier-history request moved toapp-shell, where the request originates, so the hook no longer tracks a load it does not own.23 files changed, 1321 insertions, 999 deletions.
Refs #4099
Verification
Layout shift, measured with alternating within-instance A/B, four sessions, three repetitions each. Only same-instance comparisons are used — the same build across restarts varies far more than the effect.
Left:
main. Right: this branch. Same session switch, the three seconds after it.Both the table and the capture were measured at
31539bc66, where the prefetch was removed and Astryx still owned scrolling. They are not re-measured at the tip: the single authority only removes writers ofscrollTop, so it cannot reintroduce the shift, and what it changes beyond that is scroll position, which CLS cannot see. The Playwright assertions below are the tip's evidence.CLS was the wrong instrument for the rest of it: it measures layout shift, and every remaining defect was a wrong scroll position, which it cannot see. Those are now covered by nine Playwright assertions against a real Electron window (
apps/desktop/e2e/transcript-scroll.spec.ts), each asserting where an element ended up rather than a pixel delta:[data-virtual-turn-id], which is where a turn-shaped growth signal stops seeing itmouse.wheelover a genuinely overflowing nested container, with a guard assertion proving the wheel was consumedpointer-events/opacity, sincetoBeVisibleis true atopacity: 0)WheelEvent, because a real wheel scrolls and the scroll would settle the question by itselfGreen on CI at the current head (
test, Linux/xvfb). Locally:transcript-scroll9 passed,@maka/uitranscript-scroll-authority9 passed anduse-turn-virtualizer12 passed, plusnpm run format:checkandtsc --noEmitforpackages/uiand the desktop renderer. Not run: the full repository suite.What review found
Three rounds of adversarial review found fourteen defects. Eleven were the same problem — a fact about the viewport re-derived somewhere that did not own it — and the collapse to a single boolean plus one-shot commands makes them structurally inexpressible rather than fixed one at a time:
isVisibleisauthority.getSnapshot().awayFromTailand itsonClickisauthority.pinToTail()— read and write on one object, with no second source that can go stale.pinToTail()consults no precondition, so no state exists that could veto it.pinToTail(), a jump isreleasePin(). They write the same variable instead of overruling each other, and the later call wins because the call order says so.deltaY < 0 && animatingRef.current), and gestures bubble — a wheel a nested scroller consumed passed through the transcript anyway. CDP also showedanimatingRefis true at rest, so that conjunct never constrained anything. Release now comes from the authority's own reading of the scroller, andscrolldoes not bubble, so a gesture the transcript never acted on cannot reach it.scrollevents at an offset the authority did not write, and each silently re-answered "is the reader following the tail". Scroll geometry is now the discriminator.use-chat-scrollread the rawscrollevent and asked whenever the offset was near the start — which fired on the authority's own pin writes (on any transcript shorter than about three viewports the tail is inside that band), and missed the one gesture that matters: a wheel up atscrollTop === 0, where the scroller cannot move and no scroll event follows. It now subscribes tosubscribeToReaderScroll, and the wheel releases the pin because a reader asking for what is above them is no longer following what is below.Review focus
Two behaviours change deliberately:
Two upstream defects surfaced while adopting the Astryx hook, both filed: gesture release is dead under
prefers-reduced-motion(facebook/astryx#5662), andjumpToBottomdoes not cancel its in-flight spring (facebook/astryx#5663). Neither is reachable now that Astryx's scroll layer is off for the transcript.AI use
Select exactly one:
Tool(s) and scope: Claude Code — root-cause investigation, the CDP measurement harness, the code changes, the E2E spec, and this description. Independent adversarial review agents produced the findings above; each was verified against source, and the behaviours were then measured against a running build. The contributor of record reviewed the final diff and owns the merge decision.
Checklist
Does this PR entail a change in behavior?