fix: stabilize transparent stream thinking scrollbar

This commit is contained in:
Stacey2911 committed 2026-07-04 13:23:37 +10:00
1 parent badb93d472
commit 82a76eda4b
5 files changed
+982 -2

No files matched your search

+1
View File
@@ -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;}
+25
View File
@@ -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;
@@ -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
@@ -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
@@ -1065,6 +1065,7 @@ global._syncToolCallGroupSummary=()=>{{}};
eval(extractFunc('_transparentLiveRowAttributePairs'));
eval(extractFunc('_transparentLiveRowInteractiveState'));
eval(extractFunc('_rehydrateTransparentLiveRow'));
eval(extractFunc('_refreshTransparentThinkingLiveRow'));
eval(extractFunc('_refreshTransparentLiveRow'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));