fix: stabilize transparent stream thinking scrollbar
This commit is contained in:
1 parent
badb93d472
commit
82a76eda4b
5 files changed
+982
-2
No files matched your search
@@ -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;}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
Reference in new issue
Block a user