diff --git a/public/js/virtual-scroll.ts b/public/js/virtual-scroll.ts index 0ab2c87a8..fa70b3ea7 100644 --- a/public/js/virtual-scroll.ts +++ b/public/js/virtual-scroll.ts @@ -662,6 +662,21 @@ export class VirtualScroll { el.style.transform = `translateY(${vItem.start}px)`; } + // Absolute positioning does not determine reading or keyboard order. + // Put new older rows before retained rows without replacing their hosts. + const focused = this.innerEl.contains(document.activeElement) + ? document.activeElement as HTMLElement : null; + let cursor = this.innerEl.firstElementChild; + for (const vItem of virtualItems) { + const el = this.mounted.get(vItem.index); + if (!el) continue; + if (el !== cursor) this.innerEl.insertBefore(el, cursor); + cursor = el.nextElementSibling; + } + if (focused?.isConnected && document.activeElement !== focused) { + focused.focus({ preventScroll: true }); + } + // Lazy render before measuring; markdown/code/math change heights. if (this.onLazyRender) { const lazyTargets = this.innerEl.querySelectorAll('.lazy-pending'); diff --git a/structure/frontend.md b/structure/frontend.md index fd37f5b12..d8be60c01 100644 --- a/structure/frontend.md +++ b/structure/frontend.md @@ -70,6 +70,8 @@ Native request feedback follows the full selected form identity (including view, Canonical terminal previews and public answers can arrive in either order. A later run-bound native-present or print answer corrects only its own earlier canonical row, without another completion/unread notification. Native-absent diagnostics are notices, not Activity answers. Virtual scroll uses stable message IDs and additive live remount/recycle hooks; cache correction updates only existing assistant rows in the captured browser cache scope and run. The full answer is never read back from the bounded reducer. +Virtualized rows are reconciled into geometric order in the DOM before lazy rendering and post-render hooks. Backward scrolling inserts older rows before retained newer rows, so reading and Tab order agree without replacing message or Activity hosts. Retained focus survives reordering without scrolling; evicted rows and focus outside the transcript are not restored. + ### Retained Activity and saved answers `activity-history.ts` admits only owned transcript/discovery hosts. It queues at most16 reads behind one active job, retains64 host records, cancels recycled/navigation jobs and bounds each read job to30s. Targeted replay buffers only that run while unrelated live turns continue. Historical stored execution scope is preserved; it need not equal the currently selected live scope. Focused terminal previews cannot be evicted, and recycled offscreen previews are preferred for eviction. Remounts reject nested copied message keys. diff --git a/tests/unit/virtual-scroll-dom-order.test.ts b/tests/unit/virtual-scroll-dom-order.test.ts new file mode 100644 index 000000000..38ed43209 --- /dev/null +++ b/tests/unit/virtual-scroll-dom-order.test.ts @@ -0,0 +1,104 @@ +import '../setup/isolated-home.ts'; +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { setupWebUiDom, resetWebUiDom } from './web-ui-test-dom.ts'; + +let visible = [2, 3]; +let change: () => void = () => {}; +// Control only the measured window. The real VirtualScroll owns DOM/lifecycle. +class Geometry { + options: Record; + constructor(options: Record) { + this.options = options; + change = options['onChange'] as () => void; + } + _didMount() { return () => {}; } + _willUpdate() {} + getVirtualItems() { return visible.map(index => ({ index, start: index * 100, size: 100, end: (index + 1) * 100, key: index })); } + getTotalSize() { return 400; } + setOptions(options: Record) { this.options = options; } + measureElement() {} + measure() {} + scrollToIndex() {} + scrollToOffset() {} +} +mock.module('@tanstack/virtual-core', { namedExports: { + Virtualizer: Geometry, elementScroll() {}, observeElementRect() {}, observeElementOffset() {}, +} }); +mock.module('../../public/js/render.js', { namedExports: { releaseMermaidNodes() {} } }); +mock.module('../../public/js/features/process-block.js', { namedExports: { releaseProcessBlockDetails() {} } }); + +let view: import('../../public/js/virtual-scroll.ts').VirtualScroll; +test.beforeEach(async () => { + setupWebUiDom(); visible = [2, 3]; + const { VirtualScroll } = await import('../../public/js/virtual-scroll.ts'); + view = new VirtualScroll('chatMessages'); + view.setItems(Array.from({ length: 4 }, (_, index) => ({ + id: String(index), messageId: String(index), height: 100, + html: `
`, + })), { autoActivate: false }); + view.activateIfNeeded(false); +}); +test.afterEach(() => { view.clear(); resetWebUiDom(); mock.restoreAll(); }); + +const inner = () => document.querySelector('.vs-inner')!; +const order = () => Array.from(inner().children, el => Number((el as HTMLElement).dataset['vsIdx'])); +const button = (index: number) => document.getElementById(`row-${index}`)!; + +test('backward window orders DOM before lazy/postRender and retains focused node identity', () => { + const retained = button(2); const row = retained.parentElement; + retained.focus(); + const observations: number[][] = []; + const recycled: HTMLElement[] = []; + view.onLazyRender = () => { observations.push(order()); }; + view.onPostRender = () => { observations.push(order()); }; + view.addLifecycleHooks({ postRender: () => { observations.push(order()); }, recycle: el => { recycled.push(el); } }); + visible = [0, 1, 2, 3]; change(); + assert.deepEqual(order(), [0, 1, 2, 3]); + assert.deepEqual(observations, [[0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3]]); + assert.equal(button(2), retained); assert.equal(retained.parentElement, row); + assert.equal(document.activeElement, retained); + assert.deepEqual(recycled, []); + visible = [1, 2, 3]; change(); + assert.deepEqual(order(), [1, 2, 3]); assert.equal(document.activeElement, retained); + assert.deepEqual(recycled.map(el => el.dataset['messageId']), ['0']); +}); + +test('outside focus is not stolen when older rows mount', () => { + const outside = document.getElementById('btnSend')!; outside.focus(); + visible = [0, 1, 2, 3]; change(); + assert.deepEqual(order(), [0, 1, 2, 3]); + assert.equal(document.activeElement, outside); +}); + +test('an evicted focused row is never restored', () => { + const removed = button(2); removed.focus(); + const focus = mock.method(removed, 'focus'); + visible = [0, 1]; change(); + assert.deepEqual(order(), [0, 1]); assert.equal(removed.isConnected, false); + assert.notEqual(document.activeElement, removed); assert.equal(focus.mock.callCount(), 0); +}); + +test('unchanged ordered window performs no DOM moves or focus calls', () => { + visible = [0, 1, 2, 3]; change(); + const retained = button(2); retained.focus(); + const move = mock.method(inner(), 'insertBefore'); + const focus = mock.method(retained, 'focus'); + change(); + assert.equal(move.mock.callCount(), 0); assert.equal(focus.mock.callCount(), 0); + assert.equal(document.activeElement, retained); +}); + +test('explicitly reordered retained DOM restores lost focus without scrolling', () => { + visible = [0, 1, 2, 3]; change(); + // Boundary probe only: simulate an external DOM owner, NOT reversed geometry + // or a normal TanStack scroll window. JSDOM moves lose focused descendants. + for (const index of [0, 1, 2, 3]) inner().prepend(button(index).parentElement!); + assert.deepEqual(order(), [3, 2, 1, 0]); + const retained = button(0); retained.focus(); + const focus = mock.method(retained, 'focus'); + change(); + assert.deepEqual(order(), [0, 1, 2, 3]); assert.equal(button(0), retained); + assert.equal(document.activeElement, retained); assert.equal(focus.mock.callCount(), 1); + assert.deepEqual(focus.mock.calls[0]?.arguments, [{ preventScroll: true }]); +});