fix(chat): re-anchor the transcript when the queue band resizes - #7764
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Invisible-when-correct fix: the transcript tail no longer clips behind the queue band, no strings or controls change, and re-anchoring is gated on FOLLOW so parked readers are never yanked. The only behavioral surface a user touches is scroll position: the observer fires [UX-REVIEWED] 054b4bd |
Design Review (Fable 5) — ✅ PASSDesign-level review of Convention check done — Design-Verdict: PASS A measured, disclosed page-level mitigation — resize-driven re-anchor beats the mount-time effect it generalizes, and the tracked root fix (#7769) makes it deletable. [DESIGN-REVIEWED] 054b4bd |
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: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All checks are done. The change is fully declared, grounded in a live-reported defect, and every artifact follows established repo conventions (337 sibling capture scripts, 571 committed screenshots, source-contract tests matching the two neighbouring mask guards). The uncovered bands and the coexisting tip/survey effect are declared and deferred to a filed root-cause issue (#7769). First-Principles-Verdict: PASS A reported sliced-glyph defect gets the smallest fix that survives an animated band, with the root cause named, measured, and filed rather than hidden. What this change shipsIntent: stop the reply's last line rendering sliced above a queued-message card when a send lands behind a busy turn — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 054b4bd |
A send that lands behind a busy turn mounts the queue stack between the transcript and the composer AND appends a queued row to the message array. The virtualizer's viewport branch does re-pin for the scroller shrink, but the append regroups and remounts tail rows while the band's spring animates, so the re-pin can land on interior heights that are still settling: the scroller reports at-bottom while the content sits a card-height (~21px) low, recovering or not depending on which re-render lands last. Users saw the reply's last line sliced at the clip edge directly above the queue card, intermittently. A ResizeObserver on the composer status stack re-anchors while FOLLOW holds, after every layout step of the band's animation, so the final write always follows the last height change instead of racing it. The one-shot tip/survey compensation effect cannot do this: a mount-time re-anchor measures a half-grown band. Pinned by a source-contract test beside the two existing mask guards; the geometry is asserted and photographed by scripts/capture-queue-band-reanchor.mjs against the real built SPA (deepest anchor dip pre-fix ~21px, post-fix <= 3px, desktop and phone). The virtualizer-level root fix that would make this observer deletable is tracked in issue #7769.
cbd8a4d to
054b4bd
Compare
ai-review-disposition — first-principlesFinding 1 (framing vs code: "the virtualizer does not know about it" contradicted by the viewport branch) — accepted, fixed in place. The lane was right and the probe proves it: a frame-by-frame geometry trace on the pre-fix build shows the viewport re-pin firing and Finding 2 (second page-level compensation; sibling bands uncovered) — accepted-and-deferred to #7769. The subtraction is taken at face value: the root fix belongs inside the virtualizer's follow mechanism, and once it lands, this observer, the |
What
A send that lands behind a busy turn does two things in one commit window: it mounts the queue stack between the transcript and the composer (spring-animated, so the scroller's box shrinks over ~400ms), and it appends a queued row to the message array, which regroups and remounts tail rows. The virtualizer's viewport branch does re-pin for the shrink — but that re-pin can land on interior heights that are still settling from the remount. Measured frame-by-frame on the pre-fix build: the scroller's own math reports "at bottom" while the content sits a card-height (~21px) low, and whether it recovers depends on which re-render lands last — the defect reads as intermittent. The offset eats the transcript's tail clearance; wherever the budget is thinner than the offset (phones most of all), the reply's last line renders as sliced glyphs directly above the queue card (reported live from a session: half-cut CJK glyphs above a queued document-feedback card).
The tip card and the survey card have similar geometry and are compensated by the existing
[activeTip, surveyLayoutTick]effect. The queue stack cannot ride that effect: its height is spring-animated, so a one-shot re-anchor at mount time measures a half-grown band.How
A
ResizeObserveron the existingcomposer-status-stackwrapper (the element #6912's guard already pins as the band area) re-anchors viascrollBottom(true)at every layout step while FOLLOW holds — the observer fires post-layout, so the final write always follows the last height change instead of racing it, and it covers every band in the stack (progress bars, sub-agent delivery line, queue stack) rather than enumerating them as effect deps. Attached with a callback ref so it survives the chat column unmounting and remounting. Gated onvGetFollowRefexactly like the tip/survey effect: a reader parked above the bottom is never yanked.This is a page-level mitigation by design: the virtualizer-level root fix (why the viewport re-pin can settle low when a tail-row remount coincides with an animated viewport shrink) is diagnosed and tracked in #7769 — landing it would make this observer AND the tip/survey effect deletable, and would cover the remaining bands (folder-suggestion card, follow-up options, knowledge card) for free.
website/src/pages/ChatPage.tsx: the callback ref + observer;refattribute on the stack wrapper.website/src/test/ChatPage.queueBandReanchor.test.tsx: source-contract pin beside the two existing mask guards (happy-dom has no layout, so a real observer can never fire there).website/src/test/ChatPage.statusStackAboveMask.test.tsx,website/src/test/ChatPage.composerChromeOcclusion.test.tsx: their stack-wrapper regexes track the addedrefattribute; every assertion unchanged.website/scripts/capture-queue-band-reanchor.mjs: self-checking harness — boots the real built SPA parked at the bottom, pushes aqueue_pushframe through the stubbed websocket, samples clearance across the spring. Pre-fix: deepest anchor dip ~21px (sometimes permanent, sometimes recovered — the race; the BEFORE expectation allows up to 3 runs). Post-fix: dip ≤ 3px, single-run strict, desktop + phone.Evidence
Same fixture, same crop; BEFORE captured at the dip (the transcript sits a card-height lower; on thin tail budgets that is the sliced line):
Tested
npx tsc -bclean; targetedvitest runon ChatPage.queueBandReanchor / statusStackAboveMask / composerChromeOcclusion / fadeClearance — 29 passed (re-run after the rebase onto current main).no linked issue: reported live from the dashboard (queued document-feedback card slicing the reply's last line); root-cause follow-up filed as #7769.
Pattern harvest
Rule candidate: any in-flow band that mounts between a scroll container and the content it anchors must re-anchor the scroller on RESIZE, not on mount — a one-shot compensation effect measures an animated band mid-flight. Prefer one ResizeObserver on the band's shared wrapper over enumerating each band as effect deps; the dependency-list route is how the queue band was missed after the tip/survey effect shipped. When a follow re-pin exists but still loses, probe frame-by-frame before claiming the mechanism is absent — the loss here was settling interior heights, not a missing observer (#7769).