2026-06-01 20:34:06 +00:00
|
|
|
"""Regression: sticky manual unpin during streaming (supersedes #3250 timeout tuning).
|
2026-05-31 16:54:55 +00:00
|
|
|
|
2026-06-01 20:34:06 +00:00
|
|
|
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.
|
2026-05-31 16:54:55 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
|
|
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
2026-06-01 20:34:06 +00:00
|
|
|
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
2026-05-31 16:54:55 +00:00
|
|
|
|
|
|
|
|
|
2026-06-01 20:34:06 +00:00
|
|
|
def _scroll_listener_block() -> str:
|
2026-05-31 16:54:55 +00:00
|
|
|
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:
|
2026-06-01 20:34:06 +00:00
|
|
|
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(" ", "")
|
2026-07-04 21:24:17 +00:00
|
|
|
# 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"
|
2026-06-01 20:34:06 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-05-31 16:54:55 +00:00
|
|
|
)
|
2026-06-01 20:34:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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(" ", "")
|