"""Browserless regression for transparent stream live row reconciliation.""" import json import os import shutil import subprocess from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] NODE = shutil.which("node") def _run_node_script(script, ui_js_path=None): assert NODE, "node is required for DOM-executed anchor render tests" env = os.environ.copy() if ui_js_path is not None: env["UI_JS_PATH"] = ui_js_path result = subprocess.run([NODE, "-e", script], env=env, text=True, capture_output=True, check=False) assert result.returncode == 0, result.stderr return json.loads(result.stdout) def test_transparent_thinking_scroll_container_reserves_gutter(): css = (ROOT / "static" / "style.css").read_text(encoding="utf-8") # Find the transparent thinking scroll container block and verify scrollbar-gutter:stable is present. # There are multiple blocks with this selector — check that at least one has the property. import re selector = ".transparent-event-row .thinking-card.open .thinking-card-body{" pattern = re.escape(selector) + r'([\s\S]*?)\}' for match in re.finditer(pattern, css): if "scrollbar-gutter:stable" in match.group(1): return # Test passes — found the property in a matching block assert False, f"scrollbar-gutter:stable not found in any {selector} block" @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_transparent_live_scene_reuses_matching_rows_and_removes_stale_rows(): script = """ const fs = require('fs'); const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8'); function extractFunc(name){{ const marker = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); const start = src.search(marker); if(start < 0) throw new Error(name + ' not found'); let i = src.indexOf('{{', start) + 1; let depth = 1; while(depth > 0 && i < src.length){{ if(src[i] === '{{') depth += 1; else if(src[i] === '}}') depth -= 1; i += 1; }} return src.slice(start, i); }} class FakeElement {{ static moves = 0; constructor(tag='div'){{ this.tagName = String(tag).toUpperCase(); this.children = []; this.parentNode = null; this.attributes = Object.create(null); this.dataset = Object.create(null); this.style = Object.create(null); this.hidden = false; this.id = ''; this._textContent = ''; this._innerHTML = ''; this._classes = new Set(); const self = this; this.classList = {{ add(...names){{ names.forEach(name=>self._classes.add(name)); }}, remove(...names){{ names.forEach(name=>self._classes.delete(name)); }}, contains(name){{ return self._classes.has(name); }}, toggle(name, force){{ if(force === true){{ self._classes.add(name); return true; }} if(force === false){{ self._classes.delete(name); return false; }} if(self._classes.has(name)){{ self._classes.delete(name); return false; }} self._classes.add(name); return true; }}, }}; }} get parentElement(){{ return this.parentNode; }} get firstChild(){{ return this.children[0]||null; }} get nextSibling(){{ if(!this.parentNode) return null; const siblings = this.parentNode.children; const idx = siblings.indexOf(this); return idx >= 0 ? (siblings[idx + 1] || null) : null; }} get className(){{ return Array.from(this._classes).join(' '); }} set className(value){{ this._classes = new Set(String(value).trim().split(/\\s+/).filter(Boolean)); }} get textContent(){{ return this._textContent; }} set textContent(value){{ this._textContent = String(value ?? ''); this._innerHTML = this._textContent; this.children = []; }} get innerHTML(){{ return this._innerHTML; }} set innerHTML(value){{ this._innerHTML = String(value ?? ''); this._textContent = this._innerHTML; this.children = []; }} setAttribute(name, value){{ const key = String(name); const val = String(value); this.attributes[key] = val; if(key === 'id') this.id = val; if(key.startsWith('data-')){{ const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); this.dataset[dataKey] = val; }} if(key === 'class'){ this.className = val; }} }} getAttribute(name){{ return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; }} getAttributeNames(){{ return Object.keys(this.attributes); }} removeAttribute(name){{ delete this.attributes[name]; if(name === 'id') this.id = ''; if(name.startsWith('data-')){{ const dataKey = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); delete this.dataset[dataKey]; }} if(name === 'class'){ this._classes = new Set(); }} }} appendChild(child){{ this.appendChildCount = (this.appendChildCount || 0) + 1; if(child && child.tagName === '#FRAGMENT'){{ child.children.slice().forEach(grandchild=>this.appendChild(grandchild)); child.children = []; return child; }} if(child && child.parentNode){{ FakeElement.moves += 1; child.remove(); }} if(!child) return null; child.parentNode = this; this.children.push(child); return child; }} insertBefore(child, refNode){{ this.insertBeforeCount = (this.insertBeforeCount || 0) + 1; if(child && child.parentNode){{ FakeElement.moves += 1; child.remove(); }} if(!child) return null; const idx = this.children.indexOf(refNode); child.parentNode = this; if(idx < 0) this.children.push(child); else this.children.splice(idx, 0, child); return child; }} remove(){{ if(!this.parentNode) return; const siblings = this.parentNode.children; const idx = siblings.indexOf(this); if(idx >= 0) siblings.splice(idx, 1); this.parentNode = null; }} matches(selector){{ return matchesSelector(this, selector); }} querySelector(selector){{ return this.querySelectorAll(selector)[0] || null; }} querySelectorAll(selector){{ const out = []; const walk = (node)=>{ for(const child of node.children){ if(matchesSelector(child, selector)) out.push(child); walk(child); } }; walk(this); return out; }} closest(selector){{ let node = this; while(node){ if(matchesSelector(node, selector)) return node; node = node.parentNode; }} return null; }} }} function matchesSelector(el, selector){{ if(!selector) return false; const options = selector.split(',').map(part=>part.trim()).filter(Boolean); return options.some(part=>matchesSimple(el, part)); }} function matchesSimple(el, selector){{ selector = selector.replace(/^:scope\\s*>\\s*/, '').trim(); if(!selector) return false; const idMatch = selector.match(/#([^.\\[#]+)/); if(idMatch && el.id !== idMatch[1]) return false; const clsMatches = selector.match(/\\.([A-Za-z0-9_-]+)/g) || []; for(const cls of clsMatches){{ const name = cls.slice(1); if(!el.classList.contains(name)) return false; }} const attrMatches = selector.match(/\\[([^=\\]]+)(?:=\\"([^\\"]*)\\")?\\]/g) || []; for(const attrMatch of attrMatches){{ const [, name, expected] = attrMatch.match(/\\[([^=\\]]+)(?:=\\"([^\\"]*)\\")?\\]/); const value = el.getAttribute(name); if(value === null) return false; if(expected !== undefined && String(value) !== String(expected)) return false; }} return !!(idMatch || clsMatches.length || attrMatches.length); }} global.window = {{}}; global.document = {{ createElement:(tag)=>new FakeElement(tag), createTextNode:(text)=>{{ const node = new FakeElement('#text'); node.textContent = text; return node; }}, createDocumentFragment:()=>new FakeElement('#fragment'), }}; global.CSS = {{ escape:(value)=>String(value) }}; global.requestAnimationFrame = (fn)=>fn(); global.S = {{ session:{{ session_id: 'session-1', pending_started_at: 123 }}, activeStreamId:'stream-1' }}; global._captureMessageScrollSnapshot = () => ({{ scrollHeight: 1000 }}); global._prepareLiveAnchorScrollRebuildGuard = () => ({{ readerAwayFromBottom:false, release:null }}); global._restoreMessageScrollSnapshotSameFrame = () => {{}}; global.scrollIfPinned = () => {{}}; global._moveLiveRunStatusToTurnEnd = () => {{}}; global._messageUserUnpinned = false; global._syncTransparentEventControls = () => {{}}; global._anchorSceneRowsForRendering = (scene) => scene && scene.activity_rows || []; global._anchorSceneNodeForRow = (row) => {{ const node = new FakeElement('div'); node.classList.add('assistant-segment'); node.textContent = String(row && (row.text || row.thinking&&row.thinking.text || '') || ''); return node; }}; global._decorateTransparentEventRow = (node, opts) => {{ node.classList.add('transparent-event-row'); node.setAttribute('data-transparent-event-row','1'); if(opts && Object.prototype.hasOwnProperty.call(opts,'type')) node.setAttribute('data-event-type', opts.type); if(opts && Object.prototype.hasOwnProperty.call(opts,'text')) node.setAttribute('data-text', opts.text); if(opts && Object.prototype.hasOwnProperty.call(opts,'status')) node.setAttribute('data-event-status', opts.status); return node; }}; global._thinkingActivityNode = (text)=>{{ const node = new FakeElement('div'); node.classList.add('agent-activity-thinking'); node.textContent = text || ''; return node; }}; global._anchorSceneToolCallFromRow = (row) => ({{ name:(row.tool && row.tool.name) || row.tool_name || 'tool', done:true }}); global._autoCompressionWorklogNode = () => new FakeElement('div'); global._autoCompressionPreviewText = () => 'preview'; global._transparentToolStatus = () => 'done'; global.buildToolCard = () => {{ const node = new FakeElement('div'); node.classList.add('tool-card-row'); return node; }}; const emptyState = new FakeElement('div'); const msgInner = new FakeElement('div'); const messages = new FakeElement('div'); const turn = new FakeElement('div'); turn.id = 'liveAssistantTurn'; const liveRunStatus = new FakeElement('div'); liveRunStatus.id = 'liveRunStatus'; msgInner.appendChild(turn); turn.appendChild(liveRunStatus); global.document._findById = (id) => id === 'emptyState' ? emptyState : id === 'msgInner' ? msgInner : id === 'messages' ? messages : id === 'liveAssistantTurn' ? turn : null; global.$ = (id)=>global.document._findById(id); global._createAssistantTurn = () => turn; global._assistantTurnBlocks = () => turn; global._anchorSceneTransparentNodeForRow = (row) => null; eval(extractFunc('_anchorSceneLiveTokenFinalPrefix')); eval(extractFunc('_anchorSceneTransparentNodeForRow')); eval(extractFunc('_transparentLiveRowKey')); eval(extractFunc('_transparentLiveRowsCompatible')); eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_bindTransparentFadeCleanup')); eval(extractFunc('_appendTransparentFadeText')); eval(extractFunc('_refreshTransparentFadeProseRow')); eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild')); eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); const firstScene = {{ version:'activity_scene_v1', activity_rows:[ {{ row_id:'row-kept', role:'prose', source_event_type:'process_prose', text:'first progress line' }}, {{ row_id:'row-stale', role:'prose', source_event_type:'process_prose', text:'will be removed' }}, ], }}; const secondScene = {{ version:'activity_scene_v1', activity_rows:[ {{ row_id:'row-kept', role:'prose', source_event_type:'process_prose', text:'updated progress line' }}, {{ row_id:'row-new', role:'prose', source_event_type:'process_prose', text:'new row appears' }}, ], }}; const thirdScene = {{ version:'activity_scene_v1', activity_rows:[ {{ role:'prose', source_event_type:'process_prose', text:'keyless row first' }}, ], }}; const fourthScene = {{ version:'activity_scene_v1', activity_rows:[ {{ role:'prose', source_event_type:'process_prose', text:'keyless row second' }}, ], }}; const firstRender = _renderLiveAnchorActivitySceneTransparent('stream-1', firstScene, {{ sessionId:'session-1' }}); const keptAfterFirst = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-kept\"]'); const staleAfterFirst = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-stale\"]'); const firstFooter = turn.querySelector('#liveRunStatus'); turn.insertBeforeCount = 0; turn.appendChildCount = 0; const secondRender = _renderLiveAnchorActivitySceneTransparent('stream-1', secondScene, {{ sessionId:'session-1' }}); const secondInsertBeforeCount = turn.insertBeforeCount || 0; const secondAppendChildCount = turn.appendChildCount || 0; const keptAfterSecond = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-kept\"]'); const staleAfterSecond = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-stale\"]'); const newAfterSecond = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-new\"]'); const rows = turn.children.filter((child) => child.classList.contains('transparent-event-row')); const movesBeforeStableRender = FakeElement.moves; const stableOrderRender = _renderLiveAnchorActivitySceneTransparent('stream-1', secondScene, {{ sessionId:'session-1' }}); const movesAfterStableRender = FakeElement.moves; const idxs = {{ keptDirect: turn.children.indexOf(keptAfterSecond), freshDirect: turn.children.indexOf(newAfterSecond), footerDirect: turn.children.indexOf(firstFooter), staleInVisibleRows: rows.findIndex((child) => child.getAttribute('data-anchor-row-id') === 'row-stale'), rowKeeps: rows.indexOf(keptAfterSecond), rowNew: rows.indexOf(newAfterSecond), stale: rows.findIndex((child) => child.getAttribute('data-anchor-row-id') === 'row-stale'), }}; turn.insertBeforeCount = 0; turn.appendChildCount = 0; _renderLiveAnchorActivitySceneTransparent('stream-1', secondScene, {{ sessionId:'session-1' }}); const stableInsertBeforeCount = turn.insertBeforeCount || 0; const stableAppendChildCount = turn.appendChildCount || 0; const reorderedScene = {{ version:'activity_scene_v1', activity_rows:[ {{ row_id:'row-new', role:'prose', source_event_type:'process_prose', text:'new row appears' }}, {{ row_id:'row-kept', role:'prose', source_event_type:'process_prose', text:'updated progress line' }}, ], }}; turn.insertBeforeCount = 0; turn.appendChildCount = 0; _renderLiveAnchorActivitySceneTransparent('stream-1', reorderedScene, {{ sessionId:'session-1' }}); const reorderInsertBeforeCount = turn.insertBeforeCount || 0; const reorderedIds = turn.children .filter((child) => child.classList.contains('transparent-event-row')) .map((child) => child.getAttribute('data-anchor-row-id')); firstFooter.remove(); turn.insertBefore(firstFooter, turn.children[0] || null); turn.insertBeforeCount = 0; turn.appendChildCount = 0; _renderLiveAnchorActivitySceneTransparent('stream-1', reorderedScene, {{ sessionId:'session-1' }}); const footerRepairInsertBeforeCount = turn.insertBeforeCount || 0; const footerRepairIdxs = {{ newRow: turn.children.indexOf(turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-new\"]')), keptRow: turn.children.indexOf(turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-kept\"]')), footer: turn.children.indexOf(firstFooter), }}; _renderLiveAnchorActivitySceneTransparent('stream-1', thirdScene, {{ sessionId:'session-1' }}); const keylessRowsAfterThird = turn.querySelectorAll('.transparent-event-row[data-anchor-row-id=\"\"]'); _renderLiveAnchorActivitySceneTransparent('stream-1', fourthScene, {{ sessionId:'session-1' }}); const keylessRowsAfterFourth = turn.querySelectorAll('.transparent-event-row[data-anchor-row-id=\"\"]'); const fadeParent = new FakeElement('div'); const fadeExisting = new FakeElement('div'); fadeExisting.className = 'assistant-segment transparent-event-row'; fadeExisting.setAttribute('data-anchor-row-role', 'prose'); fadeExisting.setAttribute('data-anchor-row-id', 'fade-row'); fadeExisting.setAttribute('data-anchor-source-event-type', 'process_prose'); const staleBody = new FakeElement('div'); staleBody.className = 'msg-body stream-fade-active'; const oldSpan = new FakeElement('span'); oldSpan.className = 'stream-fade-word is-new'; oldSpan.textContent = 'old'; staleBody.appendChild(oldSpan); staleBody.appendChild((()=>{{ const n = new FakeElement('#text'); n.textContent = ' '; return n; }})()); fadeExisting.appendChild(staleBody); fadeExisting.setAttribute('data-stream-fade-text', 'old '); fadeParent.appendChild(fadeExisting); const fadeCandidate = new FakeElement('div'); fadeCandidate.className = 'assistant-segment transparent-event-row'; fadeCandidate.setAttribute('data-anchor-row-role', 'prose'); fadeCandidate.setAttribute('data-anchor-row-id', 'fade-row'); fadeCandidate.setAttribute('data-anchor-source-event-type', 'process_prose'); fadeCandidate.dataset.rawText = 'old new'; const fadeBody = new FakeElement('div'); fadeBody.className = 'msg-body stream-fade-active'; const fadeSpan = new FakeElement('span'); fadeSpan.className = 'stream-fade-word is-new'; fadeSpan.textContent = 'old'; fadeBody.appendChild(fadeSpan); fadeBody.appendChild((()=>{{ const n = new FakeElement('#text'); n.textContent = ' new'; return n; }})()); fadeCandidate.appendChild(fadeBody); const fadeRefresh = _refreshTransparentLiveRow(fadeExisting, fadeCandidate); const fadeSpans = fadeParent.querySelectorAll('.stream-fade-word.is-new'); process.stdout.write(JSON.stringify({{ firstRender, secondRender, stableOrderRender, sameNode: keptAfterFirst === keptAfterSecond, stableOrderMovedRows: movesAfterStableRender - movesBeforeStableRender, keptId: keptAfterSecond && keptAfterSecond.getAttribute('data-anchor-row-id'), keptSource: keptAfterSecond && keptAfterSecond.getAttribute('data-anchor-source-event-type'), keptText: keptAfterSecond && keptAfterSecond.textContent, staleGone: staleAfterSecond === null, staleAfterFirst: staleAfterFirst !== null, idxs, secondInsertBeforeCount, secondAppendChildCount, stableInsertBeforeCount, stableAppendChildCount, reorderInsertBeforeCount, reorderedIds, footerRepairInsertBeforeCount, footerRepairIdxs, hasNewRow: !!newAfterSecond, newRowSession: newAfterSecond && newAfterSecond.getAttribute('data-session-id'), keylessAfterThird: keylessRowsAfterThird.length, keylessAfterFourth: keylessRowsAfterFourth.length, keylessTextsAfterFourth: keylessRowsAfterFourth.map((child) => child.textContent), totalRowsAfterFourth: turn.children.filter((child) => child.classList.contains('transparent-event-row')).length, fadeKeptExisting: fadeRefresh === fadeExisting, fadeExistingStillParented: fadeExisting.parentNode === fadeParent, fadeCandidateDetached: fadeCandidate.parentNode === null, fadeOldSpanPreserved: fadeSpans[0] === oldSpan, fadeSpanTexts: fadeSpans.map(span=>span.textContent), fadeText: fadeExisting.getAttribute('data-stream-fade-text'), fadeSpanPreserved: !!fadeParent.querySelector('.stream-fade-word.is-new'), fadeBodyActive: !!fadeParent.querySelector('.msg-body.stream-fade-active'), }})); """ script = script.replace("{{", "{").replace("}}", "}") data = _run_node_script(script, str(ROOT / "static" / "ui.js")) assert data["firstRender"] is True assert data["secondRender"] is True assert data["stableOrderRender"] is True assert data["sameNode"] is True assert data["stableOrderMovedRows"] == 0 assert data["keptId"] == "row-kept" assert data["keptSource"] == "process_prose" assert data["keptText"] == "updated progress line" assert data["staleGone"] is True assert data["staleAfterFirst"] is True assert data["idxs"]["keptDirect"] == 0 assert data["idxs"]["freshDirect"] == 1 assert data["idxs"]["footerDirect"] > data["idxs"]["freshDirect"] assert data["idxs"]["freshDirect"] < data["idxs"]["footerDirect"] assert data["idxs"]["rowKeeps"] == 0 assert data["idxs"]["rowNew"] == 1 assert data["idxs"]["stale"] == -1 assert data["idxs"]["staleInVisibleRows"] == -1 assert data["secondInsertBeforeCount"] >= 1 assert data["secondAppendChildCount"] == 0 assert data["stableInsertBeforeCount"] == 0 assert data["stableAppendChildCount"] == 0 assert data["reorderInsertBeforeCount"] >= 1 assert data["reorderedIds"] == ["row-new", "row-kept"] assert data["footerRepairInsertBeforeCount"] >= 1 assert data["footerRepairIdxs"]["newRow"] == 0 assert data["footerRepairIdxs"]["keptRow"] == 1 assert data["footerRepairIdxs"]["footer"] > data["footerRepairIdxs"]["keptRow"] assert data["hasNewRow"] is True assert data["newRowSession"] == "session-1" assert data["keylessAfterThird"] == 1 assert data["keylessAfterFourth"] == 1 assert data["keylessTextsAfterFourth"] == ["keyless row second"] assert data["totalRowsAfterFourth"] == 1 assert data["fadeKeptExisting"] is True assert data["fadeExistingStillParented"] is True assert data["fadeCandidateDetached"] is True assert data["fadeOldSpanPreserved"] is True assert data["fadeSpanTexts"] == ["old", "new"] assert data["fadeText"] == "old new" assert data["fadeSpanPreserved"] is True assert data["fadeBodyActive"] is True @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_transparent_live_row_refresh_rebinds_controls_and_carries_tool_data(): script = r""" const fs = require('fs'); const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8'); function extractFunc(name){ const marker = new RegExp('function\\s+' + name + '\\s*\\('); const start = src.search(marker); if(start < 0) throw new Error(name + ' not found'); let i = src.indexOf('{', start) + 1; let depth = 1; while(depth > 0 && i < src.length){ if(src[i] === '{') depth += 1; else if(src[i] === '}') depth -= 1; i += 1; } return src.slice(start, i); } class FakeElement { constructor(tag='div'){ this.tagName = String(tag).toUpperCase(); this.children = []; this.parentNode = null; this.attributes = Object.create(null); this.dataset = Object.create(null); this.style = Object.create(null); this.hidden = false; this.id = ''; this.onclick = null; this.onkeydown = null; this.title = ''; this._textContent = ''; this._innerHTML = ''; this._classes = new Set(); const self = this; this.classList = { add(...names){ names.forEach(name=>self._classes.add(name)); }, remove(...names){ names.forEach(name=>self._classes.delete(name)); }, contains(name){ return self._classes.has(name); }, toggle(name, force){ const next = force === undefined ? !self._classes.has(name) : !!force; if(next) self._classes.add(name); else self._classes.delete(name); return next; }, }; } get parentElement(){ return this.parentNode; } get firstChild(){ return this.children[0]||null; } get className(){ return Array.from(this._classes).join(' '); } set className(value){ this._classes = new Set(String(value).trim().split(/\s+/).filter(Boolean)); } get textContent(){ if(this.children.length) return this.children.map(child=>child.textContent).join(''); return this._textContent; } set textContent(value){ this._textContent = String(value ?? ''); this._innerHTML = this._textContent; this.children = []; } get innerHTML(){ return this._innerHTML; } set innerHTML(value){ this._innerHTML = String(value ?? ''); this._textContent = this._innerHTML; this.children = []; if(this._innerHTML.indexOf('tool-template') >= 0) buildToolTemplate(this); } setAttribute(name, value){ const key = String(name); const val = String(value); this.attributes[key] = val; if(key === 'id') this.id = val; if(key.startsWith('data-')){ const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); this.dataset[dataKey] = val; } if(key === 'class') this.className = val; } getAttribute(name){ return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; } getAttributeNames(){ return Object.keys(this.attributes); } removeAttribute(name){ delete this.attributes[name]; if(name === 'id') this.id = ''; if(name.startsWith('data-')){ const dataKey = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); delete this.dataset[dataKey]; } if(name === 'class') this._classes = new Set(); } appendChild(child){ if(child && child.parentNode) child.remove(); if(!child) return null; child.parentNode = this; this.children.push(child); return child; } insertBefore(child, refNode){ if(child && child.parentNode) child.remove(); if(!child) return null; const idx = this.children.indexOf(refNode); child.parentNode = this; if(idx < 0) this.children.push(child); else this.children.splice(idx, 0, child); return child; } remove(){ if(!this.parentNode) return; const siblings = this.parentNode.children; const idx = siblings.indexOf(this); if(idx >= 0) siblings.splice(idx, 1); this.parentNode = null; } matches(selector){ return matchesSelector(this, selector); } querySelector(selector){ return this.querySelectorAll(selector)[0] || null; } querySelectorAll(selector){ const out = []; const walk = (node)=>{ for(const child of node.children){ if(matchesSelector(child, selector)) out.push(child); walk(child); } }; walk(this); return out; } closest(selector){ let node = this; while(node){ if(matchesSelector(node, selector)) return node; node = node.parentNode; } return null; } } function matchesSelector(el, selector){ if(!selector) return false; const options = selector.split(',').map(part=>part.trim()).filter(Boolean); return options.some(part=>matchesSimple(el, part)); } function matchesSimple(el, selector){ selector = selector.replace(/^:scope\s*>\s*/, '').trim(); if(!selector) return false; const idMatch = selector.match(/#([^\.\[#\s]+)/); if(idMatch && el.id !== idMatch[1]) return false; const clsMatches = selector.match(/\.([A-Za-z0-9_-]+)/g) || []; for(const cls of clsMatches){ if(!el.classList.contains(cls.slice(1))) return false; } const attrMatches = selector.match(/\[([^=\]]+)(?:=\"([^\"]*)\")?\]/g) || []; for(const attrMatch of attrMatches){ const [, name, expected] = attrMatch.match(/\[([^=\]]+)(?:=\"([^\"]*)\")?\]/); const value = el.getAttribute(name); if(value === null) return false; if(expected !== undefined && String(value) !== String(expected)) return false; } return !!(idMatch || clsMatches.length || attrMatches.length); } function el(tag, classes){ const node = new FakeElement(tag); String(classes||'').split(/\s+/).filter(Boolean).forEach(cls=>node.classList.add(cls)); return node; } function buildToolTemplate(row){ const card = el('div', 'tool-card transparent-event-card'); const header = el('div', 'tool-card-header'); const name = el('span', 'tool-card-name'); name.textContent = 'shell'; const copy = el('span', 'transparent-event-copy'); const toggle = el('span', 'tool-card-toggle'); const detail = el('div', 'tool-card-detail'); detail.setAttribute('data-transparent-detail-mode', 'full'); const tabs = el('div', 'transparent-detail-modes'); const full = el('span', 'transparent-detail-mode active'); full.setAttribute('data-mode', 'full'); const output = el('span', 'transparent-detail-mode'); output.setAttribute('data-mode', 'output'); const args = el('div', 'tool-card-args'); const result = el('div', 'tool-card-result'); const pre = el('pre', ''); pre.textContent = 'fresh output'; result.appendChild(pre); tabs.appendChild(full); tabs.appendChild(output); detail.appendChild(tabs); detail.appendChild(args); detail.appendChild(result); header.appendChild(name); header.appendChild(copy); header.appendChild(toggle); card.appendChild(header); card.appendChild(detail); row.appendChild(card); } const copied = []; Object.defineProperty(global, 'navigator', { configurable: true, value: { clipboard: { writeText: (text)=>{ copied.push(text); return { then(fn){ fn(); return { catch(){} }; } }; } } }, }); global.document = { createElement:(tag)=>new FakeElement(tag), body:new FakeElement('body'), execCommand:()=>true }; global.t = (key)=>key === 'copy' ? 'Copy' : key; global.showToast = ()=>{}; eval(extractFunc('_copyEventToClipboard')); eval(extractFunc('_attachCopyButton')); eval(extractFunc('_setTransparentCardOpen')); eval(extractFunc('_wireTransparentHeaderToggle')); eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); const existing = new FakeElement('div'); existing.classList.add('transparent-event-row'); existing.setAttribute('data-anchor-row-id', 'row-tool'); existing.setAttribute('data-expanded', '1'); existing.innerHTML = 'tool-template-old'; existing._tcData = { name:'old_tool', args:{ stale:true }, snippet:'old output' }; const oldCard = existing.querySelector('.tool-card'); oldCard.classList.add('open'); const oldDetail = existing.querySelector('.tool-card-detail'); oldDetail.setAttribute('data-transparent-detail-mode', 'output'); const candidate = new FakeElement('div'); candidate.classList.add('transparent-event-row'); candidate.setAttribute('data-anchor-row-id', 'row-tool'); candidate.setAttribute('data-event-type', 'tool'); candidate.innerHTML = 'tool-template-new'; candidate._tcData = { name:'fresh_tool', args:{ cmd:'ls' }, snippet:'fresh output' }; const refreshed = _refreshTransparentLiveRow(existing, candidate); const header = refreshed.querySelector('.tool-card-header'); const copy = refreshed.querySelector('.transparent-event-copy'); const card = refreshed.querySelector('.tool-card'); const detail = refreshed.querySelector('.tool-card-detail'); const outputTab = refreshed.querySelector('.transparent-detail-mode[data-mode="output"]'); const fullTab = refreshed.querySelector('.transparent-detail-mode[data-mode="full"]'); const initiallyOpenAfterRefresh = card.classList.contains('open'); const ariaExpandedAfterRefresh = header.getAttribute('aria-expanded'); const detailMode = detail.getAttribute('data-transparent-detail-mode'); const outputActive = outputTab.classList.contains('active'); const fullActive = fullTab.classList.contains('active'); copy.onclick({ stopPropagation(){}, preventDefault(){}, target: copy, }); header.onclick({ target: header, preventDefault(){}, }); const afterClickOpen = card.classList.contains('open'); const afterClickExpanded = refreshed.getAttribute('data-expanded'); header.onkeydown({ key: 'Enter', target: header, preventDefault(){}, }); process.stdout.write(JSON.stringify({ sameNode: refreshed === existing, copiedText: copied[0], existingToolName: refreshed._tcData && refreshed._tcData.name, candidateHasToolData: Object.prototype.hasOwnProperty.call(candidate, '_tcData'), copyBound: typeof copy.onclick === 'function' && typeof copy.onkeydown === 'function', headerBound: typeof header.onclick === 'function' && typeof header.onkeydown === 'function', initiallyOpenAfterRefresh, ariaExpandedAfterRefresh, detailMode, outputActive, fullActive, afterClickOpen, afterClickExpanded, afterKeyOpen: card.classList.contains('open'), afterKeyExpanded: refreshed.getAttribute('data-expanded'), })); """ data = _run_node_script(script, str(ROOT / "static" / "ui.js")) assert data["sameNode"] is True assert data["copyBound"] is True assert data["headerBound"] is True assert data["existingToolName"] == "fresh_tool" assert data["candidateHasToolData"] is False assert "tool: fresh_tool" in data["copiedText"] assert '"cmd": "ls"' in data["copiedText"] assert "fresh output" in data["copiedText"] assert data["initiallyOpenAfterRefresh"] is True assert data["ariaExpandedAfterRefresh"] == "true" assert data["detailMode"] == "output" assert data["outputActive"] is True assert data["fullActive"] is False assert data["afterClickOpen"] is False assert data["afterClickExpanded"] == "0" assert data["afterKeyOpen"] is True assert data["afterKeyExpanded"] == "1" @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_transparent_live_row_refresh_skips_unchanged_child_dom_and_carries_tool_data(): script = r""" const fs = require('fs'); const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8'); function extractFunc(name){ const marker = new RegExp('function\\s+' + name + '\\s*\\('); const start = src.search(marker); if(start < 0) throw new Error(name + ' not found'); let i = src.indexOf('{', start) + 1; let depth = 1; while(depth > 0 && i < src.length){ if(src[i] === '{') depth += 1; else if(src[i] === '}') depth -= 1; i += 1; } return src.slice(start, i); } class FakeElement { constructor(tag='div'){ this.tagName = String(tag).toUpperCase(); this.children = []; this.parentNode = null; this.attributes = Object.create(null); this.dataset = Object.create(null); this.style = Object.create(null); this.id = ''; this.onclick = null; this.onkeydown = null; this._textContent = ''; this._innerHTML = ''; this.innerHTMLSetCount = 0; this._classes = new Set(); const self = this; this.classList = { add(...names){ names.forEach(name=>self._classes.add(name)); }, remove(...names){ names.forEach(name=>self._classes.delete(name)); }, contains(name){ return self._classes.has(name); }, toggle(name, force){ const next = force === undefined ? !self._classes.has(name) : !!force; if(next) self._classes.add(name); else self._classes.delete(name); return next; }, }; } get parentElement(){ return this.parentNode; } get firstChild(){ return this.children[0]||null; } get className(){ return Array.from(this._classes).join(' '); } set className(value){ this._classes = new Set(String(value).trim().split(/\s+/).filter(Boolean)); } get textContent(){ return this.children.length ? this.children.map(child=>child.textContent).join('') : this._textContent; } set textContent(value){ this._textContent = String(value ?? ''); this._innerHTML = this._textContent; this.children = []; } get innerHTML(){ return this._innerHTML; } set innerHTML(value){ this.innerHTMLSetCount += 1; this._innerHTML = String(value ?? ''); this._textContent = this._innerHTML; this.children = []; if(this._innerHTML.indexOf('tool-template') >= 0) buildToolTemplate(this); } setAttribute(name, value){ const key = String(name); const val = String(value); this.attributes[key] = val; if(key === 'id') this.id = val; if(key.startsWith('data-')){ const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); this.dataset[dataKey] = val; } if(key === 'class') this.className = val; } getAttribute(name){ return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; } getAttributeNames(){ return Object.keys(this.attributes); } removeAttribute(name){ delete this.attributes[name]; if(name === 'id') this.id = ''; if(name.startsWith('data-')){ const dataKey = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); delete this.dataset[dataKey]; } } appendChild(child){ child.parentNode = this; this.children.push(child); return child; } querySelector(selector){ return this.querySelectorAll(selector)[0] || null; } querySelectorAll(selector){ const out = []; const walk = (node)=>{ for(const child of node.children){ if(matchesSelector(child, selector)) out.push(child); walk(child); } }; walk(this); return out; } closest(selector){ let node = this; while(node){ if(matchesSelector(node, selector)) return node; node = node.parentNode; } return null; } } function matchesSelector(el, selector){ return String(selector||'').split(',').map(part=>part.trim()).filter(Boolean).some(part=>matchesSimple(el, part)); } function matchesSimple(el, selector){ const clsMatches = selector.match(/\.([A-Za-z0-9_-]+)/g) || []; for(const cls of clsMatches){ if(!el.classList.contains(cls.slice(1))) return false; } const attrMatches = selector.match(/\[([^=\]]+)(?:=\"([^\"]*)\")?\]/g) || []; for(const attrMatch of attrMatches){ const [, name, expected] = attrMatch.match(/\[([^=\]]+)(?:=\"([^\"]*)\")?\]/); const value = el.getAttribute(name); if(value === null) return false; if(expected !== undefined && String(value) !== String(expected)) return false; } return !!(clsMatches.length || attrMatches.length); } function el(tag, classes){ const node = new FakeElement(tag); String(classes||'').split(/\s+/).filter(Boolean).forEach(cls=>node.classList.add(cls)); return node; } function buildToolTemplate(row){ const card = el('div', 'tool-card transparent-event-card'); const header = el('div', 'tool-card-header'); const copy = el('span', 'transparent-event-copy'); const detail = el('div', 'tool-card-detail'); detail.setAttribute('data-transparent-detail-mode', 'full'); const full = el('span', 'transparent-detail-mode active'); full.setAttribute('data-mode', 'full'); const output = el('span', 'transparent-detail-mode'); output.setAttribute('data-mode', 'output'); detail.appendChild(full); detail.appendChild(output); header.appendChild(copy); card.appendChild(header); card.appendChild(detail); row.appendChild(card); } global.document = { createElement:(tag)=>new FakeElement(tag) }; global.t = (key)=>key === 'copy' ? 'Copy' : key; eval(extractFunc('_setTransparentCardOpen')); eval(extractFunc('_attachCopyButton')); eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); const existing = new FakeElement('div'); existing.classList.add('transparent-event-row'); existing.setAttribute('data-anchor-row-id', 'row-tool'); existing.setAttribute('data-expanded', '1'); existing.innerHTML = 'tool-template-same'; existing.innerHTMLSetCount = 0; existing._tcData = { name:'old_tool' }; const header = existing.querySelector('.tool-card-header'); const copy = existing.querySelector('.transparent-event-copy'); header.children = header.children.filter((child)=>child !== copy); copy.parentNode = null; const headerClick = function headerClick(){}; const headerKeydown = function headerKeydown(){}; const copyClick = function copyClick(){}; header.onclick = headerClick; header.onkeydown = headerKeydown; copy.onclick = copyClick; const card = existing.querySelector('.tool-card'); card.classList.add('open'); const detail = existing.querySelector('.tool-card-detail'); detail.setAttribute('data-transparent-detail-mode', 'output'); const outputTab = existing.querySelector('.transparent-detail-mode[data-mode="output"]'); const fullTab = existing.querySelector('.transparent-detail-mode[data-mode="full"]'); outputTab.classList.add('active'); fullTab.classList.remove('active'); const candidate = new FakeElement('div'); candidate.classList.add('transparent-event-row'); candidate.setAttribute('data-anchor-row-id', 'row-tool'); candidate.setAttribute('data-event-type', 'tool'); candidate.innerHTML = 'tool-template-same'; candidate._tcData = { name:'fresh_tool', args:{ cmd:'pwd' }, snippet:'fresh output' }; const refreshed = _refreshTransparentLiveRow(existing, candidate); const countAfterCarry = existing.innerHTMLSetCount; const candidateHasToolDataAfterCarry = Object.prototype.hasOwnProperty.call(candidate, '_tcData'); const ownToolNameAfterCarry = existing._tcData && existing._tcData.name; const repairedCopiesAfterCarry = existing.querySelectorAll('.transparent-event-copy'); const repairedCopy = repairedCopiesAfterCarry[0] || null; const candidateWithoutData = new FakeElement('div'); candidateWithoutData.classList.add('transparent-event-row'); candidateWithoutData.setAttribute('data-anchor-row-id', 'row-tool'); candidateWithoutData.setAttribute('data-event-type', 'tool'); candidateWithoutData.innerHTML = 'tool-template-same'; _refreshTransparentLiveRow(existing, candidateWithoutData); const repairedCopiesAfterSecondRefresh = existing.querySelectorAll('.transparent-event-copy'); process.stdout.write(JSON.stringify({ sameNode: refreshed === existing, innerHTMLSetCount: existing.innerHTMLSetCount, countAfterCarry, ownToolNameAfterCarry, candidateHasToolDataAfterCarry, hasToolDataAfterClear: Object.prototype.hasOwnProperty.call(existing, '_tcData'), cardStillOpen: card.classList.contains('open'), rowExpanded: existing.getAttribute('data-expanded'), headerAria: header.getAttribute('aria-expanded'), detailMode: detail.getAttribute('data-transparent-detail-mode'), outputActive: outputTab.classList.contains('active'), fullActive: fullTab.classList.contains('active'), headerClickSurvived: header.onclick === headerClick, headerKeydownSurvived: header.onkeydown === headerKeydown, repairedCopyCountAfterCarry: repairedCopiesAfterCarry.length, repairedCopyCountAfterSecondRefresh: repairedCopiesAfterSecondRefresh.length, repairedCopyBound: !!(repairedCopy && typeof repairedCopy.onclick === 'function' && typeof repairedCopy.onkeydown === 'function'), oldMissingCopyDetached: copy.parentNode === null, })); """ data = _run_node_script(script, str(ROOT / "static" / "ui.js")) assert data["sameNode"] is True assert data["innerHTMLSetCount"] == 0 assert data["countAfterCarry"] == 0 assert data["ownToolNameAfterCarry"] == "fresh_tool" assert data["candidateHasToolDataAfterCarry"] is False assert data["hasToolDataAfterClear"] is False assert data["cardStillOpen"] is True assert data["rowExpanded"] == "1" assert data["headerAria"] == "true" assert data["detailMode"] == "output" assert data["outputActive"] is True assert data["fullActive"] is False assert data["headerClickSurvived"] is True assert data["headerKeydownSurvived"] is True assert data["repairedCopyCountAfterCarry"] == 1 assert data["repairedCopyCountAfterSecondRefresh"] == 1 assert data["repairedCopyBound"] is True assert data["oldMissingCopyDetached"] is True @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_transparent_live_scene_inline_fallback_skips_unchanged_child_dom(): script = r""" const fs = require('fs'); const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8'); function extractFunc(name){ const marker = new RegExp('function\\s+' + name + '\\s*\\('); const start = src.search(marker); if(start < 0) throw new Error(name + ' not found'); let i = src.indexOf('{', start) + 1; let depth = 1; while(depth > 0 && i < src.length){ if(src[i] === '{') depth += 1; else if(src[i] === '}') depth -= 1; i += 1; } return src.slice(start, i); } class FakeElement { constructor(tag='div'){ this.tagName = String(tag).toUpperCase(); this.children = []; this.parentNode = null; this.attributes = Object.create(null); this.dataset = Object.create(null); this.style = Object.create(null); this.hidden = false; this.id = ''; this.onclick = null; this._textContent = ''; this._innerHTML = ''; this.innerHTMLSetCount = 0; this._classes = new Set(); const self = this; this.classList = { add(...names){ names.forEach(name=>self._classes.add(name)); }, remove(...names){ names.forEach(name=>self._classes.delete(name)); }, contains(name){ return self._classes.has(name); }, toggle(name, force){ const next = force === undefined ? !self._classes.has(name) : !!force; if(next) self._classes.add(name); else self._classes.delete(name); return next; }, }; } get parentElement(){ return this.parentNode; } get firstChild(){ return this.children[0]||null; } get className(){ return Array.from(this._classes).join(' '); } set className(value){ this._classes = new Set(String(value).trim().split(/\s+/).filter(Boolean)); } get textContent(){ return this.children.length ? this.children.map(child=>child.textContent).join('') : this._textContent; } set textContent(value){ this._textContent = String(value ?? ''); this._innerHTML = this._textContent; this.children = []; } get innerHTML(){ return this._innerHTML; } set innerHTML(value){ this.innerHTMLSetCount += 1; this._innerHTML = String(value ?? ''); this._textContent = this._innerHTML; this.children = []; if(this._innerHTML.indexOf('tool-template') >= 0) buildToolTemplate(this); } setAttribute(name, value){ const key = String(name); const val = String(value); this.attributes[key] = val; if(key === 'id') this.id = val; if(key.startsWith('data-')){ const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); this.dataset[dataKey] = val; } } getAttribute(name){ return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; } getAttributeNames(){ return Object.keys(this.attributes); } removeAttribute(name){ delete this.attributes[name]; } appendChild(child){ if(child.parentNode) child.remove(); child.parentNode = this; this.children.push(child); return child; } insertBefore(child, refNode){ if(child.parentNode) child.remove(); const idx = this.children.indexOf(refNode); child.parentNode = this; if(idx < 0) this.children.push(child); else this.children.splice(idx, 0, child); return child; } remove(){ if(!this.parentNode) return; const siblings = this.parentNode.children; const idx = siblings.indexOf(this); if(idx >= 0) siblings.splice(idx, 1); this.parentNode = null; } querySelector(selector){ return this.querySelectorAll(selector)[0] || null; } querySelectorAll(selector){ const out = []; const walk = (node)=>{ for(const child of node.children){ if(matchesSelector(child, selector)) out.push(child); walk(child); } }; walk(this); return out; } closest(selector){ let node = this; while(node){ if(matchesSelector(node, selector)) return node; node = node.parentNode; } return null; } } function matchesSelector(el, selector){ return String(selector||'').split(',').map(part=>part.trim()).filter(Boolean).some(part=>matchesSimple(el, part)); } function matchesSimple(el, selector){ selector = selector.replace(/^:scope\s*>\s*/, '').trim(); const idMatch = selector.match(/#([^\.\[#\s]+)/); if(idMatch && el.id !== idMatch[1]) return false; const clsMatches = selector.match(/\.([A-Za-z0-9_-]+)/g) || []; for(const cls of clsMatches){ if(!el.classList.contains(cls.slice(1))) return false; } const attrMatches = selector.match(/\[([^=\]]+)(?:=\"([^\"]*)\")?\]/g) || []; for(const attrMatch of attrMatches){ const [, name, expected] = attrMatch.match(/\[([^=\]]+)(?:=\"([^\"]*)\")?\]/); const value = el.getAttribute(name); if(value === null) return false; if(expected !== undefined && String(value) !== String(expected)) return false; } return !!(idMatch || clsMatches.length || attrMatches.length); } function el(tag, classes){ const node = new FakeElement(tag); String(classes||'').split(/\s+/).filter(Boolean).forEach(cls=>node.classList.add(cls)); return node; } function buildToolTemplate(row){ const card = el('div', 'tool-card transparent-event-card'); const header = el('div', 'tool-card-header'); const copy = el('span', 'transparent-event-copy'); const detail = el('div', 'tool-card-detail'); detail.setAttribute('data-transparent-detail-mode', 'full'); header.appendChild(copy); card.appendChild(header); card.appendChild(detail); row.appendChild(card); } const emptyState = new FakeElement('div'); const msgInner = new FakeElement('div'); const messages = new FakeElement('div'); const turn = new FakeElement('div'); turn.id = 'liveAssistantTurn'; const liveRunStatus = new FakeElement('div'); liveRunStatus.id = 'liveRunStatus'; turn.appendChild(liveRunStatus); msgInner.appendChild(turn); global.window = {}; global.document = { createElement:(tag)=>new FakeElement(tag) }; global.requestAnimationFrame = (fn)=>fn(); global.S = { session:{ session_id:'session-1' }, activeStreamId:'stream-1' }; global.$ = (id)=>id === 'emptyState' ? emptyState : id === 'msgInner' ? msgInner : id === 'messages' ? messages : id === 'liveAssistantTurn' ? turn : null; global._createAssistantTurn = () => turn; global._assistantTurnBlocks = () => turn; global._captureMessageScrollSnapshot = () => ({ scrollHeight:1000 }); global._prepareLiveAnchorScrollRebuildGuard = () => ({ readerAwayFromBottom:false, release:null }); global._restoreMessageScrollSnapshotSameFrame = () => {}; global.scrollIfPinned = () => {}; global._moveLiveRunStatusToTurnEnd = () => {}; global._messageUserUnpinned = false; global._anchorSceneRowsForRendering = (scene) => scene.activity_rows || []; global._syncTransparentEventControls = () => {}; global._setTransparentCardOpen = (card, open) => { card.classList.toggle('open', !!open); const row = card.closest('.transparent-event-row'); if(row) row.setAttribute('data-expanded', open ? '1' : '0'); const header = card.querySelector('.tool-card-header'); if(header) header.setAttribute('aria-expanded', open ? 'true' : 'false'); }; global._attachCopyButton = (header) => header && header.querySelector('.transparent-event-copy'); global._anchorSceneTransparentNodeForRow = (row, opts) => { const node = new FakeElement('div'); node.classList.add('transparent-event-row'); node.setAttribute('data-anchor-scene-row','1'); node.setAttribute('data-anchor-live-scene-row','1'); node.setAttribute('data-live-stream-owned','1'); node.setAttribute('data-anchor-row-id', row.row_id); node.setAttribute('data-anchor-row-role', row.role); node.setAttribute('data-anchor-source-event-type', row.source_event_type); node.setAttribute('data-anchor-stream-id', opts.streamId); node.setAttribute('data-session-id', opts.sessionId); node.innerHTML = 'tool-template-same'; node._tcData = { name: row.toolName }; return node; }; eval(extractFunc('_transparentLiveRowKey')); eval(extractFunc('_transparentLiveRowsCompatible')); eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); const firstScene = { version:'activity_scene_v1', activity_rows:[{ row_id:'row-tool', role:'tool', source_event_type:'tool_delta', toolName:'old_tool' }] }; const secondScene = { version:'activity_scene_v1', activity_rows:[{ row_id:'row-tool', role:'tool', source_event_type:'tool_delta', toolName:'fresh_tool' }] }; _renderLiveAnchorActivitySceneTransparent('stream-1', firstScene, { sessionId:'session-1' }); const row = turn.querySelector('.transparent-event-row[data-anchor-row-id="row-tool"]'); row.innerHTMLSetCount = 0; row.setAttribute('data-expanded','1'); row.querySelector('.tool-card').classList.add('open'); const header = row.querySelector('.tool-card-header'); const copy = row.querySelector('.transparent-event-copy'); const headerClick = function headerClick(){}; const copyClick = function copyClick(){}; header.onclick = headerClick; copy.onclick = copyClick; _renderLiveAnchorActivitySceneTransparent('stream-1', secondScene, { sessionId:'session-1' }); process.stdout.write(JSON.stringify({ innerHTMLSetCount: row.innerHTMLSetCount, ownToolName: row._tcData && row._tcData.name, headerClickSurvived: header.onclick === headerClick, copyClickSurvived: copy.onclick === copyClick, cardStillOpen: row.querySelector('.tool-card').classList.contains('open'), rowExpanded: row.getAttribute('data-expanded'), headerAria: header.getAttribute('aria-expanded'), })); """ data = _run_node_script(script, str(ROOT / "static" / "ui.js")) assert data["innerHTMLSetCount"] == 0 assert data["ownToolName"] == "fresh_tool" assert data["headerClickSurvived"] is True assert data["copyClickSurvived"] is True assert data["cardStillOpen"] is True assert data["rowExpanded"] == "1" assert data["headerAria"] == "true" @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_transparent_fade_helper_clears_settled_rows_without_assigning_fade(): script = """ const fs = require('fs'); const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8'); function extractFunc(name){{ const marker = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); const start = src.search(marker); if(start < 0) throw new Error(name + ' not found'); let i = src.indexOf('{{', start) + 1; let depth = 1; while(depth > 0 && i < src.length){{ if(src[i] === '{{') depth += 1; else if(src[i] === '}}') depth -= 1; i += 1; }} return src.slice(start, i); }} const htmlRegistry = new Map(); class FakeElement {{ constructor(tag='div'){{ this.tagName = String(tag).toUpperCase(); this.children = []; this.parentNode = null; this.attributes = Object.create(null); this.dataset = Object.create(null); this.style = Object.create(null); this.hidden = false; this.id = ''; this._textContent = ''; this._innerHTML = ''; this._classes = new Set(); this.onclick = null; this.onkeydown = null; this.title = ''; const self = this; this.classList = {{ add(...names){{ names.forEach(name=>self._classes.add(name)); }}, remove(...names){{ names.forEach(name=>self._classes.delete(name)); }}, contains(name){{ return self._classes.has(name); }}, toggle(name, force){{ if(force === true){{ self._classes.add(name); return true; }} if(force === false){{ self._classes.delete(name); return false; }} if(self._classes.has(name)){{ self._classes.delete(name); return false; }} self._classes.add(name); return true; }}, }}; }} cloneNode(deep){{ const clone = new FakeElement(this.tagName); clone.className = this.className; clone._textContent = this._textContent; clone._innerHTML = this._innerHTML; clone.hidden = this.hidden; clone.id = this.id; clone.title = this.title; Object.entries(this.attributes).forEach(([name, value])=>clone.setAttribute(name, value)); if(Object.prototype.hasOwnProperty.call(this, '_tcData')) clone._tcData = this._tcData; if(deep){{ this.children.forEach(child=>clone.appendChild(child.cloneNode(true))); }} return clone; }} get parentElement(){{ return this.parentNode; }} get firstChild(){{ return this.children[0]||null; }} get className(){{ return Array.from(this._classes).join(' '); }} set className(value){{ this._classes = new Set(String(value).trim().split(/\\s+/).filter(Boolean)); }} get textContent(){{ if(this.children.length) return this.children.map(child=>child.textContent).join(''); return this._textContent; }} set textContent(value){{ this._textContent = String(value ?? ''); this._innerHTML = this._textContent; this.children = []; }} get innerHTML(){{ return this._innerHTML; }} set innerHTML(value){{ this._innerHTML = String(value ?? ''); this._textContent = ''; this.children = []; const factory = htmlRegistry.get(this._innerHTML); if(factory){{ factory().forEach(child=>this.appendChild(child.cloneNode(true))); return; }} this._textContent = this._innerHTML; }} setAttribute(name, value){{ const key = String(name); const val = String(value); this.attributes[key] = val; if(key === 'id') this.id = val; if(key.startsWith('data-')){{ const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); this.dataset[dataKey] = val; }} if(key === 'class') this.className = val; }} getAttribute(name){{ return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; }} getAttributeNames(){{ return Object.keys(this.attributes); }} removeAttribute(name){{ delete this.attributes[name]; if(name === 'id') this.id = ''; if(name.startsWith('data-')){{ const dataKey = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); delete this.dataset[dataKey]; }} if(name === 'class') this._classes = new Set(); }} appendChild(child){{ if(child && child.parentNode) child.remove(); if(!child) return null; child.parentNode = this; this.children.push(child); return child; }} insertBefore(child, refNode){{ if(child && child.parentNode) child.remove(); if(!child) return null; const idx = this.children.indexOf(refNode); child.parentNode = this; if(idx < 0) this.children.push(child); else this.children.splice(idx, 0, child); return child; }} remove(){{ if(!this.parentNode) return; const siblings = this.parentNode.children; const idx = siblings.indexOf(this); if(idx >= 0) siblings.splice(idx, 1); this.parentNode = null; }} matches(selector){{ return matchesSelector(this, selector); }} querySelector(selector){{ return this.querySelectorAll(selector)[0] || null; }} querySelectorAll(selector){{ const out = []; const walk = (node)=>{ for(const child of node.children){{ if(matchesSelector(child, selector)) out.push(child); walk(child); }} }; walk(this); return out; }} closest(selector){{ let node = this; while(node){{ if(matchesSelector(node, selector)) return node; node = node.parentNode; }} return null; }} }} function matchesSelector(el, selector){{ if(!selector) return false; const options = selector.split(',').map(part=>part.trim()).filter(Boolean); return options.some(part=>matchesSimple(el, part)); }} function matchesSimple(el, selector){{ selector = selector.replace(/^:scope\\s*>\\s*/, '').trim(); if(!selector) return false; const idMatch = selector.match(/#([^.\\[#]+)/); if(idMatch && el.id !== idMatch[1]) return false; const clsMatches = selector.match(/\\.([A-Za-z0-9_-]+)/g) || []; for(const cls of clsMatches){{ if(!el.classList.contains(cls.slice(1))) return false; }} const attrMatches = selector.match(/\\[([^=\\]]+)(?:=\\"([^\\"]*)\\")?\\]/g) || []; for(const attrMatch of attrMatches){{ const [, name, expected] = attrMatch.match(/\\[([^=\\]]+)(?:=\\"([^\\"]*)\\")?\\]/); const value = el.getAttribute(name); if(value === null) return false; if(expected !== undefined && String(value) !== String(expected)) return false; }} return !!(idMatch || clsMatches.length || attrMatches.length); }} function buildToolChildren(version){{ const card = new FakeElement('div'); card.className = 'tool-card open'; const header = new FakeElement('div'); header.className = 'tool-card-header'; const name = new FakeElement('span'); name.className = 'tool-card-name'; name.textContent = version === 'first' ? 'Shell old' : 'Shell new'; const toggle = new FakeElement('span'); toggle.className = 'tool-card-toggle'; toggle.textContent = '>'; header.appendChild(name); header.appendChild(toggle); const detail = new FakeElement('div'); detail.className = 'tool-card-detail'; detail.setAttribute('data-transparent-detail-mode', 'output'); const full = new FakeElement('span'); full.className = 'transparent-detail-mode'; full.setAttribute('data-mode', 'full'); full.textContent = 'Full'; const output = new FakeElement('span'); output.className = 'transparent-detail-mode active'; output.setAttribute('data-mode', 'output'); output.textContent = 'Output'; detail.appendChild(full); detail.appendChild(output); card.appendChild(header); card.appendChild(detail); return [card]; }} function toolPayload(version){{ return version === 'first' ? {{ name:'shell', args:{{ cmd:'echo old' }}, snippet:'old payload', done:false }} : {{ name:'shell', args:{{ cmd:'echo new' }}, snippet:'new payload', done:false }}; }} function makeToolRow(version){{ const token = version === 'first' ? '__tool_row_first__' : '__tool_row_second__'; htmlRegistry.set(token, ()=>buildToolChildren(version)); const row = new FakeElement('div'); row.className = 'transparent-event-row'; row.setAttribute('data-transparent-event-row', '1'); row.setAttribute('data-event-type', 'tool'); row.setAttribute('data-anchor-scene-row', '1'); row.setAttribute('data-anchor-live-scene-row', '1'); row.setAttribute('data-anchor-row-id', 'row-tool'); row.setAttribute('data-anchor-row-role', 'tool'); row.setAttribute('data-anchor-source-event-type', 'process_tool'); row.setAttribute('data-anchor-stream-id', 'stream-1'); row.setAttribute('data-session-id', 'session-1'); row.setAttribute('data-live-stream-owned', '1'); row.setAttribute('data-expanded', '1'); row.innerHTML = token; row._tcData = toolPayload(version); const header = row.querySelector('.tool-card-header'); const card = row.querySelector('.tool-card'); const detail = row.querySelector('.tool-card-detail'); _wireTransparentHeaderToggle(header); _attachCopyButton(header); _setTransparentCardOpen(card, true); detail.setAttribute('data-transparent-detail-mode', 'output'); detail.querySelectorAll('.transparent-detail-mode').forEach(el=>el.classList.toggle('active', el.getAttribute('data-mode') === 'output')); return row; }} global.window = {{}}; global.document = {{ body:new FakeElement('body'), createElement:(tag)=>new FakeElement(tag), execCommand:()=>true, }}; global.requestAnimationFrame = (fn)=>fn(); global.CSS = {{ escape:(value)=>String(value) }}; global.t = (key)=>key; global.showToast = () => {{}}; global.S = {{ session:{{ session_id:'session-1' }}, activeStreamId:'stream-1' }}; global._captureMessageScrollSnapshot = () => ({{ scrollHeight: 1000 }}); global._prepareLiveAnchorScrollRebuildGuard = () => ({{ readerAwayFromBottom:false, release:null }}); global._restoreMessageScrollSnapshotSameFrame = () => {{}}; global.scrollIfPinned = () => {{}}; global._moveLiveRunStatusToTurnEnd = () => {{}}; global._messageUserUnpinned = false; global._syncTransparentEventControls = () => {{}}; global._anchorSceneRowsForRendering = (scene) => scene && scene.activity_rows || []; const emptyState = new FakeElement('div'); const msgInner = new FakeElement('div'); const turn = new FakeElement('div'); turn.id = 'liveAssistantTurn'; const liveRunStatus = new FakeElement('div'); liveRunStatus.id = 'liveRunStatus'; msgInner.appendChild(turn); turn.appendChild(liveRunStatus); global.document._findById = (id) => id === 'emptyState' ? emptyState : id === 'msgInner' ? msgInner : id === 'liveAssistantTurn' ? turn : null; global.$ = (id)=>global.document._findById(id); global._createAssistantTurn = () => turn; global._assistantTurnBlocks = () => turn; global._anchorSceneTransparentNodeForRow = () => null; eval(extractFunc('_attachCopyButton')); eval(extractFunc('_setTransparentCardOpen')); eval(extractFunc('_wireTransparentHeaderToggle')); eval(extractFunc('_transparentLiveRowKey')); eval(extractFunc('_transparentLiveRowsCompatible')); eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); global._copyEventToClipboard = (row) => {{ const tc = row && row._tcData || {{}}; global.__copied = JSON.stringify({{ name: tc.name || '', cmd: tc.args && tc.args.cmd || '', snippet: tc.snippet || '', }}); }}; global._anchorSceneTransparentNodeForRow = (row) => makeToolRow(row.version); (async () => {{ const firstScene = {{ version:'activity_scene_v1', activity_rows:[{{ row_id:'row-tool', role:'tool', source_event_type:'process_tool', version:'first' }}], }}; const secondScene = {{ version:'activity_scene_v1', activity_rows:[{{ row_id:'row-tool', role:'tool', source_event_type:'process_tool', version:'second' }}], }}; _renderLiveAnchorActivitySceneTransparent('stream-1', firstScene, {{ sessionId:'session-1' }}); const firstRow = turn.querySelector('.transparent-event-row[data-anchor-row-id="row-tool"]'); const firstCard = firstRow.querySelector('.tool-card'); const firstDetail = firstRow.querySelector('.tool-card-detail'); _setTransparentCardOpen(firstCard, true); firstDetail.setAttribute('data-transparent-detail-mode', 'output'); _renderLiveAnchorActivitySceneTransparent('stream-1', secondScene, {{ sessionId:'session-1' }}); const keptRow = turn.querySelector('.transparent-event-row[data-anchor-row-id="row-tool"]'); const header = keptRow.querySelector('.tool-card-header'); const copy = keptRow.querySelector('.transparent-event-copy'); const card = keptRow.querySelector('.tool-card'); const detail = keptRow.querySelector('.tool-card-detail'); copy.onclick({{ stopPropagation(){{}}, preventDefault(){{}} }}); await Promise.resolve(); process.stdout.write(JSON.stringify({{ sameNode: firstRow === keptRow, tcSnippet: keptRow._tcData && keptRow._tcData.snippet, tcCommand: keptRow._tcData && keptRow._tcData.args && keptRow._tcData.args.cmd, hasCopyHandler: !!(copy && typeof copy.onclick === 'function'), hasHeaderHandler: !!(header && typeof header.onclick === 'function'), copiedText: global.__copied || '', cardOpen: !!(card && card.classList.contains('open')), detailMode: detail && detail.getAttribute('data-transparent-detail-mode'), headerName: (header.querySelector('.tool-card-name') || {{ textContent:'' }}).textContent, }})); }})().catch(err => {{ console.error(err && err.stack || String(err)); process.exit(1); }}); """ script = script.replace("{{", "{").replace("}}", "}") data = _run_node_script(script, str(ROOT / "static" / "ui.js")) assert data["sameNode"] is True assert data["tcSnippet"] == "new payload" assert data["tcCommand"] == "echo new" assert data["hasCopyHandler"] is True assert data["hasHeaderHandler"] is True assert data["copiedText"] == '{"name":"shell","cmd":"echo new","snippet":"new payload"}' assert data["cardOpen"] is True assert data["detailMode"] == "output" assert data["headerName"] == "Shell new" @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_transparent_live_thinking_refresh_preserves_scroll_container(): script = """ const fs = require('fs'); const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8'); function extractFunc(name){ const marker = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); const start = src.search(marker); if(start < 0) throw new Error(name + ' not found'); let i = src.indexOf('{', start) + 1; let depth = 1; while(depth > 0 && i < src.length){ if(src[i] === '{') depth += 1; else if(src[i] === '}') depth -= 1; i += 1; } return src.slice(start, i); } class FakeElement { constructor(tag='div'){ this.tagName = String(tag).toUpperCase(); this.children = []; this.parentNode = null; this.attributes = Object.create(null); this._textContent = ''; this._innerHTML = ''; this.innerHTMLSetCount = 0; this._classes = new Set(); const self = this; this.classList = { add(...names){ names.forEach(name=>self._classes.add(name)); }, remove(...names){ names.forEach(name=>self._classes.delete(name)); }, contains(name){ return self._classes.has(name); }, toggle(name, force){ const enabled = force === undefined ? !self._classes.has(name) : !!force; if(enabled) self._classes.add(name); else self._classes.delete(name); return enabled; }, }; } get className(){ return Array.from(this._classes).join(' '); } set className(value){ this._classes = new Set(String(value).trim().split(/\\s+/).filter(Boolean)); } get textContent(){ return this.children.length ? this.children.map(child=>child.textContent).join('') : this._textContent; } set textContent(value){ this._textContent = String(value ?? ''); this.children = []; } get innerHTML(){ return this._innerHTML || this.children.map(child=>child.textContent).join(''); } set innerHTML(value){ this.innerHTMLSetCount += 1; this._innerHTML = String(value ?? ''); this._textContent = this._innerHTML; this.children = []; } setAttribute(name, value){ this.attributes[String(name)] = String(value); if(name === 'class') this.className = value; } getAttribute(name){ return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; } getAttributeNames(){ return Object.keys(this.attributes); } removeAttribute(name){ delete this.attributes[name]; if(name === 'class') this._classes = new Set(); } appendChild(child){ if(child.parentNode) child.remove(); child.parentNode = this; this.children.push(child); return child; } remove(){ if(!this.parentNode) return; const idx = this.parentNode.children.indexOf(this); if(idx >= 0) this.parentNode.children.splice(idx, 1); this.parentNode = null; } querySelector(selector){ return this.querySelectorAll(selector)[0] || null; } querySelectorAll(selector){ const parts = String(selector).split(',').map(s=>s.trim()).filter(Boolean); const out = []; const walk = (node)=>{ for(const child of node.children){ if(parts.some(part=>matchesSelectorPath(child, part))) out.push(child); walk(child); } }; walk(this); return out; } } function matchesSelectorPath(el, selector){ const tokens = String(selector).split(/\\s+/).filter(Boolean); if(!tokens.length) return false; if(!matchesSimple(el, tokens[tokens.length - 1])) return false; let ancestor = el.parentNode; for(let i=tokens.length - 2;i>=0;i--){ while(ancestor && !matchesSimple(ancestor, tokens[i])) ancestor = ancestor.parentNode; if(!ancestor) return false; ancestor = ancestor.parentNode; } return true; } function matchesSimple(el, selector){ const tag = String(selector).match(/^[A-Za-z][A-Za-z0-9-]*/); if(tag && el.tagName !== tag[0].toUpperCase()) return false; const classes = selector.match(/\\.([A-Za-z0-9_-]+)/g) || []; if(!classes.length) return !!tag; return classes.every(cls=>el.classList.contains(cls.slice(1))); } function thinkingRow(text, preview){ const row = new FakeElement('div'); row.className = 'agent-activity-thinking transparent-event-row transparent-thinking-event'; row.setAttribute('data-event-type', 'thinking'); row.setAttribute('data-anchor-row-id', 'think-1'); row.setAttribute('data-anchor-row-role', 'thinking'); row.setAttribute('data-anchor-source-event-type', 'reasoning'); const card = new FakeElement('div'); card.className = 'thinking-card open'; const header = new FakeElement('div'); header.className = 'thinking-card-header'; const label = new FakeElement('span'); label.className = 'thinking-card-label'; label.textContent = 'Thinking'; const previewEl = new FakeElement('span'); previewEl.className = 'transparent-event-thinking-preview'; previewEl.textContent = preview; const body = new FakeElement('div'); body.className = 'thinking-card-body'; body.scrollTop = 37; const pre = new FakeElement('pre'); pre.textContent = text; header.appendChild(label); header.appendChild(previewEl); body.appendChild(pre); card.appendChild(header); card.appendChild(body); row.appendChild(card); return row; } global._decorateTransparentEventRow = (row, opts)=>{ const preview = row.querySelector('.transparent-event-thinking-preview'); if(preview) preview.textContent = opts.preview || opts.text || ''; row.setAttribute('data-event-type', opts.type || 'thinking'); return row; }; eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); const existing = thinkingRow('short thought', 'short thought'); const originalBody = existing.querySelector('.thinking-card-body'); const originalPre = existing.querySelector('.thinking-card-body pre'); const candidate = thinkingRow('long thought\\n'.repeat(80), 'long thought'); const refreshed = _refreshTransparentLiveRow(existing, candidate); const refreshedBody = refreshed.querySelector('.thinking-card-body'); const refreshedPre = refreshed.querySelector('.thinking-card-body pre'); const refreshedPreview = refreshed.querySelector('.transparent-event-thinking-preview'); process.stdout.write(JSON.stringify({ sameRow: refreshed === existing, sameBody: refreshedBody === originalBody, samePre: refreshedPre === originalPre, textUpdated: refreshedPre.textContent === 'long thought\\n'.repeat(80), previewUpdated: refreshedPreview.textContent === 'long thought', scrollTopPreserved: refreshedBody.scrollTop === 37, innerHTMLSetCount: refreshed.innerHTMLSetCount, cardOpen: !!refreshed.querySelector('.thinking-card').classList.contains('open'), })); """ data = _run_node_script(script, str(ROOT / "static" / "ui.js")) assert data["sameRow"] is True assert data["sameBody"] is True assert data["samePre"] is True assert data["textUpdated"] is True assert data["previewUpdated"] is True assert data["scrollTopPreserved"] is True assert data["innerHTMLSetCount"] == 0 assert data["cardOpen"] is True