Merge pull request #5518 from nesquena/release/stage-5516
Release — transcript re-pin when composer grows (#5516, closes #5514/#5515)
This commit is contained in:
5 files changed
+193
No files matched your search
@@ -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)
|
||||
|
||||
@@ -1916,6 +1916,28 @@ $('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, 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 _cw=$('composerWrap')||$('msg');
|
||||
if(!_cw || typeof ResizeObserver!=='function' || typeof _repinMessagesAfterComposerResize!=='function') return;
|
||||
let _lastComposerH=_cw.offsetHeight;
|
||||
const _ro=new ResizeObserver(()=>{
|
||||
const h=_cw.offsetHeight;
|
||||
if(h<=_lastComposerH){_lastComposerH=h;return;} // shrink/no-op: enlarges the viewport, can't strand
|
||||
_lastComposerH=h;
|
||||
_repinMessagesAfterComposerResize(); // grow: re-pin the pinned reader
|
||||
});
|
||||
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
|
||||
// manual flag and reset it on the next tick to swallow that trailing Enter.
|
||||
|
||||
@@ -6181,9 +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 —
|
||||
// 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;}
|
||||
|
||||
@@ -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(_messageBottomDistance()<=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.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""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 #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.
|
||||
"""
|
||||
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, 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():
|
||||
# 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 "can't strand" 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 _messageBottomDistance(){ return el.scrollHeight - el.scrollTop - el.clientHeight; }
|
||||
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
|
||||
Reference in new issue
Block a user