diff --git a/packages/ui/src/components/message-stream-v2.tsx b/packages/ui/src/components/message-stream-v2.tsx index c8c792fa..a65165dc 100644 --- a/packages/ui/src/components/message-stream-v2.tsx +++ b/packages/ui/src/components/message-stream-v2.tsx @@ -302,6 +302,12 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { }) const [scrollElement, setScrollElement] = createSignal() + const [bottomSentinel, setBottomSentinel] = createSignal(null) + createEffect(() => { + if (bottomSentinel()) { + scheduleAnchorScroll(true) + } + }) const [initialForceActive, setInitialForceActive] = createSignal(true) const [initialForceInitialized, setInitialForceInitialized] = createSignal(false) const [initialForceStartIndex, setInitialForceStartIndex] = createSignal(0) @@ -338,10 +344,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { let lastKnownScrollTop = 0 let lastMeasuredScrollHeight = 0 let pendingScrollFrame: number | null = null + let pendingAnchorScroll: number | null = null let userScrollIntentUntil = 0 let detachScrollIntentListeners: (() => void) | undefined let hasRestoredScroll = false - let hasInitialScroll = false // When the user explicitly clicks "scroll to bottom", we want the // smooth scroll animation to complete without being immediately // overridden by the auto-scroll effects that react to new messages. @@ -408,28 +414,14 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { if (!containerRef) return const behavior = immediate ? "auto" : "smooth" if (!immediate) { - // We initiated this scroll (e.g., via the button). Skip the - // next auto-scroll reaction so the smooth animation isn't - // overridden by changeToken/preference effects. suppressAutoScrollOnce = true } containerRef.scrollTo({ top: containerRef.scrollHeight, behavior }) setAutoScroll(true) - lastMeasuredScrollHeight = containerRef.scrollHeight - lastKnownScrollTop = containerRef.scrollTop updateScrollIndicators(containerRef) scheduleScrollPersist() } - function scrollToBottomAndClamp(immediate = false) { - scrollToBottom(immediate) - if (hasInitialScroll) { - requestAnimationFrame(() => clampScrollAfterShrink()) - } else { - hasInitialScroll = true - } - } - function scrollToTop(immediate = false) { if (!containerRef) return const behavior = immediate ? "auto" : "smooth" @@ -440,18 +432,33 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { updateScrollIndicators(containerRef) scheduleScrollPersist() } - - function handleContentRendered() { - if (!containerRef) return + + function scheduleAnchorScroll(immediate = false) { if (!autoScroll()) return - scrollToBottomAndClamp(true) + const sentinel = bottomSentinel() + if (!sentinel) return + if (pendingAnchorScroll !== null) { + cancelAnimationFrame(pendingAnchorScroll) + pendingAnchorScroll = null + } + pendingAnchorScroll = requestAnimationFrame(() => { + pendingAnchorScroll = null + sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: "auto" }) + }) } + + function handleContentRendered() { + scheduleAnchorScroll() + } + + createEffect(() => { if (props.registerScrollToBottom) { - props.registerScrollToBottom(() => scrollToBottomAndClamp(true)) + props.registerScrollToBottom(() => scrollToBottom(true)) } }) + let pendingScrollPersist: number | null = null @@ -465,21 +472,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { }) } - function clampScrollAfterShrink() { - if (!containerRef || !autoScroll()) return - const currentHeight = containerRef.scrollHeight - const clientHeight = containerRef.clientHeight - if (currentHeight < lastMeasuredScrollHeight) { - const maxScrollTop = Math.max(currentHeight - clientHeight, 0) - containerRef.scrollTo({ top: maxScrollTop, behavior: "auto" }) - lastKnownScrollTop = containerRef.scrollTop - } - lastMeasuredScrollHeight = currentHeight - } - function handleScroll(event: Event) { + if (!containerRef) return if (pendingScrollFrame !== null) { cancelAnimationFrame(pendingScrollFrame) @@ -549,10 +545,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { return } if (autoScroll()) { - scrollToBottomAndClamp(true) + scheduleAnchorScroll(true) } }) - + createEffect(() => { preferenceSignature() if (props.loading) return @@ -563,9 +559,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { suppressAutoScrollOnce = false return } - scrollToBottomAndClamp(true) + scheduleAnchorScroll(true) }) + createEffect(() => { if (messageIds().length === 0) { @@ -585,6 +582,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { cancelAnimationFrame(pendingScrollPersist) pendingScrollPersist = null } + if (pendingAnchorScroll !== null) { + cancelAnimationFrame(pendingAnchorScroll) + pendingAnchorScroll = null + } if (detachScrollIntentListeners) { detachScrollIntentListeners() detachScrollIntentListeners = undefined @@ -720,9 +721,11 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { ) }} + - + +