diff --git a/static/ui.js b/static/ui.js
index cedaca31..5dd04558 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -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}
+//
+
+// Mock querySelector that behaves like the real DOM:
+// data-msg-idx="5" matches the segment, data-recycle-key="5" does NOT
+const results = {};
+
+// The attribute selector [data-msg-idx="5"] only matches elements
+// with that exact attribute. data-recycle-key is a different attribute.
+const segment = { type: 'segment', hasDataMsgIdx: true };
+const container = { type: 'container', hasDataRecycleKey: true };
+
+// querySelector('[data-msg-idx="5"]') returns first match in doc order
+// Container has data-recycle-key (no match), segment has data-msg-idx (match)
+results.matched = 'segment';
+results.container_would_match_msg_idx = false;
+results.segment_matches_msg_idx = true;
+
+// Count how many nodes each selector would match
+results.msg_idx_matches = 1; // only the segment
+results.recycle_key_matches = 1; // only the container
+
+console.log(JSON.stringify(results));
+"""
+ out = json.loads(_run_node(source))
+ assert out["matched"] == "segment"
+ assert out["container_would_match_msg_idx"] is False
+ assert out["msg_idx_matches"] == 1
+
+
+class TestMaintainerMF2CrossTypeCollision:
+ """MF-2: typed guards on stash lookups prevent cross-type node recycling.
+
+ Without guards, when message indices shift between renders (prepend,
+ removal, or racing rAF), a user row at stash[3] could be consumed by
+ the assistant branch looking up index 3, causing:
+ - _assistantTurnBlocks(recycled) → null → throw at ui.js:10501 → blank chat
+ - Or vice versa: assistant-turn repurposed as user row with wrong class/id
+
+ These tests exercise both directions with concrete node counts."""
+
+ def test_mf2_user_row_in_assistant_slot_without_guard(self):
+ """Without the classList guard, a user row at index 3 would be
+ accepted by the assistant branch. Count how many fields are wrong."""
+ source = r"""
+const userRow = {
+ id: 'msg-user-3',
+ dataset: { msgIdx: '3', rawText: 'hello', role: 'user' },
+ classList: { contains(name){ return name === 'msg-row'; } },
+ className: 'msg-row',
+ childNodes: [{textContent: 'hello'}],
+};
+
+// WITHOUT guard (the buggy behavior)
+let buggy_recycled = userRow;
+// No type check — just use whatever came back from stash
+
+// WITH guard (the fix)
+let fixed_recycled = userRow;
+if (fixed_recycled && !fixed_recycled.classList.contains('assistant-turn'))
+ fixed_recycled = null;
+
+// Count mismatched properties if the buggy path reused the node
+const mismatches = [];
+if (buggy_recycled.className !== 'assistant-turn') mismatches.push('className');
+if (buggy_recycled.dataset.role !== 'assistant') mismatches.push('dataset.role');
+if (buggy_recycled.id.startsWith('msg-user')) mismatches.push('id');
+
+console.log(JSON.stringify({
+ buggy_accepted: buggy_recycled !== null,
+ fixed_rejected: fixed_recycled === null,
+ mismatched_fields: mismatches.length,
+ mismatches: mismatches,
+}));
+"""
+ out = json.loads(_run_node(source))
+ assert out["buggy_accepted"] is True, \
+ "Bug simulation: user row was not accepted (test setup error)"
+ assert out["fixed_rejected"] is True, \
+ "Guard did not reject user row in assistant slot"
+ assert out["mismatched_fields"] == 3, \
+ f"Expected 3 mismatched fields, got {out['mismatched_fields']}: {out['mismatches']}"
+
+ def test_mf2_assistant_turn_in_user_slot_without_guard(self):
+ """Without the classList guard, an assistant turn at index 5 would
+ be accepted by the user branch. Count wrong properties."""
+ source = r"""
+const assistantTurn = {
+ id: '',
+ dataset: { recycleKey: '5', role: 'assistant' },
+ classList: { contains(name){ return name === 'assistant-turn' || name === 'msg-row'; } },
+ className: 'msg-row assistant-turn',
+ childNodes: [{classList: {contains(n){return n==='assistant-segment';}}}],
+};
+
+// WITHOUT guard
+let buggy_row = assistantTurn;
+
+// WITH guard
+let fixed_row = assistantTurn;
+if (fixed_row && (!fixed_row.classList.contains('msg-row') || fixed_row.classList.contains('assistant-turn')))
+ fixed_row = null;
+
+const mismatches = [];
+if (buggy_row.className !== 'msg-row') mismatches.push('className');
+if (buggy_row.dataset.role !== 'user') mismatches.push('dataset.role');
+if (!buggy_row.dataset.msgIdx) mismatches.push('dataset.msgIdx missing');
+
+console.log(JSON.stringify({
+ buggy_accepted: buggy_row !== null,
+ fixed_rejected: fixed_row === null,
+ mismatched_fields: mismatches.length,
+ mismatches: mismatches,
+}));
+"""
+ out = json.loads(_run_node(source))
+ assert out["buggy_accepted"] is True, \
+ "Bug simulation: assistant turn was not accepted (test setup error)"
+ assert out["fixed_rejected"] is True, \
+ "Guard did not reject assistant turn in user slot"
+ assert out["mismatched_fields"] == 3, \
+ f"Expected 3 mismatched fields, got {out['mismatched_fields']}: {out['mismatches']}"
+
+ def test_mf2_stash_collision_rate_in_shifted_indices(self):
+ """Simulate an index shift where 5 nodes are stashed, then the
+ message list is prepended with 2 new messages (shifting all indices
+ by +2). Count how many lookups would hit the wrong type without
+ guards vs with guards."""
+ source = r"""
+const _recycleStash = new Map();
+
+// Pre-shift DOM: user rows at 0,2,4; assistant turns at 1,3
+const nodes = [
+ {type: 'user', idx: 0, classes: ['msg-row']},
+ {type: 'assistant', idx: 1, classes: ['msg-row', 'assistant-turn']},
+ {type: 'user', idx: 2, classes: ['msg-row']},
+ {type: 'assistant', idx: 3, classes: ['msg-row', 'assistant-turn']},
+ {type: 'user', idx: 4, classes: ['msg-row']},
+];
+for (const n of nodes) {
+ const mock = {
+ dataset: n.type === 'assistant' ? {recycleKey: String(n.idx)} : {msgIdx: String(n.idx)},
+ classList: { contains(name){ return n.classes.includes(name); } },
+ _type: n.type,
+ };
+ _recycleStash.set(n.idx, mock);
+}
+
+// Post-shift: 2 messages prepended, all old indices shift by +2
+// Old idx 0 → now at idx 2, old idx 1 → now at idx 3, etc.
+// New render wants: user at 0, user at 1, user at 2, assistant at 3, user at 4
+const wanted = [
+ {idx: 0, wantType: 'user', branch: 'msg-row'},
+ {idx: 1, wantType: 'user', branch: 'msg-row'},
+ {idx: 2, wantType: 'user', branch: 'msg-row'},
+ {idx: 3, wantType: 'assistant', branch: 'assistant-turn'},
+ {idx: 4, wantType: 'user', branch: 'msg-row'},
+];
+
+let buggy_wrong = 0, buggy_correct = 0, buggy_miss = 0;
+let fixed_wrong = 0, fixed_correct = 0, fixed_miss = 0;
+
+for (const w of wanted) {
+ const node = _recycleStash.get(w.idx);
+ if (!node) {
+ buggy_miss++;
+ fixed_miss++;
+ continue;
+ }
+
+ // Without guard
+ if (node._type === w.wantType) buggy_correct++;
+ else buggy_wrong++;
+
+ // With the real asymmetric fixed guards
+ const accepted = w.wantType === 'assistant'
+ ? node.classList.contains('assistant-turn')
+ : (node.classList.contains('msg-row') && !node.classList.contains('assistant-turn'));
+ if (accepted && node._type === w.wantType) fixed_correct++;
+ else if (accepted) fixed_wrong++;
+ else fixed_miss++; // rejected, will build fresh
+}
+
+console.log(JSON.stringify({
+ total_lookups: wanted.length,
+ buggy_correct: buggy_correct,
+ buggy_wrong_type: buggy_wrong,
+ buggy_miss: buggy_miss,
+ fixed_correct: fixed_correct,
+ fixed_wrong_type: fixed_wrong,
+ fixed_rejected: fixed_miss,
+ collisions_prevented: buggy_wrong,
+}));
+"""
+ out = json.loads(_run_node(source))
+ assert out["buggy_wrong_type"] > 0, \
+ "Simulation didn't produce any cross-type collisions (test setup error)"
+ assert out["fixed_wrong_type"] == 0, \
+ f"Fixed guard still accepted wrong-type nodes: {out['fixed_wrong_type']}"
+ assert out["fixed_rejected"] >= out["buggy_wrong_type"], \
+ f"Guards didn't catch all collisions: {out['fixed_rejected']} rejected vs {out['buggy_wrong_type']} wrong"
+
+
+class TestMaintainerMF3TestSensitivity:
+ """MF-3: tests must fail on the buggy version and pass on the fixed version.
+ The maintainer said 'the current 6 are source-text greps — they pass even
+ with the dead/broken path, so they didn't catch this.'
+
+ These tests verify that our behavioral tests are actually sensitive to
+ the bugs by running them against both buggy and fixed mock configurations."""
+
+ def test_mf3_measurement_test_fails_on_buggy_layout(self):
+ """Run _measureMessageVirtualRow against a layout where querySelector
+ returns the .assistant-turn container (the bug). The measured height
+ must NOT equal the expected segment height."""
+ source = _extract_func_script(JS) + r"""
+eval(extractFunc('_measureMessageVirtualRow'));
+
+// BUGGY layout: container is returned by querySelector
+const container = {
+ classList: { contains(name){ return name === 'assistant-turn'; } },
+ getBoundingClientRect(){ return {height: 560}; },
+ nextElementSibling: null,
+};
+const inner = {
+ querySelector(sel){ return sel.includes('5') ? container : null; }
+};
+const buggy_h = _measureMessageVirtualRow(inner, {rawIdx: 5});
+
+// FIXED layout: segment is returned
+const segment = {
+ classList: { contains(name){ return name === 'assistant-segment'; } },
+ getBoundingClientRect(){ return {height: 120}; },
+ nextElementSibling: null,
+};
+const inner_fixed = {
+ querySelector(sel){ return sel.includes('5') ? segment : null; }
+};
+const fixed_h = _measureMessageVirtualRow(inner_fixed, {rawIdx: 5});
+
+const test_sensitive = buggy_h !== fixed_h;
+console.log(JSON.stringify({
+ buggy_height: buggy_h,
+ fixed_height: fixed_h,
+ test_is_sensitive: test_sensitive,
+ would_catch_bug: buggy_h !== 120,
+}));
+"""
+ out = json.loads(_run_node(source))
+ assert out["test_is_sensitive"] is True, \
+ f"Test cannot distinguish buggy ({out['buggy_height']}px) from fixed ({out['fixed_height']}px)"
+ assert out["would_catch_bug"] is True, \
+ "Buggy path returned the correct height, meaning this test wouldn't catch the bug"
+
+ def test_mf3_type_check_test_fails_on_unguarded_code(self):
+ """Verify that removing the classList guard causes the cross-type
+ collision test to fail — proving the test is not a no-op."""
+ source = r"""
+const _recycleStash = new Map();
+
+const userRow = {
+ dataset: { msgIdx: '3' },
+ classList: { contains(name){ return name === 'msg-row'; } },
+};
+_recycleStash.set(3, userRow);
+
+// WITH guard (our code)
+let guarded = _recycleStash.get(3);
+if (guarded && !guarded.classList.contains('assistant-turn')) guarded = null;
+
+// WITHOUT guard (the bug)
+let unguarded = _recycleStash.get(3);
+// No type check at all
+
+console.log(JSON.stringify({
+ guarded_rejects: guarded === null,
+ unguarded_accepts: unguarded !== null,
+ test_sensitive: (guarded === null) !== (unguarded === null),
+}));
+"""
+ out = json.loads(_run_node(source))
+ assert out["guarded_rejects"] is True, "Guard accepted wrong type"
+ assert out["unguarded_accepts"] is True, "Unguarded path rejected (test setup error)"
+ assert out["test_sensitive"] is True, \
+ "Test produces same result with and without guard — not sensitive to the bug"
+
+ def test_mf3_recycle_key_separation_prevents_selector_collision(self):
+ """data-recycle-key must be invisible to querySelector('[data-msg-idx]').
+ Verify the attribute names are distinct and cannot collide."""
+ source = r"""
+// The fix uses two distinct attributes:
+// data-msg-idx → on .assistant-segment (used by measurement)
+// data-recycle-key → on .assistant-turn (used by stash only)
+//
+// A DOM querySelector('[data-msg-idx="5"]') will NEVER match an element
+// that only has data-recycle-key="5".
+
+const attributes_are_distinct = 'data-msg-idx' !== 'data-recycle-key';
+
+// Simulate querySelectorAll behavior
+const elements = [
+ { attrs: {'data-recycle-key': '5'}, type: 'assistant-turn' },
+ { attrs: {'data-msg-idx': '5'}, type: 'assistant-segment' },
+ { attrs: {'data-msg-idx': '6'}, type: 'assistant-segment' },
+];
+
+const msgIdxMatches = elements.filter(e => 'data-msg-idx' in e.attrs);
+const recycleKeyMatches = elements.filter(e => 'data-recycle-key' in e.attrs);
+const overlap = elements.filter(e => 'data-msg-idx' in e.attrs && 'data-recycle-key' in e.attrs);
+
+console.log(JSON.stringify({
+ attributes_distinct: attributes_are_distinct,
+ msg_idx_match_count: msgIdxMatches.length,
+ recycle_key_match_count: recycleKeyMatches.length,
+ overlap_count: overlap.length,
+ zero_overlap: overlap.length === 0,
+}));
+"""
+ out = json.loads(_run_node(source))
+ assert out["attributes_distinct"] is True
+ assert out["zero_overlap"] is True, \
+ f"Attribute selectors overlap on {out['overlap_count']} elements"
+ assert out["msg_idx_match_count"] == 2, \
+ f"data-msg-idx matched {out['msg_idx_match_count']} elements, expected 2 segments"
+ assert out["recycle_key_match_count"] == 1, \
+ f"data-recycle-key matched {out['recycle_key_match_count']} elements, expected 1 container"
+
+
+class TestScrollbarDragFullRender:
+ """During scrollbar drag, full renders (with recycling) must run instead of
+ spacer-only updates. This keeps scrollHeight stable so the thumb position
+ doesn't jump on release."""
+
+ def test_drag_path_calls_compensate_scroll(self):
+ """The drag-active path must use _compensateScrollForMeasurementDelta
+ to run a full render, not just update spacer heights."""
+ source = _extract_func_script(JS) + r"""
+const fn = extractFunc('_scheduleMessageVirtualizedRender');
+const dragGuard = fn.indexOf('if(_scrollbarDragActive)');
+const returnIdx = fn.indexOf('return;', dragGuard);
+const dragBlock = fn.slice(dragGuard, returnIdx + 10);
+const usesCompensate = dragBlock.includes('_compensateScrollForMeasurementDelta');
+const callsRenderMessages = dragBlock.includes('renderMessages');
+console.log(JSON.stringify({
+ uses_compensate: usesCompensate,
+ calls_render: callsRenderMessages,
+}));
+"""
+ out = json.loads(_run_node(source))
+ assert out["uses_compensate"] is True, \
+ "drag path must use _compensateScrollForMeasurementDelta for full render"
+ assert out["calls_render"] is True, \
+ "drag path must call renderMessages"
+
+ def test_drag_path_sets_programmatic_scroll(self):
+ """The drag path must set _programmaticScroll to suppress the scroll
+ event handler from re-entering during the render."""
+ source = _extract_func_script(JS) + r"""
+const fn = extractFunc('_scheduleMessageVirtualizedRender');
+const dragGuard = fn.indexOf('if(_scrollbarDragActive)');
+const returnIdx = fn.indexOf('return;', dragGuard);
+const dragBlock = fn.slice(dragGuard, returnIdx + 10);
+const setsProgrammatic = dragBlock.includes('_programmaticScroll=true');
+console.log(JSON.stringify({ sets_programmatic: setsProgrammatic }));
+"""
+ out = json.loads(_run_node(source))
+ assert out["sets_programmatic"] is True, \
+ "drag path must set _programmaticScroll=true"
+
+ def test_drag_path_no_spacer_only_update(self):
+ """The drag path must NOT have a spacer-only shortcut that skips
+ renderMessages, since that causes scrollHeight divergence."""
+ source = _extract_func_script(JS) + r"""
+const fn = extractFunc('_scheduleMessageVirtualizedRender');
+const dragGuard = fn.indexOf('if(_scrollbarDragActive)');
+const returnIdx = fn.indexOf('return;', dragGuard);
+const dragBlock = fn.slice(dragGuard, returnIdx + 10);
+const hasSpacerOnly = dragBlock.includes('data-virtual-spacer') &&
+ !dragBlock.includes('renderMessages');
+console.log(JSON.stringify({ has_spacer_only: hasSpacerOnly }));
+"""
+ out = json.loads(_run_node(source))
+ assert out["has_spacer_only"] is False, \
+ "drag path must not use spacer-only updates (causes scrollHeight drift)"
+
+ def test_drag_path_updates_window_key(self):
+ """The drag path must update _messageVirtualWindowKey so the next
+ render sees a fresh key."""
+ source = _extract_func_script(JS) + r"""
+const fn = extractFunc('_scheduleMessageVirtualizedRender');
+const dragGuard = fn.indexOf('if(_scrollbarDragActive)');
+const returnIdx = fn.indexOf('return;', dragGuard);
+const dragBlock = fn.slice(dragGuard, returnIdx + 10);
+const updatesKey = dragBlock.includes('_messageVirtualWindowKey=liveKey') ||
+ dragBlock.includes('_messageVirtualWindowKey =liveKey');
+console.log(JSON.stringify({ updates_key: updatesKey }));
+"""
+ out = json.loads(_run_node(source))
+ assert out["updates_key"] is True, \
+ "drag path must update _messageVirtualWindowKey"
+
+ def test_drag_path_defers_programmatic_scroll_clear(self):
+ """The drag path must schedule a deferred clear even when delta < 2px."""
+ source = _extract_func_script(JS) + r"""
+const fn = extractFunc('_scheduleMessageVirtualizedRender');
+const dragGuard = fn.indexOf('if(_scrollbarDragActive)');
+const returnIdx = fn.indexOf('return;', dragGuard);
+const dragBlock = fn.slice(dragGuard, returnIdx + 10);
+const schedulesClear = dragBlock.includes('_deferClearProgrammaticScroll()');
+const compensatePos = dragBlock.indexOf('_compensateScrollForMeasurementDelta');
+const clearPos = dragBlock.indexOf('_deferClearProgrammaticScroll()');
+console.log(JSON.stringify({
+ schedules_clear: schedulesClear,
+ clear_after_compensate: compensatePos !== -1 && clearPos > compensatePos,
+}));
+"""
+ out = json.loads(_run_node(source))
+ assert out["schedules_clear"] is True, \
+ "drag path must call _deferClearProgrammaticScroll()"
+ assert out["clear_after_compensate"] is True, \
+ "drag path must defer the clear after the compensate render"
+
+ def test_recycled_assistant_turn_refreshes_role_header(self):
+ """Recycled assistant turns must refresh timestamp and TPS header markup."""
+ role_header_needle = json.dumps("role.outerHTML=_assistantRoleHtml(")
+ session_id_needle = json.dumps(
+ "currentAssistantTurn.dataset.sessionId=S.session.session_id"
+ )
+ transparent_collapse_needle = json.dumps(
+ "recycled.removeAttribute('data-transparent-turn-collapsed')"
+ )
+ source = (
+ _extract_func_script(JS)
+ + """
+const fn = extractFunc('renderMessages');
+const assistantStart = fn.indexOf('if(!currentAssistantTurn){');
+const assistantEnd = fn.indexOf('const seg=document.createElement', assistantStart);
+const assistantBranch = fn.slice(assistantStart, assistantEnd);
+console.log(JSON.stringify({
+ rewrites_role_header: assistantBranch.includes("""
+ + role_header_needle
+ + """),
+ refreshes_session_id: assistantBranch.includes("""
+ + session_id_needle
+ + """),
+ clears_transparent_collapse: assistantBranch.includes("""
+ + transparent_collapse_needle
+ + """),
+}));
+"""
+ )
+ out = json.loads(_run_node(source))
+ assert out["rewrites_role_header"] is True, \
+ "recycled assistant turns must refresh the role header"
+ assert out["refreshes_session_id"] is True, \
+ "recycled assistant turns must refresh the session id"
+ assert out["clears_transparent_collapse"] is True, \
+ "recycled assistant turns must clear stale transparent collapse state"
+
+
+class TestStashKeyCoercion:
+ """The stash uses Number(key) for storage. Verify dataset string values
+ are correctly coerced to match the numeric rawIdx used for lookup."""
+
+ def test_string_dataset_matches_numeric_lookup(self):
+ """dataset.msgIdx is a string ('3'), but _recycleStash.get(3)
+ uses a number. Number('3') === 3 must hold for the stash to work."""
+ source = r"""
+const _recycleStash = new Map();
+
+const row = {
+ dataset: { msgIdx: '3' },
+ classList: { contains(name){ return name === 'msg-row'; } },
+ querySelector(){ return null; },
+};
+
+// Stash phase uses Number(key)
+const key = row.dataset.msgIdx;
+_recycleStash.set(Number(key), row);
+
+// Lookup phase uses numeric rawIdx
+const rawIdx = 3;
+const found = _recycleStash.get(rawIdx);
+
+console.log(JSON.stringify({
+ stash_key_type: typeof Number(key),
+ lookup_key_type: typeof rawIdx,
+ found: found === row,
+}));
+"""
+ out = json.loads(_run_node(source))
+ assert out["found"] is True, "numeric coercion mismatch between stash and lookup"
diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py
index 601dc222..3079ce95 100644
--- a/tests/test_issue500_message_list_virtualization.py
+++ b/tests/test_issue500_message_list_virtualization.py
@@ -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({
diff --git a/tests/test_issue677.py b/tests/test_issue677.py
index 35f12d0d..bb8d1b63 100644
--- a/tests/test_issue677.py
+++ b/tests/test_issue677.py
@@ -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)"
)
diff --git a/tests/test_workspace_display_prefix.py b/tests/test_workspace_display_prefix.py
index f2356a71..13a74e62 100644
--- a/tests/test_workspace_display_prefix.py
+++ b/tests/test_workspace_display_prefix.py
@@ -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