mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Stabilize message stream virtualizer scrolling
This commit is contained in:
parent
6dde93a7a3
commit
432735c3eb
5 changed files with 393 additions and 93 deletions
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue