Stabilize message stream virtualizer scrolling

This commit is contained in:
murashit 2026-06-13 18:47:25 +09:00
parent 6dde93a7a3
commit 432735c3eb
5 changed files with 393 additions and 93 deletions

View file

@ -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" {

View file

@ -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 = () => {

View file

@ -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<HTMLElement, HTMLElement>;
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<HTMLElement, HTMLElement> {
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<HTMLElement, HTMLElement>, callback: (rect: { width: number; height: number }) => void) =>
observeMessageVirtualizerElementRect(runtime, instance, callback),
observeElementOffset: (instance: Virtualizer<HTMLElement, HTMLElement>, 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<HTMLElement, HTMLElement>) =>
measureMessageElement(runtime, element, entry, instance),
onChange: () => {
runtime.onVirtualizerChange?.();
onChange: (_instance: Virtualizer<HTMLElement, HTMLElement>, 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<VirtualizerOptions<HTMLElement, HTMLElement>["scrollToFn"]>[1],
instance: Virtualizer<HTMLElement, HTMLElement>,
): 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<HTMLElement, HTMLElement>,
@ -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);

View file

@ -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<undefined>();
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<HTMLElement>(".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<HTMLElement>(".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<undefined>();
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);

View file

@ -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<HTMLElement | null>(container);
const intentRef = useRef(intent);
intentRef.current = intent;