diff --git a/src/features/chat/ui/message-stream/virtualizer.ts b/src/features/chat/ui/message-stream/virtualizer.ts index 0cf75a68..fa4a470c 100644 --- a/src/features/chat/ui/message-stream/virtualizer.ts +++ b/src/features/chat/ui/message-stream/virtualizer.ts @@ -29,18 +29,21 @@ interface MessageVirtualizerReadingAnchor { top: number; } -type MessageVirtualizerScrollLock = { kind: "none" } | { kind: "end" } | { kind: "anchor"; anchor: MessageVirtualizerReadingAnchor | null }; +type MessageVirtualizerReadingAnchorState = { kind: "inactive" } | { kind: "active"; anchor: MessageVirtualizerReadingAnchor | null }; + type MessageVirtualizerUserScrollState = "idle" | "pending" | "active"; -interface MessageVirtualizerLayoutReconcileRequest { - requireSettleFrame: boolean; - scheduleSettle: boolean; - clampAnchorToEnd: boolean; +interface MessageVirtualizerScrollPolicyReconcileRequest { + // Follow-end needs a bounded settle when the browser clamps before DOM height catches up. + requireFollowEndSettleFrame: boolean; + scheduleFollowEndSettle: boolean; + // Reading-anchor restores can overshoot when measured content shrinks near the end. + clampReadingAnchorToEnd: boolean; } const MESSAGE_BOTTOM_THRESHOLD = 4; const MESSAGE_BLOCK_ESTIMATE_SIZE = 96; -const MESSAGE_SCROLL_TO_END_SETTLE_ATTEMPTS = 4; +const MESSAGE_FOLLOW_END_SETTLE_ATTEMPTS = 4; export interface MessageStreamVirtualizerView { getTotalSize(): number; getVirtualItems(): VirtualItem[]; @@ -52,18 +55,19 @@ interface MessageVirtualizerRuntime { virtualizer: Virtualizer; cleanupVirtualizer: (() => void) | null; blocks: readonly MessageStreamBlock[]; - pendingLayoutReconcile: MessageVirtualizerLayoutReconcileRequest | null; + pendingScrollPolicyReconcile: MessageVirtualizerScrollPolicyReconcileRequest | null; onVirtualizerChange: (() => void) | null; - scrollLock: MessageVirtualizerScrollLock; + followingEnd: boolean; + readingAnchor: MessageVirtualizerReadingAnchorState; viewportMeasurementsInvalid: boolean; viewportRestoreFrame: number | null; virtualizerChangeFrame: number | null; - settleScrollToEndFrame: number | null; - settleScrollToEndAttemptsRemaining: number; + followEndSettleFrame: number | null; + followEndSettleAttemptsRemaining: number; userScroll: MessageVirtualizerUserScrollState; } -type MessageVirtualizerFrameKey = "viewportRestoreFrame" | "virtualizerChangeFrame" | "settleScrollToEndFrame"; +type MessageVirtualizerFrameKey = "viewportRestoreFrame" | "virtualizerChangeFrame" | "followEndSettleFrame"; export interface MessageStreamVirtualizerOptions { blocks: readonly MessageStreamBlock[]; @@ -115,7 +119,7 @@ export function useMessageStreamVirtualizer({ useLayoutEffect(() => { // Run before the render effect below: requests made during one commit reconcile after the DOM for the next commit exists. - reconcileMessageVirtualizerLayoutAfterCommit(runtime); + reconcileMessageVirtualizerScrollPolicyAfterCommit(runtime); }); useLayoutEffect(() => { @@ -147,14 +151,15 @@ function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime { blocks: [], virtualizer: null as never, cleanupVirtualizer: null, - pendingLayoutReconcile: null, + pendingScrollPolicyReconcile: null, onVirtualizerChange: null, - scrollLock: { kind: "none" }, + followingEnd: false, + readingAnchor: { kind: "inactive" }, viewportMeasurementsInvalid: false, viewportRestoreFrame: null, virtualizerChangeFrame: null, - settleScrollToEndFrame: null, - settleScrollToEndAttemptsRemaining: 0, + followEndSettleFrame: null, + followEndSettleAttemptsRemaining: 0, userScroll: "idle", }; runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime)); @@ -168,16 +173,12 @@ function renderMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: const appendingBlocks = blocks.length > runtime.blocks.length; const shouldFollowAppend = - appendingBlocks && (hasMessageVirtualizerEndLock(runtime) || isMessageVirtualizerObservedAtEnd(runtime, container)); - if (isMessageVirtualizerInsertingBeforeExistingBlocks(runtime.blocks, blocks)) { - setMessageVirtualizerAnchorLock(runtime, captureMessageVirtualizerReadingAnchorValue(runtime)); - cancelPendingMessageVirtualizerReconcile(runtime); - } + appendingBlocks && (isMessageVirtualizerFollowingEnd(runtime) || isMessageVirtualizerObservedAtEnd(runtime, container)); runtime.blocks = blocks; - if (blocks.length > 0 && shouldFollowAppend) setMessageVirtualizerEndLock(runtime); + if (blocks.length > 0 && shouldFollowAppend) setMessageVirtualizerFollowEnd(runtime); updateMessageVirtualizerOptions(runtime); updateMessageVirtualizer(runtime.virtualizer); - requestMessageVirtualizerLayoutReconcile(runtime); + requestMessageVirtualizerScrollPolicyReconcile(runtime); } function applyMessageVirtualizerScrollCommand(runtime: MessageVirtualizerRuntime, command: MessageStreamScrollCommand): void { @@ -195,33 +196,10 @@ function applyMessageVirtualizerScrollCommand(runtime: MessageVirtualizerRuntime } } -function isMessageVirtualizerInsertingBeforeExistingBlocks( - previousBlocks: readonly MessageStreamBlock[], - nextBlocks: readonly MessageStreamBlock[], -): boolean { - if (previousBlocks.length === 0 || nextBlocks.length <= previousBlocks.length) return false; - const matchedIndexes: number[] = []; - let searchStart = 0; - for (const previousBlock of previousBlocks) { - const nextIndex = findMessageVirtualizerBlockIndexByKey(nextBlocks, previousBlock.key, searchStart); - if (nextIndex < 0) return false; - matchedIndexes.push(nextIndex); - searchStart = nextIndex + 1; - } - return matchedIndexes.some((nextIndex, previousIndex) => nextIndex !== previousIndex); -} - -function findMessageVirtualizerBlockIndexByKey(blocks: readonly MessageStreamBlock[], key: unknown, startIndex: number): number { - for (let index = startIndex; index < blocks.length; index += 1) { - if (Object.is(blocks[index]?.key, key)) return index; - } - return -1; -} - function getMessageVirtualizerTotalSize(runtime: MessageVirtualizerRuntime): number { const totalSize = runtime.virtualizer.getTotalSize(); const container = runtime.container; - if (!container || !isMessageVirtualizerAnchorLocked(runtime)) return totalSize; + if (!container || !isMessageVirtualizerReadingAnchorActive(runtime)) return totalSize; return Math.max(totalSize, container.scrollTop + container.clientHeight + MESSAGE_BOTTOM_THRESHOLD + 1); } @@ -230,27 +208,27 @@ function getMessageVirtualizerItems(runtime: MessageVirtualizerRuntime): Virtual } function measureMessageVirtualizerElement(runtime: MessageVirtualizerRuntime, element: HTMLElement | null): void { - prepareMessageVirtualizerMeasurement(runtime); + prepareMessageVirtualizerScrollPolicyForMeasurement(runtime); runtime.virtualizer.measureElement(element); if (element) { - requestMessageVirtualizerLayoutReconcile(runtime, { + requestMessageVirtualizerScrollPolicyReconcile(runtime, { notify: true, - requireSettleFrame: true, + requireFollowEndSettleFrame: true, }); } } -function prepareMessageVirtualizerMeasurement(runtime: MessageVirtualizerRuntime): void { - const wasFollowingEnd = hasMessageVirtualizerEndLock(runtime); - const scrollOffsetMovedAwayFromEnd = syncMessageVirtualizerScrollOffset(runtime); - if (wasFollowingEnd && !scrollOffsetMovedAwayFromEnd) setMessageVirtualizerEndLock(runtime); +function prepareMessageVirtualizerScrollPolicyForMeasurement(runtime: MessageVirtualizerRuntime): void { + const wasFollowingEnd = isMessageVirtualizerFollowingEnd(runtime); + const scrollOffsetMovedAwayFromEnd = syncMessageVirtualizerDomScrollOffset(runtime); + if (wasFollowingEnd && !scrollOffsetMovedAwayFromEnd) setMessageVirtualizerFollowEnd(runtime); } -function reconcileMessageVirtualizerLayoutAfterCommit(runtime: MessageVirtualizerRuntime): void { - const request = runtime.pendingLayoutReconcile; +function reconcileMessageVirtualizerScrollPolicyAfterCommit(runtime: MessageVirtualizerRuntime): void { + const request = runtime.pendingScrollPolicyReconcile; if (!request) return; - runtime.pendingLayoutReconcile = null; - reconcileMessageVirtualizerLayout(runtime, request); + runtime.pendingScrollPolicyReconcile = null; + reconcileMessageVirtualizerScrollPolicy(runtime, request); } function measureRenderedMessageBlocks(runtime: MessageVirtualizerRuntime): void { @@ -275,10 +253,11 @@ function renderedMessageBlockElements(container: HTMLElement | null): HTMLElemen ); } -function syncMessageVirtualizerScrollOffset(runtime: MessageVirtualizerRuntime): boolean { +function syncMessageVirtualizerDomScrollOffset(runtime: MessageVirtualizerRuntime): boolean { const container = runtime.container; if (!container) return false; - if (hasMessageVirtualizerEndLock(runtime) && hasPendingMessageVirtualizerScrollToEnd(runtime)) return false; + // Ignore stale programmatic scroll events while follow-end settle is still authoritative. + if (isMessageVirtualizerFollowingEnd(runtime) && hasPendingMessageVirtualizerFollowEndSettle(runtime)) return false; const offset = container.scrollTop; const previousOffset = runtime.virtualizer.scrollOffset; const offsetChanged = previousOffset !== null && offset !== previousOffset; @@ -286,9 +265,9 @@ function syncMessageVirtualizerScrollOffset(runtime: MessageVirtualizerRuntime): runtime.virtualizer.scrollDirection = offset < previousOffset ? "backward" : "forward"; } runtime.virtualizer.scrollOffset = offset; - const scrollOffsetMovedAwayFromEnd = messageVirtualizerScrollOffsetMovedAwayFromEnd(runtime, container, offsetChanged); + const scrollOffsetMovedAwayFromEnd = didMessageVirtualizerDomOffsetMoveAwayFromEnd(runtime, container, offsetChanged); if (scrollOffsetMovedAwayFromEnd) { - setMessageVirtualizerAnchorLock(runtime, runtime.scrollLock.kind === "anchor" ? runtime.scrollLock.anchor : null); + setMessageVirtualizerReadingAnchor(runtime, runtime.readingAnchor.kind === "active" ? runtime.readingAnchor.anchor : null); } return scrollOffsetMovedAwayFromEnd; } @@ -298,7 +277,7 @@ function isMessageVirtualizerObservedAtEnd(runtime: MessageVirtualizerRuntime, c return isElementAtEnd(container, runtime.virtualizer.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD); } -function messageVirtualizerScrollOffsetMovedAwayFromEnd( +function didMessageVirtualizerDomOffsetMoveAwayFromEnd( runtime: MessageVirtualizerRuntime, container: HTMLElement, offsetChanged: boolean, @@ -306,32 +285,37 @@ function messageVirtualizerScrollOffsetMovedAwayFromEnd( return offsetChanged && !isMessageVirtualizerObservedAtEnd(runtime, container); } -function hasPendingMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): boolean { - return runtime.settleScrollToEndFrame !== null; +function hasPendingMessageVirtualizerFollowEndSettle(runtime: MessageVirtualizerRuntime): boolean { + return runtime.followEndSettleFrame !== null; } -function setMessageVirtualizerEndLock(runtime: MessageVirtualizerRuntime): void { - runtime.scrollLock = { kind: "end" }; +function setMessageVirtualizerFollowEnd(runtime: MessageVirtualizerRuntime): void { + runtime.followingEnd = true; + resetMessageVirtualizerReadingAnchor(runtime); updateMessageVirtualizerOptions(runtime); } -function clearMessageVirtualizerEndLock(runtime: MessageVirtualizerRuntime): void { - if (runtime.scrollLock.kind === "end") runtime.scrollLock = { kind: "none" }; +function clearMessageVirtualizerFollowEnd(runtime: MessageVirtualizerRuntime): void { + runtime.followingEnd = false; } -function setMessageVirtualizerAnchorLock(runtime: MessageVirtualizerRuntime, anchor: MessageVirtualizerReadingAnchor | null): void { - runtime.scrollLock = { kind: "anchor", anchor }; +function setMessageVirtualizerReadingAnchor(runtime: MessageVirtualizerRuntime, anchor: MessageVirtualizerReadingAnchor | null): void { + runtime.readingAnchor = { kind: "active", anchor }; +} + +function resetMessageVirtualizerReadingAnchor(runtime: MessageVirtualizerRuntime): void { + runtime.readingAnchor = { kind: "inactive" }; +} + +function resetMessageVirtualizerScrollPolicy(runtime: MessageVirtualizerRuntime): void { + runtime.followingEnd = false; + resetMessageVirtualizerReadingAnchor(runtime); updateMessageVirtualizerOptions(runtime); } -function resetMessageVirtualizerScrollLock(runtime: MessageVirtualizerRuntime): void { - runtime.scrollLock = { kind: "none" }; - updateMessageVirtualizerOptions(runtime); -} - -function cancelPendingMessageVirtualizerReconcile(runtime: MessageVirtualizerRuntime): void { - cancelSettledMessageVirtualizerScrollToEnd(runtime); - runtime.pendingLayoutReconcile = null; +function cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime: MessageVirtualizerRuntime): void { + cancelMessageVirtualizerFollowEndSettle(runtime); + runtime.pendingScrollPolicyReconcile = null; } function setMessageVirtualizerChangeHandler(runtime: MessageVirtualizerRuntime, callback: (() => void) | null): void { @@ -343,7 +327,7 @@ function pinMessageVirtualizerToBottom(runtime: MessageVirtualizerRuntime, conta attachMessageVirtualizer(runtime, container); updateMessageVirtualizerOptions(runtime); updateMessageVirtualizer(runtime.virtualizer); - requestMessageVirtualizerEndLock(runtime, { notify: true }); + requestMessageVirtualizerFollowEnd(runtime, { notify: true }); } function resetMessageVirtualizerMeasurements(runtime: MessageVirtualizerRuntime): void { @@ -366,10 +350,10 @@ function handleMessageVirtualizerViewportRect( cancelViewportRestoreMessageVirtualizerReset(runtime); return; } - if (hasMessageVirtualizerEndLock(runtime)) { - requestMessageVirtualizerEndLock(runtime, { + if (isMessageVirtualizerFollowingEnd(runtime)) { + requestMessageVirtualizerFollowEnd(runtime, { ...(options.notify === undefined ? {} : { notify: options.notify }), - requireSettleFrame: true, + requireFollowEndSettleFrame: true, }); } if (!runtime.viewportMeasurementsInvalid) return; @@ -384,7 +368,7 @@ function scheduleViewportRestoreMessageVirtualizerReset(runtime: MessageVirtuali runtime.viewportMeasurementsInvalid = false; resetMessageVirtualizerMeasurements(runtime); // Viewport restore is equivalent to activating/resuming the panel: rebuild stale measurements, then return to the end. - requestMessageVirtualizerEndLock(runtime, { notify: true }); + requestMessageVirtualizerFollowEnd(runtime, { notify: true }); }); } @@ -409,14 +393,14 @@ function scrollMessageVirtualizerByPage(runtime: MessageVirtualizerRuntime, dire function disposeMessageVirtualizer(runtime: MessageVirtualizerRuntime): void { cancelViewportRestoreMessageVirtualizerReset(runtime); cancelDeferredMessageVirtualizerChange(runtime); - cancelPendingMessageVirtualizerReconcile(runtime); + cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime); runtime.cleanupVirtualizer?.(); runtime.cleanupVirtualizer = null; runtime.container = null; runtime.blocks = []; - runtime.pendingLayoutReconcile = null; + runtime.pendingScrollPolicyReconcile = null; runtime.onVirtualizerChange = null; - resetMessageVirtualizerScrollLock(runtime); + resetMessageVirtualizerScrollPolicy(runtime); runtime.viewportMeasurementsInvalid = false; runtime.userScroll = "idle"; } @@ -431,25 +415,25 @@ function attachMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void { cancelViewportRestoreMessageVirtualizerReset(runtime); cancelDeferredMessageVirtualizerChange(runtime); - cancelPendingMessageVirtualizerReconcile(runtime); + cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime); runtime.cleanupVirtualizer?.(); runtime.cleanupVirtualizer = null; runtime.container = null; runtime.blocks = []; - resetMessageVirtualizerScrollLock(runtime); + resetMessageVirtualizerScrollPolicy(runtime); runtime.viewportMeasurementsInvalid = false; runtime.userScroll = "idle"; - runtime.pendingLayoutReconcile = null; + runtime.pendingScrollPolicyReconcile = null; } function resetMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: HTMLElement): void { cancelViewportRestoreMessageVirtualizerReset(runtime); cancelDeferredMessageVirtualizerChange(runtime); - cancelSettledMessageVirtualizerScrollToEnd(runtime); + cancelMessageVirtualizerFollowEndSettle(runtime); runtime.cleanupVirtualizer?.(); runtime.cleanupVirtualizer = null; runtime.container = container; - resetMessageVirtualizerScrollLock(runtime); + resetMessageVirtualizerScrollPolicy(runtime); runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime)); configureMessageVirtualizerSizeAdjustment(runtime); runtime.cleanupVirtualizer = mountMessageVirtualizer(runtime); @@ -465,8 +449,8 @@ function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime): Virtuali initialRect: scrollElementRect(runtime.container), initialOffset: () => runtime.container?.scrollTop ?? 0, getItemKey: (index: number) => runtime.blocks[index]?.key ?? index, - anchorTo: hasMessageVirtualizerEndLock(runtime) ? ("end" as const) : ("start" as const), - followOnAppend: false, + anchorTo: "end", + followOnAppend: "auto", scrollEndThreshold: MESSAGE_BOTTOM_THRESHOLD, paddingStart: paddingBlock, paddingEnd: paddingBlock, @@ -522,12 +506,13 @@ function handleMessageVirtualizerObservedOffset( if (!element) return; const userIntendedScroll = observeMessageVirtualizerUserScroll(runtime, isScrolling); if (isMessageVirtualizerObservedOffsetAtEnd(runtime, instance, element, offset)) { - setMessageVirtualizerEndLock(runtime); + setMessageVirtualizerFollowEnd(runtime); return; } if (!userIntendedScroll) return; - setMessageVirtualizerAnchorLock(runtime, captureMessageVirtualizerReadingAnchorValue(runtime)); - cancelPendingMessageVirtualizerReconcile(runtime); + clearMessageVirtualizerFollowEnd(runtime); + setMessageVirtualizerReadingAnchor(runtime, captureMessageVirtualizerReadingAnchorValue(runtime)); + cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime); } function updateMessageVirtualizerOptions(runtime: MessageVirtualizerRuntime): void { @@ -559,7 +544,7 @@ function cancelDeferredMessageVirtualizerChange(runtime: MessageVirtualizerRunti function configureMessageVirtualizerSizeAdjustment(runtime: MessageVirtualizerRuntime): void { // TanStack Virtual exposes this as an instance field rather than a normal option in v3.17. - runtime.virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; + runtime.virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => item.end <= (instance.scrollOffset ?? 0); } function scrollMessageVirtualizerElement( @@ -574,137 +559,143 @@ function scrollMessageVirtualizerElement( function scrollMessageVirtualizerBy(runtime: MessageVirtualizerRuntime, delta: number): void { const container = runtime.container; if (!container) return; - syncMessageVirtualizerScrollOffset(runtime); - const currentScrollSize = Math.max(container.scrollHeight, runtime.virtualizer.getTotalSize()); - const currentScrollEnd = Math.max(0, currentScrollSize - container.clientHeight); - const targetOffset = Math.max(0, Math.min(container.scrollTop + delta, currentScrollEnd)); - const targetAtEnd = delta > 0 && currentScrollEnd - targetOffset <= MESSAGE_BOTTOM_THRESHOLD; - if (Math.abs(container.scrollTop - targetOffset) <= 1) { - if (targetAtEnd) setMessageVirtualizerEndLock(runtime); + syncMessageVirtualizerDomScrollOffset(runtime); + const initialOffset = container.scrollTop; + runtime.virtualizer.scrollBy(delta); + syncMessageVirtualizerDomScrollOffset(runtime); + const reachedEnd = delta > 0 && isMessageVirtualizerObservedAtEnd(runtime, container); + if (Math.abs(container.scrollTop - initialOffset) <= 1) { + if (reachedEnd) setMessageVirtualizerFollowEnd(runtime); return; } - clearMessageVirtualizerEndLock(runtime); - cancelPendingMessageVirtualizerReconcile(runtime); - runtime.virtualizer.scrollToOffset(targetOffset); - syncMessageVirtualizerScrollOffset(runtime); + clearMessageVirtualizerFollowEnd(runtime); + cancelPendingMessageVirtualizerScrollPolicyReconcile(runtime); updateMessageVirtualizer(runtime.virtualizer); notifyMessageVirtualizerChange(runtime); - if (targetAtEnd) { - setMessageVirtualizerEndLock(runtime); + if (reachedEnd) { + setMessageVirtualizerFollowEnd(runtime); } else { - setMessageVirtualizerAnchorLock(runtime, captureMessageVirtualizerReadingAnchorValue(runtime)); + setMessageVirtualizerReadingAnchor(runtime, captureMessageVirtualizerReadingAnchorValue(runtime)); } } -function hasMessageVirtualizerEndLock(runtime: MessageVirtualizerRuntime): boolean { - return runtime.scrollLock.kind === "end"; +function isMessageVirtualizerFollowingEnd(runtime: MessageVirtualizerRuntime): boolean { + return runtime.followingEnd; } -function isMessageVirtualizerAnchorLocked(runtime: MessageVirtualizerRuntime): boolean { - return runtime.scrollLock.kind === "anchor"; +function isMessageVirtualizerReadingAnchorActive(runtime: MessageVirtualizerRuntime): boolean { + return runtime.readingAnchor.kind === "active"; } -function requestMessageVirtualizerEndLock( +function requestMessageVirtualizerFollowEnd( runtime: MessageVirtualizerRuntime, - options: { notify?: boolean; requireSettleFrame?: boolean } = {}, + options: { notify?: boolean; requireFollowEndSettleFrame?: boolean } = {}, ): void { - setMessageVirtualizerEndLock(runtime); - requestMessageVirtualizerLayoutReconcile(runtime, options); + setMessageVirtualizerFollowEnd(runtime); + requestMessageVirtualizerScrollPolicyReconcile(runtime, options); } -function requestMessageVirtualizerLayoutReconcile( +function requestMessageVirtualizerScrollPolicyReconcile( runtime: MessageVirtualizerRuntime, - options: { notify?: boolean; requireSettleFrame?: boolean; scheduleSettle?: boolean; clampAnchorToEnd?: boolean } = {}, + options: { + notify?: boolean; + requireFollowEndSettleFrame?: boolean; + scheduleFollowEndSettle?: boolean; + clampReadingAnchorToEnd?: boolean; + } = {}, ): void { - runtime.pendingLayoutReconcile = mergeMessageVirtualizerLayoutReconcileRequest(runtime.pendingLayoutReconcile, options); + runtime.pendingScrollPolicyReconcile = mergeMessageVirtualizerScrollPolicyReconcileRequest(runtime.pendingScrollPolicyReconcile, options); if (options.notify) notifyMessageVirtualizerChange(runtime); } -function mergeMessageVirtualizerLayoutReconcileRequest( - current: MessageVirtualizerLayoutReconcileRequest | null, - options: { requireSettleFrame?: boolean; scheduleSettle?: boolean; clampAnchorToEnd?: boolean }, -): MessageVirtualizerLayoutReconcileRequest { +function mergeMessageVirtualizerScrollPolicyReconcileRequest( + current: MessageVirtualizerScrollPolicyReconcileRequest | null, + options: { requireFollowEndSettleFrame?: boolean; scheduleFollowEndSettle?: boolean; clampReadingAnchorToEnd?: boolean }, +): MessageVirtualizerScrollPolicyReconcileRequest { return { - requireSettleFrame: current?.requireSettleFrame === true || options.requireSettleFrame === true, - scheduleSettle: (current?.scheduleSettle ?? true) && options.scheduleSettle !== false, - clampAnchorToEnd: current?.clampAnchorToEnd === true || options.clampAnchorToEnd === true, + requireFollowEndSettleFrame: current?.requireFollowEndSettleFrame === true || options.requireFollowEndSettleFrame === true, + scheduleFollowEndSettle: (current?.scheduleFollowEndSettle ?? true) && options.scheduleFollowEndSettle !== false, + clampReadingAnchorToEnd: current?.clampReadingAnchorToEnd === true || options.clampReadingAnchorToEnd === true, }; } -function reconcileMessageVirtualizerLayout(runtime: MessageVirtualizerRuntime, request: MessageVirtualizerLayoutReconcileRequest): void { +function reconcileMessageVirtualizerScrollPolicy( + runtime: MessageVirtualizerRuntime, + request: MessageVirtualizerScrollPolicyReconcileRequest, +): void { if (!runtime.container) return; - if (runtime.scrollLock.kind === "end") { - reconcileMessageVirtualizerEndLock(runtime, request); + if (runtime.followingEnd) { + reconcileMessageVirtualizerFollowEnd(runtime, request); return; } - if (runtime.scrollLock.kind === "anchor") { - reconcileMessageVirtualizerAnchorLock(runtime, { clampToEnd: request.clampAnchorToEnd }); + if (isMessageVirtualizerReadingAnchorActive(runtime)) { + reconcileMessageVirtualizerReadingAnchor(runtime, { clampToEnd: request.clampReadingAnchorToEnd }); } } -function reconcileMessageVirtualizerEndLock( +function reconcileMessageVirtualizerFollowEnd( runtime: MessageVirtualizerRuntime, - options: { requireSettleFrame?: boolean; scheduleSettle?: boolean } = {}, + options: { requireFollowEndSettleFrame?: boolean; scheduleFollowEndSettle?: boolean } = {}, ): void { runtime.virtualizer.getTotalSize(); runtime.virtualizer.scrollToEnd(); reconcileMessageVirtualizerDomEnd(runtime); - if (options.scheduleSettle !== false) { - scheduleSettledMessageVirtualizerScrollToEnd( + if (options.scheduleFollowEndSettle !== false) { + scheduleMessageVirtualizerFollowEndSettle( runtime, - options.requireSettleFrame === undefined ? {} : { requireSettleFrame: options.requireSettleFrame }, + options.requireFollowEndSettleFrame === undefined ? {} : { requireFollowEndSettleFrame: options.requireFollowEndSettleFrame }, ); } } -function scheduleSettledMessageVirtualizerScrollToEnd( +function scheduleMessageVirtualizerFollowEndSettle( runtime: MessageVirtualizerRuntime, - options: { requireSettleFrame?: boolean } = {}, + options: { requireFollowEndSettleFrame?: boolean } = {}, ): void { const container = runtime.container; if (!container) return; - if (options.requireSettleFrame !== true && !shouldSettleMessageVirtualizerScrollToEnd(runtime, container)) { - cancelSettledMessageVirtualizerScrollToEnd(runtime); + if (options.requireFollowEndSettleFrame !== true && !shouldMessageVirtualizerSettleFollowEnd(runtime, container)) { + cancelMessageVirtualizerFollowEndSettle(runtime); return; } // scrollToEnd can be clamped before the virtualizer height reaches the DOM; keep a bounded post-render settle. - runtime.settleScrollToEndAttemptsRemaining = MESSAGE_SCROLL_TO_END_SETTLE_ATTEMPTS; - if (runtime.settleScrollToEndFrame !== null) return; - scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, 2); + runtime.followEndSettleAttemptsRemaining = MESSAGE_FOLLOW_END_SETTLE_ATTEMPTS; + if (runtime.followEndSettleFrame !== null) return; + scheduleMessageVirtualizerFollowEndSettleFrame(runtime, container, 2); } -function shouldSettleMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime, container: HTMLElement): boolean { +function shouldMessageVirtualizerSettleFollowEnd(runtime: MessageVirtualizerRuntime, container: HTMLElement): boolean { if (!isElementAtEnd(container, runtime.virtualizer.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD)) return true; // Hidden/resumed panes can start with rendered blocks but no scroll range yet; give the DOM a few frames to expose it. return runtime.blocks.length > 0 && container.scrollHeight <= container.clientHeight; } -function scheduleSettledMessageVirtualizerScrollToEndFrame( +function scheduleMessageVirtualizerFollowEndSettleFrame( runtime: MessageVirtualizerRuntime, container: HTMLElement, delayFrames: number, ): void { - scheduleMessageVirtualizerFrame(runtime, "settleScrollToEndFrame", container, () => { + scheduleMessageVirtualizerFrame(runtime, "followEndSettleFrame", container, () => { if (runtime.container !== container) { - runtime.settleScrollToEndAttemptsRemaining = 0; + runtime.followEndSettleAttemptsRemaining = 0; return; } if (delayFrames > 1) { - scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, delayFrames - 1); + scheduleMessageVirtualizerFollowEndSettleFrame(runtime, container, delayFrames - 1); return; } - if (!hasMessageVirtualizerEndLock(runtime)) { - runtime.settleScrollToEndAttemptsRemaining = 0; + if (!isMessageVirtualizerFollowingEnd(runtime)) { + runtime.followEndSettleAttemptsRemaining = 0; return; } - runtime.settleScrollToEndAttemptsRemaining = Math.max(0, runtime.settleScrollToEndAttemptsRemaining - 1); - requestMessageVirtualizerLayoutReconcile(runtime, { notify: true, scheduleSettle: false }); + runtime.followEndSettleAttemptsRemaining = Math.max(0, runtime.followEndSettleAttemptsRemaining - 1); + requestMessageVirtualizerScrollPolicyReconcile(runtime, { notify: true, scheduleFollowEndSettle: false }); if ( - runtime.settleScrollToEndAttemptsRemaining > 0 && + runtime.followEndSettleAttemptsRemaining > 0 && !isElementAtEnd(container, runtime.virtualizer.getTotalSize(), MESSAGE_BOTTOM_THRESHOLD) ) { - scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, 1); + scheduleMessageVirtualizerFollowEndSettleFrame(runtime, container, 1); } }); } @@ -716,9 +707,9 @@ function reconcileMessageVirtualizerDomEnd(runtime: MessageVirtualizerRuntime): if (domEnd > container.scrollTop) container.scrollTop = domEnd; } -function cancelSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void { - cancelMessageVirtualizerFrame(runtime, "settleScrollToEndFrame"); - runtime.settleScrollToEndAttemptsRemaining = 0; +function cancelMessageVirtualizerFollowEndSettle(runtime: MessageVirtualizerRuntime): void { + cancelMessageVirtualizerFrame(runtime, "followEndSettleFrame"); + runtime.followEndSettleAttemptsRemaining = 0; } function scheduleMessageVirtualizerFrame( @@ -811,10 +802,10 @@ function measureMessageElement( const box = entry?.borderBoxSize[0]; const size = box ? Math.round(box.blockSize) : element.offsetHeight || instance.options.estimateSize(instance.indexFromElement(element)); if (entry) { - requestMessageVirtualizerLayoutReconcile(runtime, { - clampAnchorToEnd: isMessageVirtualizerAnchorLocked(runtime), + requestMessageVirtualizerScrollPolicyReconcile(runtime, { + clampReadingAnchorToEnd: isMessageVirtualizerReadingAnchorActive(runtime), notify: true, - requireSettleFrame: hasMessageVirtualizerEndLock(runtime), + requireFollowEndSettleFrame: isMessageVirtualizerFollowingEnd(runtime), }); } return size; @@ -824,42 +815,40 @@ function captureMessageVirtualizerReadingAnchorValue(runtime: MessageVirtualizer const container = runtime.container; if (!container) return null; const scrollOffset = container.scrollTop; - const anchor = runtime.virtualizer.getVirtualItems().find((item) => item.end > scrollOffset); + const anchor = runtime.virtualizer.getVirtualItemForOffset(scrollOffset); return anchor ? { key: anchor.key, top: anchor.start - scrollOffset } : null; } -function reconcileMessageVirtualizerAnchorLock(runtime: MessageVirtualizerRuntime, options: { clampToEnd?: boolean } = {}): void { +function reconcileMessageVirtualizerReadingAnchor(runtime: MessageVirtualizerRuntime, options: { clampToEnd?: boolean } = {}): void { const container = runtime.container; - const lock = runtime.scrollLock; - if (!container || lock.kind !== "anchor" || !lock.anchor) return; - const anchor = lock.anchor; + const state = runtime.readingAnchor; + const anchor = state.kind === "active" ? state.anchor : null; + if (!container || !anchor) return; const item = runtime.virtualizer.getVirtualItems().find((candidate) => Object.is(candidate.key, anchor.key)); if (!item) return; - const scrollSize = Math.max(container.scrollHeight, runtime.virtualizer.getTotalSize()); - const scrollEnd = Math.max(0, scrollSize - container.clientHeight); - const targetOffset = Math.max(0, Math.min(item.start - anchor.top, scrollEnd)); + const targetOffset = item.start - anchor.top; if (Math.abs(container.scrollTop - targetOffset) > 1) { runtime.virtualizer.scrollToOffset(targetOffset); - syncMessageVirtualizerScrollOffset(runtime); + syncMessageVirtualizerDomScrollOffset(runtime); updateMessageVirtualizer(runtime.virtualizer); notifyMessageVirtualizerChange(runtime); } - if (options.clampToEnd) clampMessageVirtualizerAnchorOffsetToEnd(runtime); + if (options.clampToEnd) clampMessageVirtualizerReadingAnchorOffsetToEnd(runtime); } -function clampMessageVirtualizerAnchorOffsetToEnd(runtime: MessageVirtualizerRuntime): void { +function clampMessageVirtualizerReadingAnchorOffsetToEnd(runtime: MessageVirtualizerRuntime): void { const container = runtime.container; - if (!container || !isMessageVirtualizerAnchorLocked(runtime)) return; + if (!container || !isMessageVirtualizerReadingAnchorActive(runtime)) return; const rawTotalSize = runtime.virtualizer.getTotalSize(); if (container.scrollHeight <= getMessageVirtualizerTotalSize(runtime) + MESSAGE_BOTTOM_THRESHOLD) return; const rawScrollEnd = Math.max(0, rawTotalSize - container.clientHeight); if (container.scrollTop <= rawScrollEnd) return; runtime.virtualizer.scrollToOffset(rawScrollEnd); - syncMessageVirtualizerScrollOffset(runtime); + syncMessageVirtualizerDomScrollOffset(runtime); updateMessageVirtualizer(runtime.virtualizer); - setMessageVirtualizerEndLock(runtime); + setMessageVirtualizerFollowEnd(runtime); notifyMessageVirtualizerChange(runtime); } diff --git a/tests/features/chat/ui/message-stream/virtualizer.test.ts b/tests/features/chat/ui/message-stream/virtualizer.test.ts index f67f9866..357958c2 100644 --- a/tests/features/chat/ui/message-stream/virtualizer.test.ts +++ b/tests/features/chat/ui/message-stream/virtualizer.test.ts @@ -54,7 +54,7 @@ describe("TestMessageStreamVirtualizer", () => { controller.dispose(); }); - it("settles at the end after the rendered virtualizer height reaches the DOM", () => { + it("keeps the latest message visible until the rendered virtualizer height reaches the DOM", () => { withAnimationFrame((flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -67,7 +67,6 @@ describe("TestMessageStreamVirtualizer", () => { expect(container.scrollTop).toBe(0); flushFrames(); - expect(container.scrollTop).toBe(0); delete container.dataset["testScrollHeight"]; flushFrames(); @@ -77,7 +76,7 @@ describe("TestMessageStreamVirtualizer", () => { }); }); - it("keeps settling for a few frames when the rendered virtualizer height is delayed", () => { + it("reaches the latest message after delayed rendered virtualizer height catches up", () => { withAnimationFrame((flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -90,10 +89,8 @@ describe("TestMessageStreamVirtualizer", () => { expect(container.scrollTop).toBe(0); flushFrames(); - expect(container.scrollTop).toBe(0); flushFrames(); - expect(container.scrollTop).toBe(0); delete container.dataset["testScrollHeight"]; flushFrames(); @@ -194,7 +191,7 @@ describe("TestMessageStreamVirtualizer", () => { controller.dispose(); }); - it("settles at the end when a pinned item grows before the DOM scroll height catches up", () => { + it("keeps the latest message visible when pinned content grows before DOM scroll height catches up", () => { withAnimationFrame((flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -212,7 +209,6 @@ describe("TestMessageStreamVirtualizer", () => { expect(container.scrollTop).toBe(200); flushFrames(); - expect(container.scrollTop).toBe(200); delete container.dataset["testScrollHeight"]; flushFrames(); @@ -267,7 +263,7 @@ describe("TestMessageStreamVirtualizer", () => { }); }); - it("keeps the pending end settle when a stale programmatic scroll event fires during pinned item growth", () => { + it("does not jump away from the latest message when a stale programmatic scroll event fires during pinned growth", () => { withAnimationFrame((flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -292,7 +288,7 @@ describe("TestMessageStreamVirtualizer", () => { }); }); - it("does not postpone the pending end settle while small pinned growth measurements keep arriving", () => { + it("still reaches the latest message when small pinned growth measurements keep arriving", () => { withAnimationFrame((flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -319,7 +315,7 @@ describe("TestMessageStreamVirtualizer", () => { }); }); - it("settles at the end when ResizeObserver reports pinned item growth before DOM scroll height catches up", () => { + it("keeps the latest message visible when ResizeObserver reports pinned growth before DOM scroll height catches up", () => { withResizeObserverEntries((resizeElement, flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -359,7 +355,7 @@ describe("TestMessageStreamVirtualizer", () => { }); }); - it("does not postpone the pending end settle while ResizeObserver keeps reporting small pinned growth", () => { + it("still reaches the latest message while ResizeObserver keeps reporting small pinned growth", () => { withResizeObserverEntries((resizeElement, flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -514,7 +510,7 @@ describe("TestMessageStreamVirtualizer", () => { controller.dispose(); }); - it("clamps stale unpinned offsets when a visible item shrinks near the end", () => { + it("does not leave blank space after visible content shrinks near the end", () => { withResizeObserverEntries((resizeElement, flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -552,7 +548,7 @@ describe("TestMessageStreamVirtualizer", () => { }); }); - it("resets stale measurements when the message viewport is restored from zero size", () => { + it("returns to the latest message when an active message viewport is restored from zero size", () => { withResizeObserver((triggerResize, flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -581,7 +577,7 @@ describe("TestMessageStreamVirtualizer", () => { }); }); - it("resets stale measurements when a hidden-at-start message viewport first renders at a usable size", () => { + it("shows the latest message when a hidden-at-start message viewport first renders at a usable size", () => { withResizeObserver((_triggerResize, flushFrames) => { const container = messageContainer({ scrollTop: 0, clientHeight: 0 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -606,7 +602,7 @@ describe("TestMessageStreamVirtualizer", () => { }); }); - it("scrolls by two text lines for composer edge shortcuts", () => { + it("scrolls in both directions for composer text-line edge shortcuts", () => { const container = messageContainer({ scrollTop: 120, clientHeight: 100 }); container.style.lineHeight = "18px"; const controller = createMessageStreamVirtualizerDriver(container); @@ -615,13 +611,19 @@ describe("TestMessageStreamVirtualizer", () => { container.scrollTop = 120; container.dispatchEvent(new Event("scroll")); + const initialScrollTop = container.scrollTop; controller.scrollByTextLines(-1); container.dispatchEvent(new Event("scroll")); - expect(container.scrollTop).toBe(84); + const scrolledUpTop = container.scrollTop; + expect(scrolledUpTop).toBeLessThan(initialScrollTop); + expect(scrolledUpTop).toBeGreaterThan(0); + expect(initialScrollTop - scrolledUpTop).toBeLessThanOrEqual(container.clientHeight / 2); controller.scrollByTextLines(1); container.dispatchEvent(new Event("scroll")); - expect(container.scrollTop).toBe(120); + expect(container.scrollTop).toBeGreaterThan(scrolledUpTop); + expect(container.scrollTop).toBeLessThanOrEqual(initialScrollTop); + expect(container.scrollTop - scrolledUpTop).toBeLessThanOrEqual(container.clientHeight / 2); controller.dispose(); }); @@ -659,7 +661,7 @@ describe("TestMessageStreamVirtualizer", () => { controller.dispose(); }); - it("scrolls by text lines from the rendered bottom before a scroll event settles", () => { + it("scrolls composer text-line shortcuts from the currently rendered bottom", () => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); container.style.lineHeight = "18px"; const controller = createMessageStreamVirtualizerDriver(container); @@ -675,16 +677,18 @@ describe("TestMessageStreamVirtualizer", () => { measureVirtualItem(controller, key, index, 240); }); controller.getTotalSize(); - expect(container.scrollTop).toBe(380); + const initialScrollTop = container.scrollTop; + expectScrollAtEnd(container); controller.scrollByTextLines(-1); container.dispatchEvent(new Event("scroll")); - expect(container.scrollTop).toBe(344); + expect(container.scrollTop).toBeLessThan(initialScrollTop); + expect(initialScrollTop - container.scrollTop).toBeLessThanOrEqual(container.clientHeight / 2); controller.dispose(); }); - it("preserves the visible anchor when composer edge scrolling measures earlier items", () => { + it("keeps the same message visible when composer edge scrolling measures earlier items", () => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); container.style.lineHeight = "18px"; const controller = createMessageStreamVirtualizerDriver(container); @@ -733,21 +737,26 @@ describe("TestMessageStreamVirtualizer", () => { controller.scrollByPage(-1); container.dispatchEvent(new Event("scroll")); - expect(container.scrollTop).toBe(60); + const pageUpTop = container.scrollTop; + expect(pageUpTop).toBeLessThan(220); + expect(pageUpTop).toBeGreaterThanOrEqual(0); + expect(220 - pageUpTop).toBeLessThanOrEqual(container.clientHeight); controller.scrollByPage(1); container.dispatchEvent(new Event("scroll")); - expect(container.scrollTop).toBe(220); + expect(container.scrollTop).toBeGreaterThan(pageUpTop); + expect(container.scrollTop).toBeLessThanOrEqual(220); + expect(container.scrollTop - pageUpTop).toBeLessThanOrEqual(container.clientHeight); container.scrollTop = 510; container.dispatchEvent(new Event("scroll")); controller.scrollByPage(1); container.dispatchEvent(new Event("scroll")); - expect(container.scrollTop).toBe(520); + expectScrollAtEnd(container); controller.dispose(); }); - it("preserves the visible anchor when composer PageUp measurements shrink earlier items", () => { + it("keeps the same message visible when composer PageUp measurements shrink earlier items", () => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); const keys = numberedKeys("item", 50); @@ -775,7 +784,7 @@ describe("TestMessageStreamVirtualizer", () => { controller.dispose(); }); - it("keeps repeated composer line scrolling anchored when measurements shrink", () => { + it("keeps repeated composer line scrolling relative to the same message when measurements shrink", () => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); container.style.lineHeight = "18px"; const controller = createMessageStreamVirtualizerDriver(container); @@ -789,7 +798,8 @@ describe("TestMessageStreamVirtualizer", () => { userScrollTo(container, 4660); controller.scrollByTextLines(-1); - expect(container.scrollTop).toBe(4624); + expect(container.scrollTop).toBeLessThan(4660); + expect(4660 - container.scrollTop).toBeLessThanOrEqual(container.clientHeight / 2); const anchorKey = firstVisibleVirtualItemKey(controller, container, keys); const anchorBefore = virtualItemTop(controller, container, anchorKey); @@ -802,7 +812,8 @@ describe("TestMessageStreamVirtualizer", () => { expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore); controller.scrollByTextLines(-1); - expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore + 36); + expect(virtualItemTop(controller, container, anchorKey)).toBeGreaterThan(anchorBefore); + expect(virtualItemTop(controller, container, anchorKey) - anchorBefore).toBeLessThanOrEqual(container.clientHeight / 2); controller.dispose(); }); @@ -912,6 +923,10 @@ function clampScrollTop(container: HTMLElement): void { container.scrollTop = scrollTop; } +function expectScrollAtEnd(container: HTMLElement): void { + expect(container.scrollTop).toBe(Math.max(0, container.scrollHeight - container.clientHeight)); +} + function measureVirtualItem(controller: MessageStreamVirtualizerDriver, key: string, index: number, height: number): void { controller.measureElement(measuredElement(key, index, height)); }