Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

## [Unreleased]

## [v0.51.199] — 2026-06-01 — Release FS (stage-batch11 — pinned-scroll recovery + inline-math currency false-positive)

### Fixed
- Pinned chat now recovers its scroll position after a DOM rebuild: `_setMessageScrollToBottom` retries on the next layout frame, and `scrollIfPinned` re-pins when the pane has drifted more than 500px from the bottom, so a message-list rebuild no longer leaves a pinned conversation stranded mid-scroll. Closes #3319 (#3330, @jianongHe).
- The `$...$` inline-math renderer no longer treats currency like `$1,000 xuống ~$95` as math: the opening `$` followed by a digit is now rejected (aligning with smd's `se()` guard), so dollar amounts render as plain text. Digit-leading inline math (e.g. `$2x = 4$`) should now use the LaTeX-style `\(2x = 4\)` or display `$$2x = 4$$` delimiters (#3311, @toanalien).

## [v0.51.198] — 2026-06-01 — Release FR (stage-batch10 — custom-provider reasoning model-id normalize + profile skill counts + run-adapter RFC slice)

### Fixed
Expand Down
36 changes: 30 additions & 6 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -2226,8 +2226,8 @@ let _scrollPinned=true;
let _programmaticScroll=false;
let _nearBottomCount=0;
let _lastScrollTop=null;
let _lastNonMessageScrollIntentMs=0;
let _lastMessageUpwardIntentMs=0;
let _lastNonMessageScrollIntentMs=-Infinity;
let _lastMessageUpwardIntentMs=-Infinity;
let _messageUserUnpinned=false;
let _bottomSettleToken=0;
const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
Expand Down Expand Up @@ -2758,13 +2758,35 @@ function _setMessageScrollToBottom(){
_lastScrollTop=el.scrollTop;
_nearBottomCount=2;
_scrollPinned=true;
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
requestAnimationFrame(()=>{
// Retry the bottom write on the next layout frame so a DOM rebuild that
// grows the transcript after the first write doesn't strand a pinned
// conversation mid-scroll (#3319). But by this frame the user may have
// scrolled up — re-check intent and DON'T snap them back or re-pin if so;
// only release the programmatic-scroll latch.
if(_messageUserUnpinned || !_scrollPinned
|| (typeof _recentMessageUpwardIntent==='function' && _recentMessageUpwardIntent())
|| _recentNonMessageScrollIntent()){
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
return;
}
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));
}
Expand Down Expand Up @@ -2793,6 +2815,7 @@ function _settleMessageScrollToBottom(force){
function scrollIfPinned(){
if(!_scrollPinned) return;
if(_recentNonMessageScrollIntent()) return;
if(_messageBottomDistance()>500) _setMessageScrollToBottom();
_settleMessageScrollToBottom(false);
}
function scrollToBottom(){
Expand Down Expand Up @@ -3098,9 +3121,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';});
Expand Down
39 changes: 39 additions & 0 deletions tests/test_issue3319_pinned_scroll_jump.py
Original file line number Diff line number Diff line change
@@ -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)")

18 changes: 18 additions & 0 deletions tests/test_issue347.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading