Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions web/src/components/AssistantChat/HappyThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function HappyThread(props: {
// Smart scroll state: autoScroll enabled when user is near bottom
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true)
const autoScrollEnabledRef = useRef(autoScrollEnabled)
const prevAutoScrollEnabledRef = useRef(true)

// Keep refs in sync with state
useEffect(() => {
Expand Down Expand Up @@ -190,22 +191,29 @@ export function HappyThread(props: {
}
loadLockRef.current = true
loadStartedRef.current = false
// Save current autoScroll state and disable it while loading older messages
// to prevent assistant-ui from scrolling to bottom when content size changes
prevAutoScrollEnabledRef.current = autoScrollEnabledRef.current
setAutoScrollEnabled(false)
let loadPromise: Promise<unknown>
try {
loadPromise = onLoadMoreRef.current()
} catch (error) {
pendingScrollRef.current = null
loadLockRef.current = false
setAutoScrollEnabled(prevAutoScrollEnabledRef.current)
throw error
}
void loadPromise.catch((error) => {
pendingScrollRef.current = null
loadLockRef.current = false
setAutoScrollEnabled(prevAutoScrollEnabledRef.current)
console.error('Failed to load older messages:', error)
}).finally(() => {
if (!loadStartedRef.current && !isLoadingMoreRef.current && pendingScrollRef.current) {
pendingScrollRef.current = null
loadLockRef.current = false
setAutoScrollEnabled(prevAutoScrollEnabledRef.current)
}
})
}, [])
Expand Down Expand Up @@ -248,10 +256,38 @@ export function HappyThread(props: {
if (!pending || !viewport) {
return
}
const delta = viewport.scrollHeight - pending.scrollHeight
viewport.scrollTop = pending.scrollTop + delta
pendingScrollRef.current = null
loadLockRef.current = false

// Wait for DOM to update before checking scroll height
let attempts = 0
const MAX_ATTEMPTS = 5

const checkAndRestore = () => {
const delta = viewport.scrollHeight - pending.scrollHeight

// If delta is 0, content hasn't been rendered yet, wait a bit
// But limit attempts to avoid infinite loop
if (delta === 0 && viewport.scrollHeight === pending.scrollHeight) {
if (attempts++ < MAX_ATTEMPTS) {
requestAnimationFrame(checkAndRestore)
return
}
// Max attempts reached, give up and restore autoScroll state
pendingScrollRef.current = null
loadLockRef.current = false
// Use setTimeout to defer state update until after DOM operations complete
setTimeout(() => setAutoScrollEnabled(prevAutoScrollEnabledRef.current), 0)
return
}

viewport.scrollTop = pending.scrollTop + delta
pendingScrollRef.current = null
loadLockRef.current = false
// Restore previous autoScroll state after DOM has settled
// Use longer delay to ensure all DOM operations are complete
setTimeout(() => setAutoScrollEnabled(prevAutoScrollEnabledRef.current), 100)
}

requestAnimationFrame(checkAndRestore)
}, [props.messagesVersion])

useEffect(() => {
Expand All @@ -262,6 +298,8 @@ export function HappyThread(props: {
if (prevLoadingMoreRef.current && !props.isLoadingMoreMessages && pendingScrollRef.current) {
pendingScrollRef.current = null
loadLockRef.current = false
// Re-enable autoScroll if loading finished but useLayoutEffect didn't run
setAutoScrollEnabled(true)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Correctness 强制开启 autoScroll 导致滚动位置被重置

Why this is a problem: 在 useLayoutEffect 未触发时,当前实现会无条件 setAutoScrollEnabled(true),即便用户在加载前并不在底部,也会被强制打开自动滚动,导致加载历史后滚动跳到底部/位置丢失。

Suggested fix:

// 恢复为加载前的状态,避免强制开启 autoScroll
setAutoScrollEnabled(prevAutoScrollEnabledRef.current)

prevLoadingMoreRef.current = props.isLoadingMoreMessages
}, [props.isLoadingMoreMessages])
Expand Down