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;
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 = () => {
virtualizer.value.scrollToIndex(index, { behavior: 'auto', ...options });
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 = () => {
const element = scrollElement.value;
if (element && 'scrollHeight' in element) {
element.scrollTo({ top: element.scrollHeight, behavior: 'auto' });
} else {
virtualizer.value.scrollToEnd({ behavior: 'auto' });
}
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;
virtualizer.value.scrollToOffset(0, { behavior: 'auto' });
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;
},
};
}
+19 -6
View File
@@ -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' });