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
+10 -5
View File
@@ -5,7 +5,7 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
let stopped = false; let stopped = false;
async function drain() { async function drain() {
while (!stopped && !isScrolling() && (pending || loadedSnapshot)) { while (!stopped && (pending || loadedSnapshot)) {
let snapshot = loadedSnapshot; let snapshot = loadedSnapshot;
loadedSnapshot = null; loadedSnapshot = null;
@@ -29,8 +29,8 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
} }
} }
async function flush() { async function processPending() {
if (stopped || isScrolling() || (!pending && !loadedSnapshot)) return inFlight; if (stopped || (!pending && !loadedSnapshot)) return inFlight;
if (inFlight) return inFlight; if (inFlight) return inFlight;
inFlight = drain(); inFlight = drain();
try { try {
@@ -38,15 +38,20 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
} finally { } finally {
inFlight = null; inFlight = null;
} }
if ((pending || loadedSnapshot) && !isScrolling()) return flush(); if (pending || (loadedSnapshot && !isScrolling())) return processPending();
return undefined; return undefined;
} }
function flush() {
if (stopped || isScrolling()) return inFlight;
return processPending();
}
return { return {
request() { request() {
if (stopped) return Promise.resolve(); if (stopped) return Promise.resolve();
pending = true; pending = true;
return flush(); return processPending();
}, },
flush, flush,
stop() { stop() {
@@ -0,0 +1,40 @@
export function createSessionTimelineScrollPolicy({ isUserScrolling, writeScroll }) {
let explicitDepth = 0;
let deferredAdjustment = 0;
let deferredInstance = null;
function scrollToFn(offset, options = {}, instance) {
if (explicitDepth === 0 && isUserScrolling()) {
const adjustment = Number(options.adjustments);
if (Number.isFinite(adjustment)) deferredAdjustment += adjustment;
deferredInstance = instance;
return;
}
writeScroll(offset, options, instance);
}
function runExplicit(action) {
explicitDepth++;
try {
return action();
} finally {
explicitDepth--;
}
}
function flushDeferredAdjustment(instance = deferredInstance) {
if (isUserScrolling() || deferredAdjustment === 0 || !instance?.scrollElement) return false;
const adjustment = deferredAdjustment;
deferredAdjustment = 0;
deferredInstance = null;
const offset = Number(instance.scrollElement.scrollTop) || 0;
writeScroll(offset, { behavior: 'auto', adjustments: adjustment }, instance);
return true;
}
return {
scrollToFn,
runExplicit,
flushDeferredAdjustment,
};
}
@@ -1,5 +1,6 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useVirtualizer } from '@tanstack/vue-virtual'; import { elementScroll, useVirtualizer } from '@tanstack/vue-virtual';
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
function estimatedTextHeight(text = '') { function estimatedTextHeight(text = '') {
return Math.min(560, Math.ceil(String(text).length / 72) * 20); return Math.min(560, Math.ceil(String(text).length / 72) * 20);
@@ -32,8 +33,13 @@ export function useSessionTimelineViewport({
overscan = 6, overscan = 6,
gap = 14, gap = 14,
scrollPaddingEnd = 0, scrollPaddingEnd = 0,
userScroll,
}) { }) {
const followOnAppend = ref(false); const tailFollowReady = ref(false);
const scrollPolicy = createSessionTimelineScrollPolicy({
isUserScrolling: () => userScroll?.isActive() ?? false,
writeScroll: elementScroll,
});
const virtualizer = useVirtualizer(computed(() => ({ const virtualizer = useVirtualizer(computed(() => ({
count: items.value.length, count: items.value.length,
getScrollElement: () => scrollElement.value, getScrollElement: () => scrollElement.value,
@@ -44,14 +50,16 @@ export function useSessionTimelineViewport({
overscan, overscan,
gap, gap,
anchorTo: 'end', anchorTo: 'end',
followOnAppend: followOnAppend.value, followOnAppend: false,
scrollEndThreshold: 50, scrollEndThreshold: 50,
isScrollingResetDelay: 450,
useScrollendEvent: true,
useAnimationFrameWithResizeObserver: true, useAnimationFrameWithResizeObserver: true,
scrollToFn: scrollPolicy.scrollToFn,
}))); })));
const virtualRows = computed(() => virtualizer.value.getVirtualItems()); const virtualRows = computed(() => virtualizer.value.getVirtualItems());
const totalSize = computed(() => virtualizer.value.getTotalSize()); const totalSize = computed(() => virtualizer.value.getTotalSize());
const isScrolling = computed(() => virtualizer.value.isScrolling);
function measureElement(element) { function measureElement(element) {
if (!element) return; if (!element) return;
@@ -77,7 +85,9 @@ export function useSessionTimelineViewport({
function scrollToIndex(index, options = {}) { function scrollToIndex(index, options = {}) {
const scroll = () => { const scroll = () => {
scrollPolicy.runExplicit(() => {
virtualizer.value.scrollToIndex(index, { behavior: 'auto', ...options }); virtualizer.value.scrollToIndex(index, { behavior: 'auto', ...options });
});
}; };
// A far jump starts from estimates. Re-align after mounted rows have been // A far jump starts from estimates. Re-align after mounted rows have been
@@ -85,20 +95,26 @@ export function useSessionTimelineViewport({
runWithMeasurementRetry(scroll); runWithMeasurementRetry(scroll);
} }
function scrollToEnd() { async function scrollToEnd() {
const targetWindow = scrollElement.value?.ownerDocument?.defaultView;
if (targetWindow) {
await new Promise(resolve => targetWindow.requestAnimationFrame(resolve));
}
const scroll = () => { const scroll = () => {
scrollPolicy.runExplicit(() => {
const element = scrollElement.value; const element = scrollElement.value;
if (element && 'scrollHeight' in element) { if (element && 'scrollHeight' in element) {
element.scrollTo({ top: element.scrollHeight, behavior: 'auto' }); element.scrollTo({ top: element.scrollHeight, behavior: 'auto' });
} else { } else {
virtualizer.value.scrollToEnd({ behavior: 'auto' }); virtualizer.value.scrollToEnd({ behavior: 'auto' });
} }
});
}; };
runWithMeasurementRetry(scroll); scroll();
} }
function isFollowingTail() { function isFollowingTail() {
if (!followOnAppend.value) return false; if (!tailFollowReady.value) return false;
const element = scrollElement.value; const element = scrollElement.value;
if (element && 'scrollHeight' in element) { if (element && 'scrollHeight' in element) {
return element.scrollHeight - element.clientHeight - element.scrollTop <= 50; return element.scrollHeight - element.clientHeight - element.scrollTop <= 50;
@@ -107,23 +123,29 @@ export function useSessionTimelineViewport({
} }
function resetForInitialSnapshot() { function resetForInitialSnapshot() {
followOnAppend.value = false; tailFollowReady.value = false;
scrollPolicy.runExplicit(() => {
virtualizer.value.scrollToOffset(0, { behavior: 'auto' }); virtualizer.value.scrollToOffset(0, { behavior: 'auto' });
});
} }
function completeInitialSnapshot() { function completeInitialSnapshot() {
followOnAppend.value = true; tailFollowReady.value = true;
}
function settleUserScroll() {
return scrollPolicy.flushDeferredAdjustment(virtualizer.value);
} }
return { return {
virtualRows, virtualRows,
totalSize, totalSize,
isScrolling,
measureElement, measureElement,
indexAtViewportEnd, indexAtViewportEnd,
scrollToIndex, scrollToIndex,
scrollToEnd, scrollToEnd,
isFollowingTail, isFollowingTail,
settleUserScroll,
resetForInitialSnapshot, resetForInitialSnapshot,
completeInitialSnapshot, completeInitialSnapshot,
}; };
@@ -0,0 +1,88 @@
export function createSessionUserScroll({
quietMs = 450,
setTimeout: schedule = globalThis.setTimeout.bind(globalThis),
clearTimeout: cancel = globalThis.clearTimeout.bind(globalThis),
onEnd = () => {},
} = {}) {
let element = null;
let active = false;
let upwardIntent = false;
let quietTimer = null;
function clearQuietTimer() {
if (quietTimer === null) return;
cancel(quietTimer);
quietTimer = null;
}
function finish({ notify = true } = {}) {
clearQuietTimer();
if (!active) return;
active = false;
if (notify) onEnd();
}
function scheduleFallback() {
clearQuietTimer();
quietTimer = schedule(() => {
quietTimer = null;
finish();
}, quietMs);
}
function begin() {
active = true;
scheduleFallback();
}
function recordDirection(delta) {
if (delta < 0) upwardIntent = true;
else if (delta > 0) upwardIntent = false;
}
function handleWheel(event) {
recordDirection(Number(event.deltaY) || 0);
begin();
}
function handleScroll() {
if (!active) return;
scheduleFallback();
}
function handleScrollEnd() {
finish();
}
function detach() {
if (element) {
element.removeEventListener('wheel', handleWheel);
element.removeEventListener('scroll', handleScroll);
element.removeEventListener('scrollend', handleScrollEnd);
}
finish({ notify: false });
element = null;
}
return {
attach(nextElement) {
if (nextElement === element) return;
detach();
element = nextElement;
if (!element) return;
element.addEventListener('wheel', handleWheel, { passive: true });
element.addEventListener('scroll', handleScroll, { passive: true });
element.addEventListener('scrollend', handleScrollEnd, { passive: true });
},
detach,
isActive() {
return active;
},
hasUpwardIntent() {
return upwardIntent;
},
clearUpwardIntent() {
upwardIntent = false;
},
};
}
+19 -6
View File
@@ -8,6 +8,7 @@ import { applySnapshot } from '../session-timeline.mjs';
import { reconcileTimelineItems } from '../session-timeline-items.mjs'; import { reconcileTimelineItems } from '../session-timeline-items.mjs';
import { createSessionDisclosureState } from '../session-disclosures.mjs'; import { createSessionDisclosureState } from '../session-disclosures.mjs';
import { createSessionLiveReloadCoordinator } from '../session-live-reload.mjs'; import { createSessionLiveReloadCoordinator } from '../session-live-reload.mjs';
import { createSessionUserScroll } from '../session-user-scroll.mjs';
import { useSessionTimelineViewport } from '../session-timeline-viewport.mjs'; import { useSessionTimelineViewport } from '../session-timeline-viewport.mjs';
import FlapNumber from '../components/FlapNumber.vue'; import FlapNumber from '../components/FlapNumber.vue';
import SessionTimelineRow from '../components/SessionTimelineRow.vue'; import SessionTimelineRow from '../components/SessionTimelineRow.vue';
@@ -45,23 +46,26 @@ const timelineScrollMargin = ref(0);
const disclosures = createSessionDisclosureState(); const disclosures = createSessionDisclosureState();
let headerResizeObserver = null; let headerResizeObserver = null;
const NAV_HEIGHT = 52; const NAV_HEIGHT = 52;
const userScroll = createSessionUserScroll({ onEnd: handleUserScrollEnd });
const timelineViewport = useSessionTimelineViewport({ const timelineViewport = useSessionTimelineViewport({
items: timelineItems, items: timelineItems,
scrollElement: wrapRef, scrollElement: wrapRef,
scrollMargin: timelineScrollMargin, scrollMargin: timelineScrollMargin,
scrollPaddingEnd: NAV_HEIGHT, scrollPaddingEnd: NAV_HEIGHT,
userScroll,
}); });
const { virtualRows, totalSize, measureElement } = timelineViewport; const { virtualRows, totalSize, measureElement } = timelineViewport;
const liveReloadCoordinator = createSessionLiveReloadCoordinator({ const liveReloadCoordinator = createSessionLiveReloadCoordinator({
isScrolling: () => timelineViewport.isScrolling.value, isScrolling: () => userScroll.isActive(),
load: loadLiveSnapshot, load: loadLiveSnapshot,
commit: commitLiveSnapshot, commit: commitLiveSnapshot,
}); });
watch(timelineViewport.isScrolling, scrolling => { function handleUserScrollEnd() {
if (!scrolling && active.value) void liveReloadCoordinator.flush(); timelineViewport.settleUserScroll();
}); if (active.value) void liveReloadCoordinator.flush();
}
function syncTimelineScrollMargin() { function syncTimelineScrollMargin() {
timelineScrollMargin.value = timelineRef.value?.offsetTop || 0; timelineScrollMargin.value = timelineRef.value?.offsetTop || 0;
@@ -124,6 +128,7 @@ const showFontHint = ref(false);
onMounted(async () => { onMounted(async () => {
active.value = true; active.value = true;
userScroll.attach(wrapRef.value);
attachKeydown(); attachKeydown();
if (route.query.focus) { if (route.query.focus) {
state.pendingFocusUuid = route.query.focus; state.pendingFocusUuid = route.query.focus;
@@ -145,6 +150,7 @@ onMounted(async () => {
onActivated(async () => { onActivated(async () => {
active.value = true; active.value = true;
userScroll.attach(wrapRef.value);
attachKeydown(); attachKeydown();
if (route.query.focus) { if (route.query.focus) {
state.pendingFocusUuid = route.query.focus; state.pendingFocusUuid = route.query.focus;
@@ -162,6 +168,7 @@ onActivated(async () => {
onDeactivated(() => { onDeactivated(() => {
active.value = false; active.value = false;
userScroll.detach();
detachKeydown(); detachKeydown();
}); });
@@ -175,6 +182,7 @@ onUnmounted(() => {
focusTimer = null; focusTimer = null;
headerResizeObserver?.disconnect(); headerResizeObserver?.disconnect();
headerResizeObserver = null; headerResizeObserver = null;
userScroll.detach();
liveReloadCoordinator.stop(); liveReloadCoordinator.stop();
removeSessionUpdated?.(); removeSessionUpdated?.();
removeSessionUpdated = null; removeSessionUpdated = null;
@@ -183,6 +191,7 @@ onUnmounted(() => {
watch(() => props.id, async (newId, oldId) => { watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) { if (newId && newId !== oldId) {
loadRevision++; loadRevision++;
userScroll.clearUpwardIntent();
timelineViewport.resetForInitialSnapshot(); timelineViewport.resetForInitialSnapshot();
messages.value = []; messages.value = [];
timelineItems.value = []; timelineItems.value = [];
@@ -264,7 +273,9 @@ async function commitSessionSnapshot(latest) {
} }
: null; : null;
const reconciliation = tailPatch || applySnapshot(messages.value, incoming); const reconciliation = tailPatch || applySnapshot(messages.value, incoming);
const restoreTail = reconciliation.tailOnly && timelineViewport.isFollowingTail(); const restoreTail = reconciliation.tailOnly
&& !userScroll.hasUpwardIntent()
&& timelineViewport.isFollowingTail();
if (reconciliation.changed) { if (reconciliation.changed) {
messages.value = reconciliation.messages; messages.value = reconciliation.messages;
if (tailPatch) { if (tailPatch) {
@@ -294,7 +305,7 @@ async function commitSessionSnapshot(latest) {
await nextTick(); await nextTick();
timelineViewport.completeInitialSnapshot(); timelineViewport.completeInitialSnapshot();
if (restoreTail) timelineViewport.scrollToEnd(); if (restoreTail) await timelineViewport.scrollToEnd();
syncTimelineScrollMargin(); syncTimelineScrollMargin();
if (!state.pendingFocusUuid) onScroll(); if (!state.pendingFocusUuid) onScroll();
@@ -313,6 +324,7 @@ async function focusPendingMessage() {
)); ));
if (targetIndex < 0) return; if (targetIndex < 0) return;
focusedItemKey.value = timelineItems.value[targetIndex].key; focusedItemKey.value = timelineItems.value[targetIndex].key;
userScroll.clearUpwardIntent();
timelineViewport.scrollToIndex(targetIndex, { align: 'end' }); timelineViewport.scrollToIndex(targetIndex, { align: 'end' });
if (focusTimer !== null) clearTimeout(focusTimer); if (focusTimer !== null) clearTimeout(focusTimer);
focusTimer = setTimeout(() => { focusTimer = setTimeout(() => {
@@ -363,6 +375,7 @@ function navTo(target) {
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1); else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
else if (target === 'next') idx = Math.min(count - 1, currentMsgIdx.value + 1); else if (target === 'next') idx = Math.min(count - 1, currentMsgIdx.value + 1);
else return; else return;
if (target === 'last') userScroll.clearUpwardIntent();
setMessagePosition(idx, count); setMessagePosition(idx, count);
navLock = true; navLock = true;
timelineViewport.scrollToIndex(idx, { align: 'end' }); timelineViewport.scrollToIndex(idx, { align: 'end' });
+98 -5
View File
@@ -16,7 +16,8 @@ const focusMessageUuid = `message-${focusMessageIndex}`;
const stationaryAppendRuns = 3; const stationaryAppendRuns = 3;
const firstStationaryAppendIndex = messageCount; const firstStationaryAppendIndex = messageCount;
const scrollingAppendIndex = messageCount + stationaryAppendRuns; const scrollingAppendIndex = messageCount + stationaryAppendRuns;
const tailAppendIndex = scrollingAppendIndex + 1; const nearTailEscapeAppendIndex = scrollingAppendIndex + 1;
const tailAppendIndex = nearTailEscapeAppendIndex + 1;
const channels = [ const channels = [
'db:getSessions', 'db:getSessions',
'db:getSessionMessages', 'db:getSessionMessages',
@@ -488,13 +489,23 @@ async function run() {
setTimeout(() => appendMessage(win, scrollingAppendIndex), 250); setTimeout(() => appendMessage(win, scrollingAppendIndex), 250);
const scrollProbe = await win.webContents.executeJavaScript(`new Promise(resolve => { const scrollProbe = await win.webContents.executeJavaScript(`new Promise(resolve => {
const wrap = document.querySelector('.detail-wrap'); const wrap = document.querySelector('.detail-wrap');
const totalBeforeGesture = Number(document.querySelector('.flap-number')?.getAttribute('aria-label'));
const originalScrollTo = wrap.scrollTo.bind(wrap);
let programmaticScrolls = 0;
const blockAutomaticScrollEnd = event => event.stopImmediatePropagation();
wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true);
wrap.scrollTo = (...args) => {
programmaticScrolls++;
return originalScrollTo(...args);
};
const gaps = []; const gaps = [];
const startedAt = performance.now(); const startedAt = performance.now();
let previous = startedAt; let previous = startedAt;
wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: 70, bubbles: true }));
function frame(now) { function frame(now) {
gaps.push(now - previous); gaps.push(now - previous);
previous = now; previous = now;
wrap.scrollTop += 70; if (now - startedAt >= 400) wrap.scrollTop += 70;
if (now - startedAt < 1200) requestAnimationFrame(frame); if (now - startedAt < 1200) requestAnimationFrame(frame);
else { else {
const wrapRect = wrap.getBoundingClientRect(); const wrapRect = wrap.getBoundingClientRect();
@@ -504,7 +515,16 @@ async function run() {
return rect.bottom > wrapRect.top && rect.top < wrapRect.bottom; return rect.bottom > wrapRect.top && rect.top < wrapRect.bottom;
}); });
const anchorElement = anchorRow?.querySelector('[data-uuid]'); const anchorElement = anchorRow?.querySelector('[data-uuid]');
const totalBeforeScrollEnd = Number(document.querySelector('.flap-number')?.getAttribute('aria-label'));
const flapBeforeScrollEnd = Boolean(document.querySelector('.flap-slot.flipping'));
wrap.scrollTo = originalScrollTo;
wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true);
wrap.dispatchEvent(new Event('scrollend'));
resolve({ resolve({
totalBeforeGesture,
totalBeforeScrollEnd,
flapBeforeScrollEnd,
programmaticScrolls,
maxFrameGap: Math.max(...gaps), maxFrameGap: Math.max(...gaps),
frames: gaps.length, frames: gaps.length,
rows: document.querySelectorAll('.virtual-timeline-row').length, rows: document.querySelectorAll('.virtual-timeline-row').length,
@@ -523,6 +543,11 @@ async function run() {
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount + stationaryAppendRuns + 1}'`, `document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount + stationaryAppendRuns + 1}'`,
'reader-position live update', 'reader-position live update',
); );
await waitFor(
win.webContents,
`document.querySelector('.flap-slot.flipping')`,
'post-scrollend flap animation',
);
const readerState = await win.webContents.executeJavaScript(`(() => { const readerState = await win.webContents.executeJavaScript(`(() => {
const wrap = document.querySelector('.detail-wrap'); const wrap = document.querySelector('.detail-wrap');
const anchorElement = document.querySelector( const anchorElement = document.querySelector(
@@ -538,6 +563,15 @@ async function run() {
}; };
})()`, true); })()`, true);
assert(scrollProbe.rows < 60, `live scrolling keeps mounted rows bounded (${scrollProbe.rows})`); assert(scrollProbe.rows < 60, `live scrolling keeps mounted rows bounded (${scrollProbe.rows})`);
assert(
scrollProbe.totalBeforeScrollEnd === scrollProbe.totalBeforeGesture,
`wheel-to-scrollend freezes the visible timeline (${scrollProbe.totalBeforeGesture} -> ${scrollProbe.totalBeforeScrollEnd})`,
);
assert(
scrollProbe.programmaticScrolls === 0,
`wheel-to-scrollend performs zero programmatic scrollTo calls (got ${scrollProbe.programmaticScrolls})`,
);
assert(!scrollProbe.flapBeforeScrollEnd, 'wheel-to-scrollend does not start the flap animation');
assert(scrollProbe.anchor, 'reader anchor is captured before the deferred live commit'); assert(scrollProbe.anchor, 'reader anchor is captured before the deferred live commit');
assert( assert(
scrollProbe.distanceFromTail > 1000 scrollProbe.distanceFromTail > 1000
@@ -607,10 +641,69 @@ async function run() {
`(() => { const wrap = document.querySelector('.detail-wrap'); return wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop < 2; })()`, `(() => { const wrap = document.querySelector('.detail-wrap'); return wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop < 2; })()`,
'last-item scroll settlement', 'last-item scroll settlement',
); );
appendMessage(win, tailAppendIndex);
await win.webContents.executeJavaScript(`new Promise(resolve => {
const wrap = document.querySelector('.detail-wrap');
wrap.scrollTop = Math.max(0, wrap.scrollHeight - wrap.clientHeight - 20);
requestAnimationFrame(() => requestAnimationFrame(resolve));
})`, true);
await delay(200);
await win.webContents.executeJavaScript(`(() => {
const wrap = document.querySelector('.detail-wrap');
wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: -24, bubbles: true }));
})()`, true);
await delay(200);
appendMessage(win, nearTailEscapeAppendIndex);
await delay(150);
const nearTailPending = await win.webContents.executeJavaScript(`(() => {
const wrap = document.querySelector('.detail-wrap');
return {
total: Number(document.querySelector('.flap-number')?.getAttribute('aria-label')),
distanceFromTail: wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop,
};
})()`, true);
assert(
nearTailPending.total === messageCount + stationaryAppendRuns + 1,
'near-tail upward intent keeps the append pending until scrollend',
);
await win.webContents.executeJavaScript(
`document.querySelector('.detail-wrap')?.dispatchEvent(new Event('scrollend'))`,
true,
);
await waitFor( await waitFor(
win.webContents, win.webContents,
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount + stationaryAppendRuns + 2}'`, `document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount + stationaryAppendRuns + 2}'`,
'near-tail upward append settlement',
);
await delay(500);
const nearTailSettled = await win.webContents.executeJavaScript(`(() => {
const wrap = document.querySelector('.detail-wrap');
return {
current: Number(document.querySelector('.msg-nav-current')?.textContent),
total: Number(document.querySelector('.flap-number')?.getAttribute('aria-label')),
distanceFromTail: wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop,
};
})()`, true);
assert(
nearTailSettled.current < nearTailSettled.total && nearTailSettled.distanceFromTail > 20,
`near-tail upward intent is not pulled back to the tail (${JSON.stringify(nearTailSettled)})`,
);
await win.webContents.executeJavaScript(`document.querySelector('button[title="Last"]')?.click()`, true);
await waitFor(
win.webContents,
`document.querySelector('.msg-nav-current')?.textContent === '${messageCount + stationaryAppendRuns + 2}'`,
'last-item navigation after upward escape',
);
await waitFor(
win.webContents,
`(() => { const wrap = document.querySelector('.detail-wrap'); return wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop < 2; })()`,
'tail re-entry settlement',
);
appendMessage(win, tailAppendIndex);
await waitFor(
win.webContents,
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount + stationaryAppendRuns + 3}'`,
'tail-follow total update', 'tail-follow total update',
); );
await delay(1000); await delay(1000);
@@ -625,8 +718,8 @@ async function run() {
}; };
})()`, true); })()`, true);
assert( assert(
tailState.current === messageCount + stationaryAppendRuns + 2 && tailState.distanceFromTail < 2, tailState.current === messageCount + stationaryAppendRuns + 3 && tailState.distanceFromTail < 2,
`tail follow reaches item ${messageCount + stationaryAppendRuns + 2} (${JSON.stringify(tailState)})`, `tail follow reaches item ${messageCount + stationaryAppendRuns + 3} (${JSON.stringify(tailState)})`,
); );
const reduction = (1 - initial.rows / initial.total) * 100; const reduction = (1 - initial.rows / initial.total) * 100;
+5 -5
View File
@@ -11,7 +11,7 @@ import {
import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs'; import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs';
import { createSessionPatch } from '../app/src/shared/session-patch.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 scrolling = true;
let loads = 0; let loads = 0;
const commits = []; 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(); 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, []); assert.deepEqual(commits, []);
scrolling = false; scrolling = false;
await coordinator.flush(); await coordinator.flush();
assert.equal(loads, 1); assert.equal(loads, 3, 'scroll end reuses the freshest pending snapshot');
assert.deepEqual(commits, [1]); assert.deepEqual(commits, [3]);
await coordinator.flush(); 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 () => { 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/); 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.equal(appPackage.devDependencies['@tanstack/vue-virtual'], '^3.13.32');
assert.match(viewportModule, /useVirtualizer/); assert.match(viewportModule, /useVirtualizer/);
assert.match(viewportModule, /overscan/); assert.match(viewportModule, /overscan/);
assert.match(viewportModule, /anchorTo:\s*'end'/); 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, /resetForInitialSnapshot/);
assert.match(viewportModule, /completeInitialSnapshot/); 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, /useAnimationFrameWithResizeObserver:\s*true/);
assert.match(viewportModule, /scrollPaddingEnd/); assert.match(viewportModule, /scrollPaddingEnd/);
assert.match(viewportModule, /scrollToIndex/); assert.match(viewportModule, /scrollToIndex/);
assert.match(viewportModule, /if \(!element\) return/); 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', () => { 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();
});