fix(virtualizer): follow re-pins through an animated viewport shrink - #7932
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of This PR is a behavioral fix to chat scroll-follow logic — no new UI surfaces, strings, or screenshots. Let me verify there's nothing user-facing beyond the scroll behavior itself by checking the touched files are purely logic/tests, which the diff confirms: two hooks, one controller, three test files. The change fixes a real UX defect (follow releasing during the product's own layout animations, leaving the transcript a card-height below bottom) while explicitly preserving the user's ability to break follow with a genuine scroll-up — the tests pin both directions of that boundary. UX-Verdict: PASS Pure follow-pin logic fix: the transcript now stays glued to the bottom through the app's own layout shrinks, while a real user scroll-up still releases follow. No user-facing strings, surfaces, or states are added; user control over follow is explicitly preserved and test-pinned in both hooks ("a real scroll-up inside a viewport shrink still releases", "a genuine gesture still holds pins off for the settle window"). [UX-REVIEWED] 238303d |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix at the right layer — the follow mechanism itself — replacing a per-band mitigation, with measured evidence and both consumers of the shared evaluator wired. Suggestions
[DESIGN-REVIEWED] 238303d |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All counts are run: First-Principles-Verdict: PASS A measured misattribution bug fixed at its cause — the follow mechanism itself — with the sibling scroller fixed in the same stroke. What this change shipsIntent: keep the transcript pinned to the bottom while chrome below it animates its height — a FIX (issue #7769).
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 238303d |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
4951a4e to
1986fb7
Compare
|
Dispositioning the two advisory rounds on First Principles - Watch: Design - a single First Principles - Subtraction: deliver the deferred deletion of the |
… so follow re-pins through an animated viewport shrink A send that queues behind a busy turn regroups the turn and remounts tail rows, shrinking the content so the layout engine clamps scrollTop; the queue band then mounts below the transcript and spring-animates the scroller's box smaller over the next several frames. The follow mechanism read both of those as user activity: 1. The passive scroll listener stamped the clamp's scroll event into lastUserScrollAtRef, arming the 150ms SCROLL_SETTLE_MS gate that holds ResizeObserver pins off during a gesture. Measured on the real build: the box shrank 617 -> 588 across 130ms with scrollTop frozen at the clamped value, and the first pin landed at sinceInput 154ms - one frame after the last shrink step. Genuine input keeps its own, earlier signal from the persistent wheel/touch/key/scrollbar intent listeners, so declining to stamp a clamp loses nothing. 2. evaluateAutoPin's scroll-up release compared distance-from-bottom against the box the animation had just applied. Our own shrink inflates that distance with no user input, so a clamp (scrollTop below our last write) plus a shrink formed a full scroll-up signature out of two of our own layout changes. It now judges against the box the reference was a bottom for, forgiving only the shrink's own pixels. evaluateAutoPin is shared, so both of its consumers get the allowance: useChatScrollFollow (the plain non-virtualized scroller behind ChatPane, ChatEmbed and SideChat) observes the scroller's own box alongside the content wrapper and names turn-collapse shrink as a first-class event, so it carries the identical clamp-plus-shrink signature - and having no clamp re-baseline of its own, it released follow permanently. Its header promises every chat surface follows and releases with identical semantics; wiring both keeps that true. Measured with scripts/capture-queue-band-reanchor.mjs against a build with the ChatPage composer-status-stack mitigation removed, so the virtualizer is isolated: deepest dip 20.2 / 19.8 / 17.2 px before, 0.0 / 1.5 / 0.0 px after. Closes #7769
1986fb7 to
238303d
Compare
buluoray
left a comment
There was a problem hiding this comment.
Verdict: 0 blocking, 1 non-blocking. Approving.
Reviewed at head 238303d.
What I verified
I traced the fix through the pure decision core and both consumers, reading source at the head SHA (not the shared diverged local checkout).
FollowController.tsevaluateAutoPin: the release predicate changed from
distanceFromBottom(geom) > epsilontodistanceFromBottom(geom) - viewportShrink > epsilon,
withviewportShrink = Math.max(0, args.viewportShrink ?? 0). This is plain
px arithmetic that directly moves the release decision — not a
percentage/viewport unit that could fail to resolve, and not inert.target
is stillbottomTarget(geom)recomputed from live geometry, so pins always
land on the current-frame bottom.- Both consumers wired:
useVirtualChat.ts:1419anduseChatScrollFollow.ts:101
computelastWriteClientH - clientHeightand pass it. The Design and
First-Principles bots independently confirmed exactly 2 real consumers. - Follows the animation rather than fighting it: every pin re-baselines
lastWriteClientHRefto the current (shrunk) box (thewritePin/
"already at bottom" /writeScrollTopbranches), so each RO tick forgives
only that frame's incremental shrink and re-pins to that frame's bottom.
It settles at the true bottom (PR's measured dip 20px -> 0px). - No stale-index restore: the pin target is recomputed from live geom on every
evaluation;lastWriteTopRefandlastWriteClientHRefare updated in
lockstep. I checked all lastWriteTopRef write sites in the diff — each got a
paired lastWriteClientHRef write (-1alongside-1on every reset), so no
desync was introduced. - Non-animated path is behavior-preserving: refs start at
-1, giving
viewportShrink = 0, which makes the predicate byte-identical to the prior
one. - Settle-gate change is bounded:
layoutClamp = stick && clampedAtBottom
gates thelastUserScrollAtRefstamp, andclampedAtBottomuses
SELF_SCROLL_EPSILON(2px). A user scroll-up beyond 2px is not a clamp, so
it still stamps and still gates pins; the "genuine gesture still holds pins
off for the settle window" test pins that boundary. Real input is stamped
earlier by the persistent wheel/touch/key/scrollbar listeners, so declining
to stamp on the clamp loses nothing. - The allowance is safe against masking real input: it forgives only the
measured shrink. TheFollowController"forgives only its own pixels" test
(200px drag inside a 29px shrink -> releases) and the negative-shrink clamp
test confirm the bound. - Tests are falsifiable at the pure-function level: the
FollowController
case asserts the pre-fix behavior inline (noviewportShrink->stick:false)
against the fixed behavior (viewportShrink:29->stick:true,pin:true,
target:625). This genuinely reddens on main and is fully observable without
a DOM. - Scope: only
.ts/.tsxlogic and test files changed — no JSX layout
containers, icons, interactive elements, emoji, or user-facing strings.
None of the six AUTOSDE blocking frontend rules apply, and no new i18n
catalog entry is needed.
Non-blocking findings
useVirtualChat.ts/useChatScrollFollow.ts(lockstep refs) — the
lastWriteTopRef/lastWriteClientHRefpairing is enforced only by
convention across ~7 write sites in two hooks. It is correct in this diff,
but a future edit could touch one ref and miss its partner, silently
over- or under-forgiving a shrink. Consider folding them into a single ref
({ top, clientH }) so desync becomes structurally impossible. Same
suggestion the Design bot raised; not blocking because every current site
is paired.
What I could not verify
I did not observe the geometry live — I did not run the SPA or the
capture-queue-band-reanchor.mjs probe. My confidence rests on reading the
source and the falsifiable pure-function tests, plus the PR's reported
frame-by-frame measurements, which I did not independently reproduce.
Problem / Motivation
When a send queues behind a busy turn, the transcript settles up to a
card-height (about 20px, one collapsed queue card) BELOW where "at bottom"
should put it, for the length of the queue band's spring animation. It eats the
transcript's tail clearance, so wherever the remaining budget is thinner than
the offset the last line lands under the clip edge and renders as sliced glyphs
directly above the queue card. Whether it recovered depended on which
re-render landed last, which is why it read as intermittent.
PR #7764 shipped a page-level mitigation (a ResizeObserver on
composer-status-stackthat re-anchors while FOLLOW holds) and deliberatelydid not touch the virtualizer. This fixes it inside the virtualizer's own
follow mechanism, which is what #7769 asks for.
Why it matters
The mitigation only covers the bands ChatPage happens to observe. Three
animated bands are covered by neither it nor the
[activeTip, surveyLayoutTick]compensation effect: FolderSuggestionCard's animated mountin the tip slot, the follow-up options row, and the knowledge-fetch card. Any
chrome that animates its height below the transcript hits the same defect, so
fixing the follow mechanism covers all of them at once instead of adding a
per-band observer each time.
What changed (motivation -> approach -> change)
The issue left the root cause open (height-cache redistribution on regroup, or
spacer-sync ordering versus the pin write). It is neither. Instrumenting the
real built SPA frame by frame shows the viewport re-pin is not landing short -
it is not running at all:
Two of our own layout changes were being attributed to the user, and each has
its own fix:
The settle gate. A send that queues regroups the turn and remounts tail
rows, so the content shrinks and the layout engine clamps scrollTop. The
passive scroll listener stamped that clamp's scroll event into
lastUserScrollAtRef, which arms the 150msSCROLL_SETTLE_MSgate whosejob is to hold RO-driven pins off while a gesture is in flight. The whole
window was therefore spent on our own reflow, and every viewport re-pin of
the band's animation was suppressed - the pin resumed at 154ms, one frame
after the last shrink step.
stickandlastWriteTopalready treat thisclamp as ours (the
clampedAtBottombranch immediately below); the gate nowagrees with them. Genuine input is unaffected: the persistent
wheel/touch/key/scrollbar intent listeners stamp at input time, which is
EARLIER than the scroll event this branch handles.
The scroll-up release.
evaluateAutoPincompareddistanceFromBottomagainst the box the animation had just applied. Our ownshrink inflates that distance with no user input, so a clamp (scrollTop
below our last write) plus a shrink formed a complete user-scroll-up
signature out of two of our own changes - releasing follow mid-animation
with nothing able to re-arm it. It now takes a
viewportShrinkallowanceand judges against the box
lastWriteTopwas a bottom FOR. Only theshrink's own pixels are forgiven, so a genuine drag in the same tick still
releases. This one is ordering-dependent (the clamp's re-baseline needs its
scroll event to have dispatched first), which is exactly the RO-versus-
scroll-event race FollowController's header says the design must not rely
on.
The new
lastWriteClientHRefis kept in lockstep withlastWriteTopRefatevery site that writes it,
-1alongside-1.evaluateAutoPinis shared, so both of its consumers get the allowance.useChatScrollFollow- the plain non-virtualized scroller behind ChatPane,ChatEmbed and SideChat - runs one observer over the content wrapper AND the
scroller's own box, and names turn-collapse shrink as a first-class event, so
one tick there can carry the same clamp plus the same viewport shrink (a pane
drag, a keyboard, chrome mounting below). It is in fact more exposed than the
virtualizer was: it has no clamp re-baseline to fall back on, so the release
was permanent rather than ordering-dependent. Its header promises every chat
surface follows and releases with identical semantics; wiring both keeps that
promise true instead of leaving one caller on the old behavior. Its settle-gate
half does not apply - that hook has no gesture gate.
Not in this PR: deleting the
composer-status-stackobserver and the[activeTip, surveyLayoutTick]effect. The measurement below shows both arenow redundant, but removing them also means rewriting
ChatPage.queueBandReanchor.test.tsx, which is CI's only guard on thisbehavior - worth its own reviewable change rather than riding along here.
Tests
Three cases that fail on
mainand pass here, plus three that pin theboundaries:
useVirtualChat.viewportResize: "re-pins through the shrink animation when acontent clamp preceded it" - the measured cause. A clamp scroll event, then a
viewport shrink well inside the settle window. Fails on main (
expected 1475 to be 1504).useVirtualChat.viewportResize: "re-pins when a tail-row remount clampsscrollTop in the same tick as the shrink" - the release guard, both halves in
one RO tick. Fails on main (
expected 1572 to be 1601).FollowController: "OUR OWN viewport shrink does not read as a scroll-up,even on top of a clamp" - the same case at the pure-function level, asserting
the old behavior inline for contrast. Fails on main.
useVirtualChat.viewportResize: "a genuine gesture still holds pins off forthe settle window" - a
wheelevent still suppresses the pin, and followresumes once 150ms elapses. Passes on main and here.
FollowController: "the allowance forgives only its own pixels" - a 200pxdrag inside a 29px shrink still releases.
FollowController: "a viewport GROW never widens the guard" - a negativeshrink is clamped to 0.
useChatScrollFollow: "a turn-collapse SHRINK in the same tick as a viewportshrink keeps following" - the sibling case on the plain scroller. Fails
without the hook change (
expected 400 to be 429, the same card-height lowsettle), and asserts follow is still armed by pinning the next growth.
useChatScrollFollow: "a real scroll-up inside a viewport shrink stillreleases follow" - the matching boundary on that surface.
19 files / 213 tests green across every suite covering the three changed
sources (
FollowController, alluseVirtualChat.*,UseVirtualChatCoverage,virtualizerHeightOwner,useChatScrollFollow,ChatPane.scrollChrome,ChatPage.queueBandReanchor).tsc -band eslint clean.Manual verification
website/scripts/capture-queue-band-reanchor.mjsagainst the real built SPA,run with the ChatPage
composer-status-stackmitigation removed so thevirtualizer is isolated (the repro recipe the issue names). Deepest dip across
the band's spring animation, desktop-dark / desktop-light / phone-dark:
--expect offsetpasses)--expect anchoredpasses)--expect anchoredpasses)The middle row is the load-bearing one: the virtualizer now holds the anchor by
itself, at least as well as #7764's measured 3px. The third row confirms the
two mechanisms do not fight - both drive to the same target.
The frame-by-frame trace in "What changed" came from a temporary probe build
that recorded every RO tick and
pinAutoevaluation; the probe is not part ofthis diff.
Related Issues
Closes #7769
Pattern harvest
Rule candidate: review-prompt
Pattern: a scroll/follow guard that infers user intent from geometry must
measure against the geometry as of its own last write, never the geometry the
current frame just applied - and a layout clamp must not be stamped as input on
any path that already recognises it as a clamp somewhere else. Both bugs here
are the same shape: the code had already special-cased the clamp for two of
three consumers (
stick,lastWriteTop) and missed the third (the settle-gatetimestamp), and had a distance test whose denominator our own animation moves.