# PR Draft: Fix sticky auto-scroll during streaming chat responses Fixes #308 ## Summary This change makes chat auto-scroll easier to escape while assistant output is still streaming. The goal is to stop the viewport from repeatedly pulling the user back toward the bottom once they begin scrolling upward to inspect earlier content. ## Why Before this change, streaming updates could keep reasserting bottom-follow behavior during active rendering. That made auto-scroll feel sticky and forced users to scroll repeatedly or forcefully just to review earlier parts of an in-progress response. The intended behavior is simpler: once the user scrolls upward to leave follow mode, the UI should respect that decision instead of fighting it during subsequent stream updates. ## What Changed 1. Removed render-time force-bottom behavior from the shared follow-scroll helper path. 2. Updated streamed reasoning output to restore scroll without forcing the viewport back to the bottom. 3. Updated streamed tool-call output to use the same non-forcing restore behavior. ## Scope Boundaries Included: - Sticky auto-scroll behavior during streamed chat output - Shared follow-scroll behavior used by streamed nested panes - Reasoning and tool-call streaming paths that reused the same forced follow behavior Not included: - A full rewrite of the virtualized message list follow model - Broader scroll UX changes outside the streaming follow/escape behavior - Unrelated UI or plugin configuration changes in the worktree ## Technical Notes The core problem was not basic auto-scroll itself, but a render-time path that could keep forcing bottom-follow behavior while new streamed content was arriving. That meant a user's attempt to scroll upward could be overridden repeatedly by subsequent stream updates, which is why the auto-scroll felt sticky. The fix removes that override and keeps render-time restoration dependent on the current follow state instead. ## Files Changed - `packages/ui/src/lib/follow-scroll.tsx` - `packages/ui/src/components/message-block.tsx` - `packages/ui/src/components/tool-call.tsx` ## Verification Performed: 1. Reproduced the sticky auto-scroll behavior with a long multi-line streaming response. 2. Verified that scrolling upward during streaming now disengages follow more naturally in the affected streamed panes. 3. Ran `npm run typecheck --workspace @codenomad/ui`. 4. Ran `npm run build --workspace @codenomad/ui`. Build note: - The UI typecheck passes. - The UI build succeeds. - The build still emits existing third-party and chunk-size warnings unrelated to this change. ## Risks and Follow-up 1. The broader scroll-follow model is still more heuristic-heavy than ideal, so there may be future follow-up work to simplify it further. 2. This PR intentionally applies the smallest targeted fix to the known snap-back path instead of rewriting the full chat scroll system. --------- Co-authored-by: Shantur Rathore <i@shantur.com>
263 lines
9.2 KiB
TypeScript
263 lines
9.2 KiB
TypeScript
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
|
|
|
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
|
|
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
|
|
|
interface FollowScrollOptions {
|
|
getScrollTopSnapshot: Accessor<number>
|
|
setScrollTopSnapshot: (next: number) => void
|
|
sentinelMarginPx: number
|
|
sentinelClassName: string
|
|
intentWindowMs?: number
|
|
intentKeys?: ReadonlySet<string>
|
|
}
|
|
|
|
export interface FollowScrollHelpers {
|
|
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
|
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
|
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
|
restoreAfterRender: () => void
|
|
autoScroll: Accessor<boolean>
|
|
}
|
|
|
|
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
|
|
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
|
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
|
|
|
let scrollContainerRef: HTMLDivElement | undefined
|
|
let detachScrollIntentListeners: (() => void) | undefined
|
|
|
|
let pendingScrollFrame: number | null = null
|
|
let pendingAnchorScroll: number | null = null
|
|
let userScrollIntentUntil = 0
|
|
let lastKnownScrollTop = options.getScrollTopSnapshot()
|
|
let pointerInteractionActive = false
|
|
let suppressNextScrollHandling = false
|
|
|
|
function restoreScrollPosition(forceBottom = false) {
|
|
const container = scrollContainerRef
|
|
if (!container) return
|
|
suppressNextScrollHandling = true
|
|
if (forceBottom) {
|
|
container.scrollTop = container.scrollHeight
|
|
lastKnownScrollTop = container.scrollTop
|
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
|
} else {
|
|
container.scrollTop = lastKnownScrollTop
|
|
}
|
|
}
|
|
|
|
function persistScrollSnapshot(element?: HTMLElement | null) {
|
|
if (!element) return
|
|
lastKnownScrollTop = element.scrollTop
|
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
|
}
|
|
|
|
function markUserScrollIntent() {
|
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
|
|
}
|
|
|
|
function hasUserScrollIntent() {
|
|
if (pointerInteractionActive) {
|
|
return true
|
|
}
|
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
return now <= userScrollIntentUntil
|
|
}
|
|
|
|
function attachScrollIntentListeners(element: HTMLDivElement) {
|
|
if (detachScrollIntentListeners) {
|
|
detachScrollIntentListeners()
|
|
detachScrollIntentListeners = undefined
|
|
}
|
|
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
|
|
const handlePointerIntent = () => {
|
|
pointerInteractionActive = true
|
|
markUserScrollIntent()
|
|
}
|
|
const clearPointerIntent = () => {
|
|
pointerInteractionActive = false
|
|
}
|
|
const handleKeyIntent = (event: KeyboardEvent) => {
|
|
if (intentKeys.has(event.key)) {
|
|
markUserScrollIntent()
|
|
}
|
|
}
|
|
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
|
element.addEventListener("pointerdown", handlePointerIntent)
|
|
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
|
element.addEventListener("keydown", handleKeyIntent)
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("pointerup", clearPointerIntent)
|
|
window.addEventListener("pointercancel", clearPointerIntent)
|
|
window.addEventListener("mouseup", clearPointerIntent)
|
|
window.addEventListener("touchend", clearPointerIntent)
|
|
window.addEventListener("touchcancel", clearPointerIntent)
|
|
}
|
|
detachScrollIntentListeners = () => {
|
|
element.removeEventListener("wheel", handlePointerIntent)
|
|
element.removeEventListener("pointerdown", handlePointerIntent)
|
|
element.removeEventListener("touchstart", handlePointerIntent)
|
|
element.removeEventListener("keydown", handleKeyIntent)
|
|
if (typeof window !== "undefined") {
|
|
window.removeEventListener("pointerup", clearPointerIntent)
|
|
window.removeEventListener("pointercancel", clearPointerIntent)
|
|
window.removeEventListener("mouseup", clearPointerIntent)
|
|
window.removeEventListener("touchend", clearPointerIntent)
|
|
window.removeEventListener("touchcancel", clearPointerIntent)
|
|
}
|
|
pointerInteractionActive = false
|
|
}
|
|
}
|
|
|
|
function scheduleAnchorScroll(immediate = false) {
|
|
if (!autoScroll()) return
|
|
const sentinel = bottomSentinel()
|
|
const container = scrollContainerRef
|
|
if (!sentinel || !container) return
|
|
if (pendingAnchorScroll !== null) {
|
|
cancelAnimationFrame(pendingAnchorScroll)
|
|
pendingAnchorScroll = null
|
|
}
|
|
pendingAnchorScroll = requestAnimationFrame(() => {
|
|
pendingAnchorScroll = null
|
|
const containerRect = container.getBoundingClientRect()
|
|
const sentinelRect = sentinel.getBoundingClientRect()
|
|
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
|
|
if (Math.abs(delta) > 1) {
|
|
suppressNextScrollHandling = true
|
|
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
|
}
|
|
lastKnownScrollTop = container.scrollTop
|
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
|
})
|
|
}
|
|
|
|
function isAtBottom(container: HTMLDivElement) {
|
|
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
|
|
}
|
|
|
|
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
|
|
const container = containerOverride ?? scrollContainer()
|
|
if (!container) return
|
|
if (suppressNextScrollHandling) {
|
|
suppressNextScrollHandling = false
|
|
return
|
|
}
|
|
const isUserScroll = hasUserScrollIntent()
|
|
const atBottomFromScroll = isAtBottom(container)
|
|
const atBottom = atBottomFromScroll || bottomSentinelVisible()
|
|
|
|
if (isUserScroll || !atBottom) {
|
|
if (atBottom) {
|
|
if (!autoScroll()) setAutoScroll(true)
|
|
} else if (autoScroll()) {
|
|
setAutoScroll(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
updateFollowModeFromScroll(event.currentTarget)
|
|
persistScrollSnapshot(event.currentTarget)
|
|
}
|
|
|
|
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
|
|
const next = element || undefined
|
|
if (next === scrollContainerRef) {
|
|
return
|
|
}
|
|
scrollContainerRef = next
|
|
setScrollContainer(scrollContainerRef)
|
|
if (scrollContainerRef) {
|
|
lastKnownScrollTop = options.getScrollTopSnapshot()
|
|
restoreScrollPosition(autoScroll())
|
|
}
|
|
}
|
|
|
|
const renderSentinel = (config?: { disableTracking?: boolean }) => {
|
|
if (config?.disableTracking) return null
|
|
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
|
}
|
|
|
|
const restoreAfterRender = () => {
|
|
const container = scrollContainerRef
|
|
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
|
if (autoScroll()) {
|
|
setAutoScroll(false)
|
|
}
|
|
requestAnimationFrame(() => {
|
|
restoreScrollPosition(false)
|
|
})
|
|
return
|
|
}
|
|
|
|
// Never let a render-time caller force follow mode back on after the user
|
|
// has already escaped it. Staying pinned should depend on the current
|
|
// follow state, not on a caller opting into forceBottom.
|
|
const shouldFollow = autoScroll()
|
|
requestAnimationFrame(() => {
|
|
restoreScrollPosition(shouldFollow)
|
|
if (shouldFollow) {
|
|
scheduleAnchorScroll(true)
|
|
}
|
|
})
|
|
}
|
|
|
|
createEffect(() => {
|
|
const container = scrollContainer()
|
|
if (!container) return
|
|
attachScrollIntentListeners(container)
|
|
onCleanup(() => {
|
|
if (detachScrollIntentListeners) {
|
|
detachScrollIntentListeners()
|
|
detachScrollIntentListeners = undefined
|
|
}
|
|
})
|
|
})
|
|
|
|
createEffect(() => {
|
|
const container = scrollContainer()
|
|
const sentinel = bottomSentinel()
|
|
if (!container || !sentinel) return
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.target === sentinel) {
|
|
setBottomSentinelVisible(entry.isIntersecting)
|
|
}
|
|
})
|
|
},
|
|
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
|
|
)
|
|
observer.observe(sentinel)
|
|
onCleanup(() => observer.disconnect())
|
|
})
|
|
|
|
onCleanup(() => {
|
|
if (pendingScrollFrame !== null) {
|
|
cancelAnimationFrame(pendingScrollFrame)
|
|
pendingScrollFrame = null
|
|
}
|
|
if (pendingAnchorScroll !== null) {
|
|
cancelAnimationFrame(pendingAnchorScroll)
|
|
pendingAnchorScroll = null
|
|
}
|
|
if (detachScrollIntentListeners) {
|
|
detachScrollIntentListeners()
|
|
detachScrollIntentListeners = undefined
|
|
}
|
|
})
|
|
|
|
return {
|
|
registerContainer,
|
|
handleScroll,
|
|
renderSentinel,
|
|
restoreAfterRender,
|
|
autoScroll,
|
|
}
|
|
}
|