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:
@@ -5,7 +5,7 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
|
||||
let stopped = false;
|
||||
|
||||
async function drain() {
|
||||
while (!stopped && !isScrolling() && (pending || loadedSnapshot)) {
|
||||
while (!stopped && (pending || loadedSnapshot)) {
|
||||
let snapshot = loadedSnapshot;
|
||||
loadedSnapshot = null;
|
||||
|
||||
@@ -29,8 +29,8 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (stopped || isScrolling() || (!pending && !loadedSnapshot)) return inFlight;
|
||||
async function processPending() {
|
||||
if (stopped || (!pending && !loadedSnapshot)) return inFlight;
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = drain();
|
||||
try {
|
||||
@@ -38,15 +38,20 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
if ((pending || loadedSnapshot) && !isScrolling()) return flush();
|
||||
if (pending || (loadedSnapshot && !isScrolling())) return processPending();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (stopped || isScrolling()) return inFlight;
|
||||
return processPending();
|
||||
}
|
||||
|
||||
return {
|
||||
request() {
|
||||
if (stopped) return Promise.resolve();
|
||||
pending = true;
|
||||
return flush();
|
||||
return processPending();
|
||||
},
|
||||
flush,
|
||||
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 { useVirtualizer } from '@tanstack/vue-virtual';
|
||||
import { elementScroll, useVirtualizer } from '@tanstack/vue-virtual';
|
||||
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
|
||||
|
||||
function estimatedTextHeight(text = '') {
|
||||
return Math.min(560, Math.ceil(String(text).length / 72) * 20);
|
||||
@@ -32,8 +33,13 @@ export function useSessionTimelineViewport({
|
||||
overscan = 6,
|
||||
gap = 14,
|
||||
scrollPaddingEnd = 0,
|
||||
userScroll,
|
||||
}) {
|
||||
const followOnAppend = ref(false);
|
||||
const tailFollowReady = ref(false);
|
||||
const scrollPolicy = createSessionTimelineScrollPolicy({
|
||||
isUserScrolling: () => userScroll?.isActive() ?? false,
|
||||
writeScroll: elementScroll,
|
||||
});
|
||||
const virtualizer = useVirtualizer(computed(() => ({
|
||||
count: items.value.length,
|
||||
getScrollElement: () => scrollElement.value,
|
||||
@@ -44,14 +50,16 @@ export function useSessionTimelineViewport({
|
||||
overscan,
|
||||
gap,
|
||||
anchorTo: 'end',
|
||||
followOnAppend: followOnAppend.value,
|
||||
followOnAppend: false,
|
||||
scrollEndThreshold: 50,
|
||||
isScrollingResetDelay: 450,
|
||||
useScrollendEvent: true,
|
||||
useAnimationFrameWithResizeObserver: true,
|
||||
scrollToFn: scrollPolicy.scrollToFn,
|
||||
})));
|
||||
|
||||
const virtualRows = computed(() => virtualizer.value.getVirtualItems());
|
||||
const totalSize = computed(() => virtualizer.value.getTotalSize());
|
||||
const isScrolling = computed(() => virtualizer.value.isScrolling);
|
||||
|
||||
function measureElement(element) {
|
||||
if (!element) return;
|
||||
@@ -77,7 +85,9 @@ export function useSessionTimelineViewport({
|
||||
|
||||
function scrollToIndex(index, options = {}) {
|
||||
const scroll = () => {
|
||||
scrollPolicy.runExplicit(() => {
|
||||
virtualizer.value.scrollToIndex(index, { behavior: 'auto', ...options });
|
||||
});
|
||||
};
|
||||
|
||||
// A far jump starts from estimates. Re-align after mounted rows have been
|
||||
@@ -85,20 +95,26 @@ export function useSessionTimelineViewport({
|
||||
runWithMeasurementRetry(scroll);
|
||||
}
|
||||
|
||||
function scrollToEnd() {
|
||||
async function scrollToEnd() {
|
||||
const targetWindow = scrollElement.value?.ownerDocument?.defaultView;
|
||||
if (targetWindow) {
|
||||
await new Promise(resolve => targetWindow.requestAnimationFrame(resolve));
|
||||
}
|
||||
const scroll = () => {
|
||||
scrollPolicy.runExplicit(() => {
|
||||
const element = scrollElement.value;
|
||||
if (element && 'scrollHeight' in element) {
|
||||
element.scrollTo({ top: element.scrollHeight, behavior: 'auto' });
|
||||
} else {
|
||||
virtualizer.value.scrollToEnd({ behavior: 'auto' });
|
||||
}
|
||||
});
|
||||
};
|
||||
runWithMeasurementRetry(scroll);
|
||||
scroll();
|
||||
}
|
||||
|
||||
function isFollowingTail() {
|
||||
if (!followOnAppend.value) return false;
|
||||
if (!tailFollowReady.value) return false;
|
||||
const element = scrollElement.value;
|
||||
if (element && 'scrollHeight' in element) {
|
||||
return element.scrollHeight - element.clientHeight - element.scrollTop <= 50;
|
||||
@@ -107,23 +123,29 @@ export function useSessionTimelineViewport({
|
||||
}
|
||||
|
||||
function resetForInitialSnapshot() {
|
||||
followOnAppend.value = false;
|
||||
tailFollowReady.value = false;
|
||||
scrollPolicy.runExplicit(() => {
|
||||
virtualizer.value.scrollToOffset(0, { behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
|
||||
function completeInitialSnapshot() {
|
||||
followOnAppend.value = true;
|
||||
tailFollowReady.value = true;
|
||||
}
|
||||
|
||||
function settleUserScroll() {
|
||||
return scrollPolicy.flushDeferredAdjustment(virtualizer.value);
|
||||
}
|
||||
|
||||
return {
|
||||
virtualRows,
|
||||
totalSize,
|
||||
isScrolling,
|
||||
measureElement,
|
||||
indexAtViewportEnd,
|
||||
scrollToIndex,
|
||||
scrollToEnd,
|
||||
isFollowingTail,
|
||||
settleUserScroll,
|
||||
resetForInitialSnapshot,
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { applySnapshot } from '../session-timeline.mjs';
|
||||
import { reconcileTimelineItems } from '../session-timeline-items.mjs';
|
||||
import { createSessionDisclosureState } from '../session-disclosures.mjs';
|
||||
import { createSessionLiveReloadCoordinator } from '../session-live-reload.mjs';
|
||||
import { createSessionUserScroll } from '../session-user-scroll.mjs';
|
||||
import { useSessionTimelineViewport } from '../session-timeline-viewport.mjs';
|
||||
import FlapNumber from '../components/FlapNumber.vue';
|
||||
import SessionTimelineRow from '../components/SessionTimelineRow.vue';
|
||||
@@ -45,23 +46,26 @@ const timelineScrollMargin = ref(0);
|
||||
const disclosures = createSessionDisclosureState();
|
||||
let headerResizeObserver = null;
|
||||
const NAV_HEIGHT = 52;
|
||||
const userScroll = createSessionUserScroll({ onEnd: handleUserScrollEnd });
|
||||
|
||||
const timelineViewport = useSessionTimelineViewport({
|
||||
items: timelineItems,
|
||||
scrollElement: wrapRef,
|
||||
scrollMargin: timelineScrollMargin,
|
||||
scrollPaddingEnd: NAV_HEIGHT,
|
||||
userScroll,
|
||||
});
|
||||
const { virtualRows, totalSize, measureElement } = timelineViewport;
|
||||
const liveReloadCoordinator = createSessionLiveReloadCoordinator({
|
||||
isScrolling: () => timelineViewport.isScrolling.value,
|
||||
isScrolling: () => userScroll.isActive(),
|
||||
load: loadLiveSnapshot,
|
||||
commit: commitLiveSnapshot,
|
||||
});
|
||||
|
||||
watch(timelineViewport.isScrolling, scrolling => {
|
||||
if (!scrolling && active.value) void liveReloadCoordinator.flush();
|
||||
});
|
||||
function handleUserScrollEnd() {
|
||||
timelineViewport.settleUserScroll();
|
||||
if (active.value) void liveReloadCoordinator.flush();
|
||||
}
|
||||
|
||||
function syncTimelineScrollMargin() {
|
||||
timelineScrollMargin.value = timelineRef.value?.offsetTop || 0;
|
||||
@@ -124,6 +128,7 @@ const showFontHint = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
active.value = true;
|
||||
userScroll.attach(wrapRef.value);
|
||||
attachKeydown();
|
||||
if (route.query.focus) {
|
||||
state.pendingFocusUuid = route.query.focus;
|
||||
@@ -145,6 +150,7 @@ onMounted(async () => {
|
||||
|
||||
onActivated(async () => {
|
||||
active.value = true;
|
||||
userScroll.attach(wrapRef.value);
|
||||
attachKeydown();
|
||||
if (route.query.focus) {
|
||||
state.pendingFocusUuid = route.query.focus;
|
||||
@@ -162,6 +168,7 @@ onActivated(async () => {
|
||||
|
||||
onDeactivated(() => {
|
||||
active.value = false;
|
||||
userScroll.detach();
|
||||
detachKeydown();
|
||||
});
|
||||
|
||||
@@ -175,6 +182,7 @@ onUnmounted(() => {
|
||||
focusTimer = null;
|
||||
headerResizeObserver?.disconnect();
|
||||
headerResizeObserver = null;
|
||||
userScroll.detach();
|
||||
liveReloadCoordinator.stop();
|
||||
removeSessionUpdated?.();
|
||||
removeSessionUpdated = null;
|
||||
@@ -183,6 +191,7 @@ onUnmounted(() => {
|
||||
watch(() => props.id, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadRevision++;
|
||||
userScroll.clearUpwardIntent();
|
||||
timelineViewport.resetForInitialSnapshot();
|
||||
messages.value = [];
|
||||
timelineItems.value = [];
|
||||
@@ -264,7 +273,9 @@ async function commitSessionSnapshot(latest) {
|
||||
}
|
||||
: null;
|
||||
const reconciliation = tailPatch || applySnapshot(messages.value, incoming);
|
||||
const restoreTail = reconciliation.tailOnly && timelineViewport.isFollowingTail();
|
||||
const restoreTail = reconciliation.tailOnly
|
||||
&& !userScroll.hasUpwardIntent()
|
||||
&& timelineViewport.isFollowingTail();
|
||||
if (reconciliation.changed) {
|
||||
messages.value = reconciliation.messages;
|
||||
if (tailPatch) {
|
||||
@@ -294,7 +305,7 @@ async function commitSessionSnapshot(latest) {
|
||||
|
||||
await nextTick();
|
||||
timelineViewport.completeInitialSnapshot();
|
||||
if (restoreTail) timelineViewport.scrollToEnd();
|
||||
if (restoreTail) await timelineViewport.scrollToEnd();
|
||||
syncTimelineScrollMargin();
|
||||
if (!state.pendingFocusUuid) onScroll();
|
||||
|
||||
@@ -313,6 +324,7 @@ async function focusPendingMessage() {
|
||||
));
|
||||
if (targetIndex < 0) return;
|
||||
focusedItemKey.value = timelineItems.value[targetIndex].key;
|
||||
userScroll.clearUpwardIntent();
|
||||
timelineViewport.scrollToIndex(targetIndex, { align: 'end' });
|
||||
if (focusTimer !== null) clearTimeout(focusTimer);
|
||||
focusTimer = setTimeout(() => {
|
||||
@@ -363,6 +375,7 @@ function navTo(target) {
|
||||
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
|
||||
else if (target === 'next') idx = Math.min(count - 1, currentMsgIdx.value + 1);
|
||||
else return;
|
||||
if (target === 'last') userScroll.clearUpwardIntent();
|
||||
setMessagePosition(idx, count);
|
||||
navLock = true;
|
||||
timelineViewport.scrollToIndex(idx, { align: 'end' });
|
||||
|
||||
@@ -16,7 +16,8 @@ const focusMessageUuid = `message-${focusMessageIndex}`;
|
||||
const stationaryAppendRuns = 3;
|
||||
const firstStationaryAppendIndex = messageCount;
|
||||
const scrollingAppendIndex = messageCount + stationaryAppendRuns;
|
||||
const tailAppendIndex = scrollingAppendIndex + 1;
|
||||
const nearTailEscapeAppendIndex = scrollingAppendIndex + 1;
|
||||
const tailAppendIndex = nearTailEscapeAppendIndex + 1;
|
||||
const channels = [
|
||||
'db:getSessions',
|
||||
'db:getSessionMessages',
|
||||
@@ -488,13 +489,23 @@ async function run() {
|
||||
setTimeout(() => appendMessage(win, scrollingAppendIndex), 250);
|
||||
const scrollProbe = await win.webContents.executeJavaScript(`new Promise(resolve => {
|
||||
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 startedAt = performance.now();
|
||||
let previous = startedAt;
|
||||
wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: 70, bubbles: true }));
|
||||
function frame(now) {
|
||||
gaps.push(now - previous);
|
||||
previous = now;
|
||||
wrap.scrollTop += 70;
|
||||
if (now - startedAt >= 400) wrap.scrollTop += 70;
|
||||
if (now - startedAt < 1200) requestAnimationFrame(frame);
|
||||
else {
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
@@ -504,7 +515,16 @@ async function run() {
|
||||
return rect.bottom > wrapRect.top && rect.top < wrapRect.bottom;
|
||||
});
|
||||
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({
|
||||
totalBeforeGesture,
|
||||
totalBeforeScrollEnd,
|
||||
flapBeforeScrollEnd,
|
||||
programmaticScrolls,
|
||||
maxFrameGap: Math.max(...gaps),
|
||||
frames: gaps.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}'`,
|
||||
'reader-position live update',
|
||||
);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.flap-slot.flipping')`,
|
||||
'post-scrollend flap animation',
|
||||
);
|
||||
const readerState = await win.webContents.executeJavaScript(`(() => {
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
const anchorElement = document.querySelector(
|
||||
@@ -538,6 +563,15 @@ async function run() {
|
||||
};
|
||||
})()`, true);
|
||||
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.distanceFromTail > 1000
|
||||
@@ -607,10 +641,69 @@ async function run() {
|
||||
`(() => { const wrap = document.querySelector('.detail-wrap'); return wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop < 2; })()`,
|
||||
'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(
|
||||
win.webContents,
|
||||
`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',
|
||||
);
|
||||
await delay(1000);
|
||||
@@ -625,8 +718,8 @@ async function run() {
|
||||
};
|
||||
})()`, true);
|
||||
assert(
|
||||
tailState.current === messageCount + stationaryAppendRuns + 2 && tailState.distanceFromTail < 2,
|
||||
`tail follow reaches item ${messageCount + stationaryAppendRuns + 2} (${JSON.stringify(tailState)})`,
|
||||
tailState.current === messageCount + stationaryAppendRuns + 3 && tailState.distanceFromTail < 2,
|
||||
`tail follow reaches item ${messageCount + stationaryAppendRuns + 3} (${JSON.stringify(tailState)})`,
|
||||
);
|
||||
|
||||
const reduction = (1 - initial.rows / initial.total) * 100;
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user