Skip to content

fix(virtualizer): follow re-pins through an animated viewport shrink - #7932

Merged
buluoray merged 1 commit into
mainfrom
fix/virtualizer-repin-height-7769
Sep 3, 2026
Merged

fix(virtualizer): follow re-pins through an animated viewport shrink#7932
buluoray merged 1 commit into
mainfrom
fix/virtualizer-repin-height-7769

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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-stack that re-anchors while FOLLOW holds) and deliberately
did 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 mount
in 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:

t=108  st=1295 sh=1912 ch=617 d=0     <- content shrank 125px, scrollTop clamped
t=130  st=1295 sh=1912 ch=611 d=6     <- RO tick: viewportResized, stick=true,
t=157  st=1295 sh=1912 ch=603 d=14       shouldFollow=true ... and no pinAuto
t=193  st=1295 sh=1912 ch=597 d=20
t=243  st=1295 sh=1912 ch=589 d=27
t=262  st=1295 sh=1912 ch=588 d=28    <- sinceInput crosses 150ms; pin finally runs
t=275  st=1324 sh=1912 ch=588 d=0

Two of our own layout changes were being attributed to the user, and each has
its own fix:

  1. 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 150ms SCROLL_SETTLE_MS gate whose
    job 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. stick and lastWriteTop already treat this
    clamp as ours (the clampedAtBottom branch immediately below); the gate now
    agrees 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.

  2. The scroll-up release. evaluateAutoPin compared
    distanceFromBottom 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 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 viewportShrink allowance
    and judges against the box lastWriteTop was a bottom FOR. Only the
    shrink'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 lastWriteClientHRef is kept in lockstep with lastWriteTopRef at
every site that writes it, -1 alongside -1.

evaluateAutoPin is 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-stack observer and the
[activeTip, surveyLayoutTick] effect. The measurement below shows both are
now redundant, but removing them also means rewriting
ChatPage.queueBandReanchor.test.tsx, which is CI's only guard on this
behavior - worth its own reviewable change rather than riding along here.

Tests

Three cases that fail on main and pass here, plus three that pin the
boundaries:

  • useVirtualChat.viewportResize: "re-pins through the shrink animation when a
    content 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 clamps
    scrollTop 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 for
    the settle window" - a wheel event still suppresses the pin, and follow
    resumes once 150ms elapses. Passes on main and here.
  • FollowController: "the allowance forgives only its own pixels" - a 200px
    drag inside a 29px shrink still releases.
  • FollowController: "a viewport GROW never widens the guard" - a negative
    shrink is clamped to 0.
  • useChatScrollFollow: "a turn-collapse SHRINK in the same tick as a viewport
    shrink keeps following" - the sibling case on the plain scroller. Fails
    without the hook change (expected 400 to be 429, the same card-height low
    settle), and asserts follow is still armed by pinning the next growth.
  • useChatScrollFollow: "a real scroll-up inside a viewport shrink still
    releases follow" - the matching boundary on that surface.

19 files / 213 tests green across every suite covering the three changed
sources (FollowController, all useVirtualChat.*, UseVirtualChatCoverage,
virtualizerHeightOwner, useChatScrollFollow, ChatPane.scrollChrome,
ChatPage.queueBandReanchor). tsc -b and eslint clean.

Manual verification

website/scripts/capture-queue-band-reanchor.mjs against the real built SPA,
run with the ChatPage composer-status-stack mitigation removed so the
virtualizer is isolated (the repro recipe the issue names). Deepest dip across
the band's spring animation, desktop-dark / desktop-light / phone-dark:

build deepest dip
main virtualizer, mitigation removed 20.2 / 19.8 / 17.2 px (--expect offset passes)
this fix, mitigation removed 0.0 / 1.5 / 0.0 px (--expect anchored passes)
this fix, mitigation in place (shipped config) 3.4 / 0.0 / 0.0 px (--expect anchored passes)

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 pinAuto evaluation; the probe is not part of
this 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-gate
timestamp), and had a distance test whose denominator our own animation moves.

@chenmingwei23
chenmingwei23 requested a review from a team September 2, 2026 17:01
@chenmingwei23
chenmingwei23 requested a review from a team as a code owner September 2, 2026 17:01
@chenmingwei23
chenmingwei23 requested a review from dwu96 September 2, 2026 17:01
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of 238303dc3f3ee2e7ba3200d3995fdba32c33159e — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of 238303dc3f3ee2e7ba3200d3995fdba32c33159e — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • lastWriteTopRef/lastWriteClientHRef lockstep is enforced only by convention across ~7 write sites in two hooks; bundling them into one ref ({ top, clientHeight }) makes the invariant structural and un-desyncable by the next edit.

[DESIGN-REVIEWED] 238303d

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of 238303dc3f3ee2e7ba3200d3995fdba32c33159e — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All counts are run: evaluateAutoPin's new viewportShrink parameter has exactly 2 real consumers (useVirtualChat.ts:1419, useChatScrollFollow.ts:101); the superseded symptom-level mitigation exists at ChatPage.tsx:3390-3403 and ChatPage.tsx:8304 and is declared deferred. The change sits at cause level and fixes the named sibling surface in the same PR.

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 ships

Intent: keep the transcript pinned to the bottom while chrome below it animates its height — a FIX (issue #7769).

  1. Transcript no longer settles a card-height low while the queue band animates in — justified (frame-by-frame trace in the description names the cause).
  2. A browser layout clamp no longer counts as user input for the 150ms pin-hold gate — justified, cause-level.
  3. Follow no longer releases when our own viewport shrink coincides with a content clamp — justified, cause-level.
  4. The plain chat scroller (ChatPane, ChatEmbed, SideChat) gets the same shrink forgiveness — declared sibling fix, so no unfixed siblings ride behind the root cause.
  5. evaluateAutoPin gains an optional viewportShrink parameter — 2 consumers, counted (useVirtualChat.ts:1419, useChatScrollFollow.ts:101).
  6. Real gestures and real scroll-ups behave exactly as before — boundary pinned by 4 tests.

Subtractions

  • The description concedes the point-patch predecessors are now dead weight: "The measurement below shows both are now redundant." Delete the composer-status-stack ResizeObserver mitigation and the [activeTip, surveyLayoutTick] effect (ChatPage.tsx:3390-3403, ChatPage.tsx:8304) plus their guard test in the follow-up the author promises — the deferral reason (rewriting ChatPage.queueBandReanchor.test.tsx) is named and legitimate, but the redundancy is measured, so the deletion should not slip.

[FIRST-PRINCIPLES-REVIEWED] 238303d

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 238303dc3f3ee2e7ba3200d3995fdba32c33159e — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 238303d

Verdict parsed from the review's SHA-scoped output markers for commit 238303dc3f3ee2e7ba3200d3995fdba32c33159e.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 238303dc3f3ee2e7ba3200d3995fdba32c33159e: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 238303dc3f3ee2e7ba3200d3995fdba32c33159e and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 238303d

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 238303dc3f3ee2e7ba3200d3995fdba32c33159e: <one-sentence reason>

@chenmingwei23
chenmingwei23 force-pushed the fix/virtualizer-repin-height-7769 branch from 4951a4e to 1986fb7 Compare September 2, 2026 17:46
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Dispositioning the two advisory rounds on 4951a4ea7. New head is 1986fb746.

First Principles - Watch: useChatScrollFollow left on the old semantics. Fixed, not
deferred. This was self-introduced by the diff: it made the two consumers of a shared guard
diverge against that hook's own header promise. It is not immune either - it runs one observer
over the content wrapper AND the scroller's own box, and its header names turn-collapse SHRINK as
a first-class event, so one tick there carries the same clamp plus the same viewport shrink (pane
drag, keyboard, chrome mounting below). It is in fact more exposed than the virtualizer was: with
no clamp re-baseline of its own the release was permanent, not ordering-dependent. Pinned by a
test that fails without the change with expected 400 to be 429 - the same card-height low
settle - plus the matching real-scroll-up boundary. The settle-gate half of the fix does not
apply there; that hook has no gesture gate.

Design - a single recordWrite(top, clientH) helper for the lockstep pair. Taken in the one
place it genuinely closes the seam: useChatScrollFollow.writePin now owns both writes, so that
surface cannot be split. Declining it inside useVirtualChat, because there a helper would
rename the risk rather than remove it - the caller still has to supply which height, and the
sites deliberately differ. Two pass the immutable geom snapshot the pin decision was made
from, one passes el.clientHeight at write time, and two write the -1 sentinel where no
height exists. A helper that re-read clientHeight itself would silently substitute a fresh
layout read for the snapshot at exactly the sites whose correctness depends on using the
snapshot. Opus verified the six pairs hold one-for-one as shipped.

First Principles - Subtraction: deliver the deferred deletion of the composerBandRef
observer and the [activeTip, surveyLayoutTick] effect.
Still deferred, and the measurement
does support it (0.0/1.5/0.0 px with the mitigation removed). Holding it back because deleting
that observer also means rewriting ChatPage.queueBandReanchor.test.tsx, which asserts the
observer's presence in source and is CI's only enforced guard on this behavior. Replacing that
guard is a decision about what CI should pin, not a mechanical subtraction, and it is
independently verifiable - so it earns its own reviewable change rather than riding along here.
Keeping both mechanisms in the meantime is measured harmless: the shipped configuration
(mitigation in place plus this fix) runs 3.4/0.0/0.0 px, since both paths drive to the same
target.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 2, 2026
… 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
@chenmingwei23
chenmingwei23 force-pushed the fix/virtualizer-repin-height-7769 branch from 1986fb7 to 238303d Compare September 2, 2026 20:41
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 2, 2026
@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 2, 2026

@buluoray buluoray left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts evaluateAutoPin: the release predicate changed from
    distanceFromBottom(geom) > epsilon to distanceFromBottom(geom) - viewportShrink > epsilon,
    with viewportShrink = 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 still bottomTarget(geom) recomputed from live geometry, so pins always
    land on the current-frame bottom.
  • Both consumers wired: useVirtualChat.ts:1419 and useChatScrollFollow.ts:101
    compute lastWriteClientH - clientHeight and 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
    lastWriteClientHRef to the current (shrunk) box (the writePin /
    "already at bottom" / writeScrollTop branches), 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; lastWriteTopRef and lastWriteClientHRef are updated in
    lockstep. I checked all lastWriteTopRef write sites in the diff — each got a
    paired lastWriteClientHRef write (-1 alongside -1 on 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 the lastUserScrollAtRef stamp, and clampedAtBottom uses
    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. The FollowController "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 (no viewportShrink -> 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/.tsx logic 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

  1. useVirtualChat.ts / useChatScrollFollow.ts (lockstep refs) — the
    lastWriteTopRef/lastWriteClientHRef pairing 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.

@buluoray
buluoray merged commit 3128dc1 into main Sep 3, 2026
65 checks passed
@buluoray
buluoray deleted the fix/virtualizer-repin-height-7769 branch September 3, 2026 00:06
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

virtualizer: follow re-pin can settle a card-height low when a tail-row remount coincides with an animated viewport shrink

2 participants