fix(#5367): preserve transparent stream live rows on rerender (rehydrate controls)

Clean rebase of rodboev's #5400 (rebase-first).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermesandrodboev committed 2026-07-02 14:49:05 +00:00
1 parent d096b5f5d9
commit 4e74de45f8
3 files changed
+859 -7

No files matched your search

+124 -4
View File
@@ -10824,7 +10824,26 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){
if(!blocks) return false;
const scrollSnapshot=_captureMessageScrollSnapshot();
const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(scrollSnapshot);
blocks.querySelectorAll('[data-anchor-scene-owner="1"],[data-anchor-scene-row="1"]').forEach(el=>el.remove());
const activeStreamId = String(streamId || S.activeStreamId || '');
const activeSessionId = String(S.session && S.session.session_id || '');
const preserveByKey = new Map();
blocks.querySelectorAll('.transparent-event-row[data-live-stream-owned="1"][data-anchor-row-id]').forEach(node=>{
if(!node||!node.getAttribute) return;
const rowStream = String(node.getAttribute('data-anchor-stream-id') || '');
if(rowStream && rowStream !== activeStreamId) return;
const rowSession = String(node.getAttribute('data-session-id') || '');
if(rowSession && activeSessionId && rowSession !== activeSessionId) return;
const key = _transparentLiveRowKey(node, activeStreamId);
if(key && !preserveByKey.has(key)) preserveByKey.set(key, node);
});
blocks.querySelectorAll('[data-anchor-scene-owner="1"]').forEach(el=>el.remove());
blocks.querySelectorAll('[data-anchor-scene-row="1"]').forEach(el=>{
if(el.getAttribute('data-live-stream-owned') === '1'){
const key = _transparentLiveRowKey(el, activeStreamId);
if(key && preserveByKey.get(key) === el) return;
}
el.remove();
});
// Clear every legacy live activity surface this renderer can replace. The
// anchor-scene rows are now the source of truth for visible live activity.
blocks.querySelectorAll(
@@ -10834,7 +10853,6 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){
'.tool-card-row[data-live-tid],'+
'.agent-activity-thinking[data-live-thinking="1"],'+
'.transparent-event-row[data-live-tid],'+
'[data-live-stream-owned="1"],'+
'.interim-collapse-toggle'
).forEach(el=>el.remove());
// Match the compact path: keep legacy live segments as hidden anchors so
@@ -10854,10 +10872,18 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){
sessionId:S.session&&S.session.session_id,
});
if(!node) continue;
if(liveFooter&&liveFooter.parentElement===blocks) blocks.insertBefore(node,liveFooter);
else blocks.appendChild(node);
const key = _transparentLiveRowKey(node, activeStreamId);
const existing = key ? preserveByKey.get(key) : null;
const renderedNode = existing && _transparentLiveRowsCompatible(existing, node)
? _refreshTransparentLiveRow(existing, node)
: node;
if(existing) preserveByKey.delete(key);
if(!renderedNode) continue;
if(liveFooter&&liveFooter.parentElement===blocks) blocks.insertBefore(renderedNode,liveFooter);
else blocks.appendChild(renderedNode);
wrote=true;
}
preserveByKey.forEach(stale=>stale.remove());
if(wrote) _syncTransparentEventControls(turn);
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
@@ -10870,6 +10896,100 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){
if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();
return wrote;
}
function _transparentLiveRowKey(node, streamId){
if(!node || !node.getAttribute) return '';
const rowId = String(node.getAttribute('data-anchor-row-id') || '').trim();
if(!rowId) return '';
const rowStreamId = String(streamId || node.getAttribute('data-anchor-stream-id') || '').trim();
const rowRole = String(node.getAttribute('data-anchor-row-role') || 'activity').trim();
const rowSource = String(node.getAttribute('data-anchor-source-event-type') || '').trim();
return `${rowStreamId}\u0000${rowId}\u0000${rowRole}\u0000${rowSource}`;
}
function _transparentLiveRowsCompatible(existing, candidate){
if(!existing || !candidate) return false;
return !!(
existing.getAttribute('data-anchor-row-id') === candidate.getAttribute('data-anchor-row-id') &&
existing.getAttribute('data-anchor-row-role') === candidate.getAttribute('data-anchor-row-role') &&
existing.getAttribute('data-anchor-source-event-type') === candidate.getAttribute('data-anchor-source-event-type')
);
}
function _transparentLiveRowAttributePairs(node){
if(!node) return [];
if(typeof node.getAttributeNames === 'function'){
return node.getAttributeNames().map(name=>[name, node.getAttribute(name)]);
}
const attrs = node.attributes;
if(!attrs || typeof attrs !== 'object') return [];
if(typeof attrs.length === 'number'){
const pairs = [];
for(let i=0;i<attrs.length;i++){
const attr = typeof attrs.item === 'function' ? attrs.item(i) : attrs[i];
if(!attr || !attr.name) continue;
pairs.push([attr.name, attr.value]);
}
return pairs;
}
return Object.keys(attrs).map(name=>[name, attrs[name]]);
}
function _transparentLiveRowInteractiveState(row){
const card = row&&row.querySelector ? row.querySelector('.tool-card,.thinking-card') : null;
const detail = row&&row.querySelector ? row.querySelector('.tool-card-detail') : null;
return {
expanded: !!((card&&card.classList&&card.classList.contains('open')) || (row&&row.getAttribute&&row.getAttribute('data-expanded')==='1')),
detailMode: detail&&detail.getAttribute ? String(detail.getAttribute('data-transparent-detail-mode') || '') : '',
};
}
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;
const header = existing.querySelector ? existing.querySelector('.tool-card-header,.thinking-card-header') : null;
if(header){
if(typeof _wireTransparentHeaderToggle === 'function') _wireTransparentHeaderToggle(header);
if(typeof _attachCopyButton === 'function') _attachCopyButton(header);
}
const card = existing.querySelector ? existing.querySelector('.tool-card,.thinking-card') : null;
if(card){
if(typeof _setTransparentCardOpen === 'function') _setTransparentCardOpen(card, !!(preservedState&&preservedState.expanded));
else if(card.classList&&typeof card.classList.toggle === 'function') card.classList.toggle('open', !!(preservedState&&preservedState.expanded));
}
const detail = existing.querySelector ? existing.querySelector('.tool-card-detail') : null;
if(detail && preservedState && preservedState.detailMode){
detail.setAttribute('data-transparent-detail-mode', preservedState.detailMode);
detail.querySelectorAll('.transparent-detail-mode').forEach(el=>{
const mode = String(el.getAttribute('data-mode') || '');
if(el.classList && typeof el.classList.toggle === 'function') el.classList.toggle('active', mode===preservedState.detailMode);
});
}
}
function _refreshTransparentLiveRow(existing, node){
if(!existing || !node || !existing.getAttribute) return node;
if(existing===node) return existing;
const preservedState = _transparentLiveRowInteractiveState(existing);
const pairs = _transparentLiveRowAttributePairs(node);
const kept = Object.create(null);
for(const pair of pairs){
const [name, value] = pair;
kept[String(name)] = String(value ?? '');
}
for(const [name] of _transparentLiveRowAttributePairs(existing)){
if(!Object.prototype.hasOwnProperty.call(kept, name)) existing.removeAttribute(name);
}
for(const pair of pairs){
const [name, value] = pair;
existing.setAttribute(name, value);
}
existing.className = node.className || '';
existing.innerHTML = node.innerHTML || '';
_rehydrateTransparentLiveRow(existing, node, preservedState);
return existing;
}
function _renderLiveAnchorActivitySceneForStream(streamId, sessionId, opts){
const mode=(typeof isTransparentStream==='function'&&isTransparentStream())
? 'transparent_stream'
@@ -0,0 +1,726 @@
"""Browserless regression for transparent stream live row reconciliation."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
NODE = shutil.which("node")
def _run_node_script(script, ui_js_path=None):
assert NODE, "node is required for DOM-executed anchor render tests"
env = os.environ.copy()
if ui_js_path is not None:
env["UI_JS_PATH"] = ui_js_path
result = subprocess.run([NODE, "-e", script], env=env, text=True, capture_output=True, check=False)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_transparent_live_scene_reuses_matching_rows_and_removes_stale_rows():
script = """
const fs = require('fs');
const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8');
function extractFunc(name){{
const marker = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(marker);
if(start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start) + 1;
let depth = 1;
while(depth > 0 && i < src.length){{
if(src[i] === '{{') depth += 1;
else if(src[i] === '}}') depth -= 1;
i += 1;
}}
return src.slice(start, i);
}}
class FakeElement {{
constructor(tag='div'){{
this.tagName = String(tag).toUpperCase();
this.children = [];
this.parentNode = null;
this.attributes = Object.create(null);
this.dataset = Object.create(null);
this.style = Object.create(null);
this.hidden = false;
this.id = '';
this._textContent = '';
this._innerHTML = '';
this._classes = new Set();
const self = this;
this.classList = {{
add(...names){{ names.forEach(name=>self._classes.add(name)); }},
remove(...names){{ names.forEach(name=>self._classes.delete(name)); }},
contains(name){{ return self._classes.has(name); }},
toggle(name, force){{
if(force === true){{ self._classes.add(name); return true; }}
if(force === false){{ self._classes.delete(name); return false; }}
if(self._classes.has(name)){{ self._classes.delete(name); return false; }}
self._classes.add(name);
return true;
}},
}};
}}
get parentElement(){{ return this.parentNode; }}
get firstChild(){{ return this.children[0]||null; }}
get className(){{
return Array.from(this._classes).join(' ');
}}
set className(value){{
this._classes = new Set(String(value).trim().split(/\\s+/).filter(Boolean));
}}
get textContent(){{
return this._textContent;
}}
set textContent(value){{
this._textContent = String(value ?? '');
this._innerHTML = this._textContent;
this.children = [];
}}
get innerHTML(){{
return this._innerHTML;
}}
set innerHTML(value){{
this._innerHTML = String(value ?? '');
this._textContent = this._innerHTML;
this.children = [];
}}
setAttribute(name, value){{
const key = String(name);
const val = String(value);
this.attributes[key] = val;
if(key === 'id') this.id = val;
if(key.startsWith('data-')){{
const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
this.dataset[dataKey] = val;
}}
if(key === 'class'){
this.className = val;
}}
}}
getAttribute(name){{
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null;
}}
getAttributeNames(){{
return Object.keys(this.attributes);
}}
removeAttribute(name){{
delete this.attributes[name];
if(name === 'id') this.id = '';
if(name.startsWith('data-')){{
const dataKey = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
delete this.dataset[dataKey];
}}
if(name === 'class'){
this._classes = new Set();
}}
}}
appendChild(child){{
if(child && child.parentNode) child.remove();
if(!child) return null;
child.parentNode = this;
this.children.push(child);
return child;
}}
insertBefore(child, refNode){{
if(child && child.parentNode) child.remove();
if(!child) return null;
const idx = this.children.indexOf(refNode);
child.parentNode = this;
if(idx < 0) this.children.push(child);
else this.children.splice(idx, 0, child);
return child;
}}
remove(){{
if(!this.parentNode) return;
const siblings = this.parentNode.children;
const idx = siblings.indexOf(this);
if(idx >= 0) siblings.splice(idx, 1);
this.parentNode = null;
}}
matches(selector){{
return matchesSelector(this, selector);
}}
querySelector(selector){{
return this.querySelectorAll(selector)[0] || null;
}}
querySelectorAll(selector){{
const out = [];
const walk = (node)=>{
for(const child of node.children){
if(matchesSelector(child, selector)) out.push(child);
walk(child);
}
};
walk(this);
return out;
}}
closest(selector){{
let node = this;
while(node){
if(matchesSelector(node, selector)) return node;
node = node.parentNode;
}}
return null;
}}
}}
function matchesSelector(el, selector){{
if(!selector) return false;
const options = selector.split(',').map(part=>part.trim()).filter(Boolean);
return options.some(part=>matchesSimple(el, part));
}}
function matchesSimple(el, selector){{
selector = selector.replace(/^:scope\\s*>\\s*/, '').trim();
if(!selector) return false;
const idMatch = selector.match(/#([^.\\[#]+)/);
if(idMatch && el.id !== idMatch[1]) return false;
const clsMatches = selector.match(/\\.([A-Za-z0-9_-]+)/g) || [];
for(const cls of clsMatches){{
const name = cls.slice(1);
if(!el.classList.contains(name)) return false;
}}
const attrMatches = selector.match(/\\[([^=\\]]+)(?:=\\"([^\\"]*)\\")?\\]/g) || [];
for(const attrMatch of attrMatches){{
const [, name, expected] = attrMatch.match(/\\[([^=\\]]+)(?:=\\"([^\\"]*)\\")?\\]/);
const value = el.getAttribute(name);
if(value === null) return false;
if(expected !== undefined && String(value) !== String(expected)) return false;
}}
return !!(idMatch || clsMatches.length || attrMatches.length);
}}
global.window = {{}};
global.document = {{ createElement:(tag)=>new FakeElement(tag) }};
global.CSS = {{ escape:(value)=>String(value) }};
global.requestAnimationFrame = (fn)=>fn();
global.S = {{ session:{{ session_id: 'session-1', pending_started_at: 123 }}, activeStreamId:'stream-1' }};
global._captureMessageScrollSnapshot = () => ({{ scrollHeight: 1000 }});
global._prepareLiveAnchorScrollRebuildGuard = () => ({{ readerAwayFromBottom:false, release:null }});
global._restoreMessageScrollSnapshotSameFrame = () => {{}};
global.scrollIfPinned = () => {{}};
global._moveLiveRunStatusToTurnEnd = () => {{}};
global._messageUserUnpinned = false;
global._syncTransparentEventControls = () => {{}};
global._anchorSceneRowsForRendering = (scene) => scene && scene.activity_rows || [];
global._anchorSceneNodeForRow = (row) => {{
const node = new FakeElement('div');
node.classList.add('assistant-segment');
node.textContent = String(row && (row.text || row.thinking&&row.thinking.text || '') || '');
return node;
}};
global._decorateTransparentEventRow = (node, opts) => {{
node.classList.add('transparent-event-row');
node.setAttribute('data-transparent-event-row','1');
if(opts && Object.prototype.hasOwnProperty.call(opts,'type')) node.setAttribute('data-event-type', opts.type);
if(opts && Object.prototype.hasOwnProperty.call(opts,'text')) node.setAttribute('data-text', opts.text);
if(opts && Object.prototype.hasOwnProperty.call(opts,'status')) node.setAttribute('data-event-status', opts.status);
return node;
}};
global._thinkingActivityNode = (text)=>{{
const node = new FakeElement('div');
node.classList.add('agent-activity-thinking');
node.textContent = text || '';
return node;
}};
global._anchorSceneToolCallFromRow = (row) => ({{
name:(row.tool && row.tool.name) || row.tool_name || 'tool',
done:true
}});
global._autoCompressionWorklogNode = () => new FakeElement('div');
global._autoCompressionPreviewText = () => 'preview';
global._transparentToolStatus = () => 'done';
global.buildToolCard = () => {{
const node = new FakeElement('div');
node.classList.add('tool-card-row');
return node;
}};
const emptyState = new FakeElement('div');
const msgInner = new FakeElement('div');
const messages = new FakeElement('div');
const turn = new FakeElement('div');
turn.id = 'liveAssistantTurn';
const liveRunStatus = new FakeElement('div');
liveRunStatus.id = 'liveRunStatus';
msgInner.appendChild(turn);
turn.appendChild(liveRunStatus);
global.document._findById = (id) => id === 'emptyState' ? emptyState : id === 'msgInner' ? msgInner : id === 'messages' ? messages : id === 'liveAssistantTurn' ? turn : null;
global.$ = (id)=>global.document._findById(id);
global._createAssistantTurn = () => turn;
global._assistantTurnBlocks = () => turn;
global._anchorSceneTransparentNodeForRow = (row) => null;
eval(extractFunc('_anchorSceneTransparentNodeForRow'));
eval(extractFunc('_transparentLiveRowKey'));
eval(extractFunc('_transparentLiveRowsCompatible'));
eval(extractFunc('_transparentLiveRowAttributePairs'));
eval(extractFunc('_transparentLiveRowInteractiveState'));
eval(extractFunc('_rehydrateTransparentLiveRow'));
eval(extractFunc('_refreshTransparentLiveRow'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
const firstScene = {{
version:'activity_scene_v1',
activity_rows:[
{{ row_id:'row-kept', role:'prose', source_event_type:'process_prose', text:'first progress line' }},
{{ row_id:'row-stale', role:'prose', source_event_type:'process_prose', text:'will be removed' }},
],
}};
const secondScene = {{
version:'activity_scene_v1',
activity_rows:[
{{ row_id:'row-kept', role:'prose', source_event_type:'process_prose', text:'updated progress line' }},
{{ row_id:'row-new', role:'prose', source_event_type:'process_prose', text:'new row appears' }},
],
}};
const thirdScene = {{
version:'activity_scene_v1',
activity_rows:[
{{ role:'prose', source_event_type:'process_prose', text:'keyless row first' }},
],
}};
const fourthScene = {{
version:'activity_scene_v1',
activity_rows:[
{{ role:'prose', source_event_type:'process_prose', text:'keyless row second' }},
],
}};
const firstRender = _renderLiveAnchorActivitySceneTransparent('stream-1', firstScene, {{ sessionId:'session-1' }});
const keptAfterFirst = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-kept\"]');
const staleAfterFirst = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-stale\"]');
const firstFooter = turn.querySelector('#liveRunStatus');
const secondRender = _renderLiveAnchorActivitySceneTransparent('stream-1', secondScene, {{ sessionId:'session-1' }});
const keptAfterSecond = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-kept\"]');
const staleAfterSecond = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-stale\"]');
const newAfterSecond = turn.querySelector('.transparent-event-row[data-anchor-row-id=\"row-new\"]');
const rows = turn.children.filter((child) => child.classList.contains('transparent-event-row'));
const idxs = {{
keptDirect: turn.children.indexOf(keptAfterSecond),
freshDirect: turn.children.indexOf(newAfterSecond),
footerDirect: turn.children.indexOf(firstFooter),
staleInVisibleRows: rows.findIndex((child) => child.getAttribute('data-anchor-row-id') === 'row-stale'),
rowKeeps: rows.indexOf(keptAfterSecond),
rowNew: rows.indexOf(newAfterSecond),
stale: rows.findIndex((child) => child.getAttribute('data-anchor-row-id') === 'row-stale'),
}};
_renderLiveAnchorActivitySceneTransparent('stream-1', thirdScene, {{ sessionId:'session-1' }});
const keylessRowsAfterThird = turn.querySelectorAll('.transparent-event-row[data-anchor-row-id=\"\"]');
_renderLiveAnchorActivitySceneTransparent('stream-1', fourthScene, {{ sessionId:'session-1' }});
const keylessRowsAfterFourth = turn.querySelectorAll('.transparent-event-row[data-anchor-row-id=\"\"]');
process.stdout.write(JSON.stringify({{
firstRender,
secondRender,
sameNode: keptAfterFirst === keptAfterSecond,
keptId: keptAfterSecond && keptAfterSecond.getAttribute('data-anchor-row-id'),
keptSource: keptAfterSecond && keptAfterSecond.getAttribute('data-anchor-source-event-type'),
keptText: keptAfterSecond && keptAfterSecond.textContent,
staleGone: staleAfterSecond === null,
staleAfterFirst: staleAfterFirst !== null,
idxs,
hasNewRow: !!newAfterSecond,
newRowSession: newAfterSecond && newAfterSecond.getAttribute('data-session-id'),
keylessAfterThird: keylessRowsAfterThird.length,
keylessAfterFourth: keylessRowsAfterFourth.length,
keylessTextsAfterFourth: keylessRowsAfterFourth.map((child) => child.textContent),
totalRowsAfterFourth: turn.children.filter((child) => child.classList.contains('transparent-event-row')).length,
}}));
"""
script = script.replace("{{", "{").replace("}}", "}")
data = _run_node_script(script, str(ROOT / "static" / "ui.js"))
assert data["firstRender"] is True
assert data["secondRender"] is True
assert data["sameNode"] is True
assert data["keptId"] == "row-kept"
assert data["keptSource"] == "process_prose"
assert data["keptText"] == "updated progress line"
assert data["staleGone"] is True
assert data["staleAfterFirst"] is True
assert data["idxs"]["keptDirect"] == 0
assert data["idxs"]["freshDirect"] == 1
assert data["idxs"]["footerDirect"] > data["idxs"]["freshDirect"]
assert data["idxs"]["freshDirect"] < data["idxs"]["footerDirect"]
assert data["idxs"]["rowKeeps"] == 0
assert data["idxs"]["rowNew"] == 1
assert data["idxs"]["stale"] == -1
assert data["idxs"]["staleInVisibleRows"] == -1
assert data["hasNewRow"] is True
assert data["newRowSession"] == "session-1"
assert data["keylessAfterThird"] == 1
assert data["keylessAfterFourth"] == 1
assert data["keylessTextsAfterFourth"] == ["keyless row second"]
assert data["totalRowsAfterFourth"] == 1
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_transparent_live_scene_rehydrates_copy_button_and_tcdata_after_reconcile():
script = """
const fs = require('fs');
const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8');
function extractFunc(name){{
const marker = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(marker);
if(start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start) + 1;
let depth = 1;
while(depth > 0 && i < src.length){{
if(src[i] === '{{') depth += 1;
else if(src[i] === '}}') depth -= 1;
i += 1;
}}
return src.slice(start, i);
}}
const htmlRegistry = new Map();
class FakeElement {{
constructor(tag='div'){{
this.tagName = String(tag).toUpperCase();
this.children = [];
this.parentNode = null;
this.attributes = Object.create(null);
this.dataset = Object.create(null);
this.style = Object.create(null);
this.hidden = false;
this.id = '';
this._textContent = '';
this._innerHTML = '';
this._classes = new Set();
this.onclick = null;
this.onkeydown = null;
this.title = '';
const self = this;
this.classList = {{
add(...names){{ names.forEach(name=>self._classes.add(name)); }},
remove(...names){{ names.forEach(name=>self._classes.delete(name)); }},
contains(name){{ return self._classes.has(name); }},
toggle(name, force){{
if(force === true){{ self._classes.add(name); return true; }}
if(force === false){{ self._classes.delete(name); return false; }}
if(self._classes.has(name)){{ self._classes.delete(name); return false; }}
self._classes.add(name);
return true;
}},
}};
}}
cloneNode(deep){{
const clone = new FakeElement(this.tagName);
clone.className = this.className;
clone._textContent = this._textContent;
clone._innerHTML = this._innerHTML;
clone.hidden = this.hidden;
clone.id = this.id;
clone.title = this.title;
Object.entries(this.attributes).forEach(([name, value])=>clone.setAttribute(name, value));
if(Object.prototype.hasOwnProperty.call(this, '_tcData')) clone._tcData = this._tcData;
if(deep){{
this.children.forEach(child=>clone.appendChild(child.cloneNode(true)));
}}
return clone;
}}
get parentElement(){{ return this.parentNode; }}
get firstChild(){{ return this.children[0]||null; }}
get className(){{
return Array.from(this._classes).join(' ');
}}
set className(value){{
this._classes = new Set(String(value).trim().split(/\\s+/).filter(Boolean));
}}
get textContent(){{
if(this.children.length) return this.children.map(child=>child.textContent).join('');
return this._textContent;
}}
set textContent(value){{
this._textContent = String(value ?? '');
this._innerHTML = this._textContent;
this.children = [];
}}
get innerHTML(){{
return this._innerHTML;
}}
set innerHTML(value){{
this._innerHTML = String(value ?? '');
this._textContent = '';
this.children = [];
const factory = htmlRegistry.get(this._innerHTML);
if(factory){{
factory().forEach(child=>this.appendChild(child.cloneNode(true)));
return;
}}
this._textContent = this._innerHTML;
}}
setAttribute(name, value){{
const key = String(name);
const val = String(value);
this.attributes[key] = val;
if(key === 'id') this.id = val;
if(key.startsWith('data-')){{
const dataKey = key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
this.dataset[dataKey] = val;
}}
if(key === 'class') this.className = val;
}}
getAttribute(name){{
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null;
}}
getAttributeNames(){{
return Object.keys(this.attributes);
}}
removeAttribute(name){{
delete this.attributes[name];
if(name === 'id') this.id = '';
if(name.startsWith('data-')){{
const dataKey = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
delete this.dataset[dataKey];
}}
if(name === 'class') this._classes = new Set();
}}
appendChild(child){{
if(child && child.parentNode) child.remove();
if(!child) return null;
child.parentNode = this;
this.children.push(child);
return child;
}}
insertBefore(child, refNode){{
if(child && child.parentNode) child.remove();
if(!child) return null;
const idx = this.children.indexOf(refNode);
child.parentNode = this;
if(idx < 0) this.children.push(child);
else this.children.splice(idx, 0, child);
return child;
}}
remove(){{
if(!this.parentNode) return;
const siblings = this.parentNode.children;
const idx = siblings.indexOf(this);
if(idx >= 0) siblings.splice(idx, 1);
this.parentNode = null;
}}
matches(selector){{
return matchesSelector(this, selector);
}}
querySelector(selector){{
return this.querySelectorAll(selector)[0] || null;
}}
querySelectorAll(selector){{
const out = [];
const walk = (node)=>{
for(const child of node.children){{
if(matchesSelector(child, selector)) out.push(child);
walk(child);
}}
};
walk(this);
return out;
}}
closest(selector){{
let node = this;
while(node){{
if(matchesSelector(node, selector)) return node;
node = node.parentNode;
}}
return null;
}}
}}
function matchesSelector(el, selector){{
if(!selector) return false;
const options = selector.split(',').map(part=>part.trim()).filter(Boolean);
return options.some(part=>matchesSimple(el, part));
}}
function matchesSimple(el, selector){{
selector = selector.replace(/^:scope\\s*>\\s*/, '').trim();
if(!selector) return false;
const idMatch = selector.match(/#([^.\\[#]+)/);
if(idMatch && el.id !== idMatch[1]) return false;
const clsMatches = selector.match(/\\.([A-Za-z0-9_-]+)/g) || [];
for(const cls of clsMatches){{
if(!el.classList.contains(cls.slice(1))) return false;
}}
const attrMatches = selector.match(/\\[([^=\\]]+)(?:=\\"([^\\"]*)\\")?\\]/g) || [];
for(const attrMatch of attrMatches){{
const [, name, expected] = attrMatch.match(/\\[([^=\\]]+)(?:=\\"([^\\"]*)\\")?\\]/);
const value = el.getAttribute(name);
if(value === null) return false;
if(expected !== undefined && String(value) !== String(expected)) return false;
}}
return !!(idMatch || clsMatches.length || attrMatches.length);
}}
function buildToolChildren(version){{
const card = new FakeElement('div');
card.className = 'tool-card open';
const header = new FakeElement('div');
header.className = 'tool-card-header';
const name = new FakeElement('span');
name.className = 'tool-card-name';
name.textContent = version === 'first' ? 'Shell old' : 'Shell new';
const toggle = new FakeElement('span');
toggle.className = 'tool-card-toggle';
toggle.textContent = '>';
header.appendChild(name);
header.appendChild(toggle);
const detail = new FakeElement('div');
detail.className = 'tool-card-detail';
detail.setAttribute('data-transparent-detail-mode', 'output');
const full = new FakeElement('span');
full.className = 'transparent-detail-mode';
full.setAttribute('data-mode', 'full');
full.textContent = 'Full';
const output = new FakeElement('span');
output.className = 'transparent-detail-mode active';
output.setAttribute('data-mode', 'output');
output.textContent = 'Output';
detail.appendChild(full);
detail.appendChild(output);
card.appendChild(header);
card.appendChild(detail);
return [card];
}}
function toolPayload(version){{
return version === 'first'
? {{ name:'shell', args:{{ cmd:'echo old' }}, snippet:'old payload', done:false }}
: {{ name:'shell', args:{{ cmd:'echo new' }}, snippet:'new payload', done:false }};
}}
function makeToolRow(version){{
const token = version === 'first' ? '__tool_row_first__' : '__tool_row_second__';
htmlRegistry.set(token, ()=>buildToolChildren(version));
const row = new FakeElement('div');
row.className = 'transparent-event-row';
row.setAttribute('data-transparent-event-row', '1');
row.setAttribute('data-event-type', 'tool');
row.setAttribute('data-anchor-scene-row', '1');
row.setAttribute('data-anchor-live-scene-row', '1');
row.setAttribute('data-anchor-row-id', 'row-tool');
row.setAttribute('data-anchor-row-role', 'tool');
row.setAttribute('data-anchor-source-event-type', 'process_tool');
row.setAttribute('data-anchor-stream-id', 'stream-1');
row.setAttribute('data-session-id', 'session-1');
row.setAttribute('data-live-stream-owned', '1');
row.setAttribute('data-expanded', '1');
row.innerHTML = token;
row._tcData = toolPayload(version);
const header = row.querySelector('.tool-card-header');
const card = row.querySelector('.tool-card');
const detail = row.querySelector('.tool-card-detail');
_wireTransparentHeaderToggle(header);
_attachCopyButton(header);
_setTransparentCardOpen(card, true);
detail.setAttribute('data-transparent-detail-mode', 'output');
detail.querySelectorAll('.transparent-detail-mode').forEach(el=>el.classList.toggle('active', el.getAttribute('data-mode') === 'output'));
return row;
}}
global.window = {{}};
global.document = {{
body:new FakeElement('body'),
createElement:(tag)=>new FakeElement(tag),
execCommand:()=>true,
}};
global.requestAnimationFrame = (fn)=>fn();
global.CSS = {{ escape:(value)=>String(value) }};
global.t = (key)=>key;
global.showToast = () => {{}};
global.S = {{ session:{{ session_id:'session-1' }}, activeStreamId:'stream-1' }};
global._captureMessageScrollSnapshot = () => ({{ scrollHeight: 1000 }});
global._prepareLiveAnchorScrollRebuildGuard = () => ({{ readerAwayFromBottom:false, release:null }});
global._restoreMessageScrollSnapshotSameFrame = () => {{}};
global.scrollIfPinned = () => {{}};
global._moveLiveRunStatusToTurnEnd = () => {{}};
global._messageUserUnpinned = false;
global._syncTransparentEventControls = () => {{}};
global._anchorSceneRowsForRendering = (scene) => scene && scene.activity_rows || [];
const emptyState = new FakeElement('div');
const msgInner = new FakeElement('div');
const turn = new FakeElement('div');
turn.id = 'liveAssistantTurn';
const liveRunStatus = new FakeElement('div');
liveRunStatus.id = 'liveRunStatus';
msgInner.appendChild(turn);
turn.appendChild(liveRunStatus);
global.document._findById = (id) => id === 'emptyState' ? emptyState : id === 'msgInner' ? msgInner : id === 'liveAssistantTurn' ? turn : null;
global.$ = (id)=>global.document._findById(id);
global._createAssistantTurn = () => turn;
global._assistantTurnBlocks = () => turn;
global._anchorSceneTransparentNodeForRow = () => null;
eval(extractFunc('_attachCopyButton'));
eval(extractFunc('_setTransparentCardOpen'));
eval(extractFunc('_wireTransparentHeaderToggle'));
eval(extractFunc('_transparentLiveRowKey'));
eval(extractFunc('_transparentLiveRowsCompatible'));
eval(extractFunc('_transparentLiveRowAttributePairs'));
eval(extractFunc('_transparentLiveRowInteractiveState'));
eval(extractFunc('_rehydrateTransparentLiveRow'));
eval(extractFunc('_refreshTransparentLiveRow'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
global._copyEventToClipboard = (row) => {{
const tc = row && row._tcData || {{}};
global.__copied = JSON.stringify({{
name: tc.name || '',
cmd: tc.args && tc.args.cmd || '',
snippet: tc.snippet || '',
}});
}};
global._anchorSceneTransparentNodeForRow = (row) => makeToolRow(row.version);
(async () => {{
const firstScene = {{
version:'activity_scene_v1',
activity_rows:[{{ row_id:'row-tool', role:'tool', source_event_type:'process_tool', version:'first' }}],
}};
const secondScene = {{
version:'activity_scene_v1',
activity_rows:[{{ row_id:'row-tool', role:'tool', source_event_type:'process_tool', version:'second' }}],
}};
_renderLiveAnchorActivitySceneTransparent('stream-1', firstScene, {{ sessionId:'session-1' }});
const firstRow = turn.querySelector('.transparent-event-row[data-anchor-row-id="row-tool"]');
const firstCard = firstRow.querySelector('.tool-card');
const firstDetail = firstRow.querySelector('.tool-card-detail');
_setTransparentCardOpen(firstCard, true);
firstDetail.setAttribute('data-transparent-detail-mode', 'output');
_renderLiveAnchorActivitySceneTransparent('stream-1', secondScene, {{ sessionId:'session-1' }});
const keptRow = turn.querySelector('.transparent-event-row[data-anchor-row-id="row-tool"]');
const header = keptRow.querySelector('.tool-card-header');
const copy = keptRow.querySelector('.transparent-event-copy');
const card = keptRow.querySelector('.tool-card');
const detail = keptRow.querySelector('.tool-card-detail');
copy.onclick({{ stopPropagation(){{}}, preventDefault(){{}} }});
await Promise.resolve();
process.stdout.write(JSON.stringify({{
sameNode: firstRow === keptRow,
tcSnippet: keptRow._tcData && keptRow._tcData.snippet,
tcCommand: keptRow._tcData && keptRow._tcData.args && keptRow._tcData.args.cmd,
hasCopyHandler: !!(copy && typeof copy.onclick === 'function'),
hasHeaderHandler: !!(header && typeof header.onclick === 'function'),
copiedText: global.__copied || '',
cardOpen: !!(card && card.classList.contains('open')),
detailMode: detail && detail.getAttribute('data-transparent-detail-mode'),
headerName: (header.querySelector('.tool-card-name') || {{ textContent:'' }}).textContent,
}}));
}})().catch(err => {{
console.error(err && err.stack || String(err));
process.exit(1);
}});
"""
script = script.replace("{{", "{").replace("}}", "}")
data = _run_node_script(script, str(ROOT / "static" / "ui.js"))
assert data["sameNode"] is True
assert data["tcSnippet"] == "new payload"
assert data["tcCommand"] == "echo new"
assert data["hasCopyHandler"] is True
assert data["hasHeaderHandler"] is True
assert data["copiedText"] == '{"name":"shell","cmd":"echo new","snippet":"new payload"}'
assert data["cardOpen"] is True
assert data["detailMode"] == "output"
assert data["headerName"] == "Shell new"
@@ -1058,9 +1058,15 @@ global._renderAnchorSceneRowsIntoWorklog=(group, rows)=>{{
}};
global._syncToolCallGroupSummary=()=>{{}};
eval(extractFunc('_anchorSceneTransparentNodeForRow'));
eval(extractFunc('renderLiveAnchorActivityScene'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
eval(extractFunc('_anchorSceneTransparentNodeForRow'));
eval(extractFunc('renderLiveAnchorActivityScene'));
eval(extractFunc('_transparentLiveRowKey'));
eval(extractFunc('_transparentLiveRowsCompatible'));
eval(extractFunc('_transparentLiveRowAttributePairs'));
eval(extractFunc('_transparentLiveRowInteractiveState'));
eval(extractFunc('_rehydrateTransparentLiveRow'));
eval(extractFunc('_refreshTransparentLiveRow'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
const existingTurn=global._createAssistantTurn();
existingTurn.id='liveAssistantTurn';