Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions public/js/virtual-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('.lazy-pending');
Expand Down
2 changes: 2 additions & 0 deletions structure/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh the structure counts for the changed trees

This change adds a test file and 15 lines under public/, but leaves structure/str_func.md and its count verifier untouched. The repository sync guide explicitly requires updating both whenever tests/ or public/ changes, so refresh the derived structure metadata and its verification coverage in this commit.

AGENTS.md reference: structure/AGENTS.md:L7-L7

Useful? React with 👍 / 👎.


### 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.
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/virtual-scroll-dom-order.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
constructor(options: Record<string, unknown>) {
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<string, unknown>) { 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: `<div class="msg" data-message-id="${index}"><button id="row-${index}" class="lazy-pending">메시지 ${index}</button></div>`,
})), { autoActivate: false });
view.activateIfNeeded(false);
});
test.afterEach(() => { view.clear(); resetWebUiDom(); mock.restoreAll(); });

const inner = () => document.querySelector<HTMLElement>('.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 }]);
});
Loading