diff --git a/static/boot.js b/static/boot.js index 3ac19b5a..3bee6262 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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 diff --git a/static/ui.js b/static/ui.js index 5f3ba8dd..0719a52b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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; } } diff --git a/tests/test_issue5514_composer_grow_scroll_pin.py b/tests/test_issue5514_composer_grow_scroll_pin.py index affbbf54..1ac95bb5 100644 --- a/tests/test_issue5514_composer_grow_scroll_pin.py +++ b/tests/test_issue5514_composer_grow_scroll_pin.py @@ -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