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 () => {