From b32446ee3c01ab33b1eecc96fe072e461292649f Mon Sep 17 00:00:00 2001 From: tfq Date: Fri, 6 Feb 2026 09:54:19 +0800 Subject: [PATCH 1/6] fix(web): prevent autoScroll from interfering with history load scroll position When loading older messages, the assistant-ui library's autoScroll feature was interfering with our manual scroll position restoration, causing the viewport to jump to the top instead of maintaining position. Root cause: ThreadPrimitive.Viewport's autoScroll triggers scrollToBottom() on content resize when autoScroll=true and isAtBottom=true. This conflicts with our useLayoutEffect scroll position restoration. Fix: Temporarily disable autoScroll during history load: 1. Set autoScrollEnabled=false when handleLoadMore starts 2. Restore scroll position in useLayoutEffect as before 3. Re-enable autoScroll after position is restored 4. Handle error cases to ensure autoScroll is always re-enabled via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- web/src/components/AssistantChat/HappyThread.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 09654f17c..02439ae26 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -190,22 +190,28 @@ export function HappyThread(props: { } loadLockRef.current = true loadStartedRef.current = false + // Disable autoScroll while loading older messages to prevent assistant-ui + // from scrolling to bottom when content size changes + setAutoScrollEnabled(false) let loadPromise: Promise try { loadPromise = onLoadMoreRef.current() } catch (error) { pendingScrollRef.current = null loadLockRef.current = false + setAutoScrollEnabled(true) throw error } void loadPromise.catch((error) => { pendingScrollRef.current = null loadLockRef.current = false + setAutoScrollEnabled(true) console.error('Failed to load older messages:', error) }).finally(() => { if (!loadStartedRef.current && !isLoadingMoreRef.current && pendingScrollRef.current) { pendingScrollRef.current = null loadLockRef.current = false + setAutoScrollEnabled(true) } }) }, []) @@ -252,6 +258,9 @@ export function HappyThread(props: { viewport.scrollTop = pending.scrollTop + delta pendingScrollRef.current = null loadLockRef.current = false + // Re-enable autoScroll after scroll position is restored + // Use setTimeout to ensure the scroll position is applied before re-enabling + setTimeout(() => setAutoScrollEnabled(true), 0) }, [props.messagesVersion]) useEffect(() => { @@ -262,6 +271,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) } prevLoadingMoreRef.current = props.isLoadingMoreMessages }, [props.isLoadingMoreMessages]) From 94746b533f4cf83cea93719b7036e2b777720cfe Mon Sep 17 00:00:00 2001 From: tfq Date: Fri, 6 Feb 2026 10:09:27 +0800 Subject: [PATCH 2/6] debug(web): add console logs to diagnose scroll position restoration Add detailed logging to understand why scroll position jumps to top when loading older messages instead of maintaining position. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../components/AssistantChat/HappyThread.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 02439ae26..8efbdb55f 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -184,14 +184,17 @@ export function HappyThread(props: { if (!viewport) { return } - pendingScrollRef.current = { + const scrollState = { scrollTop: viewport.scrollTop, scrollHeight: viewport.scrollHeight } + console.log('[HappyThread] Saving scroll position before load:', scrollState) + pendingScrollRef.current = scrollState loadLockRef.current = true loadStartedRef.current = false // Disable autoScroll while loading older messages to prevent assistant-ui // from scrolling to bottom when content size changes + console.log('[HappyThread] Disabling autoScroll') setAutoScrollEnabled(false) let loadPromise: Promise try { @@ -255,12 +258,23 @@ export function HappyThread(props: { return } const delta = viewport.scrollHeight - pending.scrollHeight - viewport.scrollTop = pending.scrollTop + delta + const newScrollTop = pending.scrollTop + delta + console.log('[HappyThread] Restoring scroll position:', { + oldScrollTop: pending.scrollTop, + oldScrollHeight: pending.scrollHeight, + newScrollHeight: viewport.scrollHeight, + delta, + newScrollTop + }) + viewport.scrollTop = newScrollTop pendingScrollRef.current = null loadLockRef.current = false // Re-enable autoScroll after scroll position is restored - // Use setTimeout to ensure the scroll position is applied before re-enabling - setTimeout(() => setAutoScrollEnabled(true), 0) + // Delay slightly to ensure the scroll position is applied before re-enabling + setTimeout(() => { + console.log('[HappyThread] Re-enabling autoScroll, current scrollTop:', viewport.scrollTop) + setAutoScrollEnabled(true) + }, 50) }, [props.messagesVersion]) useEffect(() => { From 48ea4e18fabcc4358f005959be9f51429e1ba7c4 Mon Sep 17 00:00:00 2001 From: tfq Date: Fri, 6 Feb 2026 10:14:03 +0800 Subject: [PATCH 3/6] fix(web): wait for DOM update before restoring scroll position The previous implementation tried to restore scroll position immediately when messagesVersion changed, but the new messages hadn't been rendered to DOM yet, causing delta to be 0 and scroll position to remain at top. Fix: Use requestAnimationFrame to wait for DOM update and check if scrollHeight has actually changed before restoring position. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../components/AssistantChat/HappyThread.tsx | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 8efbdb55f..9f49acac8 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -257,24 +257,38 @@ export function HappyThread(props: { if (!pending || !viewport) { return } - const delta = viewport.scrollHeight - pending.scrollHeight - const newScrollTop = pending.scrollTop + delta - console.log('[HappyThread] Restoring scroll position:', { - oldScrollTop: pending.scrollTop, - oldScrollHeight: pending.scrollHeight, - newScrollHeight: viewport.scrollHeight, - delta, - newScrollTop - }) - viewport.scrollTop = newScrollTop - pendingScrollRef.current = null - loadLockRef.current = false - // Re-enable autoScroll after scroll position is restored - // Delay slightly to ensure the scroll position is applied before re-enabling - setTimeout(() => { - console.log('[HappyThread] Re-enabling autoScroll, current scrollTop:', viewport.scrollTop) - setAutoScrollEnabled(true) - }, 50) + + // Wait for DOM to update before checking scroll height + const checkAndRestore = () => { + const delta = viewport.scrollHeight - pending.scrollHeight + + // If delta is 0, content hasn't been rendered yet, wait a bit + if (delta === 0 && viewport.scrollHeight === pending.scrollHeight) { + console.log('[HappyThread] Content not rendered yet, waiting...') + requestAnimationFrame(checkAndRestore) + return + } + + const newScrollTop = pending.scrollTop + delta + console.log('[HappyThread] Restoring scroll position:', { + oldScrollTop: pending.scrollTop, + oldScrollHeight: pending.scrollHeight, + newScrollHeight: viewport.scrollHeight, + delta, + newScrollTop + }) + viewport.scrollTop = newScrollTop + pendingScrollRef.current = null + loadLockRef.current = false + // Re-enable autoScroll after scroll position is restored + // Delay slightly to ensure the scroll position is applied before re-enabling + setTimeout(() => { + console.log('[HappyThread] Re-enabling autoScroll, current scrollTop:', viewport.scrollTop) + setAutoScrollEnabled(true) + }, 50) + } + + requestAnimationFrame(checkAndRestore) }, [props.messagesVersion]) useEffect(() => { From 79fc79c07d0f994851817a59294453efaed9c480 Mon Sep 17 00:00:00 2001 From: tfq Date: Fri, 6 Feb 2026 10:17:28 +0800 Subject: [PATCH 4/6] chore(web): remove debug console logs from scroll position fix Remove temporary debug logging added during development. The fix is working correctly - scroll position is now properly maintained when loading older messages. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../components/AssistantChat/HappyThread.tsx | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 9f49acac8..b35adb11f 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -184,17 +184,14 @@ export function HappyThread(props: { if (!viewport) { return } - const scrollState = { + pendingScrollRef.current = { scrollTop: viewport.scrollTop, scrollHeight: viewport.scrollHeight } - console.log('[HappyThread] Saving scroll position before load:', scrollState) - pendingScrollRef.current = scrollState loadLockRef.current = true loadStartedRef.current = false // Disable autoScroll while loading older messages to prevent assistant-ui // from scrolling to bottom when content size changes - console.log('[HappyThread] Disabling autoScroll') setAutoScrollEnabled(false) let loadPromise: Promise try { @@ -264,28 +261,15 @@ export function HappyThread(props: { // If delta is 0, content hasn't been rendered yet, wait a bit if (delta === 0 && viewport.scrollHeight === pending.scrollHeight) { - console.log('[HappyThread] Content not rendered yet, waiting...') requestAnimationFrame(checkAndRestore) return } - const newScrollTop = pending.scrollTop + delta - console.log('[HappyThread] Restoring scroll position:', { - oldScrollTop: pending.scrollTop, - oldScrollHeight: pending.scrollHeight, - newScrollHeight: viewport.scrollHeight, - delta, - newScrollTop - }) - viewport.scrollTop = newScrollTop + viewport.scrollTop = pending.scrollTop + delta pendingScrollRef.current = null loadLockRef.current = false // Re-enable autoScroll after scroll position is restored - // Delay slightly to ensure the scroll position is applied before re-enabling - setTimeout(() => { - console.log('[HappyThread] Re-enabling autoScroll, current scrollTop:', viewport.scrollTop) - setAutoScrollEnabled(true) - }, 50) + setTimeout(() => setAutoScrollEnabled(true), 50) } requestAnimationFrame(checkAndRestore) From adabc9822919b500822ffb65613ffd613dd3ebc5 Mon Sep 17 00:00:00 2001 From: tfq Date: Mon, 9 Feb 2026 17:17:03 +0800 Subject: [PATCH 5/6] fix(web): improve scroll position restoration robustness Address code review feedback: 1. Preserve autoScroll state across history loads - Save autoScrollEnabled state before disabling it - Restore previous state instead of forcing true - Prevents unwanted scroll-to-bottom when user is viewing middle content 2. Add max attempts limit to requestAnimationFrame loop - Limit to 5 attempts to prevent infinite loop - Gracefully handle cases where DOM doesn't update - Improves performance and prevents browser hang via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../components/AssistantChat/HappyThread.tsx | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index b35adb11f..b77b2d240 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -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(() => { @@ -190,8 +191,9 @@ export function HappyThread(props: { } loadLockRef.current = true loadStartedRef.current = false - // Disable autoScroll while loading older messages to prevent assistant-ui - // from scrolling to bottom when content size changes + // 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 try { @@ -199,19 +201,19 @@ export function HappyThread(props: { } catch (error) { pendingScrollRef.current = null loadLockRef.current = false - setAutoScrollEnabled(true) + setAutoScrollEnabled(prevAutoScrollEnabledRef.current) throw error } void loadPromise.catch((error) => { pendingScrollRef.current = null loadLockRef.current = false - setAutoScrollEnabled(true) + 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(true) + setAutoScrollEnabled(prevAutoScrollEnabledRef.current) } }) }, []) @@ -256,20 +258,31 @@ export function HappyThread(props: { } // 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) { - requestAnimationFrame(checkAndRestore) + if (attempts++ < MAX_ATTEMPTS) { + requestAnimationFrame(checkAndRestore) + return + } + // Max attempts reached, give up and restore autoScroll state + pendingScrollRef.current = null + loadLockRef.current = false + setAutoScrollEnabled(prevAutoScrollEnabledRef.current) return } viewport.scrollTop = pending.scrollTop + delta pendingScrollRef.current = null loadLockRef.current = false - // Re-enable autoScroll after scroll position is restored - setTimeout(() => setAutoScrollEnabled(true), 50) + // Restore previous autoScroll state instead of forcing it to true + setTimeout(() => setAutoScrollEnabled(prevAutoScrollEnabledRef.current), 50) } requestAnimationFrame(checkAndRestore) From 08f28c839b84f62381354ee7e4334907d52ea6d6 Mon Sep 17 00:00:00 2001 From: tfq Date: Mon, 9 Feb 2026 17:42:51 +0800 Subject: [PATCH 6/6] fix(web): defer autoScroll state updates to avoid DOM conflicts Wrap setAutoScrollEnabled calls in setTimeout to ensure they execute after all DOM operations complete. This prevents 'removeChild' errors that occur when React tries to reconcile while DOM is being modified. - Use setTimeout(fn, 0) for max attempts case - Use setTimeout(fn, 100) for normal case to ensure DOM stability via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- web/src/components/AssistantChat/HappyThread.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index b77b2d240..06363fc77 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -274,15 +274,17 @@ export function HappyThread(props: { // Max attempts reached, give up and restore autoScroll state pendingScrollRef.current = null loadLockRef.current = false - setAutoScrollEnabled(prevAutoScrollEnabledRef.current) + // 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 instead of forcing it to true - setTimeout(() => setAutoScrollEnabled(prevAutoScrollEnabledRef.current), 50) + // 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)