From d07bdaacdb24cd2c6dcd91bed5f010327931532f Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 9 Apr 2026 20:09:43 -0500 Subject: [PATCH] fix: preserve scroll position when streaming finishes Only re-enable auto-scroll when a user message is added (new prompt), not when an assistant message is finalized. Previously, the completion of streaming would snap the view to the bottom even if the user had scrolled up to read earlier content. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Logan Nguyen --- src/view/ConversationView.tsx | 13 +++-- src/view/__tests__/ConversationView.test.tsx | 56 ++++++++++++++++++-- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index 4a761a1..d2e0a76 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -118,15 +118,20 @@ export function ConversationView({ }, []); /** - * Re-enable auto-scroll whenever a new message is added. Sending a message - * is an explicit "I want to see the response" action. + * Re-enable auto-scroll only when the user sends a new message. + * Sending a message is an explicit "I want to see the response" action. + * When an assistant message is finalized (streaming completes), we preserve + * the current scroll lock state so the user can keep reading where they are. */ useEffect(() => { if (messages.length > prevMessagesLengthRef.current) { - shouldAutoScrollRef.current = true; + const newest = messages[messages.length - 1]; + if (newest?.role === 'user') { + shouldAutoScrollRef.current = true; + } } prevMessagesLengthRef.current = messages.length; - }, [messages.length]); + }, [messages.length, messages]); /** * Auto-scroll the chat container to the bottom when new content arrives, diff --git a/src/view/__tests__/ConversationView.test.tsx b/src/view/__tests__/ConversationView.test.tsx index 13daf87..d914232 100644 --- a/src/view/__tests__/ConversationView.test.tsx +++ b/src/view/__tests__/ConversationView.test.tsx @@ -134,7 +134,7 @@ describe('ConversationView', () => { expect(scrollEl.scrollTop).toBe(0); }); - it('auto-scroll re-enables when a new message is added', () => { + it('auto-scroll re-enables when a new user message is added', () => { const { container, rerender } = render( { scrollEl.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 })); }); - // Add a new message — this should re-enable auto-scroll because + // Add a new user message — this should re-enable auto-scroll because // sending a message is an explicit "I want to see the response" action act(() => { rerender( @@ -178,7 +178,57 @@ describe('ConversationView', () => { // Auto-scroll should have re-engaged (scrollTop set via rAF in test env // may not fire, but the branch is exercised — the key assertion is that - // adding a message doesn't leave auto-scroll disabled) + // adding a user message doesn't leave auto-scroll disabled) + }); + + it('auto-scroll stays disabled when assistant message is finalized', () => { + const { container, rerender } = render( + , + ); + + const scrollEl = container.querySelector( + '.chat-messages-scroll', + ) as HTMLElement; + expect(scrollEl).not.toBeNull(); + + Object.defineProperty(scrollEl, 'scrollTop', { + value: 0, + configurable: true, + writable: true, + }); + + // User scrolls up during streaming — disables auto-scroll + act(() => { + scrollEl.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 })); + }); + + // Streaming finishes: assistant message is committed to the messages array + act(() => { + rerender( + , + ); + }); + + // scrollTop should remain 0: auto-scroll was NOT re-enabled by the + // assistant message, so the user can keep reading where they scrolled + expect(scrollEl.scrollTop).toBe(0); }); it('auto-scroll re-enables when user scrolls back to bottom via wheel', async () => {