"""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(" ", "")