From 432735c3eb342edf384074e4e2a21c9547f69f77 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 13 Jun 2026 18:47:25 +0900 Subject: [PATCH] Stabilize message stream virtualizer scrolling --- .../chat/ui/message-stream/text-item.tsx | 3 +- .../chat/ui/message-stream/viewport.tsx | 3 - .../chat/ui/message-stream/virtualizer.ts | 281 +++++++++++++----- .../blocks-and-messages.test.tsx | 90 +++++- .../ui/message-stream/virtualizer.test.ts | 109 ++++++- 5 files changed, 393 insertions(+), 93 deletions(-) diff --git a/src/features/chat/ui/message-stream/text-item.tsx b/src/features/chat/ui/message-stream/text-item.tsx index 9329dd87..7bca6709 100644 --- a/src/features/chat/ui/message-stream/text-item.tsx +++ b/src/features/chat/ui/message-stream/text-item.tsx @@ -129,7 +129,6 @@ function TextContent({ item, context, contentRef, collapsed = false }: TextConte const content = localRef.current; if (!content) return; const currentContext = contextRef.current; - content.replaceChildren(); if (rendersMarkdown) { currentContext.renderMarkdown(content, text); } else { @@ -158,7 +157,7 @@ function TextContent({ item, context, contentRef, collapsed = false }: TextConte } function textItemContentKey(item: TextDisplayItem): string { - return `${item.id}\u001f${contentRenderMode(item)}\u001f${item.text}`; + return `${item.id}\u001f${contentRenderMode(item)}`; } function contentRenderMode(item: TextDisplayItem): "markdown" | "text" { diff --git a/src/features/chat/ui/message-stream/viewport.tsx b/src/features/chat/ui/message-stream/viewport.tsx index 760f5d3e..dae92fa3 100644 --- a/src/features/chat/ui/message-stream/viewport.tsx +++ b/src/features/chat/ui/message-stream/viewport.tsx @@ -95,9 +95,6 @@ function MessageStreamBlockHost({ if (!element) return; const remeasure = () => { if (blockRef.current === element && element.isConnected) measureBlock(element); - element.win.requestAnimationFrame(() => { - if (blockRef.current === element && element.isConnected) measureBlock(element); - }); }; element.addEventListener(MESSAGE_CONTENT_RENDERED_EVENT, remeasure); cleanupContentRenderedListener.current = () => { diff --git a/src/features/chat/ui/message-stream/virtualizer.ts b/src/features/chat/ui/message-stream/virtualizer.ts index cc0f9202..7f06c415 100644 --- a/src/features/chat/ui/message-stream/virtualizer.ts +++ b/src/features/chat/ui/message-stream/virtualizer.ts @@ -1,4 +1,11 @@ -import { elementScroll, observeElementOffset, observeElementRect, Virtualizer, type VirtualItem } from "@tanstack/virtual-core"; +import { + elementScroll, + observeElementOffset, + observeElementRect, + Virtualizer, + type VirtualItem, + type VirtualizerOptions, +} from "@tanstack/virtual-core"; import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import type { MessageStreamBlock } from "./context"; @@ -6,15 +13,20 @@ import type { MessageStreamBlock } from "./context"; export type MessageStreamScrollIntent = "auto" | "force-bottom" | "follow-bottom" | "preserve"; type MessageScrollDirection = -1 | 1; -interface MessageVirtualizerRenderPlan { - generation: number; - shouldScrollToBottom: boolean; -} - interface MessageVirtualizerMeasurePlan { shouldSettleAtEnd: boolean; } +interface MessageVirtualizerReadingAnchor { + key: unknown; + top: number; +} + +type MessageVirtualizerScrollMode = + | { kind: "free" } + | { kind: "follow-end" } + | { kind: "preserve-anchor"; anchor: MessageVirtualizerReadingAnchor | null }; + const MESSAGE_BOTTOM_THRESHOLD = 4; const MESSAGE_BLOCK_ESTIMATE_SIZE = 96; const MESSAGE_SCROLL_TO_END_SETTLE_ATTEMPTS = 4; @@ -37,16 +49,16 @@ interface MessageVirtualizerRuntime { virtualizer: Virtualizer; cleanupVirtualizer: (() => void) | null; blocks: readonly MessageStreamBlock[]; - commitGeneration: number; - bottomReconcileCommitGeneration: number | null; - renderGeneration: number; + pendingBottomReconcileAfterCommit: boolean; + pendingBottomReconcileRequiresSettle: boolean; onVirtualizerChange: (() => void) | null; - followEndIntent: boolean; - preserveReadingAnchor: boolean; + scrollMode: MessageVirtualizerScrollMode; viewportMeasurementsInvalid: boolean; viewportRestoreFrame: number | null; + virtualizerChangeFrame: number | null; settleScrollToEndFrame: number | null; settleScrollToEndAttemptsRemaining: number; + bottomScrollTargetOffset: number | null; userScrollIntentUntil: number; } @@ -145,16 +157,16 @@ function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime { blocks: [], virtualizer: null as never, cleanupVirtualizer: null, - commitGeneration: 0, - bottomReconcileCommitGeneration: null, - renderGeneration: 0, + pendingBottomReconcileAfterCommit: false, + pendingBottomReconcileRequiresSettle: false, onVirtualizerChange: null, - followEndIntent: false, - preserveReadingAnchor: false, + scrollMode: { kind: "free" }, viewportMeasurementsInvalid: false, viewportRestoreFrame: null, + virtualizerChangeFrame: null, settleScrollToEndFrame: null, settleScrollToEndAttemptsRemaining: 0, + bottomScrollTargetOffset: null, userScrollIntentUntil: 0, }; runtime.virtualizer = new Virtualizer(messageVirtualizerOptions(runtime)); @@ -162,18 +174,20 @@ function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime { return runtime; } -function prepareMessageVirtualizerRender( +function renderMessageVirtualizer( runtime: MessageVirtualizerRuntime, container: HTMLElement, intent: MessageStreamScrollIntent, blocks: readonly MessageStreamBlock[], -): MessageVirtualizerRenderPlan { +): void { attachMessageVirtualizer(runtime, container); handleMessageVirtualizerViewportElement(runtime, container); const appendingBlocks = blocks.length > runtime.blocks.length; const shouldFollowAppend = - intent === "auto" && appendingBlocks && (runtime.followEndIntent || isMessageVirtualizerObservedAtEnd(runtime, container)); + intent === "auto" && + appendingBlocks && + (hasMessageVirtualizerFollowEndIntent(runtime) || isMessageVirtualizerObservedAtEnd(runtime, container)); runtime.blocks = blocks; const shouldScrollToBottom = blocks.length > 0 && (intent === "force-bottom" || intent === "follow-bottom" || shouldFollowAppend); if (intent === "preserve") { @@ -183,35 +197,16 @@ function prepareMessageVirtualizerRender( updateMessageVirtualizerOptions(runtime); updateMessageVirtualizer(runtime.virtualizer); - return { - generation: ++runtime.renderGeneration, - shouldScrollToBottom, - }; -} - -function completeMessageVirtualizerRender(runtime: MessageVirtualizerRuntime, plan: MessageVirtualizerRenderPlan): void { - if (plan.generation !== runtime.renderGeneration) return; - - updateMessageVirtualizer(runtime.virtualizer); - if (plan.shouldScrollToBottom) { + if (shouldScrollToBottom) { requestMessageVirtualizerScrollToEnd(runtime); return; } } -function renderMessageVirtualizer( - runtime: MessageVirtualizerRuntime, - container: HTMLElement, - intent: MessageStreamScrollIntent, - blocks: readonly MessageStreamBlock[], -): void { - completeMessageVirtualizerRender(runtime, prepareMessageVirtualizerRender(runtime, container, intent, blocks)); -} - function getMessageVirtualizerTotalSize(runtime: MessageVirtualizerRuntime): number { const totalSize = runtime.virtualizer.getTotalSize(); const container = runtime.container; - if (!container || !runtime.preserveReadingAnchor) return totalSize; + if (!container || !isMessageVirtualizerPreservingReadingAnchor(runtime)) return totalSize; return Math.max(totalSize, container.scrollTop + container.clientHeight + MESSAGE_BOTTOM_THRESHOLD + 1); } @@ -222,7 +217,8 @@ function getMessageVirtualizerItems(runtime: MessageVirtualizerRuntime): Virtual function measureMessageVirtualizerElement(runtime: MessageVirtualizerRuntime, element: HTMLElement | null): void { const plan = prepareMessageVirtualizerMeasurement(runtime); runtime.virtualizer.measureElement(element); - if (element && plan.shouldSettleAtEnd) requestMessageVirtualizerScrollToEnd(runtime); + restoreMessageVirtualizerReadingAnchor(runtime); + if (element && plan.shouldSettleAtEnd) requestMessageVirtualizerScrollToEnd(runtime, { forceSettle: true }); } function prepareMessageVirtualizerMeasurement(runtime: MessageVirtualizerRuntime): MessageVirtualizerMeasurePlan { @@ -234,15 +230,15 @@ function prepareMessageVirtualizerMeasurement(runtime: MessageVirtualizerRuntime } function reconcileMessageVirtualizerBottomAfterCommit(runtime: MessageVirtualizerRuntime): void { - runtime.commitGeneration += 1; - const reconcileGeneration = runtime.bottomReconcileCommitGeneration; - if (reconcileGeneration === null || reconcileGeneration > runtime.commitGeneration) return; - runtime.bottomReconcileCommitGeneration = null; + if (!runtime.pendingBottomReconcileAfterCommit) return; + const forceSettle = runtime.pendingBottomReconcileRequiresSettle; + runtime.pendingBottomReconcileAfterCommit = false; + runtime.pendingBottomReconcileRequiresSettle = false; if (!runtime.container || !hasMessageVirtualizerFollowEndIntent(runtime)) return; runtime.virtualizer.getTotalSize(); runtime.virtualizer.scrollToEnd(); reconcileMessageVirtualizerDomEnd(runtime); - scheduleSettledMessageVirtualizerScrollToEnd(runtime); + scheduleSettledMessageVirtualizerScrollToEnd(runtime, { force: forceSettle }); } function measureRenderedMessageBlocks(runtime: MessageVirtualizerRuntime): void { @@ -303,30 +299,30 @@ function hasPendingMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRunt } function markMessageVirtualizerFollowEndIntent(runtime: MessageVirtualizerRuntime): void { - runtime.followEndIntent = true; - runtime.preserveReadingAnchor = false; + runtime.scrollMode = { kind: "follow-end" }; updateMessageVirtualizerOptions(runtime); } function clearMessageVirtualizerFollowEndIntent(runtime: MessageVirtualizerRuntime): void { - runtime.followEndIntent = false; + if (runtime.scrollMode.kind === "follow-end") runtime.scrollMode = { kind: "free" }; } function markMessageVirtualizerReadingAnchorIntent(runtime: MessageVirtualizerRuntime): void { - runtime.followEndIntent = false; - runtime.preserveReadingAnchor = true; + if (runtime.scrollMode.kind !== "preserve-anchor") { + runtime.scrollMode = { kind: "preserve-anchor", anchor: null }; + } updateMessageVirtualizerOptions(runtime); } function resetMessageVirtualizerScrollIntent(runtime: MessageVirtualizerRuntime): void { - runtime.followEndIntent = false; - runtime.preserveReadingAnchor = false; + runtime.scrollMode = { kind: "free" }; updateMessageVirtualizerOptions(runtime); } function cancelPendingMessageVirtualizerBottomFollow(runtime: MessageVirtualizerRuntime): void { cancelSettledMessageVirtualizerScrollToEnd(runtime); - runtime.bottomReconcileCommitGeneration = null; + runtime.pendingBottomReconcileAfterCommit = false; + runtime.pendingBottomReconcileRequiresSettle = false; } function setMessageVirtualizerChangeHandler(runtime: MessageVirtualizerRuntime, callback: (() => void) | null): void { @@ -352,7 +348,7 @@ function resetMessageVirtualizerMeasurements(runtime: MessageVirtualizerRuntime) // A hidden Obsidian pane can leave TanStack measurements stale while the message stream state is still valid. // Reset only the virtualizer runtime, then measure the still-rendered blocks. resetMessageVirtualizer(runtime, container); - runtime.onVirtualizerChange?.(); + notifyMessageVirtualizerChange(runtime); measureRenderedMessageBlocks(runtime); } @@ -403,13 +399,13 @@ function scrollMessageVirtualizerByPage(runtime: MessageVirtualizerRuntime, dire function disposeMessageVirtualizer(runtime: MessageVirtualizerRuntime): void { cancelViewportRestoreMessageVirtualizerReset(runtime); + cancelDeferredMessageVirtualizerChange(runtime); cancelPendingMessageVirtualizerBottomFollow(runtime); runtime.cleanupVirtualizer?.(); runtime.cleanupVirtualizer = null; runtime.container = null; runtime.blocks = []; - runtime.commitGeneration = 0; - runtime.renderGeneration = 0; + runtime.pendingBottomReconcileAfterCommit = false; runtime.onVirtualizerChange = null; resetMessageVirtualizerScrollIntent(runtime); runtime.viewportMeasurementsInvalid = false; @@ -425,12 +421,12 @@ function attachMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void { cancelViewportRestoreMessageVirtualizerReset(runtime); + cancelDeferredMessageVirtualizerChange(runtime); cancelPendingMessageVirtualizerBottomFollow(runtime); runtime.cleanupVirtualizer?.(); runtime.cleanupVirtualizer = null; runtime.container = null; runtime.blocks = []; - runtime.renderGeneration = 0; resetMessageVirtualizerScrollIntent(runtime); runtime.viewportMeasurementsInvalid = false; runtime.userScrollIntentUntil = 0; @@ -438,6 +434,7 @@ function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void { function resetMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: HTMLElement): void { cancelViewportRestoreMessageVirtualizerReset(runtime); + cancelDeferredMessageVirtualizerChange(runtime); cancelSettledMessageVirtualizerScrollToEnd(runtime); runtime.cleanupVirtualizer?.(); runtime.cleanupVirtualizer = null; @@ -449,7 +446,7 @@ function resetMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: updateMessageVirtualizer(runtime.virtualizer); } -function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime) { +function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime): VirtualizerOptions { const paddingBlock = messageBlockPadding(runtime.container); return { count: runtime.blocks.length, @@ -463,19 +460,22 @@ function messageVirtualizerOptions(runtime: MessageVirtualizerRuntime) { scrollEndThreshold: MESSAGE_BOTTOM_THRESHOLD, paddingStart: paddingBlock, paddingEnd: paddingBlock, - // Obsidian/Electron can report ResizeObserver loop warnings during resume when message rendering mutates the DOM heavily. - // Deferring measurement keeps those resume measurements from feeding back into the same observer delivery. - useAnimationFrameWithResizeObserver: true, overscan: 8, observeElementRect: (instance: Virtualizer, callback: (rect: { width: number; height: number }) => void) => observeMessageVirtualizerElementRect(runtime, instance, callback), observeElementOffset: (instance: Virtualizer, callback: (offset: number, isScrolling: boolean) => void) => observeMessageVirtualizerElementOffset(runtime, instance, callback), - scrollToFn: elementScroll, + scrollToFn: (offset, options, instance) => { + scrollMessageVirtualizerElement(runtime, offset, options, instance); + }, measureElement: (element: HTMLElement, entry: ResizeObserverEntry | undefined, instance: Virtualizer) => measureMessageElement(runtime, element, entry, instance), - onChange: () => { - runtime.onVirtualizerChange?.(); + onChange: (_instance: Virtualizer, sync: boolean) => { + if (sync) { + notifyMessageVirtualizerChange(runtime); + } else { + scheduleDeferredMessageVirtualizerChange(runtime); + } }, }; } @@ -510,13 +510,14 @@ function handleMessageVirtualizerObservedOffset( const element = instance.scrollElement; if (!element) return; if (isMessageVirtualizerObservedOffsetAtEnd(runtime, instance, element, offset)) { - if (runtime.preserveReadingAnchor && instance.scrollDirection === "backward") return; + if (isMessageVirtualizerPreservingReadingAnchor(runtime) && instance.scrollDirection === "backward") return; markMessageVirtualizerFollowEndIntent(runtime); return; } if (!userIntendedMessageVirtualizerScroll(runtime, element)) return; markMessageVirtualizerReadingAnchorIntent(runtime); cancelPendingMessageVirtualizerBottomFollow(runtime); + captureMessageVirtualizerReadingAnchor(runtime); } function updateMessageVirtualizerOptions(runtime: MessageVirtualizerRuntime): void { @@ -524,52 +525,130 @@ function updateMessageVirtualizerOptions(runtime: MessageVirtualizerRuntime): vo configureMessageVirtualizerSizeAdjustment(runtime); } +function notifyMessageVirtualizerChange(runtime: MessageVirtualizerRuntime): void { + cancelDeferredMessageVirtualizerChange(runtime); + runtime.onVirtualizerChange?.(); +} + +function scheduleDeferredMessageVirtualizerChange(runtime: MessageVirtualizerRuntime): void { + const container = runtime.container; + if (!container) { + notifyMessageVirtualizerChange(runtime); + return; + } + if (runtime.virtualizerChangeFrame !== null) return; + runtime.virtualizerChangeFrame = container.win.requestAnimationFrame(() => { + runtime.virtualizerChangeFrame = null; + if (runtime.container !== container) return; + notifyMessageVirtualizerChange(runtime); + }); +} + +function cancelDeferredMessageVirtualizerChange(runtime: MessageVirtualizerRuntime): void { + const container = runtime.container; + if (container && runtime.virtualizerChangeFrame !== null) { + container.win.cancelAnimationFrame(runtime.virtualizerChangeFrame); + } + runtime.virtualizerChangeFrame = null; +} + function configureMessageVirtualizerSizeAdjustment(runtime: MessageVirtualizerRuntime): void { // TanStack Virtual exposes this as an instance field rather than a normal option in v3.17. runtime.virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => shouldAdjustMessageScrollPositionOnItemSizeChange(runtime, item, delta, instance); } +function scrollMessageVirtualizerElement( + runtime: MessageVirtualizerRuntime, + offset: number, + options: Parameters["scrollToFn"]>[1], + instance: Virtualizer, +): void { + // A bottom scroll can be clamped until the DOM scroll range catches up; remember the requested offset so the + // settle loop can distinguish a real mismatch from an already-stable bottom. + if (hasMessageVirtualizerFollowEndIntent(runtime)) { + runtime.bottomScrollTargetOffset = + runtime.settleScrollToEndFrame === null || runtime.bottomScrollTargetOffset === null + ? offset + : Math.max(runtime.bottomScrollTargetOffset, offset); + } + elementScroll(offset, options, instance); +} + function scrollMessageVirtualizerBy(runtime: MessageVirtualizerRuntime, delta: number): void { const container = runtime.container; if (!container) return; - clearMessageVirtualizerFollowEndIntent(runtime); - cancelPendingMessageVirtualizerBottomFollow(runtime); syncMessageVirtualizerScrollOffset(runtime); - markMessageVirtualizerReadingAnchorIntent(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) markMessageVirtualizerFollowEndIntent(runtime); + return; + } + + clearMessageVirtualizerFollowEndIntent(runtime); + cancelPendingMessageVirtualizerBottomFollow(runtime); + markMessageVirtualizerReadingAnchorIntent(runtime); runtime.virtualizer.scrollToOffset(targetOffset); syncMessageVirtualizerScrollOffset(runtime); + updateMessageVirtualizer(runtime.virtualizer); + notifyMessageVirtualizerChange(runtime); + if (targetAtEnd) { + markMessageVirtualizerFollowEndIntent(runtime); + } else { + captureMessageVirtualizerReadingAnchor(runtime); + } } function hasMessageVirtualizerFollowEndIntent(runtime: MessageVirtualizerRuntime): boolean { - return runtime.followEndIntent; + return runtime.scrollMode.kind === "follow-end"; } -function requestMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void { +function isMessageVirtualizerPreservingReadingAnchor(runtime: MessageVirtualizerRuntime): boolean { + return runtime.scrollMode.kind === "preserve-anchor"; +} + +function requestMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime, options: { forceSettle?: boolean } = {}): void { markMessageVirtualizerFollowEndIntent(runtime); runtime.virtualizer.getTotalSize(); runtime.virtualizer.scrollToEnd(); reconcileMessageVirtualizerDomEnd(runtime); - scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime); - scheduleSettledMessageVirtualizerScrollToEnd(runtime); + scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime, options); + scheduleSettledMessageVirtualizerScrollToEnd(runtime, { force: options.forceSettle === true }); } -function scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime: MessageVirtualizerRuntime): void { - runtime.bottomReconcileCommitGeneration = runtime.commitGeneration + 1; +function scheduleMessageVirtualizerBottomReconcileAfterCommit( + runtime: MessageVirtualizerRuntime, + options: { forceSettle?: boolean } = {}, +): void { + runtime.pendingBottomReconcileAfterCommit = true; + if (options.forceSettle === true) runtime.pendingBottomReconcileRequiresSettle = true; } -function scheduleSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime): void { +function scheduleSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime, options: { force?: boolean } = {}): void { const container = runtime.container; if (!container) return; + if (options.force !== true && !shouldSettleMessageVirtualizerScrollToEnd(runtime, container)) { + cancelSettledMessageVirtualizerScrollToEnd(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); } +function shouldSettleMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerRuntime, container: HTMLElement): boolean { + const domEnd = scrollEnd(container.scrollHeight, container.clientHeight); + if (Math.abs(container.scrollTop - domEnd) > MESSAGE_BOTTOM_THRESHOLD) return true; + const scrollTarget = runtime.bottomScrollTargetOffset ?? runtime.virtualizer.scrollOffset; + if (scrollTarget !== null && Math.abs(container.scrollTop - scrollTarget) > 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( runtime: MessageVirtualizerRuntime, container: HTMLElement, @@ -595,6 +674,8 @@ function scheduleSettledMessageVirtualizerScrollToEndFrame( runtime.settleScrollToEndAttemptsRemaining = Math.max(0, runtime.settleScrollToEndAttemptsRemaining - 1); if (runtime.settleScrollToEndAttemptsRemaining > 0 && !isElementAtEnd(container, totalSize, MESSAGE_BOTTOM_THRESHOLD)) { scheduleSettledMessageVirtualizerScrollToEndFrame(runtime, container, 1); + } else { + runtime.bottomScrollTargetOffset = null; } }); } @@ -613,6 +694,7 @@ function cancelSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerR } runtime.settleScrollToEndFrame = null; runtime.settleScrollToEndAttemptsRemaining = 0; + runtime.bottomScrollTargetOffset = null; } function messageBlockPadding(element: HTMLElement | null): number { @@ -652,6 +734,10 @@ function isElementAtEnd(element: HTMLElement, fallbackScrollSize: number, thresh return scrollSize - element.clientHeight - element.scrollTop <= threshold; } +function scrollEnd(scrollSize: number, viewportSize: number): number { + return Math.max(0, scrollSize - viewportSize); +} + function isMessageVirtualizerObservedOffsetAtEnd( runtime: MessageVirtualizerRuntime, instance: Virtualizer, @@ -681,7 +767,11 @@ function measureMessageElement( ): number { const box = entry?.borderBoxSize[0]; const size = box ? Math.round(box.blockSize) : element.offsetHeight || instance.options.estimateSize(instance.indexFromElement(element)); - if (entry && hasMessageVirtualizerFollowEndIntent(runtime)) scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime); + if (entry && hasMessageVirtualizerFollowEndIntent(runtime)) { + // ResizeObserver measurements arrive while TanStack and DOM heights are still converging, so keep the bounded settle. + scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime, { forceSettle: true }); + scheduleSettledMessageVirtualizerScrollToEnd(runtime, { force: true }); + } return size; } @@ -694,7 +784,9 @@ function shouldAdjustMessageScrollPositionOnItemSizeChange( const scrollOffset = instance.scrollOffset ?? 0; if (!hasMessageVirtualizerFollowEndIntent(runtime)) { if (instance.scrollDirection === "backward") return false; - return runtime.preserveReadingAnchor && shouldPreserveMessageScrollAnchorOnItemSizeChange(item, delta, scrollOffset); + return ( + isMessageVirtualizerPreservingReadingAnchor(runtime) && shouldPreserveMessageScrollAnchorOnItemSizeChange(item, delta, scrollOffset) + ); } if (runtime.container && !isMessageVirtualizerObservedAtEnd(runtime, runtime.container)) return false; return item.start < scrollOffset && instance.scrollDirection !== "backward"; @@ -706,6 +798,39 @@ function shouldPreserveMessageScrollAnchorOnItemSizeChange(item: VirtualItem, de return previousEnd <= scrollOffset; } +function captureMessageVirtualizerReadingAnchor(runtime: MessageVirtualizerRuntime): void { + const container = runtime.container; + if (!container || runtime.scrollMode.kind !== "preserve-anchor") { + return; + } + const scrollOffset = container.scrollTop; + const anchor = runtime.virtualizer.getVirtualItems().find((item) => item.end > scrollOffset); + runtime.scrollMode = { + kind: "preserve-anchor", + anchor: anchor ? { key: anchor.key, top: anchor.start - scrollOffset } : null, + }; +} + +function restoreMessageVirtualizerReadingAnchor(runtime: MessageVirtualizerRuntime): void { + const container = runtime.container; + const mode = runtime.scrollMode; + if (!container || mode.kind !== "preserve-anchor" || !mode.anchor) return; + const anchor = mode.anchor; + + 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)); + if (Math.abs(container.scrollTop - targetOffset) <= 1) return; + + runtime.virtualizer.scrollToOffset(targetOffset); + syncMessageVirtualizerScrollOffset(runtime); + updateMessageVirtualizer(runtime.virtualizer); + notifyMessageVirtualizerChange(runtime); +} + function textLineHeight(element: HTMLElement): number { const style = element.win.getComputedStyle(element); const lineHeight = Number.parseFloat(style.lineHeight); diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx index 9cb55399..65982885 100644 --- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx @@ -471,7 +471,8 @@ describe("message stream rendering and message actions", () => { it("updates keyed message content", () => { const parent = document.createElement("div"); const renderMarkdown = (element: HTMLElement, text: string) => { - element.createDiv({ text: `markdown:${text}` }); + const rendered = element.createDiv({ text: `markdown:${text}` }); + element.replaceChildren(rendered); }; const baseContext = { activeThreadId: "thread", @@ -529,7 +530,90 @@ describe("message stream rendering and message actions", () => { unmountUiRootInAct(parent); }); - it("ignores stale async markdown renders after streaming content is replaced", async () => { + it("keeps rendered markdown content while replacement rendering is pending", async () => { + const parent = document.createElement("div"); + const secondRender = deferred(); + const renderMarkdown = vi.spyOn(MarkdownRenderer, "render"); + renderMarkdown + .mockImplementationOnce((_app, text: string, element: HTMLElement) => { + element.textContent = `rendered:${text}`; + return Promise.resolve(); + }) + .mockImplementationOnce((_app, text: string, element: HTMLElement) => + secondRender.promise.then(() => { + element.textContent = `rendered:${text}`; + }), + ); + const markdownRenderer = new MarkdownMessageRenderer({ + app: { workspace: { getActiveFile: vi.fn(() => null) } } as never, + owner: {} as never, + vaultPath: "/vault", + }); + const baseContext = { + activeThreadId: "thread", + turnLifecycle: idleTurnLifecycle(), + historyCursor: null, + loadingHistory: false, + disclosures: emptyDisclosures(), + forkActionsItemId: null, + loadOlderTurns: vi.fn(), + renderMarkdown: (element: HTMLElement, text: string) => { + markdownRenderer.renderMarkdown(element, text); + }, + }; + + renderMessageStreamBlocksInAct( + parent, + messageStreamBlocks({ + ...baseContext, + displayItems: [ + { + id: "a1", + kind: "message", + role: "assistant", + text: "old", + turnId: "turn-1", + messageKind: "assistantResponse", + messageState: "completed", + }, + ], + }), + ); + await Promise.resolve(); + const content = expectPresent(parent.querySelector(".codex-panel__message-content")); + expect(content.textContent).toBe("rendered:old"); + + renderMessageStreamBlocksInAct( + parent, + messageStreamBlocks({ + ...baseContext, + displayItems: [ + { + id: "a1", + kind: "message", + role: "assistant", + text: "new", + turnId: "turn-1", + messageKind: "assistantResponse", + messageState: "completed", + }, + ], + }), + ); + const contentAfterUpdate = expectPresent(parent.querySelector(".codex-panel__message-content")); + expect(contentAfterUpdate).toBe(content); + expect(contentAfterUpdate.textContent).toBe("rendered:old"); + + secondRender.resolve(undefined); + await Promise.resolve(); + await Promise.resolve(); + + expect(contentAfterUpdate.textContent).toBe("rendered:new"); + renderMarkdown.mockRestore(); + unmountUiRootInAct(parent); + }); + + it("ignores stale async markdown renders after streaming content updates", async () => { const parent = document.createElement("div"); const firstRender = deferred(); const renderMarkdown = vi.spyOn(MarkdownRenderer, "render"); @@ -604,7 +688,7 @@ describe("message stream rendering and message actions", () => { await Promise.resolve(); await Promise.resolve(); - expect(staleContent.isConnected).toBe(false); + expect(staleContent.isConnected).toBe(true); expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("fresh:new"); renderMarkdown.mockRestore(); unmountUiRootInAct(parent); diff --git a/tests/features/chat/ui/message-stream/virtualizer.test.ts b/tests/features/chat/ui/message-stream/virtualizer.test.ts index d8b7aa87..b9fc6951 100644 --- a/tests/features/chat/ui/message-stream/virtualizer.test.ts +++ b/tests/features/chat/ui/message-stream/virtualizer.test.ts @@ -394,6 +394,43 @@ describe("TestMessageStreamVirtualizer", () => { }); }); + it("defers ResizeObserver-triggered rerenders until the next animation frame", () => { + withResizeObserverEntries((resizeElement, flushFrames) => { + const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); + let renderCount = 0; + const controller = createMessageStreamVirtualizerDriver(container, { + onRender: () => { + renderCount += 1; + }, + }); + const command = measuredElement("command", 1, 100); + + container.dataset["testTotalSize"] = "300"; + controller.render( + [ + { key: "first", node: null }, + { key: "command", node: null }, + ], + "follow-bottom", + ); + controller.getTotalSize(); + measureVirtualItem(controller, "first", 0, 200); + controller.measureElement(command); + void act(flushFrames); + const settledRenderCount = renderCount; + + container.dataset["testTotalSize"] = "450"; + resizeElement(command, 250); + + expect(renderCount).toBe(settledRenderCount); + + void act(flushFrames); + + expect(renderCount).toBeGreaterThan(settledRenderCount); + controller.dispose(); + }); + }); + it("uses the pre-render bottom position when appending after an item resize", () => { const container = messageContainer({ scrollTop: 0, clientHeight: 160 }); const controller = createMessageStreamVirtualizerDriver(container); @@ -553,6 +590,39 @@ describe("TestMessageStreamVirtualizer", () => { controller.dispose(); }); + it("keeps bottom-following intent when composer edge scrolling is already at the bottom", () => { + const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); + container.style.lineHeight = "18px"; + const controller = createMessageStreamVirtualizerDriver(container); + + renderVirtualItems(controller, container, ["first"], [300], "force-bottom"); + expect(container.scrollTop).toBe(200); + + controller.scrollByTextLines(1); + expect(container.scrollTop).toBe(200); + + renderVirtualItems(controller, container, ["first", "second"], [300, 140], "auto"); + expect(container.scrollTop).toBe(340); + controller.dispose(); + }); + + it("repins when composer edge scrolling reaches the bottom", () => { + const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); + container.style.lineHeight = "18px"; + const controller = createMessageStreamVirtualizerDriver(container); + + renderVirtualItems(controller, container, ["first"], [300], "force-bottom"); + userScrollTo(container, 180); + + controller.scrollByTextLines(1); + expect(container.scrollTop).toBe(200); + + container.dataset["testTotalSize"] = "340"; + controller.repinToBottomIfPinned(); + expect(container.scrollTop).toBe(240); + controller.dispose(); + }); + it("scrolls by text lines from the rendered bottom before a scroll event settles", () => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); container.style.lineHeight = "18px"; @@ -578,7 +648,7 @@ describe("TestMessageStreamVirtualizer", () => { controller.dispose(); }); - it("does not compound composer edge scrolling with backward remeasurements", () => { + it("preserves the visible anchor when composer edge scrolling measures earlier items", () => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); container.style.lineHeight = "18px"; const controller = createMessageStreamVirtualizerDriver(container); @@ -589,11 +659,12 @@ describe("TestMessageStreamVirtualizer", () => { controller.scrollByTextLines(-1); container.dispatchEvent(new Event("scroll")); expect(container.scrollTop).toBe(584); + const anchorBefore = virtualItemTop(controller, container, "third"); container.dataset["testTotalSize"] = "980"; measureVirtualItem(controller, "first", 0, 500); - expect(container.scrollTop).toBe(584); + expect(virtualItemTop(controller, container, "third")).toBe(anchorBefore); controller.dispose(); }); @@ -640,7 +711,7 @@ describe("TestMessageStreamVirtualizer", () => { controller.dispose(); }); - it("keeps a composer PageUp target when newly measured items shrink the total size", () => { + it("preserves the visible anchor when composer PageUp measurements shrink earlier items", () => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); const controller = createMessageStreamVirtualizerDriver(container); const keys = numberedKeys("item", 50); @@ -654,6 +725,8 @@ describe("TestMessageStreamVirtualizer", () => { controller.scrollByPage(-1); expect(container.scrollTop).toBe(4620); + const anchorKey = firstVisibleVirtualItemKey(controller, container, keys); + const anchorBefore = virtualItemTop(controller, container, anchorKey); for (const item of controller.getVirtualItems()) { measureVirtualItem(controller, keys[item.index] ?? "", item.index, 20); @@ -662,11 +735,11 @@ describe("TestMessageStreamVirtualizer", () => { clampScrollTop(container); container.dispatchEvent(new Event("scroll")); - expect(container.scrollTop).toBe(4620); + expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore); controller.dispose(); }); - it("keeps repeated composer line scrolling near the bottom when measurements shrink", () => { + it("keeps repeated composer line scrolling anchored when measurements shrink", () => { const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); container.style.lineHeight = "18px"; const controller = createMessageStreamVirtualizerDriver(container); @@ -681,6 +754,8 @@ describe("TestMessageStreamVirtualizer", () => { controller.scrollByTextLines(-1); expect(container.scrollTop).toBe(4624); + const anchorKey = firstVisibleVirtualItemKey(controller, container, keys); + const anchorBefore = virtualItemTop(controller, container, anchorKey); for (const item of controller.getVirtualItems()) { measureVirtualItem(controller, keys[item.index] ?? "", item.index, 20); @@ -689,8 +764,9 @@ describe("TestMessageStreamVirtualizer", () => { clampScrollTop(container); container.dispatchEvent(new Event("scroll")); + expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore); controller.scrollByTextLines(-1); - expect(container.scrollTop).toBe(4588); + expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore + 36); controller.dispose(); }); @@ -777,6 +853,18 @@ function renderVirtualItems( container.dispatchEvent(new Event("scroll")); } +function firstVisibleVirtualItemKey(controller: MessageStreamVirtualizerDriver, container: HTMLElement, keys: string[]): string { + const firstItem = controller.getVirtualItems().find((item) => item.end > container.scrollTop); + if (!firstItem) throw new Error("Expected a rendered virtual item."); + return keys[firstItem.index] ?? ""; +} + +function virtualItemTop(controller: MessageStreamVirtualizerDriver, container: HTMLElement, key: string): number { + const item = controller.getVirtualItems().find((candidate) => candidate.key === key); + if (!item) throw new Error(`Expected virtual item ${key}.`); + return item.start - container.scrollTop; +} + function userScrollTo(container: HTMLElement, scrollTop: number): void { container.dispatchEvent(new Event("wheel")); container.scrollTop = scrollTop; @@ -822,7 +910,10 @@ interface MessageStreamVirtualizerDriver extends MessageStreamVirtualizerHandle dispose(): void; } -function createMessageStreamVirtualizerDriver(container: HTMLElement): MessageStreamVirtualizerDriver { +function createMessageStreamVirtualizerDriver( + container: HTMLElement, + options: { onRender?: () => void } = {}, +): MessageStreamVirtualizerDriver { const host = document.createElement("div"); document.body.appendChild(host); let view: MessageStreamVirtualizerView | null = null; @@ -837,6 +928,7 @@ function createMessageStreamVirtualizerDriver(container: HTMLElement): MessageSt blocks: renderBlocks, container, intent, + ...(options.onRender ? { onRender: options.onRender } : {}), onHandle: (next) => (handle = next), onView: (next) => (view = next), }), @@ -875,15 +967,18 @@ function VirtualizerHarness({ blocks, container, intent, + onRender, onHandle, onView, }: { blocks: readonly MessageStreamBlock[]; container: HTMLElement; intent: "auto" | "force-bottom" | "follow-bottom" | "preserve"; + onRender?: () => void; onHandle: (handle: MessageStreamVirtualizerHandle | null) => void; onView: (view: MessageStreamVirtualizerView) => void; }) { + onRender?.(); const scrollElementRef = useRef(container); const intentRef = useRef(intent); intentRef.current = intent;