Stabilize message stream autoscroll
This commit is contained in:
@@ -16,6 +16,9 @@ import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
const SCROLL_DIRECTION_THRESHOLD = 10
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
||||
const TOOL_ICON = "🔧"
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
@@ -527,21 +530,49 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let lastScrollHeight = 0
|
||||
let autoScrollLocked = true
|
||||
let lastKnownScrollTop = 0
|
||||
let pendingScrollFrame: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
|
||||
function setAutoScrollState(enabled: boolean) {
|
||||
autoScrollLocked = enabled
|
||||
setAutoScroll(enabled)
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||
}
|
||||
|
||||
function lockAutoScroll() {
|
||||
setAutoScrollState(true)
|
||||
function hasUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function unlockAutoScroll() {
|
||||
setAutoScrollState(false)
|
||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
if (!element) return
|
||||
const handlePointerIntent = () => markUserScrollIntent()
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (SCROLL_INTENT_KEYS.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
}
|
||||
}
|
||||
|
||||
function setContainerRef(element: HTMLDivElement | null) {
|
||||
containerRef = element || undefined
|
||||
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
||||
attachScrollIntentListeners(containerRef)
|
||||
}
|
||||
|
||||
function isNearBottom(element: HTMLDivElement, offset = 48) {
|
||||
@@ -559,140 +590,69 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
setShowScrollTopButton(hasItems && !isNearTop(element))
|
||||
}
|
||||
|
||||
function detachResizeObserver() {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleResizeEvent() {
|
||||
function scrollToBottom(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const currentHeight = containerRef.scrollHeight
|
||||
const heightDecreased = currentHeight < lastScrollHeight
|
||||
lastScrollHeight = currentHeight
|
||||
if (heightDecreased && shouldMaintainAutoScroll()) {
|
||||
containerRef.scrollTop = Math.max(currentHeight - containerRef.clientHeight, 0)
|
||||
lockAutoScroll()
|
||||
queueAutoScroll(true)
|
||||
return
|
||||
}
|
||||
if (shouldMaintainAutoScroll()) {
|
||||
queueAutoScroll(true)
|
||||
} else {
|
||||
updateScrollIndicators(containerRef)
|
||||
scheduleScrollPersist()
|
||||
}
|
||||
}
|
||||
|
||||
function attachResizeObserver(element: HTMLDivElement | undefined) {
|
||||
detachResizeObserver()
|
||||
if (!element) return
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
handleResizeEvent()
|
||||
})
|
||||
resizeObserver.observe(element)
|
||||
}
|
||||
|
||||
function setContainerRef(element: HTMLDivElement | null) {
|
||||
containerRef = element || undefined
|
||||
if (containerRef) {
|
||||
lastScrollHeight = containerRef.scrollHeight
|
||||
}
|
||||
attachResizeObserver(containerRef)
|
||||
}
|
||||
|
||||
function shouldMaintainAutoScroll() {
|
||||
return autoScrollLocked
|
||||
}
|
||||
|
||||
|
||||
function applyScrollToBottom(immediate: boolean, options?: { preserveAuto?: boolean }) {
|
||||
if (!containerRef) return
|
||||
const preserveAuto = options?.preserveAuto ?? false
|
||||
if (preserveAuto && !shouldMaintainAutoScroll()) {
|
||||
return
|
||||
}
|
||||
if (!preserveAuto) {
|
||||
lockAutoScroll()
|
||||
}
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
if (preserveAuto && !shouldMaintainAutoScroll()) {
|
||||
updateScrollIndicators(containerRef)
|
||||
scheduleScrollPersist()
|
||||
return
|
||||
}
|
||||
if (!isNearBottom(containerRef)) {
|
||||
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior: "auto" })
|
||||
}
|
||||
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
||||
setAutoScroll(true)
|
||||
lastKnownScrollTop = containerRef.scrollTop
|
||||
updateScrollIndicators(containerRef)
|
||||
scheduleScrollPersist()
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = false) {
|
||||
applyScrollToBottom(immediate, { preserveAuto: false })
|
||||
}
|
||||
|
||||
function scrollToTop(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
unlockAutoScroll()
|
||||
containerRef.scrollTo({ top: 0, behavior })
|
||||
setAutoScroll(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
containerRef.scrollTo({ top: 0, behavior })
|
||||
lastKnownScrollTop = containerRef.scrollTop
|
||||
updateScrollIndicators(containerRef)
|
||||
scheduleScrollPersist()
|
||||
})
|
||||
}
|
||||
|
||||
let pendingAutoScrollId: number | null = null
|
||||
|
||||
function cancelPendingAutoScroll() {
|
||||
if (pendingAutoScrollId !== null) {
|
||||
cancelAnimationFrame(pendingAutoScrollId)
|
||||
pendingAutoScrollId = null
|
||||
}
|
||||
}
|
||||
|
||||
function queueAutoScroll(immediate = true) {
|
||||
cancelPendingAutoScroll()
|
||||
if (!shouldMaintainAutoScroll()) {
|
||||
return
|
||||
}
|
||||
pendingAutoScrollId = requestAnimationFrame(() => {
|
||||
pendingAutoScrollId = null
|
||||
applyScrollToBottom(immediate, { preserveAuto: true })
|
||||
})
|
||||
}
|
||||
|
||||
let pendingScrollPersist: number | null = null
|
||||
function scheduleScrollPersist() {
|
||||
if (pendingScrollPersist !== null) return
|
||||
pendingScrollPersist = requestAnimationFrame(() => {
|
||||
pendingScrollPersist = null
|
||||
if (!containerRef) return
|
||||
lastScrollHeight = containerRef.scrollHeight
|
||||
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
if (!containerRef) return
|
||||
updateScrollIndicators(containerRef)
|
||||
if (event.isTrusted) {
|
||||
const atBottom = isNearBottom(containerRef)
|
||||
if (!atBottom) {
|
||||
unlockAutoScroll()
|
||||
} else {
|
||||
lockAutoScroll()
|
||||
}
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
scheduleScrollPersist()
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
pendingScrollFrame = requestAnimationFrame(() => {
|
||||
pendingScrollFrame = null
|
||||
if (!containerRef) return
|
||||
const previousTop = lastKnownScrollTop
|
||||
const currentTop = containerRef.scrollTop
|
||||
const movingUp = currentTop < previousTop - SCROLL_DIRECTION_THRESHOLD
|
||||
const movingDown = currentTop > previousTop + SCROLL_DIRECTION_THRESHOLD
|
||||
lastKnownScrollTop = currentTop
|
||||
const atBottom = isNearBottom(containerRef)
|
||||
if (isUserScroll) {
|
||||
if (movingUp && !atBottom && autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
} else if (movingDown && atBottom && !autoScroll()) {
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}
|
||||
updateScrollIndicators(containerRef)
|
||||
scheduleScrollPersist()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -702,10 +662,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
fallback: () => scrollToBottom(true),
|
||||
onApplied: (snapshot) => {
|
||||
if (snapshot) {
|
||||
setAutoScrollState(snapshot.atBottom)
|
||||
setAutoScroll(snapshot.atBottom)
|
||||
} else {
|
||||
const atBottom = isNearBottom(target)
|
||||
setAutoScrollState(atBottom)
|
||||
setAutoScroll(atBottom)
|
||||
}
|
||||
updateScrollIndicators(target)
|
||||
},
|
||||
@@ -722,7 +682,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
}
|
||||
previousToken = token
|
||||
if (autoScroll()) {
|
||||
queueAutoScroll(true)
|
||||
scrollToBottom(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -730,18 +690,23 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
if (messageRecords().length === 0) {
|
||||
setShowScrollTopButton(false)
|
||||
setShowScrollBottomButton(false)
|
||||
lockAutoScroll()
|
||||
cancelPendingAutoScroll()
|
||||
setAutoScroll(true)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
detachResizeObserver()
|
||||
cancelPendingAutoScroll()
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
pendingScrollFrame = null
|
||||
}
|
||||
if (pendingScrollPersist !== null) {
|
||||
cancelAnimationFrame(pendingScrollPersist)
|
||||
pendingScrollPersist = null
|
||||
}
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
if (containerRef) {
|
||||
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user