diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index 35b3a2833..3e063a168 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -38,7 +38,7 @@ export const RightPanel = () => { const [activeTab, setActiveTab] = useState<'chat' | 'processes'>('chat'); const textareaRef = useRef(null); // Keep streamed replies pinned unless the user intentionally scrolls away from the bottom. - const { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom } = useAutoScroll( + const { scrollContainerRef, messagesContainerRef, isAtBottom, scrollToBottom } = useAutoScroll( chatMessages, isChatLoading, ); @@ -314,7 +314,7 @@ export const RightPanel = () => { ) : ( -
+
{chatMessages.map((message) => (
{/* User message - compact label style */} @@ -390,14 +390,12 @@ export const RightPanel = () => { ))}
)} - {/* Scroll anchor */} -
{/* Scroll to bottom */} {/* Input */} diff --git a/gitnexus-web/src/hooks/useAutoScroll.ts b/gitnexus-web/src/hooks/useAutoScroll.ts index 075152b9e..58a0aedcc 100644 --- a/gitnexus-web/src/hooks/useAutoScroll.ts +++ b/gitnexus-web/src/hooks/useAutoScroll.ts @@ -1,45 +1,71 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -const BOTTOM_THRESHOLD = 100; +const DEFAULT_BOTTOM_THRESHOLD = 100; +const USER_SCROLL_EPSILON = 5; export interface UseAutoScrollResult { scrollContainerRef: React.RefObject; - messagesEndRef: React.RefObject; + messagesContainerRef: React.RefObject; isAtBottom: boolean; - scrollToBottom: () => void; + scrollToBottom: (behavior?: ScrollBehavior) => void; } -function isNearBottom(element: HTMLDivElement): boolean { - return element.scrollHeight - element.scrollTop - element.clientHeight <= BOTTOM_THRESHOLD; +function isNearBottom(element: HTMLElement, threshold: number): boolean { + return element.scrollHeight - element.scrollTop - element.clientHeight <= threshold; } -export function useAutoScroll( - chatMessages: unknown[], +export function useAutoScroll( + chatMessages: T[], isChatLoading: boolean, + bottomThreshold = DEFAULT_BOTTOM_THRESHOLD, ): UseAutoScrollResult { const scrollContainerRef = useRef(null); - const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); + const shouldStickToBottomRef = useRef(true); const lastScrollTopRef = useRef(0); - const frameIdRef = useRef(null); + const scrollFrameIdRef = useRef(null); const syncScrollState = useCallback(() => { const element = scrollContainerRef.current; if (!element) return; - const nearBottom = isNearBottom(element); + const currentScrollTop = element.scrollTop; + const nearBottom = isNearBottom(element, bottomThreshold); if (nearBottom) { shouldStickToBottomRef.current = true; - } else if (element.scrollTop < lastScrollTopRef.current) { + } else if (currentScrollTop < lastScrollTopRef.current - USER_SCROLL_EPSILON) { shouldStickToBottomRef.current = false; } - lastScrollTopRef.current = element.scrollTop; + lastScrollTopRef.current = currentScrollTop; setIsAtBottom(nearBottom); - }, []); + }, [bottomThreshold]); + + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = 'smooth') => { + const element = scrollContainerRef.current; + if (!element) return; + + shouldStickToBottomRef.current = true; + + if (behavior === 'auto') { + element.scrollTop = element.scrollHeight; + lastScrollTopRef.current = element.scrollTop; + setIsAtBottom(isNearBottom(element, bottomThreshold)); + return; + } + + element.scrollTo({ + top: element.scrollHeight, + behavior, + }); + }, + [bottomThreshold], + ); useEffect(() => { const element = scrollContainerRef.current; @@ -48,12 +74,12 @@ export function useAutoScroll( lastScrollTopRef.current = element.scrollTop; const handleScroll = () => { - if (frameIdRef.current !== null) { - cancelAnimationFrame(frameIdRef.current); + if (scrollFrameIdRef.current !== null) { + cancelAnimationFrame(scrollFrameIdRef.current); } - frameIdRef.current = requestAnimationFrame(() => { - frameIdRef.current = null; + scrollFrameIdRef.current = requestAnimationFrame(() => { + scrollFrameIdRef.current = null; syncScrollState(); }); }; @@ -63,32 +89,57 @@ export function useAutoScroll( return () => { element.removeEventListener('scroll', handleScroll); - if (frameIdRef.current !== null) { - cancelAnimationFrame(frameIdRef.current); - frameIdRef.current = null; + + if (scrollFrameIdRef.current !== null) { + cancelAnimationFrame(scrollFrameIdRef.current); + scrollFrameIdRef.current = null; } }; }, [syncScrollState]); - const jumpToBottom = useCallback(() => { - const element = scrollContainerRef.current; - if (!element) return; + useEffect(() => { + const content = messagesContainerRef.current; + const scrollEl = scrollContainerRef.current; + if (!content || !scrollEl || typeof ResizeObserver === 'undefined') return; - element.scrollTop = element.scrollHeight; - lastScrollTopRef.current = element.scrollTop; - }, []); + let resizeFrameId: number | null = null; + + const observer = new ResizeObserver(() => { + if (shouldStickToBottomRef.current) { + if (resizeFrameId !== null) { + cancelAnimationFrame(resizeFrameId); + } + + resizeFrameId = requestAnimationFrame(() => { + resizeFrameId = null; + scrollToBottom('auto'); + }); + } else { + syncScrollState(); + } + }); + + observer.observe(content); + + return () => { + observer.disconnect(); + + if (resizeFrameId !== null) { + cancelAnimationFrame(resizeFrameId); + resizeFrameId = null; + } + }; + }, [chatMessages.length, scrollToBottom, syncScrollState]); useLayoutEffect(() => { if (!shouldStickToBottomRef.current) return; - jumpToBottom(); - setIsAtBottom(true); - }, [chatMessages, isChatLoading, jumpToBottom]); + scrollToBottom('auto'); + }, [chatMessages.length, isChatLoading, scrollToBottom]); - const scrollToBottom = useCallback(() => { - shouldStickToBottomRef.current = true; - setIsAtBottom(true); - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); - }, []); - - return { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom }; -} + return { + scrollContainerRef, + messagesContainerRef, + isAtBottom, + scrollToBottom, + }; +} \ No newline at end of file diff --git a/gitnexus-web/test/unit/use-auto-scroll.test.tsx b/gitnexus-web/test/unit/use-auto-scroll.test.tsx index 6d68dd823..a8bc1a795 100644 --- a/gitnexus-web/test/unit/use-auto-scroll.test.tsx +++ b/gitnexus-web/test/unit/use-auto-scroll.test.tsx @@ -8,7 +8,7 @@ interface HarnessProps { } function AutoScrollHarness({ messages, isChatLoading }: HarnessProps) { - const { scrollContainerRef, messagesEndRef, isAtBottom, scrollToBottom } = useAutoScroll( + const { scrollContainerRef, messagesContainerRef, isAtBottom, scrollToBottom } = useAutoScroll( messages, isChatLoading, ); @@ -17,9 +17,15 @@ function AutoScrollHarness({ messages, isChatLoading }: HarnessProps) { <>
{String(isAtBottom)}
-
+ {messages.length > 0 ? ( +
+ {messages.map((message, index) => ( +
{String(message)}
+ ))} +
+ ) : null}
- @@ -65,11 +71,34 @@ async function scrollContainer(element: HTMLDivElement, scrollTop: number) { await flushAnimationFrame(); } -describe('useAutoScroll', () => { - const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; +const resizeObserverInstances: ResizeObserverMock[] = []; +class ResizeObserverMock { + callback: ResizeObserverCallback; + observedElements: Element[] = []; + observe = vi.fn((element: Element) => { + this.observedElements.push(element); + }); + unobserve = vi.fn(); + disconnect = vi.fn(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + resizeObserverInstances.push(this); + } +} + +async function triggerResize(instance: ResizeObserverMock) { + await act(async () => { + instance.callback([], instance as unknown as ResizeObserver); + }); + await flushAnimationFrame(); +} + +describe('useAutoScroll', () => { beforeEach(() => { vi.useFakeTimers(); + resizeObserverInstances.length = 0; vi.stubGlobal( 'requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { @@ -82,30 +111,45 @@ describe('useAutoScroll', () => { clearTimeout(frameId); }), ); - Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, - value: vi.fn(), + value: function (options: ScrollToOptions) { + if (options.top !== undefined) { + Object.defineProperty(this, 'scrollTop', { + configurable: true, + writable: true, + value: options.top, + }); + } + }, }); + vi.stubGlobal('ResizeObserver', ResizeObserverMock); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); - Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { - configurable: true, - value: originalScrollIntoView, - }); + }); + + it('starts with isAtBottom true and auto-scrolls the very first message', () => { + const { rerender } = render(); + + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + + const container = screen.getByTestId('container') as HTMLDivElement; + setScrollMetrics(container, { scrollTop: 0, scrollHeight: 500, clientHeight: 200 }); + + rerender(); + + expect(container.scrollTop).toBe(500); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); }); it('follows streaming updates while the view stays pinned to the bottom', () => { const { rerender } = render(); const container = screen.getByTestId('container') as HTMLDivElement; - setScrollMetrics(container, { - scrollTop: 700, - scrollHeight: 1000, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); rerender(); @@ -117,22 +161,13 @@ describe('useAutoScroll', () => { const { rerender } = render(); const container = screen.getByTestId('container') as HTMLDivElement; - setScrollMetrics(container, { - scrollTop: 700, - scrollHeight: 1000, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); await scrollContainer(container, 700); - await scrollContainer(container, 250); expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false'); - setScrollMetrics(container, { - scrollTop: 250, - scrollHeight: 1400, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 }); rerender(); expect(container.scrollTop).toBe(250); @@ -142,58 +177,113 @@ describe('useAutoScroll', () => { const { rerender } = render(); const container = screen.getByTestId('container') as HTMLDivElement; - setScrollMetrics(container, { - scrollTop: 700, - scrollHeight: 1000, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); await scrollContainer(container, 700); await scrollContainer(container, 250); - setScrollMetrics(container, { - scrollTop: 1120, - scrollHeight: 1400, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1400, clientHeight: 200 }); await scrollContainer(container, 1120); expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); - setScrollMetrics(container, { - scrollTop: 1120, - scrollHeight: 1800, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1800, clientHeight: 200 }); rerender(); expect(container.scrollTop).toBe(1800); }); - it('scrollToBottom re-engages auto-scroll and uses the sentinel element', async () => { + it('scrollToBottom re-engages auto-scroll and scrolls to the container bottom', async () => { const { rerender } = render(); const container = screen.getByTestId('container') as HTMLDivElement; - const scrollIntoView = vi.mocked(HTMLElement.prototype.scrollIntoView); + const scrollTo = vi.spyOn(container, 'scrollTo'); - setScrollMetrics(container, { - scrollTop: 700, - scrollHeight: 1000, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); await scrollContainer(container, 700); await scrollContainer(container, 250); fireEvent.click(screen.getByRole('button', { name: 'Scroll to bottom' })); - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' }); - expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' }); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false'); - setScrollMetrics(container, { - scrollTop: 250, - scrollHeight: 1600, - clientHeight: 200, - }); + setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1600, clientHeight: 200 }); rerender(); expect(container.scrollTop).toBe(1600); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + }); + + it('re-pins to the latest bottom when inner content grows asynchronously', async () => { + render(); + const container = screen.getByTestId('container') as HTMLDivElement; + + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); + await scrollContainer(container, 700); + + setScrollMetrics(container, { scrollTop: 1000, scrollHeight: 1450, clientHeight: 200 }); + await triggerResize(resizeObserverInstances[0]); + + expect(container.scrollTop).toBe(1450); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true'); + }); + + it('does not auto-scroll on async growth after user intentionally scrolls away', async () => { + render(); + const container = screen.getByTestId('container') as HTMLDivElement; + + setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 }); + await scrollContainer(container, 700); + await scrollContainer(container, 250); + + setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 }); + await triggerResize(resizeObserverInstances[0]); + + expect(container.scrollTop).toBe(250); + expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false'); + }); + + it('cancels the pending ResizeObserver rAF when the component unmounts', () => { + const cancelRAF = vi.mocked(cancelAnimationFrame); + + const { unmount } = render(); + const container = screen.getByTestId('container') as HTMLDivElement; + + setScrollMetrics(container, { scrollTop: 950, scrollHeight: 1000, clientHeight: 200 }); + + const callsBefore = cancelRAF.mock.calls.length; + + act(() => { + resizeObserverInstances[0].callback( + [], + resizeObserverInstances[0] as unknown as ResizeObserver, + ); + }); + + unmount(); + + expect(cancelRAF.mock.calls.length).toBeGreaterThan(callsBefore); + + expect(() => vi.runAllTimers()).not.toThrow(); + }); + + it('attaches the observer when the messages wrapper first appears and disconnects on unmount', () => { + const { rerender, unmount } = render( + , + ); + + expect(screen.queryByTestId('messages-container')).toBeNull(); + expect(resizeObserverInstances).toHaveLength(0); + + rerender(); + + const messagesContainer = screen.getByTestId('messages-container'); + const resizeObserver = resizeObserverInstances[0]; + + expect(resizeObserverInstances).toHaveLength(1); + expect(resizeObserver.observe).toHaveBeenCalledWith(messagesContainer); + + unmount(); + + expect(resizeObserver.disconnect).toHaveBeenCalledTimes(1); }); });