diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f687ead8..7b8b2149c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] +## [v0.51.576] — 2026-06-22 — Release UI (scroll read-position + mobile horizontal-pan fixes) + +### Fixed + +- **The transcript no longer yanks you back to the bottom while you're reading mid-stream.** Scroll re-pinning now requires a deliberate move toward the bottom (and ignores tiny scroll jitter), so reading earlier messages during an active stream stays put instead of getting hijacked back to the latest token. Thanks @rodboev. (#4584, fixes #4295) +- **No more accidental horizontal panning of the mobile transcript.** Wide content (long code lines, URLs) could let the message area pan sideways on phones; the transcript now clips horizontal overflow and wraps long words. Thanks @rodboev. (#4583, fixes #4553) + ## [v0.51.575] — 2026-06-22 — Release UH (session-list perf for long histories) ### Fixed diff --git a/static/style.css b/static/style.css index 55ff1aeb4..b81ce66eb 100644 --- a/static/style.css +++ b/static/style.css @@ -1640,7 +1640,7 @@ .workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;} .chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);} .messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;} - .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;} + .messages{flex:1;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;} /* Overlay scroll controls so they do not affect the transcript's native scroll geometry. */ .scroll-to-bottom-btn{position:absolute;right:20px;bottom:16px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;} .scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);} @@ -2407,7 +2407,7 @@ .topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:4px 8px!important;white-space:nowrap;} .settings-main{padding:18px 16px;} .hermes-action-grid{grid-template-columns:1fr;} - .messages-inner{padding:12px 10px 20px;} + .messages-inner{padding:12px 10px 20px;max-width:100%;overflow-x:hidden;word-break:break-word;min-width:0;} .msg-body{padding-left:0;max-width:100%;} .msg-role{font-size:12px;} .composer-wrap{padding:8px 10px 12px!important;} diff --git a/static/ui.js b/static/ui.js index 3d52866b0..fe172398a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3632,9 +3632,9 @@ function _recordNonMessageScrollIntent(e){ const target=e&&e.target; if(!el||!target) return; if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now(); - else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY<0)){ + else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY< -30)){ _cancelBottomSettle(); - if(typeof e.deltaY==='number'&&e.deltaY<0){ + if(typeof e.deltaY==='number'&&e.deltaY< -30){ _messageUserUnpinned=true; _nearBottomCount=0; _scrollPinned=false; @@ -3808,7 +3808,8 @@ if(typeof window!=='undefined'){ cancelAnimationFrame(_scrollRaf); _scrollRaf=requestAnimationFrame(()=>{ const top=el.scrollTop; - const nearBottom=el.scrollHeight-top-el.clientHeight<250; + const bottomDistance=el.scrollHeight-top-el.clientHeight; + const nearBottom=bottomDistance<250; const movedUp=_lastScrollTop!==null&&top<_lastScrollTop-2; const movedDown=_lastScrollTop!==null&&top>_lastScrollTop+2; _lastScrollTop=top; @@ -3820,13 +3821,16 @@ if(typeof window!=='undefined'){ }else if(movedDown&&nearBottom){ _nearBottomCount=_nearBottomCount+1; if(_nearBottomCount>=2){ - _scrollPinned=true; - _messageUserUnpinned=false; + if(!_messageUserUnpinned||bottomDistance<=80){ + _messageUserUnpinned=false; + _scrollPinned=true; + } + _nearBottomCount=0; } }else if(!_messageUserUnpinned){ if(nearBottom){ _nearBottomCount=_nearBottomCount+1; - if(_nearBottomCount>=2) _scrollPinned=true; + if(_nearBottomCount>=2){_scrollPinned=true;_nearBottomCount=0;} }else{ _nearBottomCount=0; _scrollPinned=false; diff --git a/tests/test_issue1360_streaming_scroll_hardening.py b/tests/test_issue1360_streaming_scroll_hardening.py index 39f57c1fd..1520bc186 100644 --- a/tests/test_issue1360_streaming_scroll_hardening.py +++ b/tests/test_issue1360_streaming_scroll_hardening.py @@ -30,7 +30,7 @@ def test_messages_scroller_disables_browser_scroll_anchoring(): def test_scroll_repin_dead_zone_is_wider_for_mac_app_windows(): - assert "clientHeight<250" in UI_JS, ( + assert "clientHeight<250" in UI_JS or "bottomDistance<250" in UI_JS, ( "The near-bottom re-pin threshold should be at least 250px so small " "macOS app windows and trackpad momentum do not re-pin too eagerly." ) diff --git a/tests/test_issue1731_upward_scroll_unpins.py b/tests/test_issue1731_upward_scroll_unpins.py index f0de4cefe..69e9c0696 100644 --- a/tests/test_issue1731_upward_scroll_unpins.py +++ b/tests/test_issue1731_upward_scroll_unpins.py @@ -106,7 +106,7 @@ def test_wheel_touch_upward_intent_unpins_immediately_inside_messages(): fn_end = UI_JS.index("function _recentNonMessageScrollIntent", fn_start) fn = UI_JS[fn_start:fn_end] assert "_messageUserUnpinned=true" in fn.replace(" ", "") - assert "e.deltaY<0" in fn and "e.type==='touchmove'" in fn + assert "e.deltaY< -30" in fn and "e.type==='touchmove'" in fn def test_downward_path_preserves_macos_momentum_hysteresis(): @@ -126,7 +126,7 @@ def test_repin_threshold_is_still_250px(): stay. Direction detection is the new lever, not threshold relaxation. """ block = _scroll_listener_block() - assert "clientHeight<250" in block, ( + assert "clientHeight<250" in block or "bottomDistance<250" in block, ( "The 250px re-pin dead zone must remain — #1360 / #677 require it " "for macOS small-window + trackpad momentum cases. The #1731 fix " "uses direction detection, not threshold changes." diff --git a/tests/test_issue4295_scroll_pin_reentry.py b/tests/test_issue4295_scroll_pin_reentry.py new file mode 100644 index 000000000..d1c5216e4 --- /dev/null +++ b/tests/test_issue4295_scroll_pin_reentry.py @@ -0,0 +1,158 @@ +"""Behavioral regression locks for #4295 scroll re-pin handling.""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).parent.parent +NODE = shutil.which("node") + + +def _ui_js() -> str: + return (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _balanced_block(src: str, brace_start: int) -> str: + depth = 0 + for i in range(brace_start, len(src)): + ch = src[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[brace_start + 1 : i] + raise AssertionError("balanced block not found") + + +def _scroll_listener_raf_body() -> str: + src = _ui_js() + listener_start = src.index("el.addEventListener('scroll'") + raf_start = src.index("requestAnimationFrame(()=>", listener_start) + brace_start = src.index("{", raf_start) + return _balanced_block(src, brace_start) + + +def _record_non_message_scroll_intent() -> str: + src = _ui_js() + start = src.index("function _recordNonMessageScrollIntent") + end = src.index("function _recentNonMessageScrollIntent", start) + return src[start:end] + + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _run_scroll_listener(samples: list[dict[str, int]]) -> dict[str, int | bool | None]: + payload = { + "body": _scroll_listener_raf_body(), + "samples": samples, + } + script = ( + "const payload = " + json.dumps(payload) + ";\n" + + r""" +const step = new Function( + 'el', + '_lastScrollTop', + '_nearBottomCount', + '_scrollPinned', + '_messageUserUnpinned', + '_newMessageCueVisible', + '_programmaticScroll', + '_cancelBottomSettle', + '_clearNewMessageScrollCue', + '_syncScrollToBottomCue', + '_updateSessionStartJumpButton', + '_isSessionEndlessScrollEnabled', + '_messagesTruncated', + '_loadOlderMessages', + payload.body + ` +return { + _lastScrollTop, + _nearBottomCount, + _scrollPinned, + _messageUserUnpinned, +}; +` +); + +let state = { + _lastScrollTop: 800, + _nearBottomCount: 0, + _scrollPinned: true, + _messageUserUnpinned: false, +}; + +const noop = () => {}; +for (const sample of payload.samples) { + state = step( + sample, + state._lastScrollTop, + state._nearBottomCount, + state._scrollPinned, + state._messageUserUnpinned, + false, + false, + noop, + noop, + noop, + noop, + () => false, + false, + noop + ); +} + +console.log(JSON.stringify(state)); +""" + ) + result = subprocess.run( + [NODE, "-e", script], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + return json.loads(result.stdout.strip()) + + +class TestScrollPinReentry: + def test_manual_scroll_back_to_true_bottom_rearms_follow(self): + state = _run_scroll_listener( + [ + {"scrollTop": 760, "scrollHeight": 1000, "clientHeight": 200}, + {"scrollTop": 770, "scrollHeight": 1000, "clientHeight": 200}, + {"scrollTop": 780, "scrollHeight": 1000, "clientHeight": 200}, + ] + ) + assert state["_scrollPinned"] is True, ( + "Reaching the true bottom tail after a manual scroll-up must re-arm auto-follow." + ) + assert state["_messageUserUnpinned"] is False, ( + "The sticky unpin flag must clear once the reader manually scrolls back to the real bottom." + ) + + def test_near_bottom_proximity_alone_does_not_repin(self): + state = _run_scroll_listener( + [ + {"scrollTop": 760, "scrollHeight": 1200, "clientHeight": 200}, + {"scrollTop": 775, "scrollHeight": 1200, "clientHeight": 200}, + {"scrollTop": 790, "scrollHeight": 1200, "clientHeight": 200}, + ] + ) + assert state["_scrollPinned"] is False, ( + "The reader must stay unpinned inside the 250px near-bottom band until they reach the true bottom tail." + ) + assert state["_messageUserUnpinned"] is True, ( + "Near-bottom proximity alone must not clear the sticky unpin flag." + ) + + def test_scroll_up_threshold_prevents_jitter_unpin(self): + record = _record_non_message_scroll_intent() + assert "e.deltaY< -30" in record or "e.deltaY < -30" in record, ( + "The wheel-intent path must keep the 30px upward threshold to avoid touch jitter false unpins." + ) diff --git a/tests/test_issue4553_mobile_transcript_overflow.py b/tests/test_issue4553_mobile_transcript_overflow.py new file mode 100644 index 000000000..6714eac2e --- /dev/null +++ b/tests/test_issue4553_mobile_transcript_overflow.py @@ -0,0 +1,51 @@ +import re +from pathlib import Path + + +def test_messages_rule_has_overflow_x_hidden(): + """Verify .messages rule includes overflow-x:hidden property.""" + css_file = Path(__file__).resolve().parent.parent / "static" / "style.css" + content = css_file.read_text() + + # Find the .messages{ rule + messages_rule_match = re.search(r'\.messages\{[^}]*overflow-y:auto[^}]*\}', content) + assert messages_rule_match, ".messages rule not found" + + messages_rule = messages_rule_match.group(0) + assert 'overflow-x:hidden' in messages_rule, ( + ".messages rule does not contain overflow-x:hidden" + ) + + +def test_messages_inner_mobile_has_containment(): + """Verify .messages-inner in mobile breakpoint includes containment properties.""" + css_file = Path(__file__).resolve().parent.parent / "static" / "style.css" + content = css_file.read_text() + + # Find the @media(max-width:640px) block and then .messages-inner within a reasonable window + media_match = re.search(r'@media\(max-width:640px\)\{', content) + assert media_match, "@media(max-width:640px) block not found" + + # Extract content after the media query opening brace + media_start = media_match.start() + remaining_content = content[media_start:media_start + 5000] # Look ahead 5000 chars + + # Find .messages-inner rule within this section + messages_inner_match = re.search(r'\.messages-inner\{([^}]*)\}', remaining_content) + assert messages_inner_match, ".messages-inner rule not found in @media(max-width:640px) block" + + messages_inner_rule = messages_inner_match.group(0) + + # Verify all required properties are present + assert 'max-width:100%' in messages_inner_rule, ( + ".messages-inner in mobile block missing max-width:100%" + ) + assert 'overflow-x:hidden' in messages_inner_rule, ( + ".messages-inner in mobile block missing overflow-x:hidden" + ) + assert 'word-break:break-word' in messages_inner_rule, ( + ".messages-inner in mobile block missing word-break:break-word" + ) + assert 'min-width:0' in messages_inner_rule, ( + ".messages-inner in mobile block missing min-width:0" + ) diff --git a/tests/test_issue677.py b/tests/test_issue677.py index 0406a40c6..35f12d0df 100644 --- a/tests/test_issue677.py +++ b/tests/test_issue677.py @@ -132,7 +132,7 @@ class TestScrollPinningFix: assert scroll_listener_start != -1, "scroll event listener not found" # After #1360 fix, the nearBottom + btn logic lives inside an rAF # callback — extend search window to cover the full listener block. - listener_block = UI_JS[scroll_listener_start:scroll_listener_start + 1400] + listener_block = UI_JS[scroll_listener_start:scroll_listener_start + 1600] assert "_syncScrollToBottomCue(showBottomButton" in listener_block, ( "Scroll listener must show/hide scrollToBottomBtn based on _scrollPinned (#677)" ) diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py index 43d762c27..c4821c250 100644 --- a/tests/test_tars_scroll_reset_regressions.py +++ b/tests/test_tars_scroll_reset_regressions.py @@ -84,7 +84,7 @@ def test_message_scroll_listener_does_not_downgrade_explicit_bottom_pin_on_first assert "_nearBottomCount=2" in set_bottom assert "_scrollPinned=_nearBottomCount>=2" not in listener_block - assert "if(_nearBottomCount>=2) _scrollPinned=true" in listener_block + assert "if(_nearBottomCount>=2){" in listener_block assert "_scrollPinned=false" in listener_block @@ -95,7 +95,7 @@ def test_user_scroll_cancels_delayed_bottom_settling(): assert "function _cancelBottomSettle" in UI_JS assert "_cancelBottomSettle();" in listener_block - assert "e.deltaY<0" in record + assert "e.deltaY< -30" in record assert "_cancelBottomSettle();" in record assert "_scrollPinned=false" in record assert "if(_messageUserUnpinned) return;" in pinned