1378 lines
52 KiB
Python
1378 lines
52 KiB
Python
"""Regression coverage for issue #500 transcript virtualization."""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
|
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
|
|
NODE = shutil.which("node")
|
|
|
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
|
|
|
|
def _run_node(source: str) -> str:
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".cjs", encoding="utf-8", dir=REPO_ROOT, delete=False
|
|
) as script:
|
|
script.write(source)
|
|
script_path = Path(script.name)
|
|
try:
|
|
result = subprocess.run(
|
|
[NODE, str(script_path)],
|
|
cwd=str(REPO_ROOT),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
finally:
|
|
script_path.unlink(missing_ok=True)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _extract_func_script(js: str) -> str:
|
|
return f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
"""
|
|
|
|
|
|
def test_message_virtual_window_virtualizes_older_history_but_keeps_recent_tail():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
const metrics = _messageVirtualWindow({
|
|
total: 240,
|
|
scrollTop: 120 * 70,
|
|
viewportHeight: 720,
|
|
heights: Array.from({length: 240}, (_, i) => i >= 190 ? 220 : 120),
|
|
defaultHeight: 120,
|
|
bufferPx: 240,
|
|
threshold: 80,
|
|
keepTailCount: 50,
|
|
});
|
|
console.log(JSON.stringify(metrics));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["virtualized"] is True
|
|
assert 60 <= metrics["start"] <= 75
|
|
assert metrics["end"] <= metrics["tailStart"] == 190
|
|
assert metrics["topPad"] > 0
|
|
assert metrics["bottomPad"] > 0
|
|
|
|
|
|
def test_message_virtual_window_collapses_to_tail_only_near_bottom():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
const metrics = _messageVirtualWindow({
|
|
total: 240,
|
|
scrollTop: 120 * 260,
|
|
viewportHeight: 720,
|
|
heights: Array.from({length: 240}, () => 120),
|
|
defaultHeight: 120,
|
|
bufferPx: 240,
|
|
threshold: 80,
|
|
keepTailCount: 50,
|
|
});
|
|
console.log(JSON.stringify(metrics));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["virtualized"] is True
|
|
assert metrics["start"] == metrics["tailStart"] == 190
|
|
assert metrics["end"] == metrics["tailStart"]
|
|
assert metrics["bottomPad"] == 0
|
|
|
|
|
|
def test_compaction_cards_cross_virtualization_threshold_with_one_task_owner():
|
|
"""Execute the real window and compaction DOM helpers with a tiny DOM.
|
|
|
|
This deliberately reuses issue #500's proven virtualization harness instead
|
|
of evaluating all of renderMessages() against a hand-built browser clone.
|
|
"""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + r"""
|
|
const assert = require('assert');
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {
|
|
user: 120,
|
|
assistant: 160,
|
|
tool_call: 400,
|
|
default: 140,
|
|
};
|
|
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80;
|
|
const MESSAGE_VIRTUAL_BUFFER_PX = 0;
|
|
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
eval(extractFunc('_compactionDigestText'));
|
|
eval(extractFunc('_compactionCardPreview'));
|
|
eval(extractFunc('_compressionReferenceCardHtml'));
|
|
eval(extractFunc('_selectCompactionCardPlacements'));
|
|
eval(extractFunc('_pinCompactionCardAtTop'));
|
|
eval(extractFunc('_insertCompactionCardNodes'));
|
|
eval(extractFunc('_insertPreservedCompressionTaskFallback'));
|
|
eval(extractFunc('_insertCompressionLikeNodeByRawIdx'));
|
|
|
|
class MiniClassList {
|
|
constructor(...names) { this.names = new Set(names); }
|
|
add(...names) { names.forEach(name => this.names.add(name)); }
|
|
contains(name) { return this.names.has(name); }
|
|
toggle(name) {
|
|
if (this.names.has(name)) {
|
|
this.names.delete(name);
|
|
return false;
|
|
}
|
|
this.names.add(name);
|
|
return true;
|
|
}
|
|
}
|
|
class MiniNode {
|
|
constructor(label, rawIdx = null) {
|
|
this.label = label;
|
|
this.rawIdx = rawIdx;
|
|
this.children = [];
|
|
this.parentElement = null;
|
|
this.classList = new MiniClassList();
|
|
}
|
|
appendChild(node) {
|
|
if (node.parentElement) node.remove();
|
|
node.parentElement = this;
|
|
this.children.push(node);
|
|
return node;
|
|
}
|
|
insertBefore(node, reference) {
|
|
if (node.parentElement) node.remove();
|
|
node.parentElement = this;
|
|
const index = this.children.indexOf(reference);
|
|
if (index < 0) this.children.push(node);
|
|
else this.children.splice(index, 0, node);
|
|
return node;
|
|
}
|
|
remove() {
|
|
if (!this.parentElement) return;
|
|
const index = this.parentElement.children.indexOf(this);
|
|
if (index >= 0) this.parentElement.children.splice(index, 1);
|
|
this.parentElement = null;
|
|
}
|
|
closest(selector) {
|
|
let node = this;
|
|
while (node) {
|
|
if (selector === '.tool-card' && node.classList.contains('tool-card')) return node;
|
|
node = node.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
function _assistantTurnBlocks() { return null; }
|
|
function _engineAwareCompressionCopy() {
|
|
return {label: 'Context compacted', preview: 'Reference only'};
|
|
}
|
|
function esc(value) {
|
|
return String(value).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
}
|
|
function li(name) { return `<i data-icon="${name}"></i>`; }
|
|
function t(key) { return key; }
|
|
function taskCount(node) {
|
|
return (node.label === 'task' ? 1 : 0)
|
|
+ node.children.reduce((total, child) => total + taskCount(child), 0);
|
|
}
|
|
function card(kind, rawIdx, taskOwner) {
|
|
const node = new MiniNode(`${kind}:${rawIdx}`, rawIdx);
|
|
const marker = `[CONTEXT COMPACTION — REFERENCE ONLY]\n## Goal\nDigest ${rawIdx}`;
|
|
const html = _compressionReferenceCardHtml(marker, false);
|
|
const handler = html.match(/class="tool-card-header" onclick="([^"]+)"/);
|
|
assert.ok(handler, `card ${rawIdx} must expose its disclosure handler`);
|
|
const disclosure = new MiniNode(`disclosure:${rawIdx}`);
|
|
disclosure.classList.add('tool-card');
|
|
const header = new MiniNode(`header:${rawIdx}`);
|
|
header.click = () => Function(handler[1]).call(header);
|
|
disclosure.appendChild(header);
|
|
node.appendChild(disclosure);
|
|
node.disclosure = disclosure;
|
|
node.markerHtml = html;
|
|
if (taskOwner && taskOwner.kind === kind && taskOwner.rawIdx === rawIdx) {
|
|
node.appendChild(new MiniNode('task'));
|
|
}
|
|
return {kind, rawIdx, node};
|
|
}
|
|
function compactionCards(node) {
|
|
return node.children.filter(child => /^pre-window:|^inline:/.test(child.label));
|
|
}
|
|
function reopen(cardNode) {
|
|
assert.strictEqual(cardNode.disclosure.classList.contains('open'), false);
|
|
cardNode.children[0].children[0].click();
|
|
assert.strictEqual(cardNode.disclosure.classList.contains('open'), true);
|
|
cardNode.children[0].children[0].click();
|
|
assert.strictEqual(cardNode.disclosure.classList.contains('open'), false);
|
|
cardNode.children[0].children[0].click();
|
|
assert.strictEqual(cardNode.disclosure.classList.contains('open'), true);
|
|
assert.ok(cardNode.markerHtml.includes(`Digest ${cardNode.rawIdx}`));
|
|
}
|
|
|
|
const visible = Array.from({length: 100}, (_, index) => ({
|
|
rawIdx: index * 2,
|
|
m: {role: 'user'},
|
|
}));
|
|
const virtualWindow = _messageVirtualWindow({
|
|
total: visible.length,
|
|
scrollTop: 120 * 200,
|
|
viewportHeight: 720,
|
|
heights: Array.from({length: visible.length}, () => 120),
|
|
defaultHeight: 120,
|
|
bufferPx: 0,
|
|
threshold: MESSAGE_VIRTUAL_THRESHOLD_ROWS,
|
|
keepTailCount: 20,
|
|
});
|
|
assert.strictEqual(virtualWindow.virtualized, true);
|
|
const renderVisWithIdx = visible.slice(virtualWindow.start);
|
|
const firstRenderedRawIdx = renderVisWithIdx[0].rawIdx;
|
|
assert.strictEqual(firstRenderedRawIdx, 160);
|
|
|
|
let inner;
|
|
let assistantSegments;
|
|
let userRows;
|
|
function renderScenario(markers) {
|
|
const placements = _selectCompactionCardPlacements(markers, firstRenderedRawIdx);
|
|
inner = new MiniNode('inner');
|
|
assistantSegments = new Map();
|
|
userRows = new Map();
|
|
let taskOwnerNode = null;
|
|
|
|
if (placements.preWindowMarkers.length) {
|
|
const entries = placements.preWindowMarkers.map(rawIdx =>
|
|
card('pre-window', rawIdx, placements.taskOwner)
|
|
);
|
|
const mounted = _insertCompactionCardNodes(
|
|
entries,
|
|
placements.taskOwner,
|
|
node => _pinCompactionCardAtTop(inner, node),
|
|
);
|
|
taskOwnerNode = mounted.taskOwnerNode;
|
|
}
|
|
|
|
for (const entry of renderVisWithIdx) {
|
|
const row = new MiniNode(`row:${entry.rawIdx}`, entry.rawIdx);
|
|
inner.appendChild(row);
|
|
userRows.set(entry.rawIdx, row);
|
|
}
|
|
|
|
const inlineEntries = placements.inlineMarkers.map(rawIdx =>
|
|
card('inline', rawIdx, placements.taskOwner)
|
|
);
|
|
const mounted = _insertCompactionCardNodes(
|
|
inlineEntries,
|
|
placements.taskOwner,
|
|
(node, rawIdx) => _insertCompressionLikeNodeByRawIdx(node, rawIdx),
|
|
);
|
|
taskOwnerNode = mounted.taskOwnerNode || taskOwnerNode;
|
|
|
|
const fallback = new MiniNode('standalone-tasks');
|
|
fallback.appendChild(new MiniNode('task'));
|
|
const fallbackInserted = _insertPreservedCompressionTaskFallback(
|
|
taskOwnerNode,
|
|
fallback,
|
|
node => { inner.appendChild(node); return true; },
|
|
);
|
|
return {placements, inner, taskOwnerNode, fallbackInserted};
|
|
}
|
|
|
|
const mixed = renderScenario([1, 79, 159, 170]);
|
|
assert.deepStrictEqual(mixed.placements, {
|
|
preWindowMarkers: [1, 79, 159],
|
|
inlineMarkers: [170],
|
|
taskOwner: {kind: 'inline', rawIdx: 170},
|
|
});
|
|
const mixedCards = compactionCards(mixed.inner);
|
|
assert.deepStrictEqual(mixedCards.map(node => node.rawIdx), [1, 79, 159, 170]);
|
|
reopen(mixedCards[0]);
|
|
assert.strictEqual(mixedCards[1].disclosure.classList.contains('open'), false);
|
|
reopen(mixedCards[1]);
|
|
assert.ok(
|
|
mixed.inner.children.indexOf(mixed.taskOwnerNode)
|
|
< mixed.inner.children.findIndex(node => node.rawIdx > 170),
|
|
);
|
|
assert.deepStrictEqual(
|
|
mixedCards.filter(node => node.children.some(child => child.label === 'task')).map(node => node.rawIdx),
|
|
[170],
|
|
);
|
|
assert.strictEqual(taskCount(mixed.inner), 1);
|
|
assert.strictEqual(mixed.fallbackInserted, false);
|
|
|
|
const preWindowOnly = renderScenario([1, 79, 159]);
|
|
assert.deepStrictEqual(compactionCards(preWindowOnly.inner).map(node => node.rawIdx), [1, 79, 159]);
|
|
assert.strictEqual(preWindowOnly.taskOwnerNode.label, 'pre-window:159');
|
|
assert.deepStrictEqual(
|
|
compactionCards(preWindowOnly.inner)
|
|
.filter(node => node.children.some(child => child.label === 'task'))
|
|
.map(node => node.rawIdx),
|
|
[159],
|
|
);
|
|
assert.strictEqual(taskCount(preWindowOnly.inner), 1);
|
|
assert.strictEqual(preWindowOnly.fallbackInserted, false);
|
|
|
|
const noMarkers = renderScenario([]);
|
|
assert.strictEqual(noMarkers.taskOwnerNode, null);
|
|
assert.strictEqual(noMarkers.fallbackInserted, true);
|
|
assert.strictEqual(taskCount(noMarkers.inner), 1);
|
|
console.log('ok');
|
|
"""
|
|
assert _run_node(source) == "ok"
|
|
|
|
|
|
def test_render_messages_uses_virtual_window_and_spacer_measurement_path():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
render_start = js.index("function renderMessages(options)")
|
|
render_end = js.index("function _toolDisplayName", render_start)
|
|
render_body = js[render_start:render_end]
|
|
|
|
assert "_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount())" in render_body
|
|
assert "const renderVisibleIdxs=[" in render_body
|
|
assert "_messageVirtualSpacer(virtualWindow.topPad,'before')" in render_body
|
|
assert "_messageVirtualSpacer(virtualWindow.bottomPad,'after')" in render_body
|
|
assert "_updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow);" in render_body
|
|
assert "const renderableRawIdxs=new Set(visWithIdx.map(e=>e.rawIdx));" in render_body
|
|
assert "if(virtualWindow.virtualized&&renderableRawIdxs.has(aIdx)&&!renderedRawIdxs.has(aIdx)) continue;" in render_body
|
|
assert "if(hasServerOlder){" in render_body
|
|
assert "_showEarlierRenderedMessages();" not in render_body
|
|
top_spacer_idx = render_body.index("_messageVirtualSpacer(virtualWindow.topPad,'before')")
|
|
indicator_idx = render_body.index("indicator.id='loadOlderIndicator';")
|
|
assert top_spacer_idx < indicator_idx, (
|
|
"renderMessages() must place the load-older affordance after the top "
|
|
"virtual spacer so it stays visible at the top of the rendered window."
|
|
)
|
|
gap_reset_idx = render_body.index("currentAssistantTurn=null;", render_body.index("_messageVirtualSpacer(virtualWindow.bottomPad,'after')") - 220)
|
|
gap_spacer_idx = render_body.index("_messageVirtualSpacer(virtualWindow.bottomPad,'after')")
|
|
assert gap_reset_idx < gap_spacer_idx, (
|
|
"renderMessages() must reset currentAssistantTurn before inserting the "
|
|
"virtual gap spacer so assistant bubbles do not merge across the head/tail boundary."
|
|
)
|
|
|
|
|
|
def test_measurement_uses_one_primary_row_and_adjacent_activity_siblings_only():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
eval(extractFunc('_measureMessageVirtualRow'));
|
|
const nextMessage = {
|
|
hasAttribute(name){ return name === 'data-msg-idx'; },
|
|
getBoundingClientRect(){ return {height: 999}; },
|
|
nextElementSibling: null,
|
|
};
|
|
const activityGroup = {
|
|
hasAttribute(){ return false; },
|
|
matches(selector){ return selector === '.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row'; },
|
|
getBoundingClientRect(){ return {height: 60}; },
|
|
nextElementSibling: {
|
|
hasAttribute(){ return false; },
|
|
matches(){ return false; },
|
|
getBoundingClientRect(){ return {height: 5000}; },
|
|
nextElementSibling: nextMessage,
|
|
},
|
|
};
|
|
const primary = {
|
|
classList: { contains(name){ return name === 'assistant-segment'; } },
|
|
getBoundingClientRect(){ return {height: 120}; },
|
|
nextElementSibling: activityGroup,
|
|
};
|
|
const inner = {
|
|
querySelector(selector){
|
|
if(selector === '[data-msg-idx="42"]') return primary;
|
|
return null;
|
|
},
|
|
};
|
|
console.log(JSON.stringify({
|
|
total: _measureMessageVirtualRow(inner, {rawIdx: 42}),
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["total"] == 180
|
|
|
|
|
|
def test_virtual_keep_tail_count_stays_bounded_after_history_expands_render_window():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_RENDER_WINDOW_DEFAULT = 50;
|
|
let _messageRenderWindowSize = 240;
|
|
eval(extractFunc('_currentMessageRenderWindowSize'));
|
|
eval(extractFunc('_messageVirtualKeepTailCount'));
|
|
console.log(JSON.stringify({
|
|
renderWindowSize: _currentMessageRenderWindowSize(),
|
|
keepTailCount: _messageVirtualKeepTailCount(),
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["renderWindowSize"] == 240
|
|
assert metrics["keepTailCount"] == 50
|
|
|
|
|
|
def test_virtual_prepended_height_delta_uses_prefix_cache_only_when_virtualized():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {
|
|
user: 120,
|
|
assistant: 160,
|
|
tool_call: 400,
|
|
default: 140,
|
|
};
|
|
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
|
|
eval(extractFunc('_messageVirtualRoleForEntry'));
|
|
let virtualized = true;
|
|
let _messageVirtualHeightCache = [0, 220, 180, 120];
|
|
let _messageVirtualEstimatedRowHeight = 140;
|
|
function _getVisibleMessagesWithIdx(){
|
|
return [{rawIdx: 0}, {rawIdx: 1}, {rawIdx: 2}, {rawIdx: 3}];
|
|
}
|
|
function _messageVirtualKeepTailCount(){ return 2; }
|
|
function _currentMessageVirtualWindow(){
|
|
return {virtualized};
|
|
}
|
|
eval(extractFunc('_messageVirtualPrependedHeightDelta'));
|
|
const active = _messageVirtualPrependedHeightDelta(3);
|
|
virtualized = false;
|
|
const inactive = _messageVirtualPrependedHeightDelta(3);
|
|
console.log(JSON.stringify({active, inactive}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["active"] == 540
|
|
assert metrics["inactive"] is None
|
|
|
|
|
|
def test_virtual_question_jump_scroll_target_uses_visible_index_height_prefix():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let _messageVirtualHeightCache = [100, 120, 80, 140];
|
|
let _messageVirtualHeightCacheEntries = [];
|
|
let _messageVirtualHeightCacheLen = 4;
|
|
let _messageVirtualHeightCacheSrc = null;
|
|
let _messageVirtualEstimatedRowHeight = 110;
|
|
let _messageVirtualWindowKey = 'old';
|
|
let S = {messages: [{}, {}, {}, {}]};
|
|
function _messageIsRenderable(){ return true; }
|
|
eval(extractFunc('_messageVirtualHeightEntryMatches'));
|
|
eval(extractFunc('_syncMessageVirtualHeightCache'));
|
|
eval(extractFunc('_messageVisibleIndexForRawIdx'));
|
|
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
|
|
const visWithIdx = [
|
|
{rawIdx: 10, m: S.messages[0]},
|
|
{rawIdx: 12, m: S.messages[1]},
|
|
{rawIdx: 14, m: S.messages[2]},
|
|
{rawIdx: 16, m: S.messages[3]},
|
|
];
|
|
_messageVirtualHeightCacheEntries = visWithIdx;
|
|
_messageVirtualHeightCacheSrc = S.messages;
|
|
const visibleIdx = _messageVisibleIndexForRawIdx(14, visWithIdx);
|
|
const scrollTop = _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, {clientHeight: 200});
|
|
console.log(JSON.stringify({visibleIdx, scrollTop}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["visibleIdx"] == 2
|
|
assert metrics["scrollTop"] == 150
|
|
|
|
|
|
def test_height_cache_preserves_measured_prefix_across_append_only_growth():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
|
|
let _messageVirtualHeightCache = [180, 220];
|
|
let _messageVirtualHeightCacheEntries = [];
|
|
let _messageVirtualHeightCacheLen = 2;
|
|
let _messageVirtualHeightCacheSrc = null;
|
|
let _messageVirtualEstimatedRowHeight = 200;
|
|
let _messageVirtualWindowKey = 'stale-key';
|
|
function _clearMessageVirtualHeightCache() {
|
|
_messageVirtualHeightCache = [];
|
|
_messageVirtualHeightCacheEntries = [];
|
|
_messageVirtualHeightCacheLen = 0;
|
|
_messageVirtualHeightCacheSrc = null;
|
|
_messageVirtualEstimatedRowHeight = MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT;
|
|
_messageVirtualWindowKey = '';
|
|
}
|
|
eval(extractFunc('_messageVirtualHeightEntryMatches'));
|
|
eval(extractFunc('_messageVirtualHeightPrefixEntryMatches'));
|
|
eval(extractFunc('_syncMessageVirtualHeightCache'));
|
|
const first = {id: 'first'};
|
|
const second = {id: 'second'};
|
|
let S = {messages: [first, second]};
|
|
_messageVirtualHeightCacheEntries = [
|
|
{rawIdx: 0, m: first},
|
|
{rawIdx: 1, m: second},
|
|
];
|
|
_messageVirtualHeightCacheSrc = S.messages;
|
|
S = {messages: [first, second, {id: 'third'}]};
|
|
_syncMessageVirtualHeightCache([
|
|
{rawIdx: 0, m: first},
|
|
{rawIdx: 1, m: second},
|
|
{rawIdx: 2, m: S.messages[2]},
|
|
]);
|
|
console.log(JSON.stringify({
|
|
cache: _messageVirtualHeightCache,
|
|
estimated: _messageVirtualEstimatedRowHeight,
|
|
windowKey: _messageVirtualWindowKey,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["cache"][:2] == [180, 220]
|
|
assert len(metrics["cache"]) == 3
|
|
assert metrics["estimated"] == 200
|
|
assert metrics["windowKey"] == ""
|
|
|
|
|
|
def test_height_cache_preserves_measured_suffix_across_prepended_history():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
|
|
let _messageVirtualHeightCache = [180, 220];
|
|
let _messageVirtualHeightCacheEntries = [];
|
|
let _messageVirtualHeightCacheLen = 2;
|
|
let _messageVirtualHeightCacheSrc = null;
|
|
let _messageVirtualEstimatedRowHeight = 200;
|
|
let _messageVirtualWindowKey = 'stale-key';
|
|
function _clearMessageVirtualHeightCache() {
|
|
_messageVirtualHeightCache = [];
|
|
_messageVirtualHeightCacheEntries = [];
|
|
_messageVirtualHeightCacheLen = 0;
|
|
_messageVirtualHeightCacheSrc = null;
|
|
_messageVirtualEstimatedRowHeight = MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT;
|
|
_messageVirtualWindowKey = '';
|
|
}
|
|
eval(extractFunc('_messageVirtualHeightEntryMatches'));
|
|
eval(extractFunc('_messageVirtualHeightPrefixEntryMatches'));
|
|
eval(extractFunc('_syncMessageVirtualHeightCache'));
|
|
const first = {id: 'first'};
|
|
const second = {id: 'second'};
|
|
let S = {messages: [first, second]};
|
|
_messageVirtualHeightCacheEntries = [
|
|
{rawIdx: 0, m: first},
|
|
{rawIdx: 1, m: second},
|
|
];
|
|
_messageVirtualHeightCacheSrc = S.messages;
|
|
const olderA = {id: 'older-a'};
|
|
const olderB = {id: 'older-b'};
|
|
S = {messages: [olderA, olderB, first, second]};
|
|
_syncMessageVirtualHeightCache([
|
|
{rawIdx: 0, m: olderA},
|
|
{rawIdx: 1, m: olderB},
|
|
{rawIdx: 2, m: first},
|
|
{rawIdx: 3, m: second},
|
|
]);
|
|
console.log(JSON.stringify({
|
|
cache: _messageVirtualHeightCache,
|
|
estimated: _messageVirtualEstimatedRowHeight,
|
|
windowKey: _messageVirtualWindowKey,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["cache"][2:] == [180, 220]
|
|
assert len(metrics["cache"]) == 4
|
|
assert metrics["estimated"] == 200
|
|
assert metrics["windowKey"] == ""
|
|
|
|
|
|
def test_measurement_refresh_budget_is_keyed_to_window_shape_not_pad_height():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
eval(extractFunc('_messageVirtualMeasurementCycleKeyFor'));
|
|
console.log(JSON.stringify({
|
|
a: _messageVirtualMeasurementCycleKeyFor({virtualized: true, start: 10, end: 20, topPad: 1000, bottomPad: 2000, tailStart: 190}),
|
|
b: _messageVirtualMeasurementCycleKeyFor({virtualized: true, start: 10, end: 20, topPad: 1001, bottomPad: 1999, tailStart: 190}),
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["a"] == metrics["b"]
|
|
|
|
|
|
def test_tool_rows_do_not_carry_message_measurement_hook():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
build_start = js.index("function buildToolCard(tc){")
|
|
build_end = js.index("function _colorDiffLines", build_start)
|
|
build_body = js[build_start:build_end]
|
|
|
|
assert "row.dataset.msgIdx" not in build_body
|
|
assert "querySelectorAll(`[data-msg-idx=" not in js
|
|
|
|
|
|
def test_viewport_intersection_helper_detects_visible_rendered_rows_only():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let rows = [];
|
|
const container = {
|
|
getBoundingClientRect(){ return {top: 100, bottom: 300}; },
|
|
querySelectorAll(selector){
|
|
if(selector === '[data-msg-idx]') return rows;
|
|
return [];
|
|
},
|
|
};
|
|
function $(id){ return id === 'messages' ? container : null; }
|
|
eval(extractFunc('_messageViewportIntersectsRenderedRow'));
|
|
rows = [
|
|
{ getBoundingClientRect(){ return {top: 10, bottom: 90}; } },
|
|
{ getBoundingClientRect(){ return {top: 320, bottom: 360}; } },
|
|
];
|
|
const blank = _messageViewportIntersectsRenderedRow();
|
|
rows = [
|
|
{ getBoundingClientRect(){ return {top: 120, bottom: 180}; } },
|
|
];
|
|
const visible = _messageViewportIntersectsRenderedRow();
|
|
console.log(JSON.stringify({blank, visible}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["blank"] is False
|
|
assert metrics["visible"] is True
|
|
|
|
|
|
def test_render_messages_has_one_shot_virtual_blank_viewport_fallback():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
render_start = js.index("function renderMessages(options)")
|
|
render_end = js.index("function _toolDisplayName", render_start)
|
|
render_body = js[render_start:render_end]
|
|
|
|
assert "const virtualFallback=!!(options&&options._virtualFallback);" in render_body
|
|
assert "const virtualWindow=virtualFallback" in render_body
|
|
assert "if(_maybeRecoverVirtualizedBlankViewport(options, preserveScroll, virtualWindow)) return;" in render_body
|
|
assert "if(_sessionHtmlCacheSid&&S.session&&S.session.session_id===_sessionHtmlCacheSid){" in js
|
|
assert "_sessionHtmlCache.delete(_sessionHtmlCacheSid);" in js
|
|
assert "renderMessages({preserveScroll:true,_virtualFallback:true});" in js
|
|
|
|
|
|
def test_virtual_blank_viewport_recovery_evicts_stale_cache_before_fallback():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let deletes = [];
|
|
let renderCalls = [];
|
|
const _sessionHtmlCache = {
|
|
delete(sid){ deletes.push(sid); }
|
|
};
|
|
let _sessionHtmlCacheSid = 'sid-123';
|
|
const S = { session: { session_id: 'sid-123' } };
|
|
function _messageViewportIntersectsRenderedRow(){ return false; }
|
|
function renderMessages(options){ renderCalls.push(options); }
|
|
eval(extractFunc('_maybeRecoverVirtualizedBlankViewport'));
|
|
const recovered = _maybeRecoverVirtualizedBlankViewport({preserveScroll:false, someFlag:true}, true, {virtualized:true});
|
|
console.log(JSON.stringify({recovered, deletes, renderCalls}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["recovered"] is True
|
|
assert metrics["deletes"] == ["sid-123"]
|
|
assert metrics["renderCalls"] == [{"preserveScroll": True, "_virtualFallback": True}]
|
|
|
|
|
|
def test_same_frame_restore_nudges_virtual_window_when_anchor_row_is_missing():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + r"""
|
|
const ROW_HEIGHT = 120;
|
|
const TOTAL = 60;
|
|
|
|
// Rows 10-59 are mounted (tail window); rows 0-9 are virtualized out.
|
|
// Anchor points to rawIdx=5, which is in the virtualized zone.
|
|
let mountedStart = 10;
|
|
let scrollTopValue = 0;
|
|
let renderCalls = [];
|
|
let scrollTopHistory = [];
|
|
let _messageVirtualWindowKey = 'stale-key';
|
|
let _programmaticScroll = false;
|
|
let _lastScrollTop = 0;
|
|
let _messageUserUnpinned = true;
|
|
let _scrollPinned = false;
|
|
let _nearBottomCount = 0;
|
|
let _messageViewportAnchorRemounting = false;
|
|
let _messageVirtualHeightCache = Array.from({length: TOTAL}, () => ROW_HEIGHT);
|
|
let _messageVirtualHeightCacheEntries = [];
|
|
let _messageVirtualHeightCacheLen = TOTAL;
|
|
let _messageVirtualHeightCacheSrc = null;
|
|
let _messageVirtualEstimatedRowHeight = ROW_HEIGHT;
|
|
|
|
function _clearMessageVirtualHeightCache(){}
|
|
function _syncMessageVirtualHeightCache(){}
|
|
|
|
const container = {
|
|
get scrollTop(){ return scrollTopValue; },
|
|
set scrollTop(v){ scrollTopHistory.push(v); scrollTopValue = v; },
|
|
get scrollHeight(){ return TOTAL * ROW_HEIGHT; },
|
|
get clientHeight(){ return 600; },
|
|
getBoundingClientRect(){ return {top: 0, bottom: 600}; },
|
|
querySelector(selector){
|
|
const m = selector && selector.match(/\[data-msg-idx="(\d+)"\]/);
|
|
if(!m) return null;
|
|
const idx = Number(m[1]);
|
|
if(idx < mountedStart || idx >= TOTAL) return null;
|
|
const top = (idx - mountedStart) * ROW_HEIGHT;
|
|
return { getBoundingClientRect(){ return {top, bottom: top + ROW_HEIGHT}; } };
|
|
},
|
|
};
|
|
function $(id){ return id === 'messages' ? container : null; }
|
|
|
|
function _getVisibleMessagesWithIdx(){
|
|
return Array.from({length: TOTAL}, (_, i) => ({rawIdx: i}));
|
|
}
|
|
|
|
function renderMessages(opts){
|
|
renderCalls.push(JSON.parse(JSON.stringify(opts)));
|
|
mountedStart = 0;
|
|
}
|
|
|
|
function _restoreMessageViewportAnchor(anchor, delta){
|
|
const idx = Number(anchor.rawIdx) + Number(delta||0);
|
|
const row = container.querySelector(`[data-msg-idx="${idx}"]`);
|
|
if(!row) return false;
|
|
_programmaticScroll = true;
|
|
return true;
|
|
}
|
|
function requestAnimationFrame(fn){ fn(); }
|
|
|
|
eval(extractFunc('_messageVisibleIndexForRawIdx'));
|
|
eval(extractFunc('_messageSessionIndexBase'));
|
|
eval(extractFunc('_messageSessionIndexForRawIdx'));
|
|
eval(extractFunc('_messageRawIdxForSessionIndex'));
|
|
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
|
|
eval(extractFunc('_remountMessageViewportAnchor'));
|
|
eval(extractFunc('_restorePinnedMessageScrollSnapshot'));
|
|
eval(extractFunc('_restoreMessageScrollSnapshotSameFrame'));
|
|
|
|
const snapshot = {
|
|
anchor: {rawIdx: 5, topOffset: 50},
|
|
top: 100,
|
|
bottom: 6600,
|
|
scrollHeight: 7200,
|
|
pinned: false,
|
|
userUnpinned: true,
|
|
};
|
|
_restoreMessageScrollSnapshotSameFrame(snapshot);
|
|
console.log(JSON.stringify({renderCalls, scrollTopHistory}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert len(metrics["renderCalls"]) == 1, (
|
|
"_restoreMessageScrollSnapshotSameFrame must call renderMessages to mount the virtualized-out anchor row"
|
|
)
|
|
assert metrics["renderCalls"][0].get("preserveScroll") is True, (
|
|
"re-render must use preserveScroll:true to avoid scrolling to bottom"
|
|
)
|
|
assert len(metrics["scrollTopHistory"]) >= 1, (
|
|
"scrollTop must be adjusted before re-render to place anchor row in the virtual window"
|
|
)
|
|
# rawIdx=5, visIdx=5: offset=5*120=600, viewport=600, scrollTop=round(600-600*0.35)=390
|
|
assert metrics["scrollTopHistory"][0] == 390
|
|
|
|
|
|
def test_virtualize_transcript_opt_out_forces_full_render_window():
|
|
"""#4325: when window._virtualizeTranscript===false, _currentMessageVirtualWindow
|
|
must return a non-virtualized full window even for a long (>threshold) transcript,
|
|
so the whole transcript renders. When true/undefined it virtualizes as before."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
|
|
user:120,
|
|
assistant:160,
|
|
tool_call:400,
|
|
default:140,
|
|
};
|
|
function _messageVirtualDefaultHeightForRole(role){
|
|
return MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS[
|
|
role&&Object.prototype.hasOwnProperty.call(MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS,role)?role:'default'
|
|
];
|
|
}
|
|
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80;
|
|
const MESSAGE_VIRTUAL_BUFFER_PX = 900;
|
|
let _messageVirtualHeightCache = [];
|
|
let _messageVirtualEstimatedRowHeight = 140;
|
|
function _syncMessageVirtualHeightCache(){ /* no-op for the test */ }
|
|
function $(id){ return {scrollTop: 5000, clientHeight: 720}; }
|
|
function _messageVirtualRoleForEntry(){ return 'default'; }
|
|
const window = {};
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
eval(extractFunc('_currentMessageVirtualWindow'));
|
|
// 200 visible messages — well over the 80 threshold
|
|
const visWithIdx = Array.from({length: 200}, (_, i) => ({rawIdx: i}));
|
|
// OFF: opt-out → full render
|
|
window._virtualizeTranscript = false;
|
|
const off = _currentMessageVirtualWindow(visWithIdx, 50);
|
|
// ON (default): virtualizes
|
|
window._virtualizeTranscript = true;
|
|
const on = _currentMessageVirtualWindow(visWithIdx, 50);
|
|
// UNDEFINED: also virtualizes (opt-out only when explicitly false)
|
|
delete window._virtualizeTranscript;
|
|
const undef = _currentMessageVirtualWindow(visWithIdx, 50);
|
|
console.log(JSON.stringify({off, on, undef}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
# OFF → full, non-virtualized window covering every row
|
|
assert metrics["off"]["virtualized"] is False
|
|
assert metrics["off"]["start"] == 0
|
|
assert metrics["off"]["end"] == 200
|
|
assert metrics["off"]["topPad"] == 0
|
|
assert metrics["off"]["bottomPad"] == 0
|
|
# ON → virtualized (only a window of the 200 rows)
|
|
assert metrics["on"]["virtualized"] is True
|
|
assert metrics["on"]["end"] - metrics["on"]["start"] < 200
|
|
# UNDEFINED → still virtualizes (opt-out is explicit-false only)
|
|
assert metrics["undef"]["virtualized"] is True
|
|
|
|
|
|
def test_virtualize_transcript_gate_present_in_current_window_fn():
|
|
"""The opt-out gate must live in _currentMessageVirtualWindow (the single
|
|
chokepoint), guarding on window._virtualizeTranscript===false."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
start = js.index("function _currentMessageVirtualWindow(")
|
|
body = js[start:start + 900]
|
|
assert "_virtualizeTranscript===false" in body, (
|
|
"opt-out gate must check window._virtualizeTranscript===false in "
|
|
"_currentMessageVirtualWindow"
|
|
)
|
|
assert "virtualized:false" in body, "gate must return a non-virtualized window when opted out"
|
|
|
|
|
|
def test_message_virtual_default_height_for_role_returns_correct_heights():
|
|
"""Verify per-role default heights are configured."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
|
|
user:120,
|
|
assistant:160,
|
|
tool_call:400,
|
|
default:140,
|
|
};
|
|
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
|
|
console.log(JSON.stringify({
|
|
tool_call: _messageVirtualDefaultHeightForRole('tool_call'),
|
|
user: _messageVirtualDefaultHeightForRole('user'),
|
|
assistant: _messageVirtualDefaultHeightForRole('assistant'),
|
|
unknown: _messageVirtualDefaultHeightForRole('unknown'),
|
|
default: _messageVirtualDefaultHeightForRole('default'),
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["tool_call"] == 400
|
|
assert metrics["user"] == 120
|
|
assert metrics["assistant"] == 160
|
|
assert metrics["unknown"] == 140
|
|
assert metrics["default"] == 140
|
|
|
|
|
|
def test_message_virtual_role_for_entry_classifies_tool_calls():
|
|
"""Verify role classifier detects tool_calls, tool_use content, and _partial_tool_calls."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
eval(extractFunc('_messageVirtualRoleForEntry'));
|
|
console.log(JSON.stringify({
|
|
userRole: _messageVirtualRoleForEntry({m: {role: 'user'}}),
|
|
assistantNoTools: _messageVirtualRoleForEntry({m: {role: 'assistant'}}),
|
|
assistantWithToolCalls: _messageVirtualRoleForEntry({m: {role: 'assistant', tool_calls: [{id: '1'}]}}),
|
|
assistantWithToolUse: _messageVirtualRoleForEntry({m: {role: 'assistant', content: [{type: 'tool_use', id: '1'}]}}),
|
|
assistantWithPartialToolCalls: _messageVirtualRoleForEntry({m: {role: 'assistant', _partial_tool_calls: [{id: '1'}]}}),
|
|
noEntry: _messageVirtualRoleForEntry(null),
|
|
noMessage: _messageVirtualRoleForEntry({m: null}),
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["userRole"] == "user"
|
|
assert metrics["assistantNoTools"] == "assistant"
|
|
assert metrics["assistantWithToolCalls"] == "tool_call"
|
|
assert metrics["assistantWithToolUse"] == "tool_call"
|
|
assert metrics["assistantWithPartialToolCalls"] == "tool_call"
|
|
assert metrics["noEntry"] == "default"
|
|
assert metrics["noMessage"] == "default"
|
|
|
|
|
|
def test_message_virtual_window_with_role_for_idx_uses_role_defaults():
|
|
"""Verify _messageVirtualWindow uses role-specific heights when roleForIdx is provided."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
|
|
user:120,
|
|
assistant:160,
|
|
tool_call:400,
|
|
default:140,
|
|
};
|
|
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80;
|
|
const MESSAGE_VIRTUAL_BUFFER_PX = 900;
|
|
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
const visWithIdx = [
|
|
{m: {role: 'user'}},
|
|
{m: {role: 'assistant', tool_calls: [{id: '1'}]}},
|
|
{m: {role: 'assistant'}},
|
|
];
|
|
const metrics = _messageVirtualWindow({
|
|
total: 3,
|
|
scrollTop: 0,
|
|
viewportHeight: 600,
|
|
heights: [0, 0, 0],
|
|
defaultHeight: 140,
|
|
roleForIdx: (idx) => {
|
|
const entry = visWithIdx[idx];
|
|
if(!entry || !entry.m) return 'default';
|
|
if(entry.m.role === 'user') return 'user';
|
|
if(entry.m.role === 'assistant'){
|
|
if(Array.isArray(entry.m.tool_calls) && entry.m.tool_calls.length > 0) return 'tool_call';
|
|
return 'assistant';
|
|
}
|
|
return 'default';
|
|
},
|
|
bufferPx: 0,
|
|
threshold: 2,
|
|
keepTailCount: 0,
|
|
});
|
|
console.log(JSON.stringify({
|
|
virtualized: metrics.virtualized,
|
|
start: metrics.start,
|
|
end: metrics.end,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
# With 3 rows (120 + 400 + 160 = 680px) and viewport 600px, should not virtualize (below threshold)
|
|
# So this checks that the role-based defaults are being computed
|
|
assert metrics["end"] - metrics["start"] > 0
|
|
|
|
|
|
def test_message_virtual_window_cached_heights_override_role_defaults():
|
|
"""Verify cached heights take precedence over role-specific defaults."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
|
|
user:120,
|
|
assistant:160,
|
|
tool_call:400,
|
|
default:140,
|
|
};
|
|
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80;
|
|
const MESSAGE_VIRTUAL_BUFFER_PX = 900;
|
|
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
const visWithIdx = [
|
|
{m: {role: 'user'}}, // normally 120
|
|
{m: {role: 'assistant', tool_calls: [{id: '1'}]}}, // normally 400
|
|
];
|
|
// Test case 1: with cached heights 250+300=550px < 600px viewport, no virtualization needed
|
|
const metricsSmall = _messageVirtualWindow({
|
|
total: 2,
|
|
scrollTop: 0,
|
|
viewportHeight: 600,
|
|
heights: [250, 300], // Cached heights override roles
|
|
defaultHeight: 140,
|
|
roleForIdx: (idx) => {
|
|
const entry = visWithIdx[idx];
|
|
if(!entry || !entry.m) return 'default';
|
|
if(entry.m.role === 'user') return 'user';
|
|
if(entry.m.role === 'assistant'){
|
|
if(Array.isArray(entry.m.tool_calls) && entry.m.tool_calls.length > 0) return 'tool_call';
|
|
return 'assistant';
|
|
}
|
|
return 'default';
|
|
},
|
|
bufferPx: 0,
|
|
threshold: 80,
|
|
keepTailCount: 0,
|
|
});
|
|
// Test case 2: with many rows and cached heights, should use cached heights not role defaults
|
|
const largeList = Array.from({length: 100}, (_, i) => ({m: {role: i % 2 ? 'user' : 'assistant'}}));
|
|
const cachedHeights = Array.from({length: 100}, (_, i) => 200); // All 200px when cached
|
|
const metricsLarge = _messageVirtualWindow({
|
|
total: 100,
|
|
scrollTop: 0,
|
|
viewportHeight: 600,
|
|
heights: cachedHeights,
|
|
defaultHeight: 140,
|
|
roleForIdx: (idx) => {
|
|
if(largeList[idx]?.m?.role === 'user') return 'user';
|
|
return 'assistant';
|
|
},
|
|
bufferPx: 0,
|
|
threshold: 80,
|
|
keepTailCount: 0,
|
|
});
|
|
console.log(JSON.stringify({
|
|
smallVirtualized: metricsSmall.virtualized,
|
|
largeVirtualized: metricsLarge.virtualized,
|
|
largeHasWindow: metricsLarge.end > metricsLarge.start,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
# With 2 rows below threshold, should not virtualize
|
|
assert metrics["smallVirtualized"] is False
|
|
# With 100 rows above threshold, should virtualize and use cached heights
|
|
assert metrics["largeVirtualized"] is True
|
|
assert metrics["largeHasWindow"] is True
|
|
|
|
|
|
def test_offset_helpers_use_per_role_defaults_for_uncached_rows():
|
|
"""Verify _messageVirtualScrollTopForVisibleIdx and _messageVirtualPrependedHeightDelta
|
|
use per-role default heights (not the flat 140px estimate) for uncached rows,
|
|
and that these agree with _messageVirtualWindow's own accounting."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {
|
|
user: 120,
|
|
assistant: 160,
|
|
tool_call: 400,
|
|
default: 140,
|
|
};
|
|
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
|
|
eval(extractFunc('_messageVirtualRoleForEntry'));
|
|
|
|
// Three entries: user (120), tool_call (400), assistant (160) — all uncached (height=0)
|
|
const visWithIdx = [
|
|
{rawIdx: 0, m: {role: 'user'}},
|
|
{rawIdx: 1, m: {role: 'assistant', tool_calls: [{id: 'x'}]}},
|
|
{rawIdx: 2, m: {role: 'assistant'}},
|
|
];
|
|
|
|
// --- _messageVirtualScrollTopForVisibleIdx ---
|
|
let _messageVirtualHeightCache = [0, 0, 0];
|
|
let _messageVirtualHeightCacheEntries = [];
|
|
let _messageVirtualHeightCacheLen = 3;
|
|
let _messageVirtualHeightCacheSrc = null;
|
|
let _messageVirtualEstimatedRowHeight = 140;
|
|
let _messageVirtualWindowKey = '';
|
|
let S = {messages: visWithIdx.map(e => e.m)};
|
|
function _messageIsRenderable(){ return true; }
|
|
eval(extractFunc('_messageVirtualHeightEntryMatches'));
|
|
eval(extractFunc('_syncMessageVirtualHeightCache'));
|
|
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
|
|
// scrollTop to visibleIdx=2 must sum heights of idx 0 (120) and idx 1 (400) = 520
|
|
_messageVirtualHeightCacheEntries = visWithIdx;
|
|
_messageVirtualHeightCacheSrc = S.messages;
|
|
const scrollTop = _messageVirtualScrollTopForVisibleIdx(visWithIdx, 2, null);
|
|
|
|
// --- _messageVirtualPrependedHeightDelta ---
|
|
let virtualized2 = true;
|
|
let _messageVirtualHeightCache2 = [0, 0, 0];
|
|
let _messageVirtualEstimatedRowHeight2 = 140;
|
|
function _getVisibleMessagesWithIdx(){ return visWithIdx; }
|
|
function _messageVirtualKeepTailCount(){ return 0; }
|
|
function _currentMessageVirtualWindow(){ return {virtualized: virtualized2}; }
|
|
// Patch cache var used by _messageVirtualPrependedHeightDelta
|
|
eval(extractFunc('_messageVirtualPrependedHeightDelta').replace(
|
|
/_messageVirtualHeightCache/g, '_messageVirtualHeightCache2'
|
|
).replace(
|
|
/_messageVirtualEstimatedRowHeight/g, '_messageVirtualEstimatedRowHeight2'
|
|
));
|
|
// Sum of first 3 uncached entries: user(120) + tool_call(400) + assistant(160) = 680
|
|
const delta = _messageVirtualPrependedHeightDelta(3);
|
|
|
|
// --- _messageVirtualWindow agreement ---
|
|
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 2;
|
|
const MESSAGE_VIRTUAL_BUFFER_PX = 0;
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
const win = _messageVirtualWindow({
|
|
total: 3,
|
|
scrollTop: 0,
|
|
viewportHeight: 600,
|
|
heights: [0, 0, 0],
|
|
defaultHeight: 140,
|
|
roleForIdx: idx => _messageVirtualRoleForEntry(visWithIdx[idx]),
|
|
bufferPx: 0,
|
|
threshold: 2,
|
|
keepTailCount: 0,
|
|
});
|
|
// topPad is sum of rows before win.start; with start=0 it is 0, but
|
|
// the window must have consumed the same per-role heights when computing
|
|
// row positions, so verify the window spans all rows correctly
|
|
const windowCoversAll = win.start === 0 && win.end === 3;
|
|
|
|
console.log(JSON.stringify({scrollTop, delta, windowCoversAll}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
# scrollTop to idx=2 = sum of row 0 (120) + row 1 (400) = 520, no viewport offset (null container)
|
|
assert metrics["scrollTop"] == 520, (
|
|
f"expected 520 (120+400) but got {metrics['scrollTop']}; "
|
|
"offset helper must use per-role defaults, not flat 140px"
|
|
)
|
|
# prepended delta for 3 uncached rows = 120 + 400 + 160 = 680
|
|
assert metrics["delta"] == 680, (
|
|
f"expected 680 (120+400+160) but got {metrics['delta']}; "
|
|
"prepend helper must use per-role defaults, not flat 140px"
|
|
)
|
|
# windowing function must also cover the same three rows
|
|
assert metrics["windowCoversAll"] is True
|
|
|
|
|
|
def test_compensate_scroll_for_measurement_delta_no_anchor_does_not_throw():
|
|
"""When _captureMessageViewportAnchor returns null, the compensation helper
|
|
should not throw and should not mutate scrollTop."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let renderCalls = [];
|
|
let scrollTopWasMutated = false;
|
|
let scrollTopValue = 100;
|
|
const container = {
|
|
get scrollTop(){ return scrollTopValue; },
|
|
set scrollTop(v){ scrollTopWasMutated = true; scrollTopValue = v; },
|
|
getBoundingClientRect(){ return {top: 0}; },
|
|
querySelector(){ return null; },
|
|
classList: { add(){}, remove(){} },
|
|
};
|
|
function $(id){ return id === 'messages' ? container : null; }
|
|
function _captureMessageViewportAnchor(){ return null; }
|
|
let _programmaticScroll = false;
|
|
let _lastScrollTop = 0;
|
|
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
|
|
function requestAnimationFrame(cb){ cb(); }
|
|
eval(extractFunc('_compensateScrollForMeasurementDelta'));
|
|
_compensateScrollForMeasurementDelta(()=>{ _scheduleMessageVirtualizedRender(true); });
|
|
console.log(JSON.stringify({
|
|
renderCalled: renderCalls.length > 0,
|
|
scrollTopMutated: scrollTopWasMutated,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["renderCalled"] is True
|
|
assert metrics["scrollTopMutated"] is False
|
|
|
|
|
|
def test_compensate_scroll_for_measurement_delta_shifts_scroll_when_anchor_moves():
|
|
"""When the anchor row shifts position due to measurement changes,
|
|
scrollTop should be adjusted by the delta."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let scrollTopValue = 200;
|
|
let scrollHistory = [];
|
|
const container = {
|
|
get scrollTop(){ return scrollTopValue; },
|
|
set scrollTop(v){ scrollHistory.push(v); scrollTopValue = v; },
|
|
getBoundingClientRect(){ return {top: 50, bottom: 650}; },
|
|
classList: { add(){}, remove(){} },
|
|
querySelector(selector){
|
|
if(selector === '[data-msg-idx="42"]'){
|
|
// After render, the row moved from relative offset 100 to 150 (50px shift)
|
|
return {
|
|
getBoundingClientRect(){ return {top: 100}; }
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
function $(id){ return id === 'messages' ? container : null; }
|
|
function _captureMessageViewportAnchor(){
|
|
// Anchor was at topOffset 100 before the render
|
|
return {rawIdx: 42, topOffset: 100};
|
|
}
|
|
let _programmaticScroll = false;
|
|
let _programmaticScrollSetAt = 0;
|
|
let _programmaticScrollResetTimer = 0;
|
|
let _lastScrollTop = 0;
|
|
const performance = { now(){ return 1000; } };
|
|
function clearTimeout(){}
|
|
function setTimeout(cb, ms){ cb(); return 1; }
|
|
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
|
|
let renderCalls = [];
|
|
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
|
|
function requestAnimationFrame(cb){ cb(); }
|
|
eval(extractFunc('_compensateScrollForMeasurementDelta'));
|
|
_compensateScrollForMeasurementDelta(()=>{ _scheduleMessageVirtualizedRender(true); });
|
|
console.log(JSON.stringify({
|
|
scrollHistory: scrollHistory,
|
|
renderCalled: renderCalls.length > 0,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["renderCalled"] is True
|
|
# actualOffset = 100 - 50 = 50, delta = 50 - 100 = -50 (row moved up 50px)
|
|
# scrollTop should adjust: 200 + (-50) = 150
|
|
assert metrics["scrollHistory"] == [150], (
|
|
"scrollTop should shift by -50px to keep anchor in place"
|
|
)
|
|
|
|
|
|
def test_compensate_scroll_for_measurement_delta_skips_small_delta():
|
|
"""When delta < 2px, no compensation is applied (tolerance)."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let scrollTopValue = 200;
|
|
let scrollHistory = [];
|
|
const container = {
|
|
get scrollTop(){ return scrollTopValue; },
|
|
set scrollTop(v){ scrollHistory.push(v); scrollTopValue = v; },
|
|
getBoundingClientRect(){ return {top: 50, bottom: 650}; },
|
|
classList: { add(){}, remove(){} },
|
|
querySelector(selector){
|
|
if(selector === '[data-msg-idx="42"]'){
|
|
// Row moved by only 1px, within tolerance
|
|
return {
|
|
getBoundingClientRect(){ return {top: 251}; }
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
function $(id){ return id === 'messages' ? container : null; }
|
|
function _captureMessageViewportAnchor(){
|
|
return {rawIdx: 42, topOffset: 200};
|
|
}
|
|
let _programmaticScroll = false;
|
|
let _programmaticScrollSetAt = 0;
|
|
let _programmaticScrollResetTimer = 0;
|
|
let _lastScrollTop = 0;
|
|
const performance = { now(){ return 1000; } };
|
|
function clearTimeout(){}
|
|
function setTimeout(cb, ms){ cb(); return 1; }
|
|
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
|
|
let renderCalls = [];
|
|
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
|
|
function requestAnimationFrame(cb){ cb(); }
|
|
eval(extractFunc('_compensateScrollForMeasurementDelta'));
|
|
_compensateScrollForMeasurementDelta(()=>{ _scheduleMessageVirtualizedRender(true); });
|
|
console.log(JSON.stringify({
|
|
scrollHistory: scrollHistory,
|
|
renderCalled: renderCalls.length > 0,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["renderCalled"] is True
|
|
assert metrics["scrollHistory"] == [], (
|
|
"scrollTop should not change for delta < 2px"
|
|
)
|
|
|
|
|
|
def test_compensate_scroll_for_measurement_delta_sets_programmatic_scroll_flag():
|
|
"""_programmaticScroll should be set during compensation and cleared after rAF+setTimeout."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let scrollTopValue = 200;
|
|
let _programmaticScroll = false;
|
|
let _programmaticScrollSetAt = 0;
|
|
let _programmaticScrollResetTimer = 0;
|
|
let _lastScrollTop = 0;
|
|
let scrollSetCount = 0;
|
|
const performance = { now(){ return 1000; } };
|
|
function clearTimeout(){}
|
|
const container = {
|
|
get scrollTop(){ return scrollTopValue; },
|
|
set scrollTop(v){
|
|
scrollSetCount++;
|
|
scrollTopValue = v;
|
|
},
|
|
getBoundingClientRect(){ return {top: 50, bottom: 650}; },
|
|
classList: { add(){}, remove(){} },
|
|
querySelector(selector){
|
|
if(selector === '[data-msg-idx="42"]'){
|
|
return {
|
|
getBoundingClientRect(){ return {top: 300}; }
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
function $(id){ return id === 'messages' ? container : null; }
|
|
function _captureMessageViewportAnchor(){
|
|
return {rawIdx: 42, topOffset: 200};
|
|
}
|
|
let renderCalls = [];
|
|
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
|
|
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
|
|
function requestAnimationFrame(cb){ cb(); }
|
|
function setTimeout(cb, delay){ cb(); return 1; }
|
|
eval(extractFunc('_compensateScrollForMeasurementDelta'));
|
|
_compensateScrollForMeasurementDelta(()=>{ _scheduleMessageVirtualizedRender(true); });
|
|
console.log(JSON.stringify({
|
|
programmaticScrollWasTrue: _programmaticScroll === true || scrollSetCount > 0,
|
|
programmaticScrollNowFalse: _programmaticScroll === false,
|
|
scrollWasSet: scrollSetCount > 0,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
# _programmaticScroll should have been set to true when scrollTop was adjusted
|
|
assert metrics["scrollWasSet"] is True, (
|
|
"scrollTop should be set when delta > 2px"
|
|
)
|
|
# _programmaticScroll should have been cleared after the setTimeout callback
|
|
assert metrics["programmaticScrollNowFalse"] is True, (
|
|
"_programmaticScroll should be false after setTimeout completes"
|
|
)
|
|
|
|
|
|
def test_virtualized_render_uses_compensation_helper():
|
|
"""_scheduleMessageVirtualizedRender must wrap renderMessages with _compensateScrollForMeasurementDelta."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
start = js.index("function _scheduleMessageVirtualizedRender(")
|
|
end = js.index("\n// ──", start)
|
|
body = js[start:end]
|
|
|
|
assert "_compensateScrollForMeasurementDelta" in body, (
|
|
"_scheduleMessageVirtualizedRender must call "
|
|
"_compensateScrollForMeasurementDelta to compensate scroll after measurement-driven rerenders"
|
|
)
|
|
assert "renderMessages(" in body, (
|
|
"the compensation helper should wrap the actual renderMessages call"
|
|
)
|
|
|
|
|
|
def test_scroll_listener_guards_programmatic_scroll_before_marking_active():
|
|
"""The _programmaticScroll guard must appear before _markMessageVirtualScrollActive
|
|
in the scroll listener so that programmatic scrolls (e.g. from
|
|
_compensateScrollForMeasurementDelta) do not arm the 150ms settle timer."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
listener_start = js.index("el.addEventListener('scroll',()=>{")
|
|
listener_end = js.index("});", listener_start)
|
|
listener_body = js[listener_start:listener_end]
|
|
|
|
guard_pos = listener_body.index("if(_freshProgrammaticScrollActive()) return;")
|
|
mark_pos = listener_body.index("_markMessageVirtualScrollActive();")
|
|
assert guard_pos < mark_pos, (
|
|
"Scroll listener must check _programmaticScroll before calling "
|
|
"_markMessageVirtualScrollActive so programmatic scrolls do not arm "
|
|
"the 150ms settle timer and chain measurement delays"
|
|
)
|
|
|
|
|
|
def test_clear_height_cache_resets_scroll_settle_globals():
|
|
"""_clearMessageVirtualHeightCache must zero out the three scroll-settle globals
|
|
so that a deferred measurement from a previous session cannot fire against the
|
|
new session's DOM after a session switch."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let timerCleared = false;
|
|
let _messageVirtualScrollActive = true;
|
|
let _messageVirtualScrollSettleTimer = 99;
|
|
let _messageVirtualDeferredMeasurement = {some: 'stale-metrics'};
|
|
let _messageVirtualHeightCache = [100, 200];
|
|
let _messageVirtualHeightCacheEntries = [{rawIdx: 0}];
|
|
let _messageVirtualHeightCacheLen = 2;
|
|
let _messageVirtualHeightCacheSrc = {};
|
|
let _messageVirtualEstimatedRowHeight = 200;
|
|
let _messageVirtualWindowKey = 'old-key';
|
|
let _messageVirtualMeasurementCycleKey = 'old-cycle';
|
|
let _messageVirtualMeasurementRetryCount = 3;
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
|
|
// #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache
|
|
// resets the estimate via _messageVirtualDefaultHeightForRole('default'), so the harness
|
|
// must provide the role map + that helper.
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {user:120, assistant:160, tool_call:400, default:140};
|
|
function clearTimeout(id){ timerCleared = (id === 99); }
|
|
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
|
|
eval(extractFunc('_clearMessageVirtualHeightCache'));
|
|
_clearMessageVirtualHeightCache();
|
|
console.log(JSON.stringify({
|
|
scrollActive: _messageVirtualScrollActive,
|
|
settleTimer: _messageVirtualScrollSettleTimer,
|
|
deferred: _messageVirtualDeferredMeasurement,
|
|
timerCleared: timerCleared,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["scrollActive"] is False, (
|
|
"_clearMessageVirtualHeightCache must reset _messageVirtualScrollActive to false"
|
|
)
|
|
assert metrics["settleTimer"] == 0, (
|
|
"_clearMessageVirtualHeightCache must reset _messageVirtualScrollSettleTimer to 0"
|
|
)
|
|
assert metrics["deferred"] is None, (
|
|
"_clearMessageVirtualHeightCache must clear _messageVirtualDeferredMeasurement to null"
|
|
)
|
|
assert metrics["timerCleared"] is True, (
|
|
"_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer"
|
|
)
|