diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 6f6e59d5..1d59cf88 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -571,7 +571,6 @@ interface MessageBlockProps { onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void onContentRendered?: () => void - onMeasureElementChange?: (element: HTMLElement | null) => void } export default function MessageBlock(props: MessageBlockProps) { @@ -787,34 +786,10 @@ export default function MessageBlock(props: MessageBlockProps) { return resultBlock }) - let measuredBlockElement: HTMLDivElement | undefined - - onCleanup(() => { - measuredBlockElement = undefined - props.onMeasureElementChange?.(null) - }) - - const visibleBlock = createMemo(() => { - const resolved = block() - if (!resolved || resolved.items.length === 0) return null - return resolved - }) - - createEffect(() => { - if (visibleBlock()) return - if (!measuredBlockElement) return - measuredBlockElement = undefined - props.onMeasureElementChange?.(null) - }) - return ( - + {(resolvedBlock) => (
{ - measuredBlockElement = el - props.onMeasureElementChange?.(el) - }} class="message-stream-block" data-message-id={resolvedBlock().record.id} data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined} @@ -1314,12 +1289,6 @@ function ReasoningCard(props: ReasoningCardProps) { const [deletingUpTo, setDeletingUpTo] = createSignal(false) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) - let headerEl: HTMLDivElement | undefined - let actionsEl: HTMLDivElement | undefined - let primaryEl: HTMLSpanElement | undefined - let metaMeasureEl: HTMLSpanElement | undefined - const [showMetaInline, setShowMetaInline] = createSignal(true) - createEffect(() => { setExpanded(Boolean(props.defaultExpanded)) }) @@ -1347,33 +1316,6 @@ function ReasoningCard(props: ReasoningCardProps) { const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier())) - const updateMetaLayout = () => { - if (!hasMeta()) return - if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return - - const headerWidth = headerEl.getBoundingClientRect().width - const actionsWidth = actionsEl.getBoundingClientRect().width - const primaryWidth = primaryEl.getBoundingClientRect().width - const metaWidth = metaMeasureEl.getBoundingClientRect().width - - const availableLeft = Math.max(0, headerWidth - actionsWidth - 12) - setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft) - } - - createEffect(() => { - if (!hasMeta() || typeof ResizeObserver === "undefined") { - setShowMetaInline(true) - return - } - - updateMetaLayout() - const observer = new ResizeObserver(() => updateMetaLayout()) - if (headerEl) observer.observe(headerEl) - if (actionsEl) observer.observe(actionsEl) - if (primaryEl) observer.observe(primaryEl) - onCleanup(() => observer.disconnect()) - }) - const reasoningText = () => { const part = props.part as any if (!part) return "" @@ -1452,7 +1394,7 @@ function ReasoningCard(props: ReasoningCardProps) { return (
-
(headerEl = el)}> +
-
(actionsEl = el)}> +
- +
diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 53983ebb..5da2432f 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -1066,7 +1066,7 @@ export default function MessageSection(props: MessageSectionProps) { )} - renderItem={(messageId, index, options) => ( + renderItem={(messageId, index) => ( { - options.notifyItemRendered() - handleContentRendered() - }} - onMeasureElementChange={options.registerMeasureElement} + onContentRendered={handleContentRendered} /> )} renderOverlay={() => ( diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index f11b9812..567f6fd9 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -30,14 +30,7 @@ export interface VirtualFollowListState { export interface VirtualFollowListProps { items: Accessor getKey: (item: T, index: number) => string - renderItem: ( - item: T, - index: number, - options: { - registerMeasureElement: (element: HTMLElement | null) => void - notifyItemRendered: () => void - }, - ) => JSX.Element + renderItem: (item: T, index: number) => JSX.Element /** * Optional stable DOM id for the item wrapper. @@ -381,7 +374,14 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } function handleContentRendered() { - scheduleAnchorScroll() + if (autoScroll() && !anchorLock()) { + scheduleAutoPinToBottom() + return + } + if (anchorLock() && !autoScroll()) { + scheduleAnchorCorrection() + return + } } function handleScroll() { @@ -521,25 +521,51 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } let pendingAutoPin = false + let pendingAutoPinFrame: number | null = null + + function clearPendingAutoPinFrame() { + if (pendingAutoPinFrame !== null) { + cancelAnimationFrame(pendingAutoPinFrame) + pendingAutoPinFrame = null + } + } + + function applyAutoPinToBottom() { + if (!containerRef) return false + if (!autoScroll()) return false + if (anchorLock()) return false + + const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0) + if (containerRef.scrollTop !== maxScrollTop) { + containerRef.scrollTop = maxScrollTop + lastKnownScrollTop = maxScrollTop + } + return true + } + function scheduleAutoPinToBottom() { if (!containerRef) return if (pendingAutoPin) return pendingAutoPin = true + clearPendingAutoPinFrame() const gen = scrollCompensationGen - // Flush in a microtask so adjustments land before the next paint. + // Flush in a microtask so adjustments land before the next paint, + // then re-apply on the next two frames to catch deferred layout. queueMicrotask(() => { if (gen !== scrollCompensationGen) return pendingAutoPin = false - if (!containerRef) return - if (!autoScroll()) return - if (anchorLock()) return - - const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0) - if (containerRef.scrollTop !== maxScrollTop) { - containerRef.scrollTop = maxScrollTop - lastKnownScrollTop = maxScrollTop - } + if (!applyAutoPinToBottom()) return + pendingAutoPinFrame = requestAnimationFrame(() => { + pendingAutoPinFrame = null + if (gen !== scrollCompensationGen) return + if (!applyAutoPinToBottom()) return + pendingAutoPinFrame = requestAnimationFrame(() => { + pendingAutoPinFrame = null + if (gen !== scrollCompensationGen) return + applyAutoPinToBottom() + }) + }) }) } @@ -628,6 +654,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { pendingScrollCompensationScheduled = false pendingScrollCompensations = new Map() pendingAutoPin = false + clearPendingAutoPinFrame() suppressAutoScrollOnce = false pendingActiveScroll = false @@ -718,7 +745,13 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { suppressAutoScrollOnce = false return } - if (autoScroll()) scheduleAnchorScroll(true) + if (autoScroll()) { + scheduleAutoPinToBottom() + return + } + if (anchorLock() && !autoScroll()) { + scheduleAnchorCorrection() + } }) // Drop anchor lock if the anchored key is removed. @@ -825,6 +858,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { scrollCompensationGen += 1 pendingScrollCompensationScheduled = false pendingScrollCompensations = new Map() + clearPendingAutoPinFrame() clearScrollToBottomFrames() if (detachScrollIntentListeners) { detachScrollIntentListeners() @@ -888,35 +922,15 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const anchorId = () => getAnchorId(key()) const overscanPx = props.overscanPx ?? 800 const suspendMeasurements = () => measurementsSuspended() || !isActive() - const [measureElement, setMeasureElement] = createSignal() - const [contentRenderVersion, setContentRenderVersion] = createSignal(0) - let pendingContentRenderFrame: number | null = null - - const notifyItemRendered = () => { - if (pendingContentRenderFrame !== null) return - pendingContentRenderFrame = requestAnimationFrame(() => { - pendingContentRenderFrame = null - setContentRenderVersion((prev) => prev + 1) - }) - } - - onCleanup(() => { - if (pendingContentRenderFrame !== null) { - cancelAnimationFrame(pendingContentRenderFrame) - pendingContentRenderFrame = null - } - }) - + const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll() return ( { const delta = nextHeight - previousHeight @@ -943,13 +957,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { scheduleScrollCompensation(key(), delta) } }} - > - {() => - props.renderItem(item(), index, { - registerMeasureElement: (element) => setMeasureElement(element ?? undefined), - notifyItemRendered, - })} - + >{() => props.renderItem(item(), index)} ) }} diff --git a/packages/ui/src/components/virtual-item.tsx b/packages/ui/src/components/virtual-item.tsx index e3ac16cd..6a06cd29 100644 --- a/packages/ui/src/components/virtual-item.tsx +++ b/packages/ui/src/components/virtual-item.tsx @@ -4,8 +4,6 @@ const sizeCache = new Map() const DEFAULT_MARGIN_PX = 600 const MIN_PLACEHOLDER_HEIGHT = 400 const VISIBILITY_BUFFER_PX = 0 -const SETTLED_HEIGHT_EPSILON_PX = 1 -const SETTLED_HEIGHT_FRAMES = 2 type ObserverRoot = Element | Document | null @@ -162,8 +160,6 @@ interface VirtualItemProps { scrollContainer?: Accessor threshold?: number minPlaceholderHeight?: number - measureElement?: Accessor - contentRenderVersion?: Accessor class?: string contentClass?: string placeholderClass?: string @@ -182,8 +178,6 @@ export interface VirtualItemHeightChangeMeta { wasHidden: boolean } -type VisibleSettlingMode = "hidden-to-visible" | "visible-rerender" - export default function VirtualItem(props: VirtualItemProps) { const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children) const cachedHeight = sizeCache.get(props.cacheKey) @@ -196,8 +190,6 @@ export default function VirtualItem(props: VirtualItemProps) { // When content first mounts, onHeightChange deltas should reflect the DOM's // placeholder height (not 0), otherwise scroll compensation can overshoot. const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight()) - const [settlingMode, setSettlingMode] = createSignal(null) - const isSettlingVisible = createMemo(() => settlingMode() !== null) let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0) let pendingVisibility: boolean | null = null let visibilityFrame: number | null = null @@ -214,11 +206,6 @@ export default function VirtualItem(props: VirtualItemProps) { } } const queueVisibility = (nextValue: boolean) => { - if (nextValue && !isIntersecting()) { - setSettlingMode("hidden-to-visible") - } else if (!nextValue) { - setSettlingMode(null) - } pendingVisibility = nextValue if (visibilityFrame !== null) return visibilityFrame = requestAnimationFrame(() => { @@ -231,22 +218,18 @@ export default function VirtualItem(props: VirtualItemProps) { } const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true) const measurementsSuspended = () => Boolean(props.suspendMeasurements?.()) + const forceVisible = () => Boolean(props.forceVisible?.()) const shouldHideContent = createMemo(() => { - if (props.forceVisible?.()) return false + if (forceVisible()) return false if (!virtualizationEnabled()) return false return !isIntersecting() }) - const shouldHideMountedContent = createMemo(() => shouldHideContent() || settlingMode() === "hidden-to-visible") + let wrapperRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined let resizeObserver: ResizeObserver | undefined let intersectionCleanup: (() => void) | undefined - let delayedMeasureFrame: number | null = null - let delayedMeasureFrame2: number | null = null - let settlingMeasureFrame: number | null = null - let settlingStableFrames = 0 - let settlingLastHeight: number | null = null function cleanupResizeObserver() { if (resizeObserver) { @@ -255,104 +238,15 @@ export default function VirtualItem(props: VirtualItemProps) { } } - function clearDelayedMeasureFrames() { - if (delayedMeasureFrame !== null) { - cancelAnimationFrame(delayedMeasureFrame) - delayedMeasureFrame = null - } - if (delayedMeasureFrame2 !== null) { - cancelAnimationFrame(delayedMeasureFrame2) - delayedMeasureFrame2 = null - } - } - - function clearSettlingMeasurementFrame() { - if (settlingMeasureFrame !== null) { - cancelAnimationFrame(settlingMeasureFrame) - settlingMeasureFrame = null - } - settlingStableFrames = 0 - settlingLastHeight = null - } - - function getVisibleSettlingMode(): VisibleSettlingMode { - return awaitingVisibleMeasurement ? "hidden-to-visible" : "visible-rerender" - } - - function scheduleDelayedVisibleMeasurements() { - clearDelayedMeasureFrames() - delayedMeasureFrame = requestAnimationFrame(() => { - delayedMeasureFrame = null - if (shouldHideContent() || measurementsSuspended()) return - if (!contentRef) return - updateMeasuredHeight() - delayedMeasureFrame2 = requestAnimationFrame(() => { - delayedMeasureFrame2 = null - if (shouldHideContent() || measurementsSuspended()) return - if (!contentRef) return - updateMeasuredHeight() - }) - }) - } - - function commitSettledMeasurement(next: number) { - const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize" - const wasHidden = lastMeasurementWhileHidden - awaitingVisibleMeasurement = false - lastMeasurementWhileHidden = false - setSettlingMode(null) - persistMeasurement(next, { source: measurementSource, wasHidden }) - } - - function scheduleSettledVisibleMeasurement(mode = getVisibleSettlingMode()) { - if (shouldHideContent() || measurementsSuspended()) return - if (!contentRef) return - setSettlingMode(mode) - clearSettlingMeasurementFrame() - - const tick = () => { - settlingMeasureFrame = requestAnimationFrame(() => { - settlingMeasureFrame = null - if (shouldHideContent() || measurementsSuspended()) { - setSettlingMode(null) - clearSettlingMeasurementFrame() - return - } - const measurement = getMeasuredContentRect() - if (!measurement) { - tick() - return - } - const sampledHeight = Math.max(0, Math.round(measurement.rect.height * 2) / 2) - const previousSample = settlingLastHeight - settlingLastHeight = sampledHeight - if (previousSample !== null && Math.abs(sampledHeight - previousSample) <= SETTLED_HEIGHT_EPSILON_PX) { - settlingStableFrames += 1 - } else { - settlingStableFrames = 0 - } - if (settlingStableFrames >= SETTLED_HEIGHT_FRAMES) { - clearSettlingMeasurementFrame() - commitSettledMeasurement(sampledHeight) - return - } - tick() - }) - } - - tick() - } - - function scheduleContentRenderedMeasurements() { + function scheduleVisibleMeasurements() { if (shouldHideContent() || measurementsSuspended()) return if (!contentRef) return queueMicrotask(() => { if (shouldHideContent() || measurementsSuspended()) return if (!contentRef) return + updateMeasuredHeight() setupResizeObserver() - scheduleSettledVisibleMeasurement(getVisibleSettlingMode()) }) - scheduleDelayedVisibleMeasurements() } function cleanupIntersectionObserver() { @@ -362,69 +256,6 @@ export default function VirtualItem(props: VirtualItemProps) { } } - function getMeasuredContentRect(): { rect: DOMRect } | null { - const explicitMeasureElement = props.measureElement?.() - if (explicitMeasureElement?.isConnected) { - return { - rect: explicitMeasureElement.getBoundingClientRect(), - } - } - - if (!contentRef) return null - - const childElements = Array.from(contentRef.children).filter((node): node is HTMLElement => node instanceof HTMLElement) - if (childElements.length === 0) { - return { - rect: contentRef.getBoundingClientRect(), - } - } - - let top = Number.POSITIVE_INFINITY - let bottom = Number.NEGATIVE_INFINITY - let left = Number.POSITIVE_INFINITY - let right = Number.NEGATIVE_INFINITY - let sawNonZero = false - - for (const child of childElements) { - const rect = child.getBoundingClientRect() - if (rect.width <= 0 && rect.height <= 0) continue - sawNonZero = true - if (rect.top < top) top = rect.top - if (rect.bottom > bottom) bottom = rect.bottom - if (rect.left < left) left = rect.left - if (rect.right > right) right = rect.right - } - - if (!sawNonZero) { - return { - rect: contentRef.getBoundingClientRect(), - } - } - - return { - rect: { - x: left, - y: top, - top, - bottom, - left, - right, - width: Math.max(0, right - left), - height: Math.max(0, bottom - top), - toJSON: () => ({ - x: left, - y: top, - top, - bottom, - left, - right, - width: Math.max(0, right - left), - height: Math.max(0, bottom - top), - }), - } as DOMRect, - } - } - function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) { if (!Number.isFinite(nextHeight) || nextHeight < 0) { return @@ -466,11 +297,6 @@ export default function VirtualItem(props: VirtualItemProps) { } } setMeasuredHeight(normalized) - if (measurementMeta.isStaleCacheCorrection) { - requestAnimationFrame(() => { - recheckVisibilityAfterMeasurement() - }) - } if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta) } @@ -479,14 +305,16 @@ export default function VirtualItem(props: VirtualItemProps) { if (measurementsSuspended()) return // Prefer subpixel-accurate height for scroll compensation. // offsetHeight rounds to integers which can accumulate error. - const measurement = getMeasuredContentRect() - if (!measurement) return - const { rect } = measurement + const rect = contentRef.getBoundingClientRect() const next = Math.max(0, Math.round(rect.height * 2) / 2) const currentMeasured = measuredHeight() - if (next === currentMeasured) return const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize" const wasHidden = lastMeasurementWhileHidden + if (measurementSource === "initial-visible-measure") { + awaitingVisibleMeasurement = false + lastMeasurementWhileHidden = false + } + if (next === currentMeasured) return persistMeasurement(next, { source: measurementSource, wasHidden }) } @@ -499,7 +327,6 @@ export default function VirtualItem(props: VirtualItemProps) { } resizeObserver = new ResizeObserver(() => { if (measurementsSuspended()) return - if (isSettlingVisible()) return updateMeasuredHeight() }) resizeObserver.observe(contentRef) @@ -551,17 +378,11 @@ export default function VirtualItem(props: VirtualItemProps) { } try { const rootRect = (targetRoot as Element).getBoundingClientRect() - const wrapperRect = wrapperEl.getBoundingClientRect() const visible = shouldRenderByRects({ - wrapperRect, + wrapperRect: wrapperEl.getBoundingClientRect(), rootRect: { top: rootRect.top, bottom: rootRect.bottom }, margin, }) - const collapsedWhileVisible = isIntersecting() && !visible && Math.round(wrapperRect.height) <= 0 && measuredHeight() > 0 - if (collapsedWhileVisible) { - queueVisibility(true) - return - } queueVisibility(visible) return } catch { @@ -574,37 +395,6 @@ export default function VirtualItem(props: VirtualItemProps) { }) } - function recheckVisibilityAfterMeasurement() { - if (!wrapperRef) return - - const margin = props.threshold ?? DEFAULT_MARGIN_PX - const wrapperRect = wrapperRef.getBoundingClientRect() - const targetRoot = props.scrollContainer ? props.scrollContainer() : null - - if (targetRoot && !(targetRoot instanceof Document)) { - const rootRect = targetRoot.getBoundingClientRect() - const visible = shouldRenderByRects({ - wrapperRect, - rootRect: { top: rootRect.top, bottom: rootRect.bottom }, - margin, - }) - if (!visible) { - queueVisibility(false) - } - return - } - - const viewportRect = getViewportRect() - const visible = shouldRenderByRects({ - wrapperRect, - rootRect: viewportRect, - margin, - }) - if (!visible) { - queueVisibility(false) - } - } - function setWrapperRef(element: HTMLDivElement | null) { wrapperRef = element ?? undefined const root = props.scrollContainer ? props.scrollContainer() : null @@ -628,27 +418,14 @@ export default function VirtualItem(props: VirtualItemProps) { if (hidden) { awaitingVisibleMeasurement = true lastMeasurementWhileHidden = true - clearDelayedMeasureFrames() - clearSettlingMeasurementFrame() - setSettlingMode(null) } if (hidden || measurementsSuspended()) { cleanupResizeObserver() } if (!hidden && !measurementsSuspended() && contentRef) { - queueMicrotask(() => { - setupResizeObserver() - scheduleSettledVisibleMeasurement(getVisibleSettlingMode()) - }) - scheduleDelayedVisibleMeasurements() + scheduleVisibleMeasurements() } }) - createEffect(() => { - const version = props.contentRenderVersion?.() - if (version === undefined) return - if (version <= 0) return - scheduleContentRenderedMeasurements() - }) createEffect(() => { @@ -680,15 +457,13 @@ export default function VirtualItem(props: VirtualItemProps) { onCleanup(() => { cleanupResizeObserver() cleanupIntersectionObserver() - clearDelayedMeasureFrames() - clearSettlingMeasurementFrame() flushVisibility() }) const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ") const contentClass = () => { const classes = ["virtual-item-content", props.contentClass] - if (shouldHideMountedContent()) { + if (shouldHideContent()) { classes.push("virtual-item-content-hidden") } return classes.filter(Boolean).join(" ") @@ -705,7 +480,7 @@ export default function VirtualItem(props: VirtualItemProps) { class={placeholderClass()} style={{ width: "100%", - height: shouldHideMountedContent() ? `${placeholderHeight()}px` : undefined, + height: shouldHideContent() ? `${placeholderHeight()}px` : undefined, }} >