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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

### Fixed

- **Auto-scroll now resumes when you return to the bottom of a streaming reply.** If you scrolled up even once while a response was streaming, the chat permanently stopped following new output — scrolling back to the bottom didn't help; only starting a new turn re-engaged auto-follow. Returning to the very bottom of the transcript (within ~80px) now re-pins auto-scroll, so the view follows the stream again. It only re-pins at the true bottom and never while you're actively scrolling (wheel, keyboard, touch, or scrollbar drag), so reading back through history mid-stream still never yanks you down — preserving the #4295 behavior. Thanks @luperrypf. (#5544)
- **Forking a long or compacted conversation now keeps a clean turn boundary.** "Fork from here" on a large session (one big enough to have been context-compacted) could cut the copied context in the middle of a turn — or right after a tool call, before its result — leaving the new conversation with a malformed context that the model choked on. The fork now aligns its cut to a real turn boundary in the same display coordinate space `/api/session` uses (matching both the compacted and non-compacted cases), erring toward keeping slightly less rather than slicing a turn, with send-time sanitization as a backstop. Small, non-compacted forks are unchanged. Thanks @b3nw. (#5563)
- **Clearing a conversation now actually clears it — history no longer comes back after a refresh (P0 data-loss).** `Clear conversation` wiped the on-screen transcript but never recorded a truncation watermark, so the messages resurrected from the agent's state database on the next load: the cleared history reappeared after a refresh, and continuing the conversation still fed the full pre-clear context to the model. Clear now routes through the same truncate-to-empty path as the truncate action (setting the watermark that blocks state-db replay), verifies the empty state actually persisted, removes the stale backup, and — for a compressed-continuation session — detaches the compression-snapshot parent link so the archived transcript can't be stitched back in either (ordinary fork links are preserved). Thanks @rodboev and the maintainer fix. (#5556, #5553, #5532)
- **Transparent Stream now shows a running tool as running, not already-done.** The live anchor-scene renderer hardcoded the tool-status as "settled" even while the tool was still executing, so a tool call flashed straight to its finished state in Transparent Stream instead of showing its in-progress status. It now uses the row's actual settled state, so a running tool reads as running until it genuinely completes. Thanks @rodboev. (#5547, #5523)
Expand Down
23 changes: 22 additions & 1 deletion static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -4858,6 +4858,10 @@ if(typeof window!=='undefined'){
}else if(movedDown&&nearBottom){
_nearBottomCount=_nearBottomCount+1;
if(_nearBottomCount>=2){
// Only re-pin when the reader has genuinely reached the true bottom
// tail (<=80px). nearBottom spans a ~250px band, so proximity alone
// must NOT clear the sticky unpin flag (#4295) — a reader scanning the
// last lines mid-stream would otherwise get yanked back to the bottom.
if(!_messageUserUnpinned||bottomDistance<=80){
_messageUserUnpinned=false;
_scrollPinned=true;
Expand Down Expand Up @@ -5516,7 +5520,24 @@ function _settleFinalScroll(token){
}
function scrollIfPinned(){
if(!_autoScrollFollow) return;
if(_messageUserUnpinned) return;
if(_messageUserUnpinned){
// Only scrollToBottom() cleared this flag, so one scroll-up permanently
// killed auto-follow. Re-pin ONLY when the reader has genuinely returned to
// the true bottom tail (<=80px), NOT on mere near-bottom proximity — the
// #4295 invariant is that proximity alone (inside the ~250px band) must not
// re-pin, or a reader scanning the last few lines gets yanked to the bottom
// mid-stream. Also bail on ANY recent message-pane scroll intent (wheel,
// key, touch) and non-message intent, so an active scroll-up near the tail
// is never overridden. Uses the same _nearBottomCount debounce as the
// scroll listener (~4859-4866).
if(_recentNonMessageScrollIntent()||_recentMessageScrollIntent()||_recentMessageTouchScrollIntent()||_recentMessageWheelIntent()||_recentMessageKeyScrollIntent()){ _nearBottomCount=0; return; }
if(_messageBottomDistance()>80){ _nearBottomCount=0; return; }
_nearBottomCount=_nearBottomCount+1;
if(_nearBottomCount<2) return;
_nearBottomCount=0;
Comment on lines +5533 to +5537

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _nearBottomCount is used as a debounce counter by both the scroll listener's movedDown&&nearBottom branch and by the new re-pin block in scrollIfPinned(). The scroll listener resets the counter to 0 whenever it fires in the movedDown&&nearBottom path and _nearBottomCount reaches 2 without re-pinning (i.e. _messageUserUnpinned=true and bottomDistance>80). This can reset the accumulation that scrollIfPinned() started — making the effective debounce cost more than 2 ticks whenever a scroll event fires between the first and second scrollIfPinned() call at true-bottom. This doesn't break correctness (the reset is sensible: if content grew enough to push the viewport above 80px between the two calls, delaying re-pin is right), but a dedicated counter for the scrollIfPinned() path would decouple the two uses and make the debounce semantics unambiguous.

Suggested change
if(_recentNonMessageScrollIntent()||_recentMessageScrollIntent()||_recentMessageTouchScrollIntent()||_recentMessageWheelIntent()||_recentMessageKeyScrollIntent()){ _nearBottomCount=0; return; }
if(_messageBottomDistance()>80){ _nearBottomCount=0; return; }
_nearBottomCount=_nearBottomCount+1;
if(_nearBottomCount<2) return;
_nearBottomCount=0;
if(_recentNonMessageScrollIntent()||_recentMessageScrollIntent()||_recentMessageTouchScrollIntent()||_recentMessageWheelIntent()||_recentMessageKeyScrollIntent()){ _repinCount=0; return; }
if(_messageBottomDistance()>80){ _repinCount=0; return; }
_repinCount=(_repinCount||0)+1;
if(_repinCount<2) return;
_repinCount=0;

_messageUserUnpinned=false;
_scrollPinned=true;
}
if(!_scrollPinned) return;
if(_recentNonMessageScrollIntent()) return;
if(_messageBottomDistance()>500) _setMessageScrollToBottom();
Expand Down
15 changes: 13 additions & 2 deletions tests/test_issue3250_upward_scroll_intent_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,19 @@ def _scroll_listener_block() -> str:
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(" ", "")
assert "if(_messageUserUnpinned)return" in compact, (
"scrollIfPinned() must not fight a sticky manual unpin during streaming"
# 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"
)


Expand Down
13 changes: 12 additions & 1 deletion tests/test_tars_scroll_reset_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,18 @@ def test_user_scroll_cancels_delayed_bottom_settling():
assert "_cancelBottomSettle();" in record
assert "_lastNonMessageScrollIntentMs=performance.now();" in record
assert "_scrollPinned=false" in record
assert "if(_messageUserUnpinned) return;" in pinned
# scrollIfPinned() may now re-pin auto-follow (#5544), but ONLY when the
# reader has genuinely returned to the true bottom tail (<=80px) AND no
# recent scroll intent is active — it must never fight a manual scroll-up.
# Assert those guards are present instead of the old "bail unconditionally
# when unpinned" lock.
_pinned_compact = pinned.replace(" ", "")
assert "_messageBottomDistance()>80" in _pinned_compact, (
"scrollIfPinned() re-pin must require the true bottom tail (<=80px), not mere proximity"
)
assert "_recentMessageWheelIntent()" in _pinned_compact and "_recentMessageKeyScrollIntent()" in _pinned_compact, (
"scrollIfPinned() re-pin must bail on recent wheel/key scroll intent so it can't fight a reader"
)
assert "_messageUserUnpinned" in final and "return" in final
assert "_recentMessageUpwardIntent()" not in pinned

Expand Down
Loading