From 04ce7599e3ca72e8af35540f3beceabbc8fd1be7 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 1 Jun 2026 19:08:22 +0000 Subject: [PATCH] stage-batch11: #3330 pinned-scroll + #3311 inline-math currency #3330 Fix pinned chat scroll after message rebuild Co-authored-by: jianongHe #3311 fix: reject inline math when $ is followed by a digit (currency) Co-authored-by: toanalien --- static/ui.js | 21 +++++++++--- tests/test_issue3319_pinned_scroll_jump.py | 39 ++++++++++++++++++++++ tests/test_issue347.py | 18 ++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue3319_pinned_scroll_jump.py diff --git a/static/ui.js b/static/ui.js index 44f13bb5f..882b33b76 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2758,13 +2758,24 @@ function _setMessageScrollToBottom(){ _lastScrollTop=el.scrollTop; _nearBottomCount=2; _scrollPinned=true; - requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); }); + requestAnimationFrame(()=>{ + el.scrollTop=el.scrollHeight; + _lastScrollTop=el.scrollTop; + _nearBottomCount=2; + _scrollPinned=true; + requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); }); + }); } function _isMessagePaneNearBottom(threshold=250){ const el=$('messages'); if(!el) return false; return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold; } +function _messageBottomDistance(){ + const el=$('messages'); + if(!el) return 0; + return el.scrollHeight-el.scrollTop-el.clientHeight; +} function _shouldFollowMessagesOnDomReplace(){ return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200)); } @@ -2793,6 +2804,7 @@ function _settleMessageScrollToBottom(force){ function scrollIfPinned(){ if(!_scrollPinned) return; if(_recentNonMessageScrollIntent()) return; + if(_messageBottomDistance()>500) _setMessageScrollToBottom(); _settleMessageScrollToBottom(false); } function scrollToBottom(){ @@ -3098,9 +3110,10 @@ function renderMd(raw){ s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';}); // Match a single literal backslash before the display delimiter (the common LLM form). s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';}); - // Inline math: $...$ — require non-space at boundaries to avoid false positives - // e.g. "costs $5 and $10" should not trigger (space after opening $) - s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';}); + // Inline math: $...$ — require non-space/non-digit at opening boundary to avoid + // false positives on currency like "$1,000 xuống ~$95" or "costs $5 and $10". + // Aligns with smd's se() guard which also rejects $ followed by digits. + s=s.replace(/\$([^\s$\d\n][^$\n]*?[^\s$\n]|[^\s\d])\$/g,(_,m)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';}); // Also stash \(...\) LaTeX delimiters. // Match a single literal backslash before the delimiter (the common LLM form). s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';}); diff --git a/tests/test_issue3319_pinned_scroll_jump.py b/tests/test_issue3319_pinned_scroll_jump.py new file mode 100644 index 000000000..d4d115fba --- /dev/null +++ b/tests/test_issue3319_pinned_scroll_jump.py @@ -0,0 +1,39 @@ +"""Regression coverage for #3319: pinned chat should recover after DOM rebuilds.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _extract_fn(src: str, name: str) -> str: + marker = f"function {name}" + start = src.find(marker) + assert start >= 0, f"{name} not found" + brace = src.find("{", start) + assert brace >= 0, f"{name} body not found" + depth = 0 + for i in range(brace, len(src)): + ch = src[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[start : i + 1] + raise AssertionError(f"{name} body did not close") + + +def test_scroll_to_bottom_retries_after_next_layout_frame(): + fn = _extract_fn(UI_JS, "_setMessageScrollToBottom") + assert "el.scrollTop=el.scrollHeight;" in fn + assert "requestAnimationFrame(()=>{" in fn + assert fn.count("el.scrollTop=el.scrollHeight;") >= 2 + assert fn.count("_lastScrollTop=el.scrollTop;") >= 2 + + +def test_scroll_if_pinned_recovers_when_far_from_bottom(): + fn = _extract_fn(UI_JS, "scrollIfPinned") + assert "_messageBottomDistance()>500" in fn + assert "_setMessageScrollToBottom();" in fn + assert fn.index("_messageBottomDistance()>500") < fn.index("_settleMessageScrollToBottom(false)") + diff --git a/tests/test_issue347.py b/tests/test_issue347.py index 31d200bd1..489e8d7e6 100644 --- a/tests/test_issue347.py +++ b/tests/test_issue347.py @@ -429,6 +429,24 @@ def test_inline_math_regex_requires_non_space_boundaries(): f"Inline math regex must exclude spaces at boundaries to prevent false " f"positives on currency like $5. Found: {inline_line[:120]}" ) +def test_inline_math_regex_rejects_digit_after_opening_dollar(): + """The $...$ inline regex must reject $ followed by a digit. + + Currency like '$1,000 xuống ~$95' must NOT be parsed as math. + The opening $ followed by a digit (0-9) is a strong currency signal. + Aligns with smd's se() guard which also rejects $ + digit. + """ + inline_push_idx = UI_JS.find("type:'inline',src:m") + assert inline_push_idx != -1 + line_start = UI_JS.rfind('\n', 0, inline_push_idx) + 1 + inline_line = UI_JS[line_start:inline_push_idx + 50] + # The regex character class for the first char must exclude \d + assert '\\d' in inline_line, ( + f"Inline math regex must exclude digits at opening boundary to prevent " + f"false positives on currency like $1,000. Found: {inline_line[:120]}" + ) + + def test_display_math_stashed_before_inline(): """$$...$$ display math must be stashed before $...$ inline math.