A single scroll-up during streaming set _messageUserUnpinned=true and scrollIfPinned() then permanently stopped auto-follow (only scrollToBottom() cleared it) — a permanent auto-follow lockout even after the user returned to the bottom. scrollIfPinned() now re-pins, but ONLY when the reader has genuinely reached the true-bottom tail (<=80px) AND shows no active scroll intent (wheel/key/touch/non-message), reusing the listener's _nearBottomCount debounce. Proximity alone (the ~250px nearBottom band) must never re-pin — that is the #4295 invariant. Restores the listener's <=80px true-bottom gate too. Co-authored-by: luperrypf <luperrypf@users.noreply.github.com>
73 lines
3.0 KiB
Python
73 lines
3.0 KiB
Python
"""Regression: sticky manual unpin during streaming (supersedes #3250 timeout tuning).
|
|
|
|
After the user scrolls up to read earlier content, streaming tokens, tool cards,
|
|
and layout growth must not re-pin the viewport until the user scrolls back to
|
|
the bottom or clicks the scroll-to-bottom control.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _scroll_listener_block() -> str:
|
|
anchor = "el.addEventListener('scroll'"
|
|
start = UI_JS.index(anchor)
|
|
raf_start = UI_JS.index("requestAnimationFrame", start)
|
|
brace = UI_JS.index("{", raf_start)
|
|
depth = 0
|
|
for i in range(brace, len(UI_JS)):
|
|
ch = UI_JS[i]
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return UI_JS[brace : i + 1]
|
|
raise AssertionError("scroll listener rAF callback not found")
|
|
|
|
|
|
def test_scroll_if_pinned_respects_sticky_user_unpin():
|
|
fn = UI_JS[UI_JS.index("function scrollIfPinned"): UI_JS.index("function scrollToBottom")]
|
|
compact = fn.replace(" ", "")
|
|
# scrollIfPinned() re-pins auto-follow when the reader returns to the tail
|
|
# (#5544), but it must still RESPECT a sticky manual unpin: the re-pin is
|
|
# gated behind the true-bottom threshold (<=80px, not mere proximity) and
|
|
# bails on any recent scroll intent, so it never fights a reader who has
|
|
# scrolled up. Assert the guards rather than the old unconditional bail.
|
|
assert "if(_messageUserUnpinned){" in compact, (
|
|
"scrollIfPinned() must handle the sticky-unpin case explicitly"
|
|
)
|
|
assert "_messageBottomDistance()>80" in compact, (
|
|
"re-pin must require the true bottom tail (<=80px), not proximity — the #4295 invariant"
|
|
)
|
|
assert "_recentMessageWheelIntent()" in compact and "_recentMessageKeyScrollIntent()" in compact, (
|
|
"re-pin must bail on recent wheel/key scroll intent so it can't fight a manual scroll-up"
|
|
)
|
|
|
|
|
|
def test_sticky_unpin_blocks_near_bottom_repin_without_downward_scroll():
|
|
block = _scroll_listener_block()
|
|
assert "_recentMessageUpwardIntent()" not in block
|
|
compact = block.replace(" ", "")
|
|
assert "elseif(!_messageUserUnpinned)" in compact, (
|
|
"Near-bottom hysteresis re-pin must be gated off while the user is manually unpinned"
|
|
)
|
|
assert "elseif(movedDown&&nearBottom)" in compact, (
|
|
"Re-follow must require explicit downward scroll into the near-bottom zone"
|
|
)
|
|
|
|
|
|
def test_new_stream_resets_follow_state():
|
|
attach = MESSAGES_JS[MESSAGES_JS.index("function attachLiveStream"):]
|
|
assert "_resetStreamScrollFollow" in attach, (
|
|
"A fresh live stream should default to following the tail until the user scrolls up"
|
|
)
|
|
|
|
|
|
def test_scroll_to_bottom_clears_sticky_unpin():
|
|
fn = UI_JS[UI_JS.index("function scrollToBottom"): UI_JS.index("function _fmtOllamaLabel")]
|
|
assert "_messageUserUnpinned=false" in fn.replace(" ", "")
|