diff --git a/app/src/renderer/src/session-live-reload.mjs b/app/src/renderer/src/session-live-reload.mjs index efcddfa..6cb1c98 100644 --- a/app/src/renderer/src/session-live-reload.mjs +++ b/app/src/renderer/src/session-live-reload.mjs @@ -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() { diff --git a/app/src/renderer/src/session-timeline-scroll-policy.mjs b/app/src/renderer/src/session-timeline-scroll-policy.mjs new file mode 100644 index 0000000..6eec621 --- /dev/null +++ b/app/src/renderer/src/session-timeline-scroll-policy.mjs @@ -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, + }; +} diff --git a/app/src/renderer/src/session-timeline-viewport.mjs b/app/src/renderer/src/session-timeline-viewport.mjs index bf57df6..3934546 100644 --- a/app/src/renderer/src/session-timeline-viewport.mjs +++ b/app/src/renderer/src/session-timeline-viewport.mjs @@ -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, }; diff --git a/app/src/renderer/src/session-user-scroll.mjs b/app/src/renderer/src/session-user-scroll.mjs new file mode 100644 index 0000000..81a52ab --- /dev/null +++ b/app/src/renderer/src/session-user-scroll.mjs @@ -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; + }, + }; +} diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue index cc69073..3279a78 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -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' }); diff --git a/app/tests/electron-session-virtualization.mjs b/app/tests/electron-session-virtualization.mjs index c1538f0..9ed5f65 100644 --- a/app/tests/electron-session-virtualization.mjs +++ b/app/tests/electron-session-virtualization.mjs @@ -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(); @@ -502,9 +513,18 @@ async function run() { .find(row => { const rect = row.getBoundingClientRect(); 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; diff --git a/tests/session-live-reload.test.mjs b/tests/session-live-reload.test.mjs index a4e2b29..dc5b991 100644 --- a/tests/session-live-reload.test.mjs +++ b/tests/session-live-reload.test.mjs @@ -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 () => { diff --git a/tests/session-timeline-scroll-policy.test.mjs b/tests/session-timeline-scroll-policy.test.mjs new file mode 100644 index 0000000..5ebf69f --- /dev/null +++ b/tests/session-timeline-scroll-policy.test.mjs @@ -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' }]); +}); diff --git a/tests/session-timeline-virtualization.test.mjs b/tests/session-timeline-virtualization.test.mjs index 71e73a8..14ac5f1 100644 --- a/tests/session-timeline-virtualization.test.mjs +++ b/tests/session-timeline-virtualization.test.mjs @@ -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', () => { diff --git a/tests/session-user-scroll.test.mjs b/tests/session-user-scroll.test.mjs new file mode 100644 index 0000000..5cf2fc5 --- /dev/null +++ b/tests/session-user-scroll.test.mjs @@ -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(); +});