fix(chat): widen composer re-pin observer to #composerWrap (Fable #5516 finding)

Fable senior review (COMMENT, no blockers) + DRY nit:

- Widen the ResizeObserver from #msg to the whole #composerWrap so growth from the
  attachment tray / selection chips (not just textarea rows) also re-pins a bottom-
  pinned reader. Fable flagged tray/chip growth as a likely REMAINING #5515 trigger
  — same viewport-shrink physics, now covered by the same guarded re-pin at one seam.
  #composerWrap contains #msg, so the textarea path is still covered.
- DRY: _repinMessagesAfterComposerResize() now reuses _messageBottomDistance()
  instead of re-implementing the bottom-distance expression inline.

Re-verified live (composer grow -> bottomDist stays 0) + 524 scroll/composer
regression tests green + 6 issue tests green.
This commit is contained in:
nesquena-hermes 2026-07-04 05:52:14 +00:00
parent dde008748e
commit 7e7e71f695
3 changed files with 22 additions and 20 deletions

View file

@ -1918,27 +1918,25 @@ $('msg').addEventListener('input',()=>{
});
// #5514/#5515: re-pin the transcript on ANY composer height change, not only the
// ones that route through the input->autoResize path. A multi-line paste
// (WisprFlow), a draft restore, a programmatic value set, or a font/reflow can
// all grow the composer and shrink the flex:1 transcript viewport, stranding a
// pinned reader above the bottom (reads as a "random" upward jump — #5515). A
// ResizeObserver on the textarea catches every case at one seam. The re-pin
// itself is guarded (only fires when genuinely pinned), so this never fights a
// reader who scrolled away. First callback fires on observe (initial size) — the
// guard makes that a cheap no-op.
// (WisprFlow), a draft restore, an attachment tray / selection-chip appearing, a
// programmatic value set, or a font/reflow can all grow the composer and shrink
// the flex:1 transcript viewport, stranding a pinned reader above the bottom
// (reads as a "random" upward jump — #5515). Observe the whole #composerWrap
// (not just #msg) so tray/chip growth is covered too, at one seam. The re-pin is
// guarded (only fires when genuinely pinned), so it never fights a reader who
// scrolled away. First callback fires on observe (initial size) — the guard
// makes that a cheap no-op.
(()=>{
const _c=$('msg');
if(!_c || typeof ResizeObserver!=='function' || typeof _repinMessagesAfterComposerResize!=='function') return;
let _lastComposerH=_c.offsetHeight;
const _cw=$('composerWrap')||$('msg');
if(!_cw || typeof ResizeObserver!=='function' || typeof _repinMessagesAfterComposerResize!=='function') return;
let _lastComposerH=_cw.offsetHeight;
const _ro=new ResizeObserver(()=>{
const h=_c.offsetHeight;
if(h===_lastComposerH) return; // width-only / no-op resize: ignore
const grew=h>_lastComposerH;
const h=_cw.offsetHeight;
if(h<=_lastComposerH){_lastComposerH=h;return;} // shrink/no-op: enlarges the viewport, can't strand
_lastComposerH=h;
// Only a GROW shrinks the transcript viewport; a shrink enlarges it and can't
// strand a pinned reader. Re-pin on grow.
if(grew) _repinMessagesAfterComposerResize();
_repinMessagesAfterComposerResize(); // grow: re-pin the pinned reader
});
try{ _ro.observe(_c); }catch(_){ }
try{ _ro.observe(_cw); }catch(_){ }
})();
// Track IME composition for East Asian input. Safari fires the committing
// keydown AFTER compositionend with isComposing=false, so we also keep a

View file

@ -5395,7 +5395,7 @@ function _repinMessagesAfterComposerResize(){
if(!el) return;
// Already at/very near the bottom? nothing to do (avoids needless writes while
// idle-reading a short conversation that isn't scrollable).
if(el.scrollHeight-el.scrollTop-el.clientHeight<=1) return;
if(_messageBottomDistance()<=1) return;
if(typeof _setMessageScrollToBottom==='function') _setMessageScrollToBottom();
else { el.scrollTop=el.scrollHeight; }
}

View file

@ -70,11 +70,14 @@ def test_autoresize_calls_the_repin():
def test_resize_observer_installed_on_composer():
# A ResizeObserver on #msg re-pins on grow (catches non-autoResize paths).
# A ResizeObserver on the composer wrapper re-pins on grow (catches
# tray/chip/paste height changes, not just the typed-input seam).
assert "new ResizeObserver(" in BOOT_JS
assert "_repinMessagesAfterComposerResize" in BOOT_JS
# Observes the whole wrapper so attach-tray / selection-chip growth is covered.
assert "$('composerWrap')" in BOOT_JS
# Only re-pin on GROW (a shrink enlarges the viewport, can't strand a reader).
assert "if(grew) _repinMessagesAfterComposerResize();" in BOOT_JS
assert "can't strand" in BOOT_JS
# ---------------------------------------------------------------------------
@ -94,6 +97,7 @@ def _run(scenario):
// messages pane: scrollHeight fixed; clientHeight shrinks when composer grows.
const el = { scrollHeight: 8768, clientHeight: 745, scrollTop: %(scrolltop)s };
const $ = (id) => (id === 'messages' ? el : null);
function _messageBottomDistance(){ return el.scrollHeight - el.scrollTop - el.clientHeight; }
function _setMessageScrollToBottom(){ el.scrollTop = el.scrollHeight - el.clientHeight; el._pinnedCalled = true; }
%(helper)s