Anchor scroll to message stream sentinel

This commit is contained in:
Shantur Rathore
2025-12-02 12:37:49 +00:00
parent 7fde8afcf0
commit 831e59cd77

View File

@@ -302,6 +302,12 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
}) })
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>() const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
createEffect(() => {
if (bottomSentinel()) {
scheduleAnchorScroll(true)
}
})
const [initialForceActive, setInitialForceActive] = createSignal(true) const [initialForceActive, setInitialForceActive] = createSignal(true)
const [initialForceInitialized, setInitialForceInitialized] = createSignal(false) const [initialForceInitialized, setInitialForceInitialized] = createSignal(false)
const [initialForceStartIndex, setInitialForceStartIndex] = createSignal(0) const [initialForceStartIndex, setInitialForceStartIndex] = createSignal(0)
@@ -338,10 +344,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
let lastKnownScrollTop = 0 let lastKnownScrollTop = 0
let lastMeasuredScrollHeight = 0 let lastMeasuredScrollHeight = 0
let pendingScrollFrame: number | null = null let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0 let userScrollIntentUntil = 0
let detachScrollIntentListeners: (() => void) | undefined let detachScrollIntentListeners: (() => void) | undefined
let hasRestoredScroll = false let hasRestoredScroll = false
let hasInitialScroll = false
// When the user explicitly clicks "scroll to bottom", we want the // When the user explicitly clicks "scroll to bottom", we want the
// smooth scroll animation to complete without being immediately // smooth scroll animation to complete without being immediately
// overridden by the auto-scroll effects that react to new messages. // overridden by the auto-scroll effects that react to new messages.
@@ -408,28 +414,14 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
if (!containerRef) return if (!containerRef) return
const behavior = immediate ? "auto" : "smooth" const behavior = immediate ? "auto" : "smooth"
if (!immediate) { 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 suppressAutoScrollOnce = true
} }
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior }) containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
setAutoScroll(true) setAutoScroll(true)
lastMeasuredScrollHeight = containerRef.scrollHeight
lastKnownScrollTop = containerRef.scrollTop
updateScrollIndicators(containerRef) updateScrollIndicators(containerRef)
scheduleScrollPersist() scheduleScrollPersist()
} }
function scrollToBottomAndClamp(immediate = false) {
scrollToBottom(immediate)
if (hasInitialScroll) {
requestAnimationFrame(() => clampScrollAfterShrink())
} else {
hasInitialScroll = true
}
}
function scrollToTop(immediate = false) { function scrollToTop(immediate = false) {
if (!containerRef) return if (!containerRef) return
const behavior = immediate ? "auto" : "smooth" const behavior = immediate ? "auto" : "smooth"
@@ -440,18 +432,33 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
updateScrollIndicators(containerRef) updateScrollIndicators(containerRef)
scheduleScrollPersist() scheduleScrollPersist()
} }
function handleContentRendered() { function scheduleAnchorScroll(immediate = false) {
if (!containerRef) return
if (!autoScroll()) return 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(() => { createEffect(() => {
if (props.registerScrollToBottom) { if (props.registerScrollToBottom) {
props.registerScrollToBottom(() => scrollToBottomAndClamp(true)) props.registerScrollToBottom(() => scrollToBottom(true))
} }
}) })
let pendingScrollPersist: number | null = null 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) { function handleScroll(event: Event) {
if (!containerRef) return if (!containerRef) return
if (pendingScrollFrame !== null) { if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame) cancelAnimationFrame(pendingScrollFrame)
@@ -549,10 +545,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
return return
} }
if (autoScroll()) { if (autoScroll()) {
scrollToBottomAndClamp(true) scheduleAnchorScroll(true)
} }
}) })
createEffect(() => { createEffect(() => {
preferenceSignature() preferenceSignature()
if (props.loading) return if (props.loading) return
@@ -563,9 +559,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
suppressAutoScrollOnce = false suppressAutoScrollOnce = false
return return
} }
scrollToBottomAndClamp(true) scheduleAnchorScroll(true)
}) })
createEffect(() => { createEffect(() => {
if (messageIds().length === 0) { if (messageIds().length === 0) {
@@ -585,6 +582,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
cancelAnimationFrame(pendingScrollPersist) cancelAnimationFrame(pendingScrollPersist)
pendingScrollPersist = null pendingScrollPersist = null
} }
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) { if (detachScrollIntentListeners) {
detachScrollIntentListeners() detachScrollIntentListeners()
detachScrollIntentListeners = undefined detachScrollIntentListeners = undefined
@@ -720,9 +721,11 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
) )
}} }}
</Index> </Index>
<div ref={setBottomSentinel} aria-hidden="true" />
</div> </div>
<Show when={showScrollTopButton() || showScrollBottomButton()}> <Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper"> <div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}> <Show when={showScrollTopButton()}>
<button <button