From 82a76eda4b5ea89340982fa8378aabf4cbff9dfd Mon Sep 17 00:00:00 2001 From: Stacey2911 Date: Sat, 4 Jul 2026 09:29:07 +1000 Subject: [PATCH] fix: stabilize transparent stream thinking scrollbar --- static/style.css | 1 + static/ui.js | 25 + ...st_issue3820_chat_activity_display_mode.py | 2 +- ...ssue5367_transparent_live_row_reconcile.py | 955 +++++++++++++++++- ...test_live_to_final_anchor_visible_order.py | 1 + 5 files changed, 982 insertions(+), 2 deletions(-) diff --git a/static/style.css b/static/style.css index 73de7060b..34b299ec8 100644 --- a/static/style.css +++ b/static/style.css @@ -4223,6 +4223,7 @@ body.resizing .sidebar{transition:none!important;} .transparent-event-row .thinking-card.open .thinking-card-body{ border-top-color:transparent; padding:0 0 3px; + scrollbar-gutter:stable; } .transparent-event-row .tool-card-detail > *, .transparent-event-row .thinking-card-body > *{min-height:0;} diff --git a/static/ui.js b/static/ui.js index c02178066..578555e47 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11055,6 +11055,7 @@ function _rehydrateTransparentLiveRow(existing, node, preservedState){ if(!existing) return; if(node && Object.prototype.hasOwnProperty.call(node, '_tcData')) existing._tcData = node._tcData; else if(Object.prototype.hasOwnProperty.call(existing, '_tcData')) delete existing._tcData; + try{ delete node._tcData; }catch(_){} const header = existing.querySelector ? existing.querySelector('.tool-card-header,.thinking-card-header') : null; if(header){ if(typeof _wireTransparentHeaderToggle === 'function') _wireTransparentHeaderToggle(header); @@ -11075,6 +11076,26 @@ function _rehydrateTransparentLiveRow(existing, node, preservedState){ } } +function _refreshTransparentThinkingLiveRow(existing, node){ + if(!existing || !node || !existing.querySelector || !node.querySelector) return false; + const existingType = String(existing.getAttribute('data-event-type') || ''); + const nodeType = String(node.getAttribute('data-event-type') || ''); + const existingIsThinking = existingType === 'thinking' || (existing.classList&&existing.classList.contains('transparent-thinking-event')); + const nodeIsThinking = nodeType === 'thinking' || (node.classList&&node.classList.contains('transparent-thinking-event')); + if(!existingIsThinking || !nodeIsThinking) return false; + const existingPre = existing.querySelector('.thinking-card-body pre'); + const nodePre = node.querySelector('.thinking-card-body pre'); + if(!existingPre || !nodePre) return false; + const nextText = String(nodePre.textContent || ''); + if(existingPre.textContent !== nextText) existingPre.textContent = nextText; + const nodePreview = node.querySelector('.transparent-event-thinking-preview'); + const previewText = nodePreview ? String(nodePreview.textContent || '') : nextText; + if(typeof _decorateTransparentEventRow === 'function'){ + _decorateTransparentEventRow(existing,{type:'thinking',text:nextText,preview:previewText}); + } + return true; +} + function _refreshTransparentLiveRow(existing, node){ if(!existing || !node || !existing.getAttribute) return node; if(existing===node) return existing; @@ -11093,6 +11114,10 @@ function _refreshTransparentLiveRow(existing, node){ existing.setAttribute(name, value); } existing.className = node.className || ''; + if(_refreshTransparentThinkingLiveRow(existing, node)){ + _rehydrateTransparentLiveRow(existing, node, preservedState); + return existing; + } const newHtml = node.innerHTML || ''; const htmlChanged = existing.innerHTML !== newHtml; if(htmlChanged) existing.innerHTML = newHtml; diff --git a/tests/test_issue3820_chat_activity_display_mode.py b/tests/test_issue3820_chat_activity_display_mode.py index 1b3efc947..663635cfd 100644 --- a/tests/test_issue3820_chat_activity_display_mode.py +++ b/tests/test_issue3820_chat_activity_display_mode.py @@ -286,7 +286,7 @@ def test_transparent_event_row_quiet_metadata_visual_rhythm(): assert "border-radius:0;" in STYLE_CSS assert "margin-top:0;" in STYLE_CSS assert "padding:0 8px 6px 27px;" in STYLE_CSS - assert ".transparent-event-row .thinking-card.open .thinking-card-body{\n border-top-color:transparent;\n padding:0 0 3px;\n}" in STYLE_CSS + assert ".transparent-event-row .thinking-card.open .thinking-card-body{\n border-top-color:transparent;\n padding:0 0 3px;\n scrollbar-gutter:stable;\n}" in STYLE_CSS # Tabs: text-link style with an active underline (no pill background). assert ".transparent-detail-mode.active{color:var(--text);opacity:1;font-weight:600;box-shadow:inset 0 -1px 0 var(--accent);}" in STYLE_CSS diff --git a/tests/test_issue5367_transparent_live_row_reconcile.py b/tests/test_issue5367_transparent_live_row_reconcile.py index 3e8c7b0e7..944f547cd 100644 --- a/tests/test_issue5367_transparent_live_row_reconcile.py +++ b/tests/test_issue5367_transparent_live_row_reconcile.py @@ -23,6 +23,19 @@ def _run_node_script(script, ui_js_path=None): 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 = """ @@ -272,6 +285,7 @@ eval(extractFunc('_transparentLiveRowsCompatible')); eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); +eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); @@ -427,7 +441,787 @@ process.stdout.write(JSON.stringify({{ @pytest.mark.skipif(NODE is None, reason="node not on PATH") -def test_transparent_live_scene_rehydrates_copy_button_and_tcdata_after_reconcile(): +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('_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'); @@ -724,6 +1518,7 @@ eval(extractFunc('_transparentLiveRowsCompatible')); eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); +eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); global._copyEventToClipboard = (row) => {{ @@ -787,3 +1582,161 @@ global._anchorSceneTransparentNodeForRow = (row) => makeToolRow(row.version); 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 diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 48c8ec96f..3e4bceb71 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -1065,6 +1065,7 @@ global._syncToolCallGroupSummary=()=>{{}}; eval(extractFunc('_transparentLiveRowAttributePairs')); eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); + eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));