Merge branch 'peek-4478' into stage/4478-dom-recycle

This commit is contained in:
nesquena-hermes committed 2026-06-23 20:07:56 +00:00
commit ca368cc166
8 files changed
+2541 -74

No files matched your search

+110 -31
View File
@@ -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){
+304
View File
@@ -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())
+277
View File
@@ -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())
+2 -1
View File
@@ -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
View File
@@ -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)"
)
+2 -1
View File
@@ -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