fix(app): preserve momentum during live session updates

Track real user scrolling through scrollend with a quiet watchdog, defer timeline commits and virtualizer corrections until settlement, and make SessionDetail the sole tail-follow owner. Add unit and Electron regressions for scroll writes, reader anchoring, near-tail escape, explicit navigation, and flap timing.
This commit is contained in:
tommy0103
2026-07-15 01:35:05 +08:00
parent b58c34d3af
commit 82d9fbf657
10 changed files with 485 additions and 43 deletions
+5 -5
View File
@@ -11,7 +11,7 @@ import {
import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs';
import { createSessionPatch } from '../app/src/shared/session-patch.mjs';
test('live snapshots coalesce while scrolling and commit once after scroll end', async () => {
test('live snapshots keep loading while scrolling and commit only the latest after scroll end', async () => {
let scrolling = true;
let loads = 0;
const commits = [];
@@ -24,16 +24,16 @@ test('live snapshots coalesce while scrolling and commit once after scroll end',
await coordinator.request();
await coordinator.request();
await coordinator.request();
assert.equal(loads, 0);
assert.equal(loads, 3, 'patches are loaded into the pending snapshot while the timeline is frozen');
assert.deepEqual(commits, []);
scrolling = false;
await coordinator.flush();
assert.equal(loads, 1);
assert.deepEqual(commits, [1]);
assert.equal(loads, 3, 'scroll end reuses the freshest pending snapshot');
assert.deepEqual(commits, [3]);
await coordinator.flush();
assert.equal(loads, 1, 'an idle flush without another update is a no-op');
assert.equal(loads, 3, 'an idle flush without another update is a no-op');
});
test('an update arriving during an in-flight load skips the stale snapshot without overlap', async () => {
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createSessionTimelineScrollPolicy } from '../app/src/renderer/src/session-timeline-scroll-policy.mjs';
test('virtualizer scroll writes are deferred throughout a user scroll', () => {
let scrolling = true;
const element = { scrollTop: 100 };
const writes = [];
const instance = { scrollElement: element };
const policy = createSessionTimelineScrollPolicy({
isUserScrolling: () => scrolling,
writeScroll: (offset, options) => {
writes.push({ offset, ...options });
element.scrollTop = offset + (options.adjustments || 0);
},
});
policy.scrollToFn(100, { behavior: 'auto', adjustments: 24 }, instance);
policy.scrollToFn(124, { behavior: 'auto' }, instance);
assert.deepEqual(writes, [], 'momentum is never interrupted by a programmatic write');
scrolling = false;
policy.flushDeferredAdjustment(instance);
assert.deepEqual(writes, [{ offset: 100, behavior: 'auto', adjustments: 24 }]);
assert.equal(element.scrollTop, 124, 'the accumulated correction restores the reader anchor once');
policy.flushDeferredAdjustment(instance);
assert.equal(writes.length, 1, 'settlement is idempotent');
});
test('explicit UUID and pagination navigation can bypass the user-scroll guard', () => {
const element = { scrollTop: 100 };
const writes = [];
const instance = { scrollElement: element };
const policy = createSessionTimelineScrollPolicy({
isUserScrolling: () => true,
writeScroll: (offset, options) => { writes.push({ offset, ...options }); },
});
policy.runExplicit(() => {
policy.scrollToFn(720, { behavior: 'auto' }, instance);
});
assert.deepEqual(writes, [{ offset: 720, behavior: 'auto' }]);
});
+12 -3
View File
@@ -38,19 +38,28 @@ test('SessionDetail renders a measured virtual window instead of the complete ti
assert.doesNotMatch(sessionDetail, /closest\(['"]\.msg/);
});
test('timeline viewport owns dynamic measurement, overscan, anchoring, and tail-follow', () => {
test('timeline viewport owns measurement and anchoring while SessionDetail alone owns tail-follow', () => {
assert.equal(appPackage.devDependencies['@tanstack/vue-virtual'], '^3.13.32');
assert.match(viewportModule, /useVirtualizer/);
assert.match(viewportModule, /overscan/);
assert.match(viewportModule, /anchorTo:\s*'end'/);
assert.match(viewportModule, /followOnAppend:\s*followOnAppend\.value/);
assert.match(viewportModule, /followOnAppend:\s*false/);
assert.match(viewportModule, /resetForInitialSnapshot/);
assert.match(viewportModule, /completeInitialSnapshot/);
assert.doesNotMatch(viewportModule, /followOnAppend:\s*true/);
assert.match(viewportModule, /scrollToFn:\s*scrollPolicy\.scrollToFn/);
assert.match(viewportModule, /useScrollendEvent:\s*true/);
assert.match(viewportModule, /isScrollingResetDelay:\s*450/);
assert.match(viewportModule, /settleUserScroll/);
assert.match(viewportModule, /useAnimationFrameWithResizeObserver:\s*true/);
assert.match(viewportModule, /scrollPaddingEnd/);
assert.match(viewportModule, /scrollToIndex/);
assert.match(viewportModule, /if \(!element\) return/);
assert.match(sessionDetail, /isScrolling:\s*\(\) => userScroll\.isActive\(\)/);
assert.doesNotMatch(sessionDetail, /timelineViewport\.isScrolling/);
assert.match(
sessionDetail,
/!userScroll\.hasUpwardIntent\(\)[\s\S]{0,100}timelineViewport\.isFollowingTail\(\)/,
);
});
test('timeline count and disclosure classes come from renderer state rather than DOM state', () => {
+126
View File
@@ -0,0 +1,126 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createSessionUserScroll } from '../app/src/renderer/src/session-user-scroll.mjs';
function createScheduler() {
let now = 0;
let nextId = 1;
const tasks = new Map();
return {
setTimeout(callback, delay) {
const id = nextId++;
tasks.set(id, { callback, due: now + delay });
return id;
},
clearTimeout(id) {
tasks.delete(id);
},
advance(milliseconds) {
now += milliseconds;
while (true) {
const ready = [...tasks.entries()]
.filter(([, task]) => task.due <= now)
.sort((left, right) => left[1].due - right[1].due)[0];
if (!ready) return;
tasks.delete(ready[0]);
ready[1].callback();
}
},
};
}
function dispatch(target, type, properties = {}) {
const event = new Event(type);
for (const [key, value] of Object.entries(properties)) {
Object.defineProperty(event, key, { value });
}
target.dispatchEvent(event);
}
test('native scrollend, not the virtualizer 150ms reset, ends a user scroll', () => {
const scheduler = createScheduler();
const target = new EventTarget();
target.scrollTop = 100;
target.onscrollend = null;
let ended = 0;
const userScroll = createSessionUserScroll({
quietMs: 450,
setTimeout: scheduler.setTimeout,
clearTimeout: scheduler.clearTimeout,
onEnd: () => { ended++; },
});
userScroll.attach(target);
dispatch(target, 'wheel', { deltaY: -24 });
assert.equal(userScroll.isActive(), true);
assert.equal(userScroll.hasUpwardIntent(), true);
scheduler.advance(150);
assert.equal(userScroll.isActive(), true, '150ms silence must not end native momentum');
assert.equal(ended, 0);
dispatch(target, 'scrollend');
assert.equal(userScroll.isActive(), false);
assert.equal(ended, 1);
userScroll.detach();
dispatch(target, 'wheel', { deltaY: 12 });
assert.equal(userScroll.isActive(), false, 'detached controllers ignore later DOM events');
});
test('unsupported scrollend falls back to a 450ms quiet window', () => {
const scheduler = createScheduler();
const target = new EventTarget();
target.scrollTop = 0;
let ended = 0;
const userScroll = createSessionUserScroll({
quietMs: 450,
setTimeout: scheduler.setTimeout,
clearTimeout: scheduler.clearTimeout,
onEnd: () => { ended++; },
});
userScroll.attach(target);
dispatch(target, 'wheel', { deltaY: 12 });
scheduler.advance(449);
assert.equal(userScroll.isActive(), true);
scheduler.advance(1);
assert.equal(userScroll.isActive(), false);
assert.equal(ended, 1);
});
test('a missed native scrollend still settles through the quiet watchdog', () => {
const scheduler = createScheduler();
const target = new EventTarget();
target.scrollTop = 0;
target.onscrollend = null;
let ended = 0;
const userScroll = createSessionUserScroll({
quietMs: 450,
setTimeout: scheduler.setTimeout,
clearTimeout: scheduler.clearTimeout,
onEnd: () => { ended++; },
});
userScroll.attach(target);
dispatch(target, 'wheel', { deltaY: -12 });
scheduler.advance(450);
assert.equal(userScroll.isActive(), false);
assert.equal(ended, 1, 'a boundary wheel cannot freeze live commits forever');
});
test('downward wheel intent re-enables tail following after an upward escape', () => {
const target = new EventTarget();
target.scrollTop = 100;
target.onscrollend = null;
const userScroll = createSessionUserScroll();
userScroll.attach(target);
dispatch(target, 'wheel', { deltaY: -1 });
assert.equal(userScroll.hasUpwardIntent(), true);
dispatch(target, 'wheel', { deltaY: 1 });
assert.equal(userScroll.hasUpwardIntent(), false);
userScroll.detach();
});