diff --git a/app/src/renderer/src/session-timeline-scroll-policy.mjs b/app/src/renderer/src/session-timeline-scroll-policy.mjs index 65fae1a..7eb1450 100644 --- a/app/src/renderer/src/session-timeline-scroll-policy.mjs +++ b/app/src/renderer/src/session-timeline-scroll-policy.mjs @@ -1,8 +1,15 @@ -export function createSessionTimelineScrollPolicy({ isUserScrolling, writeScroll }) { +export function createSessionTimelineScrollPolicy({ + isUserScrolling, + writeScroll, + onSuppressedAdjustment = () => {}, +}) { let explicitDepth = 0; function scrollToFn(offset, options = {}, instance) { if (explicitDepth === 0 && isUserScrolling()) { + if (Number.isFinite(options.adjustments) && options.adjustments !== 0) { + onSuppressedAdjustment(offset, options, instance); + } return; } writeScroll(offset, options, instance); diff --git a/app/src/renderer/src/session-timeline-viewport.mjs b/app/src/renderer/src/session-timeline-viewport.mjs index 0b2b423..3bedb69 100644 --- a/app/src/renderer/src/session-timeline-viewport.mjs +++ b/app/src/renderer/src/session-timeline-viewport.mjs @@ -1,5 +1,10 @@ -import { computed, ref } from 'vue'; -import { defaultRangeExtractor, elementScroll, useVirtualizer } from '@tanstack/vue-virtual'; +import { computed, nextTick, ref } from 'vue'; +import { + defaultRangeExtractor, + elementScroll, + measureElement as measureVirtualElement, + useVirtualizer, +} from '@tanstack/vue-virtual'; import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs'; function estimatedTextHeight(text = '') { @@ -74,6 +79,7 @@ export function resolveReaderAnchorIndex(anchor, items = []) { export function useSessionTimelineViewport({ items, scrollElement, + timelineElement, scrollMargin, overscan = 6, gap = 14, @@ -81,9 +87,16 @@ export function useSessionTimelineViewport({ userScroll, }) { const tailFollowReady = ref(false); + let settlementActive = false; + let compensatedTimeline = null; + let originalTimelineTranslate = ''; + let suppressedAdjustment = 0; const scrollPolicy = createSessionTimelineScrollPolicy({ - isUserScrolling: () => userScroll?.isActive() ?? false, + isUserScrolling: () => ( + settlementActive || (userScroll?.isActive() ?? false) + ), writeScroll: elementScroll, + onSuppressedAdjustment: applySuppressedAdjustment, }); let virtualizer = null; const rangeExtractor = createViewportRangeExtractor({ @@ -106,17 +119,151 @@ export function useSessionTimelineViewport({ isScrollingResetDelay: 450, useScrollendEvent: true, useAnimationFrameWithResizeObserver: true, + measureElement: measureVirtualElement, scrollToFn: scrollPolicy.scrollToFn, }))); const virtualRows = computed(() => virtualizer.value.getVirtualItems()); const totalSize = computed(() => virtualizer.value.getTotalSize()); + function resolveTimelineElement(instance = virtualizer?.value) { + return timelineElement?.value + || [...(instance?.elementsCache?.values?.() || [])] + .find(element => element.isConnected)?.parentElement + || null; + } + + function applySuppressedAdjustment(_offset, options, instance) { + const adjustment = Number(options.adjustments) || 0; + if (adjustment === 0) return; + const target = resolveTimelineElement(instance); + if (!target) return; + if (compensatedTimeline !== target) { + if (compensatedTimeline) { + compensatedTimeline.style.translate = originalTimelineTranslate; + } + compensatedTimeline = target; + originalTimelineTranslate = target.style.translate || ''; + suppressedAdjustment = 0; + } + suppressedAdjustment += adjustment; + target.style.translate = `0 ${-suppressedAdjustment}px`; + } + + function clearSuppressedAdjustment() { + if (compensatedTimeline) { + compensatedTimeline.style.translate = originalTimelineTranslate; + } + compensatedTimeline = null; + originalTimelineTranslate = ''; + suppressedAdjustment = 0; + } + function measureElement(element) { if (!element) return; virtualizer.value.measureElement(element); } + async function settleAfterUserScroll(commit = () => Promise.resolve()) { + if (settlementActive) return false; + const instance = virtualizer.value; + const element = scrollElement.value; + if (!instance || !element) { + clearSuppressedAdjustment(); + return false; + } + + const scrollOffset = element.scrollTop; + const viewportRect = element.getBoundingClientRect(); + const mountedRows = [...instance.elementsCache.entries()] + .filter(([, row]) => row.isConnected) + .map(([key, row]) => ({ key, row, rect: row.getBoundingClientRect() })); + const visibleAnchor = mountedRows + .filter(({ rect }) => ( + rect.bottom > viewportRect.top && rect.top < viewportRect.bottom + )) + .sort((left, right) => left.rect.top - right.rect.top)[0]; + const fallbackMeasurement = instance.getVirtualItemForOffset(scrollOffset); + const anchor = visibleAnchor + ? { + key: visibleAnchor.key, + screenOffset: visibleAnchor.rect.top - viewportRect.top, + } + : fallbackMeasurement + ? { + key: fallbackMeasurement.key, + screenOffset: fallbackMeasurement.start - scrollOffset, + } + : null; + settlementActive = true; + try { + // Publish the coalesced live patch inside the same geometry transaction. + // Real row sizes remain live throughout; only scrollTop corrections are + // suppressed until the reader anchor can be reconciled once. + await commit(); + if (userScroll?.isActive() ?? false) return false; + + const indexByKey = new Map( + items.value.map((item, index) => [item?.key || index, index]), + ); + let appliedMeasurements = 0; + let stableFrames = 0; + for (let pass = 0; pass < 12 && stableFrames < 2; pass++) { + await nextTick(); + if (userScroll?.isActive() ?? false) return false; + let changed = false; + const settledRows = [...instance.elementsCache.entries()] + .filter(([, row]) => row.isConnected) + .map(([key, row]) => ({ key, size: Math.round(row.getBoundingClientRect().height) })) + .filter(({ size }) => size > 0); + for (const { key, size } of settledRows) { + const index = indexByKey.get(key); + if (index === undefined) continue; + const cachedSize = instance.itemSizeCache.get(key) + ?? instance.options.estimateSize(index); + if (cachedSize === size) continue; + instance.resizeItem(index, size); + appliedMeasurements++; + changed = true; + } + stableFrames = changed ? 0 : stableFrames + 1; + const targetWindow = element.ownerDocument?.defaultView; + if (targetWindow) { + await new Promise(resolve => targetWindow.requestAnimationFrame(resolve)); + } + } + + let targetOffset = scrollOffset; + if (anchor) { + const anchorIndex = indexByKey.get(anchor.key); + const nextMeasurement = anchorIndex === undefined + ? null + : instance.getMeasurements?.()[anchorIndex]; + if (nextMeasurement) targetOffset = nextMeasurement.start - anchor.screenOffset; + } + + const viewportWasRepositioned = (userScroll?.isActive() ?? false) + || Math.abs(element.scrollTop - scrollOffset) >= 1; + if (viewportWasRepositioned) { + clearSuppressedAdjustment(); + instance.scrollOffset = element.scrollTop; + } else if (Math.abs(element.scrollTop - targetOffset) >= 0.5) { + clearSuppressedAdjustment(); + instance.scrollOffset = targetOffset; + elementScroll(targetOffset, { behavior: 'auto' }, instance); + } else { + clearSuppressedAdjustment(); + instance.scrollOffset = element.scrollTop; + } + return appliedMeasurements > 0; + } catch (error) { + clearSuppressedAdjustment(); + throw error; + } finally { + settlementActive = false; + } + } + function indexAtViewportEnd(inset = 0) { const instance = virtualizer.value; const viewportSize = instance.scrollRect?.height || scrollElement.value?.clientHeight || 0; @@ -248,6 +395,7 @@ export function useSessionTimelineViewport({ virtualRows, totalSize, measureElement, + settleAfterUserScroll, indexAtViewportEnd, scrollToIndex, scrollToEnd, diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue index 24dd8fe..169d15f 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -67,19 +67,29 @@ const userScroll = createSessionUserScroll({ onEnd: handleUserScrollEnd }); const timelineViewport = useSessionTimelineViewport({ items: timelineItems, scrollElement: wrapRef, + timelineElement: timelineRef, scrollMargin: timelineScrollMargin, scrollPaddingEnd: NAV_HEIGHT, userScroll, }); -const { virtualRows, totalSize, measureElement, waitForStableLayout } = timelineViewport; +const { + virtualRows, + totalSize, + measureElement, + settleAfterUserScroll, + waitForStableLayout, +} = timelineViewport; const liveReloadCoordinator = createSessionLiveReloadCoordinator({ isScrolling: () => userScroll.isActive(), load: loadLiveSnapshot, commit: commitLiveSnapshot, }); -function handleUserScrollEnd() { - if (active.value) void liveReloadCoordinator.flush(); +async function handleUserScrollEnd() { + if (!active.value) return; + await settleAfterUserScroll(() => ( + active.value ? liveReloadCoordinator.flush() : Promise.resolve() + )); } function syncTimelineScrollMargin() { diff --git a/app/tests/electron-session-virtualization.mjs b/app/tests/electron-session-virtualization.mjs index b3900f7..064de52 100644 --- a/app/tests/electron-session-virtualization.mjs +++ b/app/tests/electron-session-virtualization.mjs @@ -65,6 +65,14 @@ const messages = Array.from({ length: messageCount }, (_, index) => ({ content_type: index === 1 ? 'tool_use' : 'text', is_meta: 0, })); +for (const startIndex of [96, 196]) { + for (let index = startIndex; index < startIndex + 8; index++) { + messages[index].text = Array.from( + { length: 120 }, + (_, paragraph) => `Unmeasured paragraph ${paragraph} for message ${index} stays visible while scrolling.`, + ).join('\n\n'); + } +} messages[focusMessageIndex].type = 'assistant'; messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repeat(700)}`; const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`; @@ -140,6 +148,98 @@ async function waitFor(webContents, expression, message, timeoutMs = 8000) { throw new Error(`Timed out waiting for ${message}`); } +async function probeOrdinaryScrollGeometry(win, { startIndex, direction }) { + await win.webContents.executeJavaScript( + `window.location.hash = '#/sessions/${sessionId}?focus=message-${startIndex}'`, + true, + ); + await waitFor( + win.webContents, + `document.querySelector('[data-uuid="message-${startIndex}"].is-focused')`, + `ordinary-scroll geometry start ${startIndex}`, + ); + await delay(100); + return win.webContents.executeJavaScript(`new Promise(resolve => { + const wrap = document.querySelector('.detail-wrap'); + const direction = ${direction}; + const originalScrollTo = wrap.scrollTo.bind(wrap); + const blockAutomaticScrollEnd = event => event.stopImmediatePropagation(); + let programmaticScrolls = 0; + let maxVisibleOverlaps = 0; + let overlapExample = null; + let previousGeometry = null; + let maxResidualMotion = 0; + let residualExample = null; + wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true); + wrap.scrollTo = (...args) => { + programmaticScrolls++; + return originalScrollTo(...args); + }; + wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: direction * 70, bubbles: true })); + const startedAt = performance.now(); + function frame(now) { + wrap.scrollTop += direction * 100; + const wrapRect = wrap.getBoundingClientRect(); + const scrollTop = wrap.scrollTop; + const rows = [...document.querySelectorAll('.virtual-timeline-row')] + .map(row => { + const rect = row.getBoundingClientRect(); + return { + index: Number(row.dataset.index), + uuid: row.querySelector('[data-uuid]')?.getAttribute('data-uuid'), + rect, + }; + }) + .filter(({ rect }) => rect.bottom > wrapRect.top && rect.top < wrapRect.bottom) + .sort((left, right) => left.index - right.index); + let overlaps = 0; + for (let index = 1; index < rows.length; index++) { + if (rows[index].rect.top < rows[index - 1].rect.bottom - 1) overlaps++; + } + if (overlaps > maxVisibleOverlaps) { + maxVisibleOverlaps = overlaps; + overlapExample = rows.slice(0, 5).map(row => ({ + index: row.index, + top: row.rect.top, + bottom: row.rect.bottom, + })); + } + const geometry = new Map(rows.filter(row => row.uuid).map(row => [ + row.uuid, + row.rect.top - wrapRect.top, + ])); + if (previousGeometry) { + for (const [uuid, top] of geometry) { + if (!previousGeometry.rows.has(uuid)) continue; + const screenDelta = top - previousGeometry.rows.get(uuid); + const scrollDelta = scrollTop - previousGeometry.scrollTop; + const residual = screenDelta + scrollDelta; + if (Math.abs(residual) > Math.abs(maxResidualMotion)) { + maxResidualMotion = residual; + residualExample = { uuid, screenDelta, scrollDelta, residual }; + } + } + } + previousGeometry = { rows: geometry, scrollTop }; + if (now - startedAt < 2000) { + requestAnimationFrame(frame); + return; + } + wrap.scrollTo = originalScrollTo; + wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true); + wrap.dispatchEvent(new Event('scrollend')); + resolve({ + programmaticScrolls, + maxVisibleOverlaps, + overlapExample, + maxResidualMotion, + residualExample, + }); + } + requestAnimationFrame(frame); + })`, true); +} + async function startRendererTrace(win, { captureScreenshots = false } = {}) { const traceEvents = []; let completeTrace; @@ -219,6 +319,8 @@ async function traceWheelPaintContinuity(win, { updateTool = false } = {}) { stop: false, wheels: 0, updateVisibleAtWheel: null, + maxVisibleOverlaps: 0, + overlapExample: null, }; const recordWheel = () => { probe.wheels++; }; const observer = new MutationObserver(() => { @@ -237,6 +339,26 @@ async function traceWheelPaintContinuity(win, { updateTool = false } = {}) { function frame(now) { probe.gaps.push(now - probe.previous); probe.previous = now; + const wrapRect = wrap.getBoundingClientRect(); + const rows = [...document.querySelectorAll('.virtual-timeline-row')] + .map(row => ({ + index: Number(row.dataset.index), + rect: row.getBoundingClientRect(), + })) + .filter(({ rect }) => rect.bottom > wrapRect.top && rect.top < wrapRect.bottom) + .sort((left, right) => left.index - right.index); + let overlaps = 0; + for (let index = 1; index < rows.length; index++) { + if (rows[index].rect.top < rows[index - 1].rect.bottom - 1) overlaps++; + } + if (overlaps > probe.maxVisibleOverlaps) { + probe.maxVisibleOverlaps = overlaps; + probe.overlapExample = rows.slice(0, 6).map(row => ({ + index: row.index, + top: row.rect.top, + bottom: row.rect.bottom, + })); + } if (!probe.stop) requestAnimationFrame(frame); } requestAnimationFrame(frame); @@ -280,7 +402,12 @@ async function traceWheelPaintContinuity(win, { updateTool = false } = {}) { probe.stop = true; probe.cleanup(); delete window.__wheelFrameProbe; - return { gaps: probe.gaps, updateVisibleAtWheel: probe.updateVisibleAtWheel }; + return { + gaps: probe.gaps, + updateVisibleAtWheel: probe.updateVisibleAtWheel, + maxVisibleOverlaps: probe.maxVisibleOverlaps, + overlapExample: probe.overlapExample, + }; })()`, true); const traceEvents = await stopRendererTrace(); const screenshots = traceEvents @@ -296,6 +423,8 @@ async function traceWheelPaintContinuity(win, { updateTool = false } = {}) { maxTaskMs: taskMetrics.maxTaskMs, maxFunctionCallMs: taskMetrics.maxFunctionCallMs, updateVisibleAtWheel: frameProbe.updateVisibleAtWheel, + maxVisibleOverlaps: frameProbe.maxVisibleOverlaps, + overlapExample: frameProbe.overlapExample, slowestChildren: taskMetrics.slowestChildren, // A blank content crop is almost uniform (< 0.035); rendered fixture rows // stay comfortably above 0.06 even while the compositor is scrolling. @@ -623,6 +752,10 @@ async function run() { wheelPaint.blankFrames === 0, `fast wheel scrolling never presents a blank timeline frame (${JSON.stringify(wheelPaint)})`, ); + assert( + wheelBaseline.maxVisibleOverlaps === 0, + `ordinary wheel scrolling never overlaps visible rows (${JSON.stringify(wheelBaseline.overlapExample)})`, + ); assert( wheelPaint.maxFrameGap < 50, `Bash tool update avoids a multi-frame renderer stall while scrolling (${JSON.stringify(wheelPaint)})`, @@ -735,6 +868,42 @@ async function run() { ); await delay(250); + const downwardGeometry = await probeOrdinaryScrollGeometry(win, { + startIndex: 40, + direction: 1, + }); + const upwardGeometry = await probeOrdinaryScrollGeometry(win, { + startIndex: 260, + direction: -1, + }); + for (const [label, geometry] of [ + ['downward', downwardGeometry], + ['upward', upwardGeometry], + ]) { + assert( + geometry.maxVisibleOverlaps === 0, + `long rows never overlap during ordinary ${label} scrolling (${JSON.stringify(geometry.overlapExample)})`, + ); + assert( + geometry.programmaticScrolls === 0, + `ordinary ${label} scrolling performs no programmatic scrollTo writes`, + ); + assert( + Math.abs(geometry.maxResidualMotion) < 1.5, + `ordinary ${label} scrolling keeps visible messages fixed to scroll input (${JSON.stringify(geometry.residualExample)})`, + ); + } + await win.webContents.executeJavaScript( + `window.location.hash = '#/sessions/${sessionId}?focus=${focusMessageUuid}'`, + true, + ); + await waitFor( + win.webContents, + `document.querySelector('[data-uuid="${focusMessageUuid}"].is-focused')`, + 'restored offscreen UUID focus', + ); + await delay(250); + await win.webContents.executeJavaScript(`(() => { const original = window.marked.parse; const originalJsonParse = JSON.parse; @@ -845,20 +1014,53 @@ async function run() { const totalBeforeGesture = Number(document.querySelector('.flap-number')?.getAttribute('aria-label')); const originalScrollTo = wrap.scrollTo.bind(wrap); let programmaticScrolls = 0; + let postScrollEndWrites = 0; + let phase = 'scrolling'; const blockAutomaticScrollEnd = event => event.stopImmediatePropagation(); wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true); wrap.scrollTo = (...args) => { - programmaticScrolls++; + if (phase === 'scrolling') programmaticScrolls++; + else postScrollEndWrites++; return originalScrollTo(...args); }; const gaps = []; const startedAt = performance.now(); let previous = startedAt; - wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: 70, bubbles: true })); + let previousGeometry = null; + let maxResidualMotion = 0; + let residualExample = null; + wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: -70, bubbles: true })); + function sampleGeometry(now) { + const wrapRect = wrap.getBoundingClientRect(); + const scrollTop = wrap.scrollTop; + const rows = new Map([...document.querySelectorAll('.virtual-timeline-row')] + .map(row => { + const rect = row.getBoundingClientRect(); + const uuid = row.querySelector('[data-uuid]')?.getAttribute('data-uuid'); + return uuid && rect.bottom > wrapRect.top && rect.top < wrapRect.bottom + ? [uuid, rect.top - wrapRect.top] + : null; + }) + .filter(Boolean)); + if (previousGeometry) { + for (const [uuid, top] of rows) { + if (!previousGeometry.rows.has(uuid)) continue; + const screenDelta = top - previousGeometry.rows.get(uuid); + const scrollDelta = scrollTop - previousGeometry.scrollTop; + const residual = screenDelta + scrollDelta; + if (Math.abs(residual) > Math.abs(maxResidualMotion)) { + maxResidualMotion = residual; + residualExample = { now: now - startedAt, uuid, screenDelta, scrollDelta, residual }; + } + } + } + previousGeometry = { rows, scrollTop }; + } function frame(now) { gaps.push(now - previous); previous = now; - if (now - startedAt >= 400) wrap.scrollTop += 70; + if (now - startedAt >= 400) wrap.scrollTop -= 70; + sampleGeometry(now); if (now - startedAt < 1200) requestAnimationFrame(frame); else { const wrapRect = wrap.getBoundingClientRect(); @@ -870,7 +1072,11 @@ async function run() { 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; + phase = 'settled'; + window.__scrollGeometryWriteProbe = { + read: () => postScrollEndWrites, + restore: () => { wrap.scrollTo = originalScrollTo; }, + }; wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true); wrap.dispatchEvent(new Event('scrollend')); resolve({ @@ -878,6 +1084,8 @@ async function run() { totalBeforeScrollEnd, flapBeforeScrollEnd, programmaticScrolls, + maxResidualMotion, + residualExample, maxFrameGap: Math.max(...gaps), frames: gaps.length, rows: document.querySelectorAll('.virtual-timeline-row').length, @@ -906,6 +1114,13 @@ async function run() { `document.title.includes('Live metadata title') && document.querySelector('.breadcrumb')?.textContent.includes('Live metadata title')`, 'shared route metadata update', ); + const postScrollEndWrites = await win.webContents.executeJavaScript(`(() => { + const probe = window.__scrollGeometryWriteProbe; + const writes = probe?.read() ?? -1; + probe?.restore(); + delete window.__scrollGeometryWriteProbe; + return writes; + })()`, true); const sharedMetadataState = await win.webContents.executeJavaScript(`(() => ({ windowTitle: document.title.includes('Live metadata title'), breadcrumb: document.querySelector('.breadcrumb')?.textContent.includes('Live metadata title'), @@ -947,6 +1162,14 @@ async function run() { scrollProbe.programmaticScrolls === 0, `wheel-to-scrollend performs zero programmatic scrollTo calls (got ${scrollProbe.programmaticScrolls})`, ); + assert( + Math.abs(scrollProbe.maxResidualMotion) < 1.5, + `wheel-to-scrollend keeps visible messages fixed to scroll input (${JSON.stringify(scrollProbe.residualExample)})`, + ); + assert( + postScrollEndWrites <= 1, + `scrollend batches deferred measurements into at most one anchor sync (got ${postScrollEndWrites})`, + ); 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( @@ -958,6 +1181,7 @@ async function run() { ); assert(scrollProbe.maxFrameGap < 250, `live scroll has no catastrophic long frame (${scrollProbe.maxFrameGap.toFixed(1)}ms)`); + await waitFor(win.webContents, `!document.querySelector('.flap-slot.flipping')`, 'tail append flap settlement'); const updatedReaderText = `Updated ${scrollProbe.anchor.uuid} ${'content identity '.repeat(20)}`; await win.webContents.executeJavaScript(`(() => { const original = window.marked.parse; @@ -980,20 +1204,118 @@ async function run() { restore: () => { window.marked.parse = original; }, }; })()`, true); - replaceMessageText(win, scrollProbe.anchor.uuid, updatedReaderText); + setTimeout(() => replaceMessageText(win, scrollProbe.anchor.uuid, updatedReaderText), 200); + const existingUpdateProbe = await win.webContents.executeJavaScript(`new Promise(resolve => { + const wrap = document.querySelector('.detail-wrap'); + const targetUuid = ${JSON.stringify(scrollProbe.anchor.uuid)}; + const targetText = ${JSON.stringify(updatedReaderText.slice(0, 40))}; + const originalScrollTo = wrap.scrollTo.bind(wrap); + const blockAutomaticScrollEnd = event => event.stopImmediatePropagation(); + let programmaticScrolls = 0; + let previousGeometry = null; + let maxResidualMotion = 0; + let residualExample = null; + let steps = 0; + wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true); + wrap.scrollTo = (...args) => { + programmaticScrolls++; + return originalScrollTo(...args); + }; + wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: -40, bubbles: true })); + const startedAt = performance.now(); + function frame(now) { + if (now - startedAt >= 250 && steps < 3) { + wrap.scrollTop -= 40; + steps++; + } + const wrapRect = wrap.getBoundingClientRect(); + const scrollTop = wrap.scrollTop; + const rows = new Map([...document.querySelectorAll('.virtual-timeline-row')] + .map(row => { + const rect = row.getBoundingClientRect(); + const uuid = row.querySelector('[data-uuid]')?.getAttribute('data-uuid'); + return uuid && rect.bottom > wrapRect.top && rect.top < wrapRect.bottom + ? [uuid, rect.top - wrapRect.top] + : null; + }) + .filter(Boolean)); + if (previousGeometry) { + for (const [uuid, top] of rows) { + if (!previousGeometry.rows.has(uuid)) continue; + const screenDelta = top - previousGeometry.rows.get(uuid); + const scrollDelta = scrollTop - previousGeometry.scrollTop; + const residual = screenDelta + scrollDelta; + if (Math.abs(residual) > Math.abs(maxResidualMotion)) { + maxResidualMotion = residual; + residualExample = { uuid, screenDelta, scrollDelta, residual }; + } + } + } + previousGeometry = { rows, scrollTop }; + if (now - startedAt < 700) { + requestAnimationFrame(frame); + return; + } + const anchorRow = [...document.querySelectorAll('.virtual-timeline-row')] + .find(row => { + const rect = row.getBoundingClientRect(); + return rect.bottom > wrapRect.top && rect.top < wrapRect.bottom; + }); + const anchorElement = anchorRow?.querySelector('[data-uuid]'); + const targetVisibleBeforeScrollEnd = Boolean( + document.querySelector('[data-uuid="' + targetUuid + '"]')?.textContent.includes(targetText), + ); + wrap.scrollTo = originalScrollTo; + wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true); + wrap.dispatchEvent(new Event('scrollend')); + resolve({ + targetVisibleBeforeScrollEnd, + programmaticScrolls, + maxResidualMotion, + residualExample, + anchor: anchorElement && { + uuid: anchorElement.getAttribute('data-uuid'), + offset: anchorRow.getBoundingClientRect().top - wrapRect.top, + }, + }); + } + requestAnimationFrame(frame); + })`, true); await waitFor( win.webContents, `document.querySelector('[data-uuid=${JSON.stringify(scrollProbe.anchor.uuid)}]')?.textContent.includes(${JSON.stringify(updatedReaderText.slice(0, 40))})`, 'visible message content update', ); - const contentIdentityCalls = await win.webContents.executeJavaScript(`(() => { + const updatedReaderState = await win.webContents.executeJavaScript(`(() => { + const wrap = document.querySelector('.detail-wrap'); + const anchorElement = document.querySelector( + ${JSON.stringify(`[data-uuid="${existingUpdateProbe.anchor?.uuid}"]`)}, + ); + const anchorRow = anchorElement?.closest('.virtual-timeline-row'); const calls = window.__timelineContentIdentityProbe.calls(); window.__timelineContentIdentityProbe.restore(); delete window.__timelineContentIdentityProbe; - return calls; + return { + calls, + anchor: anchorElement && { + uuid: anchorElement.getAttribute('data-uuid'), + offset: anchorRow.getBoundingClientRect().top - wrap.getBoundingClientRect().top, + }, + }; })()`, true); - assert(contentIdentityCalls.target === 1, `updated mounted row recomputes its Markdown once (got ${contentIdentityCalls.target})`); - assert(contentIdentityCalls.unchanged === 0, `updated mounted row leaves other mounted Markdown cached (got ${contentIdentityCalls.unchanged})`); + assert(!existingUpdateProbe.targetVisibleBeforeScrollEnd, 'existing message update stays out of the timeline until scrollend'); + assert(existingUpdateProbe.programmaticScrolls === 0, 'existing message update performs no programmatic scroll during the gesture'); + assert( + Math.abs(existingUpdateProbe.maxResidualMotion) < 1.5, + `existing message update keeps visible messages fixed to scroll input (${JSON.stringify(existingUpdateProbe.residualExample)})`, + ); + assert( + existingUpdateProbe.anchor?.uuid === updatedReaderState.anchor?.uuid + && Math.abs(existingUpdateProbe.anchor.offset - updatedReaderState.anchor.offset) < 2, + `existing message update preserves reader anchor ${existingUpdateProbe.anchor?.uuid} (${existingUpdateProbe.anchor?.offset}px -> ${updatedReaderState.anchor?.offset}px)`, + ); + assert(updatedReaderState.calls.target === 1, `updated mounted row recomputes its Markdown once (got ${updatedReaderState.calls.target})`); + assert(updatedReaderState.calls.unchanged === 0, `updated mounted row leaves other mounted Markdown cached (got ${updatedReaderState.calls.unchanged})`); assert( ipcReads.messages === 1 && ipcReads.toolCalls === 1 diff --git a/tests/session-timeline-scroll-policy.test.mjs b/tests/session-timeline-scroll-policy.test.mjs index 35d1b34..c57a701 100644 --- a/tests/session-timeline-scroll-policy.test.mjs +++ b/tests/session-timeline-scroll-policy.test.mjs @@ -7,6 +7,7 @@ test('virtualizer scroll writes are discarded throughout a user scroll', () => { let scrolling = true; const element = { scrollTop: 100 }; const writes = []; + const suppressed = []; const instance = { scrollElement: element }; const policy = createSessionTimelineScrollPolicy({ isUserScrolling: () => scrolling, @@ -14,11 +15,19 @@ test('virtualizer scroll writes are discarded throughout a user scroll', () => { writes.push({ offset, ...options }); element.scrollTop = offset + (options.adjustments || 0); }, + onSuppressedAdjustment: (offset, options) => { + suppressed.push({ offset, ...options }); + }, }); 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'); + assert.deepEqual( + suppressed, + [{ offset: 100, behavior: 'auto', adjustments: 24 }], + 'measurement adjustments are exposed for compositor compensation', + ); scrolling = false; assert.equal(element.scrollTop, 100, 'scrollend never replays a suppressed correction');