From dde008748e57971409112d200e03783e9bf66c05 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 4 Jul 2026 05:41:33 +0000 Subject: [PATCH 1/4] fix(chat): re-pin transcript to bottom when the composer grows (#5514, #5515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5514 (deterministic): with the chat pinned to the bottom, growing the composer past one row (multi-line typing, Shift+Enter, a multi-line / WisprFlow paste) shrinks the flex:1 .messages viewport by the same delta. scrollTop is unchanged but max-scrollTop rose by delta, so the reader is stranded delta-px above the bottom — the transcript 'scrolls up one row per composer row.' autoResize() resized the textarea and called updateSendBtn() but never re-pinned the transcript. Reproduced live: composer 176px -> viewport shrank 745->613 -> transcript stranded 132px above bottom while _scrollPinned stayed true. #5515 (intermittent 'random scroll up'): the same viewport-shrink from composer growth via paths that don't route through input->autoResize (paste, draft restore, programmatic value set, reflow) reads to the user as a random upward jump. Covered by a ResizeObserver seam (below). Fix: new _repinMessagesAfterComposerResize() (static/ui.js) re-pins to the bottom ONLY when the reader is genuinely still pinned (honors _messageUserUnpinned / _scrollPinned — never yanks a reader who scrolled away; verified live). Called from: (a) autoResize() after the height write, and (b) a ResizeObserver on the #msg textarea that re-pins on GROW only, catching every composer height-change path (not just the typed-input seam). Verified live on a 40-message seeded session: pinned reader stays at bottomDist=0 after a 4-line composer grow; an unpinned (scrolled-up) reader is NOT moved. Tests: tests/test_issue5514_composer_grow_scroll_pin.py (3 static wiring + 3 node-vm behavioral). 524 scroll/composer regression tests across 39 files still green. NOTE on #5515: this resolves the composer-growth cause (which the reporter's WisprFlow-paste symptom points at). If a purely non-composer intermittent jump persists, the remaining suspect is touch-device overflow-anchor:auto on content- above height change — that path has no repro yet and is tracked as needinfo on #5515. --- static/boot.js | 24 ++++ static/messages.js | 4 + static/ui.js | 20 +++ ...test_issue5514_composer_grow_scroll_pin.py | 134 ++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 tests/test_issue5514_composer_grow_scroll_pin.py diff --git a/static/boot.js b/static/boot.js index 634ff11b..3ac19b5a 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1916,6 +1916,30 @@ $('msg').addEventListener('input',()=>{ hideCmdDropdown(); } }); +// #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. +(()=>{ + const _c=$('msg'); + if(!_c || typeof ResizeObserver!=='function' || typeof _repinMessagesAfterComposerResize!=='function') return; + let _lastComposerH=_c.offsetHeight; + const _ro=new ResizeObserver(()=>{ + const h=_c.offsetHeight; + if(h===_lastComposerH) return; // width-only / no-op resize: ignore + const grew=h>_lastComposerH; + _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(); + }); + try{ _ro.observe(_c); }catch(_){ } +})(); // Track IME composition for East Asian input. Safari fires the committing // keydown AFTER compositionend with isComposing=false, so we also keep a // manual flag and reset it on the next tick to swallow that trailing Enter. diff --git a/static/messages.js b/static/messages.js index 219c6fca..4abff942 100644 --- a/static/messages.js +++ b/static/messages.js @@ -6184,6 +6184,10 @@ function autoResize(){ el.style.height='auto'; el.style.height=Math.min(el.scrollHeight,200)+'px'; updateSendBtn(); + // #5514/#5515: growing the composer shrinks the flex:1 transcript viewport, so + // a reader pinned to the bottom would be stranded above it ("scrolls up 1 row + // per composer row"). Re-pin the transcript when it's genuinely still pinned. + if(typeof _repinMessagesAfterComposerResize==='function') _repinMessagesAfterComposerResize(); } function scheduleComposerAutoResize(){ if(typeof requestAnimationFrame!=='function'){autoResize();return;} diff --git a/static/ui.js b/static/ui.js index 6af31688..5f3ba8dd 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5380,6 +5380,26 @@ function _messageBottomDistance(){ if(!el) return 0; return el.scrollHeight-el.scrollTop-el.clientHeight; } +// #5514/#5515: when the composer grows (typing multiple rows, Shift+Enter, a +// multi-line paste / WisprFlow), the flex:1 `.messages` viewport shrinks by the +// same delta. A reader pinned to the bottom is then stranded Δpx above it — the +// transcript appears to "scroll up" one row per composer row, and (the #5515 +// half) it reads as a random upward jump during normal use. autoResize() only +// resized the textarea; nothing re-pinned the transcript. Re-pin the bottom, but +// ONLY when the reader is genuinely still pinned (sticky-unpin model: honor +// _messageUserUnpinned so we never yank a reader who scrolled away, and never +// fight a stream that already unpinned). Cheap no-op when not pinned. +function _repinMessagesAfterComposerResize(){ + if(_messageUserUnpinned || !_scrollPinned) return; + const el=$('messages'); + 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(typeof _setMessageScrollToBottom==='function') _setMessageScrollToBottom(); + else { el.scrollTop=el.scrollHeight; } +} +if(typeof window!=='undefined') window._repinMessagesAfterComposerResize=_repinMessagesAfterComposerResize; function _shouldFollowMessagesOnDomReplace(){ // Final stream settlement replaces the live DOM with persisted messages. Keep // following only for users who are still pinned or effectively at the tail. diff --git a/tests/test_issue5514_composer_grow_scroll_pin.py b/tests/test_issue5514_composer_grow_scroll_pin.py new file mode 100644 index 00000000..affbbf54 --- /dev/null +++ b/tests/test_issue5514_composer_grow_scroll_pin.py @@ -0,0 +1,134 @@ +"""Regression coverage for #5514 + #5515 — transcript scrolls up when the composer +grows while the reader is pinned to the bottom. + +#5514 (deterministic): with the chat pinned to the bottom, growing the composer +(multi-line typing, Shift+Enter, a multi-line / WisprFlow paste) shrinks the +`flex:1` `.messages` viewport by the same delta, stranding the reader Δpx above +the bottom — the transcript "scrolls up one row per composer row." autoResize() +resized the textarea but never re-pinned the transcript. + +#5515 (intermittent "random scroll up"): the same class of viewport-shrink from +composer growth via paths that don't route through the input->autoResize seam +(paste, draft restore, programmatic value set, reflow) reads to the user as a +random upward jump. + +Fix: `_repinMessagesAfterComposerResize()` (static/ui.js) re-pins the transcript +to the bottom ONLY when the reader is genuinely still pinned (honors +`_messageUserUnpinned` / `_scrollPinned` so a reader who scrolled away is never +yanked back). It is called (a) from autoResize() and (b) from a ResizeObserver on +the composer textarea that catches every height-change path. + +This module verifies BOTH the static wiring and the helper's guard logic via a +node `vm` sandbox that reproduces the pinned-vs-unpinned viewport-shrink scenario. +""" +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Static wiring +# --------------------------------------------------------------------------- + +def _helper_body() -> str: + start = UI_JS.find("function _repinMessagesAfterComposerResize(") + assert start != -1, "the _repinMessagesAfterComposerResize helper must exist in ui.js" + end = UI_JS.find("\nif(typeof window!=='undefined') window._repinMessagesAfterComposerResize", start) + assert end != -1, "helper must be immediately followed by its window export" + return UI_JS[start:end] + + +def test_helper_exists_and_is_pin_guarded(): + body = _helper_body() + # Never re-pin a reader who scrolled away (sticky-unpin model). + assert "if(_messageUserUnpinned || !_scrollPinned) return;" in body + # Uses the canonical bottom-pin primitive. + assert "_setMessageScrollToBottom" in body + # Cheap no-op when already at bottom. + assert "<=1) return;" in body + + +def test_autoresize_calls_the_repin(): + start = MESSAGES_JS.find("function autoResize(") + assert start != -1 + end = MESSAGES_JS.find("function scheduleComposerAutoResize(", start) + assert end > start + body = MESSAGES_JS[start:end] + # The height write must still be there... + assert "el.style.height=Math.min(el.scrollHeight,200)+'px'" in body + # ...and the re-pin must be called after it. + assert "_repinMessagesAfterComposerResize()" in body + + +def test_resize_observer_installed_on_composer(): + # A ResizeObserver on #msg re-pins on grow (catches non-autoResize paths). + assert "new ResizeObserver(" in BOOT_JS + assert "_repinMessagesAfterComposerResize" 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 + + +# --------------------------------------------------------------------------- +# Behavioral (node vm) — the pin guard under a viewport shrink +# --------------------------------------------------------------------------- + +def _run(scenario): + node = shutil.which("node") + if not node: # pragma: no cover + pytest.skip("node not available") + body = _helper_body() + harness = textwrap.dedent( + """ + // Minimal DOM/scroll-state stub reproducing the composer-grow viewport shrink. + let _messageUserUnpinned = %(unpinned)s; + let _scrollPinned = %(pinned)s; + // 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 _setMessageScrollToBottom(){ el.scrollTop = el.scrollHeight - el.clientHeight; el._pinnedCalled = true; } + + %(helper)s + + // Simulate the composer growing by 132px: the viewport shrinks. + el.clientHeight -= 132; + _repinMessagesAfterComposerResize(); + const bottomDist = el.scrollHeight - el.scrollTop - el.clientHeight; + console.log(JSON.stringify({ scrollTop: el.scrollTop, bottomDist, pinnedCalled: !!el._pinnedCalled })); + """ + ) % {**scenario, "helper": body} + proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30) + assert proc.returncode == 0, f"node harness failed: {proc.stderr}" + return json.loads(proc.stdout.strip()) + + +def test_pinned_reader_is_repinned_after_composer_grow(): + # Reader pinned to bottom (scrollTop at old max = 8768-745 = 8023), composer + # grows -> viewport shrinks -> must re-pin so bottomDist returns to 0. + out = _run({"unpinned": "false", "pinned": "true", "scrolltop": 8023}) + assert out["pinnedCalled"] is True + assert out["bottomDist"] == 0, f"pinned reader must be re-pinned to the bottom; got {out}" + + +def test_unpinned_reader_is_not_yanked_down(): + # Reader scrolled up (unpinned). Composer grow must NOT re-pin — no yank. + out = _run({"unpinned": "true", "pinned": "false", "scrolltop": 3000}) + assert out["pinnedCalled"] is False + assert out["scrollTop"] == 3000, f"unpinned reader must not be moved; got {out}" + + +def test_pinned_but_already_at_bottom_is_a_noop(): + # scrollTop already exactly at the (post-shrink) bottom -> nothing to do. + # scrollHeight 8768, clientHeight after shrink 613 -> bottom scrollTop = 8155. + out = _run({"unpinned": "false", "pinned": "true", "scrolltop": 8155}) + # bottomDist is already <=1 at call time (8768-8155-613 = 0), so no re-pin call. + assert out["pinnedCalled"] is False + assert out["bottomDist"] == 0 From 7e7e71f6953ff5a72f9fd997c726d83a6a65f9d1 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 4 Jul 2026 05:52:14 +0000 Subject: [PATCH 2/4] fix(chat): widen composer re-pin observer to #composerWrap (Fable #5516 finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- static/boot.js | 32 +++++++++---------- static/ui.js | 2 +- ...test_issue5514_composer_grow_scroll_pin.py | 8 +++-- 3 files changed, 22 insertions(+), 20 deletions(-) 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 From c77f4cf0146087cd754085c5f5b9b4500a6b8828 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 4 Jul 2026 06:06:05 +0000 Subject: [PATCH 3/4] =?UTF-8?q?release=20#5516:=20composer-grow=20re-pin?= =?UTF-8?q?=20=E2=80=94=20hot-path=20optimization=20+=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Greptile P2 nit: autoResize() now only calls the re-pin when the composer actually grew (offsetHeight before/after compare), skipping the DOM read on a no-height-change keystroke. The #composerWrap ResizeObserver remains the safety net for growth paths that bypass autoResize. Test tightened to assert the grow-guard. CHANGELOG entry added (self-built, release owns changelog). --- CHANGELOG.md | 1 + static/messages.js | 9 +++++++-- tests/test_issue5514_composer_grow_scroll_pin.py | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80670d28..fa800554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed +- **The transcript no longer scrolls up when the composer grows.** When you were pinned to the bottom of a conversation and the composer got taller — multi-line typing, `Shift+Enter`, or a multi-line / dictation paste — the transcript viewport shrank by the same amount and left you stranded a row or two above the bottom (it read as the chat "randomly scrolling up" during normal use). The transcript now re-pins to the bottom whenever the composer grows, but only when you were genuinely still pinned there — if you'd scrolled up to read history, nothing yanks you back down. (#5516, #5514, #5515) - **A deleted conversation no longer comes back as a read-only "ghost" after a restart.** Deleting a WebUI session removed its file but left a recoverable row in the agent's state database, so after a server restart and page reload the deleted conversation could reappear as a read-only "Agent" session still showing its old transcript. Deleted WebUI sessions are now recorded in a durable tombstone that the sidebar projection, startup backup-recovery, audit, and session-claim paths all honor, so a deleted session stays gone (the backend keeps returning the 404 the client relies on to clear the stale route). Genuine crash recovery is unaffected — a session whose sidecar is lost without a delete is still restored — imported external-channel (messaging) sessions are exempt and keep their transcript, and a deleted id is automatically un-tombstoned if the same session is later re-created or re-imported. Thanks @rodboev. (#5504, #5498) - **A drag that ends over the sidebar no longer switches conversations by accident.** Releasing the mouse (a stray `pointerup`) over a session row after a drag that began elsewhere in the main content area could switch you into that conversation unintentionally. Session switching now ignores a `pointerup` that isn't a genuine click on the row. Thanks @webtecnica. (#5487, #5462) - **Extension gallery downloads no longer hang on networks with broken IPv6.** Gallery installs and registry fetches followed the OS address order (IPv6-first); on a network advertising dead IPv6 routes, every install stalled for the full TCP timeout before falling back to IPv4. Downloads now try IPv4 first (via a non-global, thread-safe per-connection override that preserves the existing redirect allowlist and timeouts), so installs are fast again on such networks. Thanks @rumotoshino. (#5458) diff --git a/static/messages.js b/static/messages.js index 4abff942..8d4c378a 100644 --- a/static/messages.js +++ b/static/messages.js @@ -6181,13 +6181,18 @@ function autoResize(){ _composerAutoResizeRaf=0; } const el=$('msg'); + const _prevComposerH=el.offsetHeight; el.style.height='auto'; el.style.height=Math.min(el.scrollHeight,200)+'px'; updateSendBtn(); // #5514/#5515: growing the composer shrinks the flex:1 transcript viewport, so // a reader pinned to the bottom would be stranded above it ("scrolls up 1 row - // per composer row"). Re-pin the transcript when it's genuinely still pinned. - if(typeof _repinMessagesAfterComposerResize==='function') _repinMessagesAfterComposerResize(); + // per composer row"). Re-pin the transcript when it's genuinely still pinned — + // but only when the composer actually GREW (a shrink enlarges the viewport and + // can't strand a reader), so the common no-height-change keystroke skips the + // re-pin's DOM read entirely. The #composerWrap ResizeObserver is the safety + // net for any growth path that doesn't route through here. + if(el.offsetHeight>_prevComposerH && typeof _repinMessagesAfterComposerResize==='function') _repinMessagesAfterComposerResize(); } function scheduleComposerAutoResize(){ if(typeof requestAnimationFrame!=='function'){autoResize();return;} diff --git a/tests/test_issue5514_composer_grow_scroll_pin.py b/tests/test_issue5514_composer_grow_scroll_pin.py index 1ac95bb5..2e25a9af 100644 --- a/tests/test_issue5514_composer_grow_scroll_pin.py +++ b/tests/test_issue5514_composer_grow_scroll_pin.py @@ -65,8 +65,10 @@ def test_autoresize_calls_the_repin(): body = MESSAGES_JS[start:end] # The height write must still be there... assert "el.style.height=Math.min(el.scrollHeight,200)+'px'" in body - # ...and the re-pin must be called after it. + # ...and the re-pin must be called after it, guarded so it only fires when the + # composer actually grew (skips the DOM read on a no-height-change keystroke). assert "_repinMessagesAfterComposerResize()" in body + assert "el.offsetHeight>_prevComposerH" in body def test_resize_observer_installed_on_composer(): From 947fa2b92d06b5a3faac4b615248584ee1795e43 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 4 Jul 2026 06:15:42 +0000 Subject: [PATCH 4/4] release #5516: fix stale test docstring (#composerWrap not textarea) --- tests/test_issue5514_composer_grow_scroll_pin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_issue5514_composer_grow_scroll_pin.py b/tests/test_issue5514_composer_grow_scroll_pin.py index 2e25a9af..e6f5b9ed 100644 --- a/tests/test_issue5514_composer_grow_scroll_pin.py +++ b/tests/test_issue5514_composer_grow_scroll_pin.py @@ -16,7 +16,8 @@ Fix: `_repinMessagesAfterComposerResize()` (static/ui.js) re-pins the transcript to the bottom ONLY when the reader is genuinely still pinned (honors `_messageUserUnpinned` / `_scrollPinned` so a reader who scrolled away is never yanked back). It is called (a) from autoResize() and (b) from a ResizeObserver on -the composer textarea that catches every height-change path. +the #composerWrap wrapper that catches every height-change path (typed input, +paste, draft restore, attachment tray / selection chip). This module verifies BOTH the static wiring and the helper's guard logic via a node `vm` sandbox that reproduces the pinned-vs-unpinned viewport-shrink scenario.