@@ -681,7 +681,7 @@
|
||||
.workspace-toggle-btn.active{color:var(--accent-text);border-color:var(--accent-bg);background:var(--accent-bg);}
|
||||
.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{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;}
|
||||
.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;}
|
||||
/* sticky-first-child: button is first child of .messages so its natural position is above viewport; sticky+bottom:16px pins it there when visible */
|
||||
.scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;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;}
|
||||
.scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);}
|
||||
|
||||
10
static/ui.js
10
static/ui.js
@@ -827,13 +827,13 @@ document.addEventListener('click',function(e){
|
||||
|
||||
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||||
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
|
||||
// Once the user scrolls back to within 150px of the bottom, re-pin.
|
||||
// Once the user scrolls back to within 250px of the bottom, re-pin.
|
||||
let _scrollPinned=true;
|
||||
(function(){
|
||||
const el=document.getElementById('messages');
|
||||
if(!el) return;
|
||||
el.addEventListener('scroll',()=>{
|
||||
const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<150;
|
||||
const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<250;
|
||||
_scrollPinned=nearBottom;
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(btn) btn.style.display=_scrollPinned?'none':'flex';
|
||||
@@ -1757,7 +1757,8 @@ function _renderQueueChips(sid){
|
||||
if(!card.classList.contains('visible')) return;
|
||||
const h=card.getBoundingClientRect().height;
|
||||
if(h>0) _msgs.style.setProperty('--queue-card-height', h+'px');
|
||||
if(typeof scrollToBottom==='function') scrollToBottom();
|
||||
if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
}, 360);
|
||||
}
|
||||
|
||||
@@ -1946,7 +1947,8 @@ function _updateQueuePill(sid,count){
|
||||
}, 360);
|
||||
}
|
||||
if(pillOuter) pillOuter.classList.remove('show');
|
||||
if(typeof scrollToBottom==='function') scrollToBottom();
|
||||
if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||||
else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
};
|
||||
} else {
|
||||
if(pillOuter) pillOuter.classList.remove('show');
|
||||
|
||||
62
tests/test_issue1360_streaming_scroll_hardening.py
Normal file
62
tests/test_issue1360_streaming_scroll_hardening.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Regression tests for #1360: streaming must not re-pin user scroll."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_function(src: str, name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
idx = src.find(marker)
|
||||
assert idx != -1, f"{name} not found"
|
||||
depth = 0
|
||||
for i, ch in enumerate(src[idx:], idx):
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[idx:i + 1]
|
||||
raise AssertionError(f"Could not extract {name}")
|
||||
|
||||
|
||||
def test_messages_scroller_disables_browser_scroll_anchoring():
|
||||
assert "overflow-anchor:none" in STYLE_CSS, (
|
||||
"#messages must disable browser scroll anchoring so tool/card inserts "
|
||||
"cannot yank the transcript while the user reads earlier content."
|
||||
)
|
||||
|
||||
|
||||
def test_scroll_repin_dead_zone_is_wider_for_mac_app_windows():
|
||||
assert "clientHeight<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."
|
||||
)
|
||||
|
||||
|
||||
def test_queue_card_measurement_does_not_force_repin_during_streaming():
|
||||
fn = _extract_function(UI_JS, "_renderQueueChips")
|
||||
measurement_idx = fn.find("setTimeout(()=>")
|
||||
assert measurement_idx != -1, "queue card measurement timeout not found"
|
||||
measurement_block = fn[measurement_idx:measurement_idx + 500]
|
||||
|
||||
assert "S.activeStreamId" in measurement_block
|
||||
assert "scrollIfPinned()" in measurement_block
|
||||
assert "!S.activeStreamId" in measurement_block
|
||||
assert "scrollToBottom()" in measurement_block
|
||||
assert measurement_block.find("scrollIfPinned()") < measurement_block.find("scrollToBottom()")
|
||||
|
||||
|
||||
def test_queue_pill_click_does_not_force_repin_during_streaming():
|
||||
fn = _extract_function(UI_JS, "_updateQueuePill")
|
||||
click_idx = fn.find("pill.onclick=()=>")
|
||||
assert click_idx != -1, "queue pill click handler not found"
|
||||
click_block = fn[click_idx:click_idx + 700]
|
||||
|
||||
assert "S.activeStreamId" in click_block
|
||||
assert "scrollIfPinned()" in click_block
|
||||
assert "!S.activeStreamId" in click_block
|
||||
assert "scrollToBottom()" in click_block
|
||||
assert click_block.find("scrollIfPinned()") < click_block.find("scrollToBottom()")
|
||||
Reference in New Issue
Block a user