Merge branch 'peek-4478' into stage/4478-dom-recycle
This commit is contained in:
commit
ca368cc166
8 files changed
+2541
-74
No files matched your search
+110
-31
@@ -436,6 +436,9 @@ let _messageVirtualMeasurementRetryCount=0;
|
||||
let _messageVirtualScrollActive=false;
|
||||
let _messageVirtualScrollSettleTimer=0;
|
||||
let _messageVirtualDeferredMeasurement=null;
|
||||
let _msgNodeRecycleEnabled=false;
|
||||
const _recycleStash=new Map();
|
||||
let _scrollbarDragActive=false;
|
||||
function _markMessageVirtualScrollActive(){
|
||||
_messageVirtualScrollActive=true;
|
||||
clearTimeout(_messageVirtualScrollSettleTimer);
|
||||
@@ -817,9 +820,10 @@ function _restoreMessageViewportAnchor(anchor, rawIdxDelta){
|
||||
const containerRect=container.getBoundingClientRect();
|
||||
const rect=row.getBoundingClientRect();
|
||||
const targetTop=Number(anchor.topOffset)||0;
|
||||
_programmaticScroll=true;
|
||||
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
||||
container.scrollTop+=(rect.top-containerRect.top)-targetTop;
|
||||
requestAnimationFrame(()=>{ _programmaticScroll=false; });
|
||||
if(typeof _deferClearProgrammaticScroll==='function') _deferClearProgrammaticScroll();
|
||||
else requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); });
|
||||
return true;
|
||||
}
|
||||
let _messageViewportAnchorRemounting=false;
|
||||
@@ -885,10 +889,10 @@ function _compensateScrollForMeasurementDelta(renderFn){
|
||||
const actualOffset=rowRect.top-containerRect.top;
|
||||
const delta=actualOffset-anchorBefore.topOffset;
|
||||
if(Math.abs(delta)<2) return;
|
||||
_programmaticScroll=true;
|
||||
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
||||
container.scrollTop=scrollTopBefore+delta;
|
||||
_lastScrollTop=container.scrollTop;
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); });
|
||||
_deferClearProgrammaticScroll();
|
||||
}
|
||||
function _messageViewportIntersectsRenderedRow(){
|
||||
const container=$('messages');
|
||||
@@ -965,7 +969,19 @@ function _scheduleMessageVirtualizedRender(force){
|
||||
const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount());
|
||||
const liveKey=_messageVirtualWindowKeyFor(liveWindow);
|
||||
if(!force&&liveKey===_messageVirtualWindowKey) return;
|
||||
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
|
||||
if(_scrollbarDragActive){
|
||||
_programmaticScroll=true;
|
||||
_programmaticScrollSetAt=performance.now();
|
||||
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
|
||||
_deferClearProgrammaticScroll();
|
||||
_messageVirtualWindowKey=liveKey;
|
||||
return;
|
||||
}
|
||||
_msgNodeRecycleEnabled=true;
|
||||
try{
|
||||
_compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); });
|
||||
}
|
||||
finally{ _msgNodeRecycleEnabled=false; }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1046,7 +1062,7 @@ async function jumpToSessionStart(){
|
||||
if(!container||!S.session) return;
|
||||
_scrollPinned=false;
|
||||
_messageUserUnpinned=true;
|
||||
_programmaticScroll=true;
|
||||
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
||||
try{
|
||||
// During active streaming, skip full message load — API response won't
|
||||
// include live messages from the current turn, and replacing S.messages
|
||||
@@ -1065,7 +1081,7 @@ async function jumpToSessionStart(){
|
||||
requestAnimationFrame(()=>{
|
||||
container.scrollTop=0;
|
||||
_updateSessionStartJumpButton();
|
||||
requestAnimationFrame(()=>{ _programmaticScroll=false; });
|
||||
_deferClearProgrammaticScroll();
|
||||
});
|
||||
}catch(e){
|
||||
console.warn('jumpToSessionStart failed:',e);
|
||||
@@ -1124,7 +1140,7 @@ async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
|
||||
if(visibleIdx>=0){
|
||||
_scrollPinned=false;
|
||||
_messageUserUnpinned=true;
|
||||
_programmaticScroll=true;
|
||||
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
||||
container.scrollTop=_messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, container);
|
||||
_messageVirtualWindowKey='';
|
||||
renderMessages({ preserveScroll:true });
|
||||
@@ -1135,7 +1151,7 @@ async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
|
||||
renderMessages({ preserveScroll:true });
|
||||
requestAnimationFrame(scrollToTarget);
|
||||
}
|
||||
requestAnimationFrame(()=>{ _programmaticScroll=false; });
|
||||
_deferClearProgrammaticScroll();
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -3667,6 +3683,9 @@ window.addEventListener('resize',function(){
|
||||
// Programmatic scrolls are ignored via _programmaticScroll. Fixes #1469 / #1360 / #1731.
|
||||
let _scrollPinned=true;
|
||||
let _programmaticScroll=false;
|
||||
let _programmaticScrollSetAt=0;
|
||||
let _programmaticScrollResetTimer=0;
|
||||
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
|
||||
let _nearBottomCount=0;
|
||||
let _lastScrollTop=null;
|
||||
// Sticky-unpin model (#3343 supersedes #3330's proximity re-pin): once the user
|
||||
@@ -3898,10 +3917,28 @@ if(typeof window!=='undefined'){
|
||||
(function(){
|
||||
const el=document.getElementById('messages');
|
||||
if(!el) return;
|
||||
el.addEventListener('pointerdown',(e)=>{
|
||||
if(e.offsetX>=el.clientWidth) _scrollbarDragActive=true;
|
||||
},{passive:true});
|
||||
window.addEventListener('pointerup',()=>{
|
||||
if(!_scrollbarDragActive) return;
|
||||
_scrollbarDragActive=false;
|
||||
_scheduleMessageVirtualizedRender(true);
|
||||
},{passive:true});
|
||||
window.addEventListener('pointercancel',()=>{
|
||||
if(!_scrollbarDragActive) return;
|
||||
_scrollbarDragActive=false;
|
||||
_scheduleMessageVirtualizedRender(true);
|
||||
},{passive:true});
|
||||
window.addEventListener('blur',()=>{ _scrollbarDragActive=false; },{passive:true});
|
||||
document.addEventListener('visibilitychange',()=>{
|
||||
if(document.visibilityState==='hidden') _scrollbarDragActive=false;
|
||||
},{passive:true});
|
||||
let _scrollRaf=0;
|
||||
el.addEventListener('scroll',()=>{
|
||||
_scheduleMessageVirtualizedRender();
|
||||
if(_programmaticScroll) return; // ignore scrolls we triggered ourselves
|
||||
if(_programmaticScroll&&(performance.now()-_programmaticScrollSetAt)>150) _programmaticScroll=false;
|
||||
if(_programmaticScroll) return;
|
||||
_markMessageVirtualScrollActive();
|
||||
cancelAnimationFrame(_scrollRaf);
|
||||
_scrollRaf=requestAnimationFrame(()=>{
|
||||
@@ -4399,7 +4436,7 @@ document.addEventListener('DOMContentLoaded',function(){
|
||||
function _setMessageScrollToBottom(){
|
||||
const el=$('messages');
|
||||
if(!el) return;
|
||||
_programmaticScroll=true;
|
||||
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
||||
el.scrollTop=el.scrollHeight;
|
||||
_lastScrollTop=el.scrollTop;
|
||||
_nearBottomCount=2;
|
||||
@@ -4412,14 +4449,14 @@ function _setMessageScrollToBottom(){
|
||||
// is the authoritative "user scrolled away" signal, so DON'T snap them back
|
||||
// or re-pin if so; only release the programmatic-scroll latch.
|
||||
if(_messageUserUnpinned || !_scrollPinned || _recentNonMessageScrollIntent()){
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||||
_deferClearProgrammaticScroll();
|
||||
return;
|
||||
}
|
||||
el.scrollTop=el.scrollHeight;
|
||||
_lastScrollTop=el.scrollTop;
|
||||
_nearBottomCount=2;
|
||||
_scrollPinned=true;
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||||
_deferClearProgrammaticScroll();
|
||||
});
|
||||
}
|
||||
function _isMessagePaneNearBottom(threshold=250){
|
||||
@@ -4533,14 +4570,12 @@ function _settleFinalScroll(token){
|
||||
_programmaticScroll=false;
|
||||
return;
|
||||
}
|
||||
_programmaticScroll=true;
|
||||
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
||||
el.scrollTop=el.scrollHeight;
|
||||
_lastScrollTop=el.scrollTop;
|
||||
_nearBottomCount=2;
|
||||
_scrollPinned=true;
|
||||
requestAnimationFrame(()=>{
|
||||
setTimeout(()=>{ _programmaticScroll=false; },0);
|
||||
});
|
||||
_deferClearProgrammaticScroll();
|
||||
}
|
||||
function scrollIfPinned(){
|
||||
if(!_autoScrollFollow) return;
|
||||
@@ -10932,7 +10967,7 @@ function _restoreMessageScrollSnapshot(snapshot){
|
||||
: false;
|
||||
}
|
||||
if(!restoredViaAnchor){
|
||||
_programmaticScroll=true;
|
||||
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
||||
el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop));
|
||||
}
|
||||
// Sync _lastScrollTop after programmatic restore so sticky-unpin does not false-trigger (#1731).
|
||||
@@ -10958,7 +10993,8 @@ function _restoreMessageScrollSnapshot(snapshot){
|
||||
}
|
||||
}
|
||||
if(!restoredViaAnchor){
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||||
if(typeof _deferClearProgrammaticScroll==='function') _deferClearProgrammaticScroll();
|
||||
else requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); });
|
||||
}
|
||||
}
|
||||
function _restoreMessageScrollSnapshotSameFrame(snapshot){
|
||||
@@ -10978,7 +11014,7 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
|
||||
const target=(snapshot.pinned===true&&Number.isFinite(bottom))
|
||||
? maxTop-Math.max(0,bottom)
|
||||
: Number(snapshot.top)||0;
|
||||
_programmaticScroll=true;
|
||||
_programmaticScroll=true;_programmaticScrollSetAt=performance.now();
|
||||
el.scrollTop=Math.max(0,Math.min(target,maxTop));
|
||||
}
|
||||
_lastScrollTop=el.scrollTop;
|
||||
@@ -10992,7 +11028,8 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
|
||||
_nearBottomCount=0;
|
||||
}
|
||||
if(!restoredViaAnchor){
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||||
if(typeof _deferClearProgrammaticScroll==='function') _deferClearProgrammaticScroll();
|
||||
else requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); });
|
||||
}
|
||||
}
|
||||
function _renderMessagesWithScrollSnapshot(options){
|
||||
@@ -11185,6 +11222,15 @@ function renderMessages(options){
|
||||
S.session && typeof S.session.compression_anchor_summary==='string'
|
||||
) ? S.session.compression_anchor_summary.trim() : '';
|
||||
const worklogDetailDisclosureState=_captureWorklogDetailDisclosureState(inner);
|
||||
_recycleStash.clear();
|
||||
if(_msgNodeRecycleEnabled){
|
||||
for(const child of Array.from(inner.children)){
|
||||
const key=child.dataset&&(child.dataset.recycleKey||child.dataset.msgIdx);
|
||||
if(!key) continue;
|
||||
if(child.id==='liveAssistantTurn'||child.querySelector&&child.querySelector('#liveAssistantTurn')) continue;
|
||||
_recycleStash.set(Number(key), child);
|
||||
}
|
||||
}
|
||||
inner.innerHTML='';
|
||||
const compressionNode=compressionState?_compressionCardsNode(compressionState):null;
|
||||
const {message:referenceMessage, rawIdx:referenceMessageRawIdx}=_latestCompressionReferenceMessage(
|
||||
@@ -11452,22 +11498,54 @@ function renderMessages(options){
|
||||
|
||||
if(isUser){
|
||||
currentAssistantTurn=null;
|
||||
const row=document.createElement('div');
|
||||
row.className='msg-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
row.dataset.role='user';
|
||||
row.dataset.rawText=String(displayContent).trim();
|
||||
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||||
let row=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null;
|
||||
if(row&&(!row.classList.contains('msg-row')||row.classList.contains('assistant-turn'))) row=null;
|
||||
const newRawText=String(displayContent).trim();
|
||||
const nextRowHtml=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||||
if(row){
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
row.dataset.role='user';
|
||||
delete row.dataset.editing;
|
||||
if(row.dataset.rawText!==newRawText||row.innerHTML!==nextRowHtml){
|
||||
row.dataset.rawText=newRawText;
|
||||
row.innerHTML=nextRowHtml;
|
||||
}
|
||||
}else{
|
||||
row=document.createElement('div');
|
||||
row.className='msg-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
row.dataset.role='user';
|
||||
row.dataset.rawText=newRawText;
|
||||
row.innerHTML=nextRowHtml;
|
||||
}
|
||||
inner.appendChild(row);
|
||||
userRows.set(rawIdx, row);
|
||||
continue;
|
||||
}
|
||||
|
||||
if(!currentAssistantTurn){
|
||||
currentAssistantTurn=_createAssistantTurn(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):'');
|
||||
let recycled=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null;
|
||||
if(recycled&&!recycled.classList.contains('assistant-turn')) recycled=null;
|
||||
if(recycled){
|
||||
const blocks=_assistantTurnBlocks(recycled);
|
||||
if(blocks) blocks.innerHTML='';
|
||||
recycled.removeAttribute('data-transparent-turn-collapsed');
|
||||
recycled.removeAttribute('data-transparent-turn-toggle-bound');
|
||||
const role=recycled.querySelector('.msg-role.assistant');
|
||||
if(role) role.outerHTML=_assistantRoleHtml(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):'');
|
||||
currentAssistantTurn=recycled;
|
||||
}else{
|
||||
currentAssistantTurn=_createAssistantTurn(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):'');
|
||||
}
|
||||
currentAssistantTurn.dataset.role='assistant';
|
||||
if(S.session) currentAssistantTurn.dataset.sessionId=S.session.session_id;
|
||||
currentAssistantTurn.dataset.recycleKey=rawIdx;
|
||||
inner.appendChild(currentAssistantTurn);
|
||||
}
|
||||
const seg=document.createElement('div');
|
||||
@@ -12346,6 +12424,7 @@ function renderMessages(options){
|
||||
}
|
||||
}
|
||||
_updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow);
|
||||
_recycleStash.clear();
|
||||
}
|
||||
|
||||
function _toolDisplayName(tc){
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Operator-run native scrollbar drag repro using Windows SendInput + CDP.
|
||||
|
||||
SendInput generates real OS-level mouse events that interact with the native
|
||||
scrollbar thumb, unlike CDP's Input.dispatchMouseEvent which only goes through
|
||||
the DOM hit-test and can't grab the OS-rendered scrollbar.
|
||||
|
||||
Flow:
|
||||
1. CDP: get Chrome window position and scrollbar coordinates
|
||||
2. SendInput: move real mouse cursor to scrollbar, click-and-drag
|
||||
3. CDP: verify scrollTop changed, flag lifecycle, content integrity
|
||||
|
||||
Run directly with Python on Windows; this is not part of the automated pytest suite.
|
||||
"""
|
||||
import sys
|
||||
import pytest
|
||||
pytest.importorskip("websockets")
|
||||
if sys.platform != "win32":
|
||||
pytest.skip("Windows-only (SendInput)", allow_module_level=True)
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
import urllib.request
|
||||
import websockets
|
||||
|
||||
# Windows SendInput structures
|
||||
MOUSEEVENTF_MOVE = 0x0001
|
||||
MOUSEEVENTF_LEFTDOWN = 0x0002
|
||||
MOUSEEVENTF_LEFTUP = 0x0004
|
||||
MOUSEEVENTF_ABSOLUTE = 0x8000
|
||||
MOUSEEVENTF_VIRTUALDESK = 0x4000 # map to virtual screen, not primary monitor
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
class MOUSEINPUT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dx", ctypes.wintypes.LONG),
|
||||
("dy", ctypes.wintypes.LONG),
|
||||
("mouseData", ctypes.wintypes.DWORD),
|
||||
("dwFlags", ctypes.wintypes.DWORD),
|
||||
("time", ctypes.wintypes.DWORD),
|
||||
("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),
|
||||
]
|
||||
|
||||
class INPUT(ctypes.Structure):
|
||||
class _INPUT(ctypes.Union):
|
||||
_fields_ = [("mi", MOUSEINPUT)]
|
||||
_fields_ = [
|
||||
("type", ctypes.wintypes.DWORD),
|
||||
("_input", _INPUT),
|
||||
]
|
||||
|
||||
# Virtual screen origin and size (all monitors combined)
|
||||
VS_X = user32.GetSystemMetrics(76) # SM_XVIRTUALSCREEN
|
||||
VS_Y = user32.GetSystemMetrics(77) # SM_YVIRTUALSCREEN
|
||||
VS_W = user32.GetSystemMetrics(78) # SM_CXVIRTUALSCREEN
|
||||
VS_H = user32.GetSystemMetrics(79) # SM_CYVIRTUALSCREEN
|
||||
|
||||
def screen_to_absolute(x, y):
|
||||
"""Convert screen pixels to SendInput absolute coords (0-65535) on virtual desktop."""
|
||||
ax = int((x - VS_X) * 65535 / VS_W)
|
||||
ay = int((y - VS_Y) * 65535 / VS_H)
|
||||
return ax, ay
|
||||
|
||||
def send_mouse(x, y, flags):
|
||||
ax, ay = screen_to_absolute(x, y)
|
||||
mi = MOUSEINPUT(ax, ay, 0, flags | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, 0, None)
|
||||
inp = INPUT(type=0) # INPUT_MOUSE = 0
|
||||
inp._input.mi = mi
|
||||
user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT))
|
||||
|
||||
def mouse_move(x, y):
|
||||
send_mouse(x, y, MOUSEEVENTF_MOVE)
|
||||
|
||||
def mouse_down(x, y):
|
||||
send_mouse(x, y, MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN)
|
||||
|
||||
def mouse_up(x, y):
|
||||
send_mouse(x, y, MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP)
|
||||
|
||||
|
||||
async def find_hermes_tab():
|
||||
tabs = json.loads(urllib.request.urlopen("http://localhost:9222/json").read())
|
||||
hermes = [t for t in tabs if "8787" in t.get("url", "") and t["type"] == "page" and "/session/" in t.get("url", "")]
|
||||
if not hermes:
|
||||
raise RuntimeError("No Hermes session tab found on :8787")
|
||||
for t in hermes:
|
||||
ws_url = t["webSocketDebuggerUrl"]
|
||||
try:
|
||||
async with websockets.connect(ws_url, max_size=1*1024*1024) as ws:
|
||||
msg = {"id": 1, "method": "Runtime.evaluate", "params": {
|
||||
"expression": "typeof _scrollbarDragActive", "returnByValue": True
|
||||
}}
|
||||
await ws.send(json.dumps(msg))
|
||||
while True:
|
||||
resp = json.loads(await ws.recv())
|
||||
if resp.get("id") == 1:
|
||||
val = resp.get("result", {}).get("result", {}).get("value", "?")
|
||||
if val == "boolean":
|
||||
return ws_url
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
raise RuntimeError("No tab with scrollbar fix deployed. Hard-refresh a Hermes session tab.")
|
||||
|
||||
MSG_ID = 0
|
||||
def next_id():
|
||||
global MSG_ID
|
||||
MSG_ID += 1
|
||||
return MSG_ID
|
||||
|
||||
async def send_cdp(ws, method, params=None):
|
||||
mid = next_id()
|
||||
msg = {"id": mid, "method": method}
|
||||
if params:
|
||||
msg["params"] = params
|
||||
await ws.send(json.dumps(msg))
|
||||
while True:
|
||||
resp = json.loads(await ws.recv())
|
||||
if resp.get("id") == mid:
|
||||
if "error" in resp:
|
||||
raise RuntimeError(f"CDP error: {resp['error']}")
|
||||
return resp.get("result", {})
|
||||
|
||||
async def ev(ws, expr):
|
||||
result = await send_cdp(ws, "Runtime.evaluate", {
|
||||
"expression": expr, "returnByValue": True, "awaitPromise": False,
|
||||
})
|
||||
if "result" in result and "value" in result["result"]:
|
||||
return result["result"]["value"]
|
||||
return result
|
||||
|
||||
async def run():
|
||||
ws_url = await find_hermes_tab()
|
||||
print(f"Connected to {ws_url}")
|
||||
|
||||
async with websockets.connect(ws_url, max_size=5*1024*1024) as ws:
|
||||
# Calibrate viewport-to-screen mapping using a known-position probe.
|
||||
# CDP dispatched mouse events report CSS coordinates, so we can place a
|
||||
# probe click, read its screen-pixel position from the CDP target bounds,
|
||||
# and compute the offset.
|
||||
target_info = await send_cdp(ws, "Browser.getWindowForTarget")
|
||||
bounds = target_info.get("bounds", {})
|
||||
print(f"Window bounds: {bounds}")
|
||||
|
||||
coords = await ev(ws, """
|
||||
(function(){
|
||||
var el = document.getElementById('messages');
|
||||
if (!el) return {error: 'no messages element'};
|
||||
var rect = el.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
clientWidth: el.clientWidth,
|
||||
scrollHeight: el.scrollHeight,
|
||||
scrollTop: el.scrollTop,
|
||||
scrollbarWidth: el.offsetWidth - el.clientWidth,
|
||||
dpr: window.devicePixelRatio,
|
||||
innerWidth: window.innerWidth,
|
||||
innerHeight: window.innerHeight,
|
||||
};
|
||||
})()
|
||||
""")
|
||||
print(f"Messages element: {coords}")
|
||||
|
||||
if coords.get("scrollbarWidth", 0) < 3:
|
||||
print("SKIP: No visible scrollbar")
|
||||
return
|
||||
|
||||
dpr = coords.get("dpr", 1)
|
||||
inner_w = coords.get("innerWidth", 0)
|
||||
inner_h = coords.get("innerHeight", 0)
|
||||
|
||||
# CDP window bounds are in CSS pixels (Chrome DPI-scales them).
|
||||
# innerWidth/innerHeight are also CSS pixels for the viewport.
|
||||
# Chrome header = window height - viewport height (all CSS px).
|
||||
chrome_header_css = bounds["height"] - inner_h
|
||||
chrome_left_css = bounds["width"] - inner_w
|
||||
|
||||
# Viewport origin in CSS pixels from screen origin
|
||||
vp_css_x = bounds["left"] + chrome_left_css
|
||||
vp_css_y = bounds["top"] + chrome_header_css
|
||||
|
||||
# Element scrollbar center in CSS pixels from screen origin
|
||||
scrollbar_css_x = vp_css_x + coords["left"] + coords["clientWidth"] + coords["scrollbarWidth"] / 2
|
||||
track_top_css = vp_css_y + coords["top"] + 20
|
||||
track_bottom_css = vp_css_y + coords["bottom"] - 20
|
||||
|
||||
# Convert to physical screen pixels for SendInput
|
||||
scrollbar_screen_x = scrollbar_css_x * dpr
|
||||
track_top_screen = track_top_css * dpr
|
||||
track_bottom_screen = track_bottom_css * dpr
|
||||
|
||||
# Clamp to the monitor containing the window center to avoid
|
||||
# overshoot into adjacent monitors (maximized windows extend
|
||||
# past screen edges for shadow/overshoot)
|
||||
pri_w = user32.GetSystemMetrics(0)
|
||||
pri_h = user32.GetSystemMetrics(1)
|
||||
scrollbar_screen_x = min(scrollbar_screen_x, pri_w - 3)
|
||||
track_top_screen = max(track_top_screen, 3)
|
||||
track_bottom_screen = min(track_bottom_screen, pri_h - 3)
|
||||
|
||||
print(f"DPR={dpr}, chrome header: {chrome_header_css:.0f}px CSS")
|
||||
print(f"Viewport origin CSS: ({vp_css_x:.0f}, {vp_css_y:.0f})")
|
||||
print(f"Scrollbar screen X: {scrollbar_screen_x:.0f}, track Y: {track_top_screen:.0f}-{track_bottom_screen:.0f}")
|
||||
|
||||
# Bring Chrome to foreground via CDP
|
||||
await send_cdp(ws, "Page.bringToFront")
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# Scroll to top first
|
||||
await ev(ws, "document.getElementById('messages').scrollTop = 0")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
pre_scroll = await ev(ws, "document.getElementById('messages').scrollTop")
|
||||
print(f"\nPre-drag scrollTop: {pre_scroll}")
|
||||
|
||||
# === NATIVE DRAG TEST ===
|
||||
print("\n=== Native Scrollbar Drag via SendInput ===")
|
||||
|
||||
start_y = track_top_screen + 30
|
||||
print(f" Mouse down at ({scrollbar_screen_x:.0f}, {start_y:.0f})")
|
||||
mouse_down(int(scrollbar_screen_x), int(start_y))
|
||||
time.sleep(0.15)
|
||||
|
||||
# Check flag immediately after native pointerdown
|
||||
flag_during = await ev(ws, "_scrollbarDragActive")
|
||||
print(f" _scrollbarDragActive after native pointerdown: {flag_during}")
|
||||
|
||||
# Drag down in steps
|
||||
positions = []
|
||||
steps = 12
|
||||
step_size = (track_bottom_screen - start_y) / steps
|
||||
for i in range(1, steps + 1):
|
||||
y = start_y + step_size * i
|
||||
mouse_move(int(scrollbar_screen_x), int(y))
|
||||
time.sleep(0.1)
|
||||
pos = await ev(ws, "document.getElementById('messages').scrollTop")
|
||||
positions.append(pos)
|
||||
print(f" Step {i}/{steps}: screen_y={y:.0f}, scrollTop={pos}")
|
||||
|
||||
# Release
|
||||
end_y = start_y + step_size * steps
|
||||
mouse_up(int(scrollbar_screen_x), int(end_y))
|
||||
time.sleep(0.3)
|
||||
|
||||
flag_after = await ev(ws, "_scrollbarDragActive")
|
||||
print(f" _scrollbarDragActive after release: {flag_after}")
|
||||
|
||||
total_delta = positions[-1] - pre_scroll if positions else 0
|
||||
increasing = sum(1 for i in range(1, len(positions)) if positions[i] > positions[i-1])
|
||||
reversals = sum(1 for i in range(1, len(positions)) if positions[i] < positions[i-1])
|
||||
|
||||
# Post-release content check
|
||||
post = await ev(ws, """
|
||||
(function(){
|
||||
var inner = document.getElementById('msgInner');
|
||||
if (!inner) return {error: 'no inner'};
|
||||
var kids = Array.from(inner.children);
|
||||
var content = kids.filter(function(c){
|
||||
return c.classList.contains('msg-row') || c.classList.contains('assistant-turn');
|
||||
});
|
||||
var blanks = content.filter(function(c){
|
||||
return c.offsetHeight > 0 && c.textContent.trim() === '';
|
||||
}).length;
|
||||
return {content: content.length, blanks: blanks};
|
||||
})()
|
||||
""")
|
||||
|
||||
print("\n=== RESULTS ===")
|
||||
print(f" Total scroll delta: {total_delta}px")
|
||||
print(f" Monotonic steps: {increasing}/{steps-1}")
|
||||
print(f" Reversals: {reversals}")
|
||||
print(f" Flag set during drag: {flag_during}")
|
||||
print(f" Flag cleared after release: {flag_after == False}")
|
||||
print(f" Content nodes: {post.get('content', 0)}, blanks: {post.get('blanks', -1)}")
|
||||
|
||||
drag_worked = total_delta > 100
|
||||
monotonic = increasing >= steps - 3
|
||||
no_blanks = post.get("blanks", -1) == 0
|
||||
flag_lifecycle = flag_during == True and flag_after == False
|
||||
|
||||
verdicts = {
|
||||
"T1 Native drag moved scroll": drag_worked,
|
||||
"T2 Monotonic scrolling": monotonic,
|
||||
"T3 No blank content after release": no_blanks,
|
||||
"T4 Flag lifecycle (set during, cleared after)": flag_lifecycle,
|
||||
}
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
for name, v in verdicts.items():
|
||||
print(f" {'PASS' if v else 'FAIL'}: {name}")
|
||||
all_pass = all(verdicts.values())
|
||||
print(f"\n ALL PASS: {all_pass}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Operator-run CDP repro for scrollbar drag flag lifecycle and event dispatch.
|
||||
|
||||
Tests the actual deployed code on localhost:8787 via Chrome DevTools Protocol.
|
||||
Verifies flag lifecycle, event handler wiring, and render behavior.
|
||||
|
||||
Run directly with Python; this is not part of the automated pytest suite.
|
||||
"""
|
||||
import pytest
|
||||
pytest.importorskip("websockets")
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import urllib.request
|
||||
import websockets
|
||||
|
||||
async def find_hermes_tab():
|
||||
"""Find a Hermes session tab that has the scrollbar drag fix deployed."""
|
||||
tabs = json.loads(urllib.request.urlopen("http://localhost:9222/json").read())
|
||||
hermes = [t for t in tabs if "8787" in t.get("url", "") and t["type"] == "page" and "/session/" in t.get("url", "")]
|
||||
if not hermes:
|
||||
raise RuntimeError("No Hermes session tab found on :8787")
|
||||
for t in hermes:
|
||||
ws_url = t["webSocketDebuggerUrl"]
|
||||
try:
|
||||
async with websockets.connect(ws_url, max_size=1*1024*1024) as ws:
|
||||
mid = 1
|
||||
msg = {"id": mid, "method": "Runtime.evaluate", "params": {
|
||||
"expression": "typeof _scrollbarDragActive",
|
||||
"returnByValue": True
|
||||
}}
|
||||
await ws.send(json.dumps(msg))
|
||||
while True:
|
||||
resp = json.loads(await ws.recv())
|
||||
if resp.get("id") == mid:
|
||||
val = resp.get("result", {}).get("result", {}).get("value", "?")
|
||||
if val == "boolean":
|
||||
return ws_url
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
# If none have the fix, reload the first one and retry
|
||||
ws_url = hermes[0]["webSocketDebuggerUrl"]
|
||||
async with websockets.connect(ws_url, max_size=1*1024*1024) as ws:
|
||||
msg = {"id": 999, "method": "Page.reload", "params": {"ignoreCache": True}}
|
||||
await ws.send(json.dumps(msg))
|
||||
while True:
|
||||
resp = json.loads(await ws.recv())
|
||||
if resp.get("id") == 999:
|
||||
break
|
||||
await asyncio.sleep(3)
|
||||
return ws_url
|
||||
|
||||
MSG_ID = 0
|
||||
|
||||
def next_id():
|
||||
global MSG_ID
|
||||
MSG_ID += 1
|
||||
return MSG_ID
|
||||
|
||||
async def send(ws, method, params=None):
|
||||
mid = next_id()
|
||||
msg = {"id": mid, "method": method}
|
||||
if params:
|
||||
msg["params"] = params
|
||||
await ws.send(json.dumps(msg))
|
||||
while True:
|
||||
resp = json.loads(await ws.recv())
|
||||
if resp.get("id") == mid:
|
||||
if "error" in resp:
|
||||
raise RuntimeError(f"CDP error: {resp['error']}")
|
||||
return resp.get("result", {})
|
||||
|
||||
async def ev(ws, expr):
|
||||
result = await send(ws, "Runtime.evaluate", {
|
||||
"expression": expr,
|
||||
"returnByValue": True,
|
||||
"awaitPromise": False,
|
||||
})
|
||||
if "result" in result and "value" in result["result"]:
|
||||
return result["result"]["value"]
|
||||
return result
|
||||
|
||||
async def run_tests():
|
||||
ws_url = await find_hermes_tab()
|
||||
print(f"Connecting to {ws_url}")
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
async with websockets.connect(ws_url, max_size=5*1024*1024) as ws:
|
||||
# Verify the fix is deployed
|
||||
flag_type = await ev(ws, "typeof _scrollbarDragActive")
|
||||
if flag_type != "boolean":
|
||||
print(f"ABORT: _scrollbarDragActive type is '{flag_type}', fix not deployed")
|
||||
print("Hard-refresh the tab (Ctrl+Shift+R) then re-run")
|
||||
return
|
||||
|
||||
# T1: Flag starts as false
|
||||
print("\n=== T1: Flag initial state ===")
|
||||
# Clean up any leftover state
|
||||
await ev(ws, "_scrollbarDragActive = false")
|
||||
flag_init = await ev(ws, "_scrollbarDragActive")
|
||||
if flag_init == False:
|
||||
print(" PASS: flag starts as false")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: expected false, got {flag_init}")
|
||||
failed += 1
|
||||
|
||||
# T2: Synthetic pointerdown on scrollbar sets the flag
|
||||
print("\n=== T2: Pointerdown on scrollbar area ===")
|
||||
result = await ev(ws, """
|
||||
(function(){
|
||||
_scrollbarDragActive = false;
|
||||
var el = document.getElementById('messages');
|
||||
if (!el) return {error: 'no messages element'};
|
||||
var e = new PointerEvent('pointerdown', {bubbles: true});
|
||||
Object.defineProperty(e, 'offsetX', {value: el.clientWidth + 5});
|
||||
el.dispatchEvent(e);
|
||||
var flagAfter = _scrollbarDragActive;
|
||||
_scrollbarDragActive = false;
|
||||
return {scrollbar_click_activates: flagAfter};
|
||||
})()
|
||||
""")
|
||||
if result.get("scrollbar_click_activates") == True:
|
||||
print(" PASS: scrollbar click activates flag")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: {result}")
|
||||
failed += 1
|
||||
|
||||
# T3: Pointerdown on content area does NOT set the flag
|
||||
print("\n=== T3: Pointerdown on content area ===")
|
||||
result = await ev(ws, """
|
||||
(function(){
|
||||
_scrollbarDragActive = false;
|
||||
var el = document.getElementById('messages');
|
||||
var e = new PointerEvent('pointerdown', {bubbles: true});
|
||||
Object.defineProperty(e, 'offsetX', {value: 100});
|
||||
el.dispatchEvent(e);
|
||||
var flagAfter = _scrollbarDragActive;
|
||||
return {content_click_ignores: !flagAfter};
|
||||
})()
|
||||
""")
|
||||
if result.get("content_click_ignores") == True:
|
||||
print(" PASS: content click does not activate flag")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: {result}")
|
||||
failed += 1
|
||||
|
||||
# T4: Pointerup clears the flag
|
||||
print("\n=== T4: Pointerup clears flag ===")
|
||||
result = await ev(ws, """
|
||||
(function(){
|
||||
_scrollbarDragActive = true;
|
||||
window.dispatchEvent(new PointerEvent('pointerup'));
|
||||
var cleared = !_scrollbarDragActive;
|
||||
return {flag_cleared: cleared};
|
||||
})()
|
||||
""")
|
||||
if result.get("flag_cleared") == True:
|
||||
print(" PASS: pointerup clears flag")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: {result}")
|
||||
failed += 1
|
||||
|
||||
# T5: Pointercancel also clears the flag
|
||||
print("\n=== T5: Pointercancel clears flag ===")
|
||||
result = await ev(ws, """
|
||||
(function(){
|
||||
_scrollbarDragActive = true;
|
||||
window.dispatchEvent(new PointerEvent('pointercancel'));
|
||||
var cleared = !_scrollbarDragActive;
|
||||
return {flag_cleared: cleared};
|
||||
})()
|
||||
""")
|
||||
if result.get("flag_cleared") == True:
|
||||
print(" PASS: pointercancel clears flag")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: {result}")
|
||||
failed += 1
|
||||
|
||||
# T6: Spacer-only path during drag (verify spacers exist)
|
||||
print("\n=== T6: Spacer-only path ===")
|
||||
result = await ev(ws, """
|
||||
(function(){
|
||||
var inner = document.getElementById('msgInner');
|
||||
if (!inner) return {error: 'no inner'};
|
||||
var before = inner.querySelector('[data-virtual-spacer="before"]');
|
||||
var after = inner.querySelector('[data-virtual-spacer="after"]');
|
||||
return {
|
||||
has_before: !!before,
|
||||
has_after: !!after,
|
||||
before_h: before ? before.style.height : null,
|
||||
after_h: after ? after.style.height : null,
|
||||
};
|
||||
})()
|
||||
""")
|
||||
if result.get("has_before") and result.get("has_after"):
|
||||
print(f" PASS: spacers present (before={result['before_h']}, after={result['after_h']})")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL or SKIP: {result}")
|
||||
failed += 1
|
||||
|
||||
# T7: Render suppression - DOM children count stable during drag
|
||||
print("\n=== T7: DOM stability during drag (render suppression) ===")
|
||||
result = await ev(ws, """
|
||||
(function(){
|
||||
var inner = document.getElementById('msgInner');
|
||||
if (!inner) return {error: 'no inner'};
|
||||
var countBefore = inner.children.length;
|
||||
var htmlLenBefore = inner.innerHTML.length;
|
||||
|
||||
_scrollbarDragActive = true;
|
||||
|
||||
// Attempt a forced render - the drag guard should intercept
|
||||
try {
|
||||
_scheduleMessageVirtualizedRender(true);
|
||||
} catch(e) {}
|
||||
|
||||
// rAF is async so the render won't fire synchronously,
|
||||
// but we can verify the flag prevents synchronous render
|
||||
var countAfter = inner.children.length;
|
||||
var htmlLenAfter = inner.innerHTML.length;
|
||||
|
||||
_scrollbarDragActive = false;
|
||||
return {
|
||||
count_before: countBefore,
|
||||
count_after: countAfter,
|
||||
html_len_before: htmlLenBefore,
|
||||
html_len_after: htmlLenAfter,
|
||||
children_preserved: countBefore === countAfter,
|
||||
};
|
||||
})()
|
||||
""")
|
||||
if result.get("children_preserved") == True:
|
||||
print(f" PASS: children preserved ({result['count_before']} -> {result['count_after']})")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: {result}")
|
||||
failed += 1
|
||||
|
||||
# T8: Full render fires after drag release
|
||||
print("\n=== T8: Full render after drag release ===")
|
||||
result = await ev(ws, """
|
||||
(function(){
|
||||
_scrollbarDragActive = false;
|
||||
// Trigger forced render, should go through full path
|
||||
try {
|
||||
_scheduleMessageVirtualizedRender(true);
|
||||
} catch(e) {}
|
||||
var inner = document.getElementById('msgInner');
|
||||
return {
|
||||
inner_exists: !!inner,
|
||||
has_children: inner ? inner.children.length > 0 : false,
|
||||
};
|
||||
})()
|
||||
""")
|
||||
if result.get("has_children") == True:
|
||||
print(" PASS: content rendered after drag release")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: {result}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"RESULTS: {passed} passed, {failed} failed, {passed+failed} total")
|
||||
print(f"{'='*50}")
|
||||
if failed == 0:
|
||||
print("ALL CDP BEHAVIORAL TESTS PASSED")
|
||||
else:
|
||||
print(f"{failed} TEST(S) FAILED")
|
||||
|
||||
asyncio.run(run_tests())
|
||||
@@ -28,7 +28,8 @@ def test_attached_files_context_is_hidden_from_user_message_display():
|
||||
|
||||
assert "function _stripAttachedFilesMarkerForDisplay" in ui_src
|
||||
assert "_stripAttachedFilesMarkerForDisplay(_stripWorkspaceDisplayPrefix(content))" in ui_src
|
||||
assert "dataset.rawText=String(displayContent).trim()" in ui_src
|
||||
assert "const newRawText=String(displayContent).trim();" in ui_src
|
||||
assert "row.dataset.rawText=newRawText;" in ui_src
|
||||
|
||||
|
||||
def test_attached_files_context_is_hidden_from_sidebar_titles():
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -926,7 +926,13 @@ function _captureMessageViewportAnchor(){
|
||||
return {rawIdx: 42, topOffset: 100};
|
||||
}
|
||||
let _programmaticScroll = false;
|
||||
let _programmaticScrollSetAt = 0;
|
||||
let _programmaticScrollResetTimer = 0;
|
||||
let _lastScrollTop = 0;
|
||||
const performance = { now(){ return 1000; } };
|
||||
function clearTimeout(){}
|
||||
function setTimeout(cb, ms){ cb(); return 1; }
|
||||
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
|
||||
let renderCalls = [];
|
||||
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
|
||||
function requestAnimationFrame(cb){ cb(); }
|
||||
@@ -972,7 +978,13 @@ function _captureMessageViewportAnchor(){
|
||||
return {rawIdx: 42, topOffset: 200};
|
||||
}
|
||||
let _programmaticScroll = false;
|
||||
let _programmaticScrollSetAt = 0;
|
||||
let _programmaticScrollResetTimer = 0;
|
||||
let _lastScrollTop = 0;
|
||||
const performance = { now(){ return 1000; } };
|
||||
function clearTimeout(){}
|
||||
function setTimeout(cb, ms){ cb(); return 1; }
|
||||
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
|
||||
let renderCalls = [];
|
||||
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
|
||||
function requestAnimationFrame(cb){ cb(); }
|
||||
@@ -996,8 +1008,12 @@ def test_compensate_scroll_for_measurement_delta_sets_programmatic_scroll_flag()
|
||||
source = _extract_func_script(js) + """
|
||||
let scrollTopValue = 200;
|
||||
let _programmaticScroll = false;
|
||||
let _programmaticScrollSetAt = 0;
|
||||
let _programmaticScrollResetTimer = 0;
|
||||
let _lastScrollTop = 0;
|
||||
let scrollSetCount = 0;
|
||||
const performance = { now(){ return 1000; } };
|
||||
function clearTimeout(){}
|
||||
const container = {
|
||||
get scrollTop(){ return scrollTopValue; },
|
||||
set scrollTop(v){
|
||||
@@ -1021,18 +1037,9 @@ function _captureMessageViewportAnchor(){
|
||||
}
|
||||
let renderCalls = [];
|
||||
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
|
||||
let timeoutCbBeingCalled = false;
|
||||
function requestAnimationFrame(cb){
|
||||
// This is called with a callback that will eventually clear _programmaticScroll
|
||||
cb();
|
||||
}
|
||||
function setTimeout(cb, delay){
|
||||
// This is called from within the rAF callback (after scrollTop is set)
|
||||
// The callback should clear _programmaticScroll
|
||||
timeoutCbBeingCalled = true;
|
||||
cb();
|
||||
timeoutCbBeingCalled = false;
|
||||
}
|
||||
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
|
||||
function requestAnimationFrame(cb){ cb(); }
|
||||
function setTimeout(cb, delay){ cb(); return 1; }
|
||||
eval(extractFunc('_compensateScrollForMeasurementDelta'));
|
||||
_compensateScrollForMeasurementDelta(()=>{ _scheduleMessageVirtualizedRender(true); });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
+15
-3
@@ -130,9 +130,21 @@ class TestScrollPinningFix:
|
||||
"""Scroll listener must hide the button when user is near the bottom (#677)."""
|
||||
scroll_listener_start = UI_JS.find("el.addEventListener('scroll'")
|
||||
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 + 1600]
|
||||
listener_open = UI_JS.find("{", scroll_listener_start)
|
||||
assert listener_open != -1, "scroll listener body not found"
|
||||
depth = 0
|
||||
listener_end = -1
|
||||
for idx in range(listener_open, len(UI_JS)):
|
||||
ch = UI_JS[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
listener_end = idx + 1
|
||||
break
|
||||
assert listener_end != -1, "scroll listener body did not terminate cleanly"
|
||||
listener_block = UI_JS[scroll_listener_start:listener_end]
|
||||
assert "_syncScrollToBottomCue(showBottomButton" in listener_block, (
|
||||
"Scroll listener must show/hide scrollToBottomBtn based on _scrollPinned (#677)"
|
||||
)
|
||||
|
||||
@@ -47,4 +47,5 @@ def test_user_render_uses_stripped_display_content_without_preempting_context_ca
|
||||
# already-stripped displayContent, so the invariant holds either way.
|
||||
assert ("_renderUserFencedBlocks(displayContent)" in render_prefix or
|
||||
"_getCachedRender(displayContent, isUser)" in render_prefix)
|
||||
assert "row.dataset.rawText=String(displayContent).trim();" in render_prefix
|
||||
assert "const newRawText=String(displayContent).trim();" in render_prefix
|
||||
assert "row.dataset.rawText=newRawText;" in render_prefix
|
||||
Reference in new issue
Block a user