From bad9fd7e2bc4e317ca9274e1c435727df6d0d580 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 17:59:43 -0400 Subject: [PATCH 1/4] fix(#4793): harden recycled assistant turns and messages scrollbar drag --- static/ui.js | 12 +- tests/test_issue4793_dom_recycle_hardening.py | 231 ++++++++++++++++++ ...test_live_to_final_anchor_visible_order.py | 14 ++ 3 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue4793_dom_recycle_hardening.py diff --git a/static/ui.js b/static/ui.js index bdbcfe4f..b33a2daa 100644 --- a/static/ui.js +++ b/static/ui.js @@ -438,6 +438,13 @@ let _messageVirtualScrollSettleTimer=0; let _messageVirtualDeferredMeasurement=null; let _msgNodeRecycleEnabled=false; const _recycleStash=new Map(); +const _recycleResetAttrs=[ + 'data-transparent-turn-collapsed', + 'data-transparent-turn-toggle-bound', + 'data-anchor-scene-live-owner', + 'data-anchor-stream-id', + 'data-live-assistant-turn', +]; let _scrollbarDragActive=false; function _markMessageVirtualScrollActive(){ _messageVirtualScrollActive=true; @@ -3918,7 +3925,7 @@ if(typeof window!=='undefined'){ const el=document.getElementById('messages'); if(!el) return; el.addEventListener('pointerdown',(e)=>{ - if(e.offsetX>=el.clientWidth) _scrollbarDragActive=true; + if(e.target===el&&e.offsetX>=el.clientWidth) _scrollbarDragActive=true; },{passive:true}); window.addEventListener('pointerup',()=>{ if(!_scrollbarDragActive) return; @@ -11526,8 +11533,7 @@ function renderMessages(options){ if(recycled){ const blocks=_assistantTurnBlocks(recycled); if(blocks) blocks.innerHTML=''; - recycled.removeAttribute('data-transparent-turn-collapsed'); - recycled.removeAttribute('data-transparent-turn-toggle-bound'); + for(const attr of _recycleResetAttrs) recycled.removeAttribute(attr); const role=recycled.querySelector('.msg-role.assistant'); if(role) role.outerHTML=_assistantRoleHtml(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):''); currentAssistantTurn=recycled; diff --git a/tests/test_issue4793_dom_recycle_hardening.py b/tests/test_issue4793_dom_recycle_hardening.py new file mode 100644 index 00000000..13bb45b7 --- /dev/null +++ b/tests/test_issue4793_dom_recycle_hardening.py @@ -0,0 +1,231 @@ +"""Regression coverage for issue #4793 DOM recycle hardening.""" + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).parent.parent.resolve() +UI_JS_PATH = REPO_ROOT / "static" / "ui.js" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _run_node(source: str) -> dict: + with tempfile.NamedTemporaryFile( + "w", suffix=".cjs", encoding="utf-8", dir=REPO_ROOT, delete=False + ) as script: + script.write(source) + script_path = Path(script.name) + try: + result = subprocess.run( + [NODE, str(script_path)], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=30, + ) + finally: + script_path.unlink(missing_ok=True) + if result.returncode != 0: + raise RuntimeError(result.stderr) + return json.loads(result.stdout.strip()) + + +def _js_prefix() -> str: + return f""" +const fs = require('fs'); +const src = fs.readFileSync({json.dumps(str(UI_JS_PATH))}, 'utf8'); +function extractBetween(startMarker, endMarker, startFrom = 0) {{ + const start = src.indexOf(startMarker, startFrom); + if (start < 0) throw new Error(startMarker + ' not found'); + const end = src.indexOf(endMarker, start); + if (end < 0) throw new Error(endMarker + ' not found'); + return src.slice(start, end); +}} +function extractBody(startMarker, endMarker, startFrom = 0) {{ + const start = src.indexOf(startMarker, startFrom); + if (start < 0) throw new Error(startMarker + ' not found'); + const end = src.indexOf(endMarker, start); + if (end < 0) throw new Error(endMarker + ' not found'); + return src.slice(start + startMarker.length, end); +}} +""" + + +def test_recycled_assistant_turn_reuse_clears_live_anchor_attrs(): + script = _js_prefix() + """ +const recycleChunk = extractBetween("if(!currentAssistantTurn){", "const seg=document.createElement('div');"); +const removedAttrs = []; +const role = { + replacement: null, + set outerHTML(value) { this.replacement = value; }, + get outerHTML() { return this.replacement; }, +}; +const recycled = { + dataset: { recycleKey: 'old-key', role: 'old-role', sessionId: 'old-session' }, + classList: { contains(name) { return name === 'assistant-turn'; } }, + removeAttribute(name) { removedAttrs.push(name); }, + querySelector(selector) { return selector === '.msg-role.assistant' ? role : null; }, +}; +const _recycleResetAttrs = [ + 'data-transparent-turn-collapsed', + 'data-transparent-turn-toggle-bound', + 'data-anchor-scene-live-owner', + 'data-anchor-stream-id', + 'data-live-assistant-turn', +]; +const _recycleStash = new Map([[7, recycled]]); +const _msgNodeRecycleEnabled = true; +let currentAssistantTurn = null; +const rawIdx = 7; +const blocks = { innerHTML: 'keep me' }; +const _assistantTurnBlocks = () => blocks; +const inner = { appendChild() {} }; +const tsTitle = 'Turn title'; +const m = { _turnTps: 42 }; +const S = { session: { session_id: 'sess-1' } }; +const _assistantRoleHtml = (title, tps) => `role:${title}:${tps}`; +const isTpsDisplayEnabled = () => true; +const _formatTurnTps = value => `tps:${value}`; +eval(recycleChunk); +console.log(JSON.stringify({ + removedAttrs, + blocksInnerHTML: blocks.innerHTML, + roleHtml: role.replacement, + currentAssistantTurnIsRecycled: currentAssistantTurn === recycled, + dataset: { + role: currentAssistantTurn.dataset.role, + sessionId: currentAssistantTurn.dataset.sessionId, + recycleKey: String(currentAssistantTurn.dataset.recycleKey), + }, +})); +""" + result = _run_node(script) + + assert result["removedAttrs"] == [ + "data-transparent-turn-collapsed", + "data-transparent-turn-toggle-bound", + "data-anchor-scene-live-owner", + "data-anchor-stream-id", + "data-live-assistant-turn", + ] + assert result["currentAssistantTurnIsRecycled"] is True + assert result["dataset"] == { + "role": "assistant", + "sessionId": "sess-1", + "recycleKey": "7", + } + + +def test_recycled_assistant_turn_reuse_keeps_block_clear_and_role_refresh(): + script = _js_prefix() + """ +const recycleChunk = extractBetween("if(!currentAssistantTurn){", "const seg=document.createElement('div');"); +const removedAttrs = []; +const role = { + replacement: null, + set outerHTML(value) { this.replacement = value; }, + get outerHTML() { return this.replacement; }, +}; +const recycled = { + dataset: { recycleKey: 'old-key' }, + classList: { contains(name) { return name === 'assistant-turn'; } }, + removeAttribute(name) { removedAttrs.push(name); }, + querySelector(selector) { return selector === '.msg-role.assistant' ? role : null; }, +}; +const _recycleResetAttrs = [ + 'data-transparent-turn-collapsed', + 'data-transparent-turn-toggle-bound', + 'data-anchor-scene-live-owner', + 'data-anchor-stream-id', + 'data-live-assistant-turn', +]; +const _recycleStash = new Map([[7, recycled]]); +const _msgNodeRecycleEnabled = true; +let currentAssistantTurn = null; +const rawIdx = 7; +const blocks = { innerHTML: 'keep me' }; +const _assistantTurnBlocks = () => blocks; +const inner = { appendChild() {} }; +const tsTitle = 'Turn title'; +const m = { _turnTps: 42 }; +const S = { session: { session_id: 'sess-1' } }; +const _assistantRoleHtml = (title, tps) => `role:${title}:${tps}`; +const isTpsDisplayEnabled = () => true; +const _formatTurnTps = value => `tps:${value}`; +eval(recycleChunk); +console.log(JSON.stringify({ + blocksInnerHTML: blocks.innerHTML, + roleHtml: role.replacement, + currentAssistantTurnIsRecycled: currentAssistantTurn === recycled, + dataset: { + role: currentAssistantTurn.dataset.role, + sessionId: currentAssistantTurn.dataset.sessionId, + recycleKey: String(currentAssistantTurn.dataset.recycleKey), + }, +})); +""" + result = _run_node(script) + + assert result["blocksInnerHTML"] == "" + assert result["roleHtml"] == "role:Turn title:tps:42" + assert result["currentAssistantTurnIsRecycled"] is True + assert result["dataset"] == { + "role": "assistant", + "sessionId": "sess-1", + "recycleKey": "7", + } + + +def test_messages_scrollbar_drag_ignores_bubbled_child_pointerdown(): + script = _js_prefix() + """ +const pointerdownBody = extractBody("el.addEventListener('pointerdown',(e)=>{", "},{passive:true});"); +let _scrollbarDragActive = false; +const el = { clientWidth: 120 }; +eval(`function pointerdownHandler(e){${pointerdownBody}}`); +pointerdownHandler({ target: { clientWidth: 120 }, offsetX: 999 }); +const bubbledArmed = _scrollbarDragActive; +_scrollbarDragActive = false; +pointerdownHandler({ target: el, offsetX: 999 }); +console.log(JSON.stringify({ + bubbledArmed, + directArmed: _scrollbarDragActive, +})); +""" + result = _run_node(script) + + assert result["bubbledArmed"] is False + assert result["directArmed"] is True + + +def test_messages_scrollbar_drag_still_arms_for_direct_messages_press_and_rerenders_on_pointerup(): + script = _js_prefix() + """ +const pointerdownBody = extractBody("el.addEventListener('pointerdown',(e)=>{", "},{passive:true});"); +const pointerupBody = extractBody("window.addEventListener('pointerup',()=>{", "},{passive:true});"); +const rerenders = []; +let _scrollbarDragActive = false; +const el = { clientWidth: 120 }; +function _scheduleMessageVirtualizedRender(force) { + rerenders.push(force); +} +eval(`function pointerdownHandler(e){${pointerdownBody}}`); +eval(`function pointerupHandler(){${pointerupBody}}`); +pointerdownHandler({ target: el, offsetX: 999 }); +const armed = _scrollbarDragActive; +pointerupHandler(); +console.log(JSON.stringify({ + armed, + afterPointerup: _scrollbarDragActive, + rerenders, +})); +""" + result = _run_node(script) + + assert result["armed"] is True + assert result["afterPointerup"] is False + assert result["rerenders"] == [True] diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 58c18fd7..ff682ed4 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -427,6 +427,20 @@ def test_live_anchor_scene_removes_legacy_interim_collapse_toggle(): assert guard_idx < remove_idx < legacy_create_idx +def test_recycled_assistant_turn_clears_live_anchor_attrs_before_role_refresh(): + reset_attrs = UI_JS[UI_JS.index("const _recycleResetAttrs="):UI_JS.index("let _scrollbarDragActive=false;")] + assert "data-anchor-scene-live-owner" in reset_attrs + assert "data-anchor-stream-id" in reset_attrs + assert "data-live-assistant-turn" in reset_attrs + + recycle = _function_body(UI_JS, "renderMessages") + recycle = recycle[recycle.index("if(!currentAssistantTurn){"):] + + loop_idx = recycle.index("for(const attr of _recycleResetAttrs) recycled.removeAttribute(attr);") + refresh_idx = recycle.index("if(role) role.outerHTML=_assistantRoleHtml(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):'');") + assert loop_idx < refresh_idx + + def test_tool_scene_rows_coalesce_by_logical_tool_call_identity(): rows = _function_body(UI_JS, "_anchorSceneRowsForRendering") key = _function_body(UI_JS, "_anchorSceneToolRowLogicalKey") From 004c9774d0c726a9c2c794699152132582f80f71 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 18:19:23 -0400 Subject: [PATCH 2/4] fix(#4793): harden recycled assistant turns and messages scrollbar drag --- tests/test_issue4346_vscroll_footer_jitter.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_issue4346_vscroll_footer_jitter.py b/tests/test_issue4346_vscroll_footer_jitter.py index 3d532f5b..0cab4af8 100644 --- a/tests/test_issue4346_vscroll_footer_jitter.py +++ b/tests/test_issue4346_vscroll_footer_jitter.py @@ -1773,8 +1773,11 @@ console.log(JSON.stringify({ session_id_needle = json.dumps( "currentAssistantTurn.dataset.sessionId=S.session.session_id" ) - transparent_collapse_needle = json.dumps( - "recycled.removeAttribute('data-transparent-turn-collapsed')" + recycle_reset_loop_needle = json.dumps( + "for(const attr of _recycleResetAttrs) recycled.removeAttribute(attr);" + ) + transparent_collapse_attr_needle = json.dumps( + "'data-transparent-turn-collapsed'" ) source = ( _extract_func_script(JS) @@ -1791,7 +1794,10 @@ console.log(JSON.stringify({ + session_id_needle + """), clears_transparent_collapse: assistantBranch.includes(""" - + transparent_collapse_needle + + recycle_reset_loop_needle + + """), + reset_list_contains_transparent_collapse: fn.includes(""" + + transparent_collapse_attr_needle + """), })); """ @@ -1802,7 +1808,9 @@ console.log(JSON.stringify({ 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" + "recycled assistant turns must clear stale attrs through the recycle reset loop" + assert out["reset_list_contains_transparent_collapse"] is True, \ + "the recycle reset list must keep clearing transparent collapse state" class TestStashKeyCoercion: From a4f33f76876f61e13951198940b9fd66f6e56134 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 18:25:39 -0400 Subject: [PATCH 3/4] test(#4793): tighten recycle reset regressions --- tests/test_issue4346_vscroll_footer_jitter.py | 5 +++- tests/test_issue4793_dom_recycle_hardening.py | 28 +++++++++---------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/test_issue4346_vscroll_footer_jitter.py b/tests/test_issue4346_vscroll_footer_jitter.py index 0cab4af8..8d3bbc02 100644 --- a/tests/test_issue4346_vscroll_footer_jitter.py +++ b/tests/test_issue4346_vscroll_footer_jitter.py @@ -1783,6 +1783,9 @@ console.log(JSON.stringify({ _extract_func_script(JS) + """ const fn = extractFunc('renderMessages'); +const resetAttrsStart = src.indexOf('const _recycleResetAttrs='); +const resetAttrsEnd = src.indexOf('let _scrollbarDragActive=false;', resetAttrsStart); +const resetAttrs = src.slice(resetAttrsStart, resetAttrsEnd); const assistantStart = fn.indexOf('if(!currentAssistantTurn){'); const assistantEnd = fn.indexOf('const seg=document.createElement', assistantStart); const assistantBranch = fn.slice(assistantStart, assistantEnd); @@ -1796,7 +1799,7 @@ console.log(JSON.stringify({ clears_transparent_collapse: assistantBranch.includes(""" + recycle_reset_loop_needle + """), - reset_list_contains_transparent_collapse: fn.includes(""" + reset_list_contains_transparent_collapse: resetAttrs.includes(""" + transparent_collapse_attr_needle + """), })); diff --git a/tests/test_issue4793_dom_recycle_hardening.py b/tests/test_issue4793_dom_recycle_hardening.py index 13bb45b7..1e3cf23c 100644 --- a/tests/test_issue4793_dom_recycle_hardening.py +++ b/tests/test_issue4793_dom_recycle_hardening.py @@ -61,6 +61,8 @@ function extractBody(startMarker, endMarker, startFrom = 0) {{ def test_recycled_assistant_turn_reuse_clears_live_anchor_attrs(): script = _js_prefix() + """ const recycleChunk = extractBetween("if(!currentAssistantTurn){", "const seg=document.createElement('div');"); +const resetListChunk = extractBetween("const _recycleResetAttrs=[", "let _scrollbarDragActive=false;") + .replace("const _recycleResetAttrs=", "var _recycleResetAttrs="); const removedAttrs = []; const role = { replacement: null, @@ -73,13 +75,7 @@ const recycled = { removeAttribute(name) { removedAttrs.push(name); }, querySelector(selector) { return selector === '.msg-role.assistant' ? role : null; }, }; -const _recycleResetAttrs = [ - 'data-transparent-turn-collapsed', - 'data-transparent-turn-toggle-bound', - 'data-anchor-scene-live-owner', - 'data-anchor-stream-id', - 'data-live-assistant-turn', -]; +eval(resetListChunk); const _recycleStash = new Map([[7, recycled]]); const _msgNodeRecycleEnabled = true; let currentAssistantTurn = null; @@ -96,6 +92,7 @@ const _formatTurnTps = value => `tps:${value}`; eval(recycleChunk); console.log(JSON.stringify({ removedAttrs, + resetAttrs: _recycleResetAttrs, blocksInnerHTML: blocks.innerHTML, roleHtml: role.replacement, currentAssistantTurnIsRecycled: currentAssistantTurn === recycled, @@ -108,6 +105,13 @@ console.log(JSON.stringify({ """ result = _run_node(script) + assert result["resetAttrs"] == [ + "data-transparent-turn-collapsed", + "data-transparent-turn-toggle-bound", + "data-anchor-scene-live-owner", + "data-anchor-stream-id", + "data-live-assistant-turn", + ] assert result["removedAttrs"] == [ "data-transparent-turn-collapsed", "data-transparent-turn-toggle-bound", @@ -126,6 +130,8 @@ console.log(JSON.stringify({ def test_recycled_assistant_turn_reuse_keeps_block_clear_and_role_refresh(): script = _js_prefix() + """ const recycleChunk = extractBetween("if(!currentAssistantTurn){", "const seg=document.createElement('div');"); +const resetListChunk = extractBetween("const _recycleResetAttrs=[", "let _scrollbarDragActive=false;") + .replace("const _recycleResetAttrs=", "var _recycleResetAttrs="); const removedAttrs = []; const role = { replacement: null, @@ -138,13 +144,7 @@ const recycled = { removeAttribute(name) { removedAttrs.push(name); }, querySelector(selector) { return selector === '.msg-role.assistant' ? role : null; }, }; -const _recycleResetAttrs = [ - 'data-transparent-turn-collapsed', - 'data-transparent-turn-toggle-bound', - 'data-anchor-scene-live-owner', - 'data-anchor-stream-id', - 'data-live-assistant-turn', -]; +eval(resetListChunk); const _recycleStash = new Map([[7, recycled]]); const _msgNodeRecycleEnabled = true; let currentAssistantTurn = null; From 6cf4f7a5d071e562180873141dc34fd866416cd3 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 18:49:50 -0400 Subject: [PATCH 4/4] test(#4793): decouple recycle reset assertions --- static/ui.js | 1 + tests/test_issue4793_dom_recycle_hardening.py | 15 ++++++--------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/static/ui.js b/static/ui.js index b33a2daa..50916be4 100644 --- a/static/ui.js +++ b/static/ui.js @@ -443,6 +443,7 @@ const _recycleResetAttrs=[ 'data-transparent-turn-toggle-bound', 'data-anchor-scene-live-owner', 'data-anchor-stream-id', + // Defensive reset for legacy/restored shells that may still carry the fallback live-turn marker. 'data-live-assistant-turn', ]; let _scrollbarDragActive=false; diff --git a/tests/test_issue4793_dom_recycle_hardening.py b/tests/test_issue4793_dom_recycle_hardening.py index 1e3cf23c..76e32b3d 100644 --- a/tests/test_issue4793_dom_recycle_hardening.py +++ b/tests/test_issue4793_dom_recycle_hardening.py @@ -105,20 +105,17 @@ console.log(JSON.stringify({ """ result = _run_node(script) - assert result["resetAttrs"] == [ + expected_attrs = { "data-transparent-turn-collapsed", "data-transparent-turn-toggle-bound", "data-anchor-scene-live-owner", "data-anchor-stream-id", "data-live-assistant-turn", - ] - assert result["removedAttrs"] == [ - "data-transparent-turn-collapsed", - "data-transparent-turn-toggle-bound", - "data-anchor-scene-live-owner", - "data-anchor-stream-id", - "data-live-assistant-turn", - ] + } + assert set(result["resetAttrs"]) == expected_attrs + assert len(result["resetAttrs"]) == len(expected_attrs) + assert set(result["removedAttrs"]) == expected_attrs + assert len(result["removedAttrs"]) == len(expected_attrs) assert result["currentAssistantTurnIsRecycled"] is True assert result["dataset"] == { "role": "assistant",