diff --git a/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx b/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx new file mode 100644 index 0000000..765f02c --- /dev/null +++ b/src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, test } from 'node:test'; + +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { useRef, useState } from 'react'; + +import { useChatFollowScroll } from './useChatFollowScroll'; + +type ObserverEntry = { callback: ResizeObserverCallback; target: Element | null }; + +const observers: ObserverEntry[] = []; +const NativeResizeObserver = globalThis.ResizeObserver; + +class TestResizeObserver { + private readonly entry: ObserverEntry; + + constructor(callback: ResizeObserverCallback) { + this.entry = { callback, target: null }; + observers.push(this.entry); + } + + observe(target: Element) { + this.entry.target = target; + } + + unobserve(target: Element) { + if (this.entry.target === target) this.entry.target = null; + } + + disconnect() { + this.entry.target = null; + } +} + +globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; + +function notifyResize(target: Element) { + for (const observer of observers) { + if (observer.target === target) observer.callback([], {} as ResizeObserver); + } +} + +function Harness() { + const [height, setHeight] = useState(200); + const containerRef = useRef(null); + const follow = useChatFollowScroll({ scrollContainerRef: containerRef, enabled: true }); + + return ( + <> +
{ + containerRef.current = node; + if (!node) return; + Object.defineProperties(node, { + clientHeight: { configurable: true, get: () => 100 }, + scrollHeight: { configurable: true, get: () => height }, + }); + }} + data-testid="container" + style={{ height: 100, overflowY: 'auto' }} + > +
+
+ + + {String(follow.isFollowing)} + + ); +} + +function setup() { + const view = render(); + const container = view.getByTestId('container') as HTMLDivElement; + const content = view.getByTestId('content'); + const grow = () => { + act(() => { + fireEvent.click(view.getByText('grow')); + notifyResize(content); + }); + }; + return { ...view, container, content, grow }; +} + +function assertAtBottom(node: HTMLDivElement) { + assert.ok(node.scrollHeight - node.scrollTop - node.clientHeight < 50); +} + +afterEach(() => { + cleanup(); + observers.length = 0; +}); + +test('follows content growth initially', () => { + const { container, grow } = setup(); + grow(); + assertAtBottom(container); +}); + +test('wheel-up intent stops following future growth', () => { + const { container, grow } = setup(); + container.scrollTop = 30; + fireEvent.wheel(container, { deltaY: -100 }); + grow(); + assert.equal(container.scrollTop, 30); +}); + +test('scrolling to the bottom resumes following', () => { + const { container, grow } = setup(); + fireEvent.wheel(container, { deltaY: -100 }); + container.scrollTop = container.scrollHeight - container.clientHeight; + fireEvent.scroll(container); + grow(); + assertAtBottom(container); +}); + +test('scrollToBottom resumes following', () => { + const { container, getByText, grow } = setup(); + fireEvent.wheel(container, { deltaY: -100 }); + fireEvent.click(getByText('bottom')); + grow(); + assertAtBottom(container); +}); + +test('a passive scroll away from the bottom does not stop following', () => { + const { container, grow } = setup(); + container.scrollTop = 20; + fireEvent.scroll(container); + grow(); + assertAtBottom(container); +}); + +after(() => { + globalThis.ResizeObserver = NativeResizeObserver; +}); diff --git a/src/components/chat/hooks/useChatFollowScroll.ts b/src/components/chat/hooks/useChatFollowScroll.ts new file mode 100644 index 0000000..ee33a61 --- /dev/null +++ b/src/components/chat/hooks/useChatFollowScroll.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { RefObject, SetStateAction } from 'react'; + +const BOTTOM_THRESHOLD = 50; + +type UseChatFollowScrollArgs = { + scrollContainerRef: RefObject; + enabled: boolean; +}; + +function isNearBottom(node: HTMLDivElement) { + return node.scrollHeight - node.scrollTop - node.clientHeight < BOTTOM_THRESHOLD; +} + +export function useChatFollowScroll({ scrollContainerRef, enabled }: UseChatFollowScrollArgs) { + const [isFollowing, setIsFollowing] = useState(true); + const isFollowingRef = useRef(true); + const enabledRef = useRef(enabled); + enabledRef.current = enabled; + + const setFollowing = useCallback((value: SetStateAction) => { + const following = typeof value === 'function' ? value(isFollowingRef.current) : value; + isFollowingRef.current = following; + setIsFollowing(following); + }, []); + const follow = useCallback(() => setFollowing(true), [setFollowing]); + const scrollToBottom = useCallback(() => { + const node = scrollContainerRef.current; + if (node) node.scrollTop = node.scrollHeight; + setFollowing(true); + }, [scrollContainerRef, setFollowing]); + const handleScroll = useCallback(() => { + const node = scrollContainerRef.current; + if (node && isNearBottom(node)) follow(); + }, [follow, scrollContainerRef]); + + useEffect(() => { + const node = scrollContainerRef.current; + if (!node) return; + + const stopFollowing = () => setFollowing(false); + const onWheel = (event: WheelEvent) => { + if (event.deltaY < 0) stopFollowing(); + }; + let touchStartY: number | null = null; + const onTouchStart = (event: TouchEvent) => { + touchStartY = event.touches[0]?.clientY ?? null; + }; + const onTouchMove = (event: TouchEvent) => { + const touchY = event.touches[0]?.clientY; + if (touchStartY !== null && touchY !== undefined && touchY > touchStartY) stopFollowing(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (document.activeElement === node && ['ArrowUp', 'PageUp', 'Home'].includes(event.key)) stopFollowing(); + }; + + node.addEventListener('scroll', handleScroll, { passive: true }); + node.addEventListener('wheel', onWheel, { passive: true }); + node.addEventListener('touchstart', onTouchStart, { passive: true }); + node.addEventListener('touchmove', onTouchMove, { passive: true }); + node.addEventListener('keydown', onKeyDown); + return () => { + node.removeEventListener('scroll', handleScroll); + node.removeEventListener('wheel', onWheel); + node.removeEventListener('touchstart', onTouchStart); + node.removeEventListener('touchmove', onTouchMove); + node.removeEventListener('keydown', onKeyDown); + }; + }, [handleScroll, scrollContainerRef, setFollowing]); + + useEffect(() => { + const node = scrollContainerRef.current; + if (!node || typeof ResizeObserver === 'undefined') return; + let content = node.firstElementChild; + const observer = new ResizeObserver(() => { + if (isFollowingRef.current && enabledRef.current) node.scrollTop = node.scrollHeight; + }); + if (content) observer.observe(content); + const frame = requestAnimationFrame(() => { + const nextContent = node.firstElementChild; + if (nextContent && nextContent !== content) { + if (content) observer.unobserve(content); + content = nextContent; + observer.observe(content); + } + }); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [enabled, scrollContainerRef]); + + return { isFollowing, setFollowing, follow, scrollToBottom, handleScroll }; +} diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 6092cdf..2261ca7 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import type { MutableRefObject } from 'react'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { authenticatedFetch } from '../../../utils/api'; import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection'; @@ -9,6 +9,7 @@ import type { ChatMessage } from '../types/types'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; import { normalizedToChatMessages } from './useChatMessages'; +import { useChatFollowScroll } from './useChatFollowScroll'; const PAGE_SIZE = 20; const FIRST_VISIBLE_COUNT = 100; @@ -94,7 +95,6 @@ export function useChatSessionState({ const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(false); const [totalMessages, setTotalMessages] = useState(0); - const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const [hasNewMessagesBelow, setHasNewMessagesBelow] = useState(false); const [tokenBudget, setTokenBudget] = useState | null>(null); const [sessionState, setSessionState] = useState | null>(null); @@ -127,7 +127,6 @@ export function useChatSessionState({ const topRequestLockRef = useRef(false); const nearTopRef = useRef(false); const restoreScrollRef = useRef(null); - const scrollBeforeRenderRef = useRef({ height: 0, top: 0 }); const ignoredOffsetRef = useRef(0); const overlayTimerRef = useRef | null>(null); const finishedTimerRef = useRef | null>(null); @@ -213,10 +212,19 @@ export function useChatSessionState({ if (activeSessionRef.current) sessionStore.clearRealtime(activeSessionRef.current); }, [sessionStore]); const rewindMessages = useCallback((count: number) => setViewHiddenCount(count), []); - const scrollToBottom = useCallback(() => { - const node = scrollContainerRef.current; - if (node) node.scrollTop = node.scrollHeight; - }, []); + const followScrollEnabled = !isLoadingMoreMessages && !restoreScrollRef.current && !searchInProgressRef.current; + const { + isFollowing, + setFollowing, + scrollToBottom, + } = useChatFollowScroll({ scrollContainerRef, enabled: followScrollEnabled }); + const isUserScrolledUp = !isFollowing; + const setIsUserScrolledUp = useCallback>>((value) => { + setFollowing(previous => { + const scrolledUp = typeof value === 'function' ? value(!previous) : value; + return !scrolledUp; + }); + }, [setFollowing]); const isNearBottom = useCallback(() => { const node = scrollContainerRef.current; return Boolean(node && node.scrollHeight - node.scrollTop - node.clientHeight < 50); @@ -270,7 +278,6 @@ export function useChatSessionState({ const node = scrollContainerRef.current; if (!node) return; const bottom = isNearBottom(); - setIsUserScrolledUp(!bottom); if (bottom) setHasNewMessagesBelow(false); const atTop = node.scrollTop < 100; if (atTop && hasMoreMessages && !loadedAllRef.current) { @@ -315,7 +322,7 @@ export function useChatSessionState({ restoreScrollRef.current = null; nearTopRef.current = false; setIsUserScrolledUp(false); - }, [selectedProject?.projectId, selectedSession?.id]); + }, [selectedProject?.projectId, selectedSession?.id, setIsUserScrolledUp]); useEffect(() => { if (!initialScrollRef.current || isLoadingSessionMessages || !scrollContainerRef.current) return; @@ -492,22 +499,6 @@ export function useChatSessionState({ const visibleMessages = useMemo(() => chatMessages.length <= visibleMessageCount ? chatMessages : chatMessages.slice(-visibleMessageCount), [chatMessages, visibleMessageCount]); - useEffect(() => { - const node = scrollContainerRef.current; - if (node) scrollBeforeRenderRef.current = { height: node.scrollHeight, top: node.scrollTop }; - }); - useEffect(() => { - const node = scrollContainerRef.current; - if (!node || !chatMessages.length || loadingMoreRef.current || isLoadingMoreMessages || restoreScrollRef.current || searchInProgressRef.current) return; - if (!isUserScrolledUp) { - setTimeout(scrollToBottom, 50); - return; - } - const previous = scrollBeforeRenderRef.current; - const difference = node.scrollHeight - previous.height; - if (difference > 0 && previous.top > 0) node.scrollTop = previous.top + difference; - }, [chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]); - const lastMessage = chatMessages[chatMessages.length - 1]; const finalMessageSize = typeof lastMessage?.content === 'string' ? lastMessage.content.length : 0; useEffect(() => { @@ -515,9 +506,8 @@ export function useChatSessionState({ const advanced = chatMessages.length > before.count || finalMessageSize > before.size; priorContentRef.current = { count: chatMessages.length, size: finalMessageSize }; if (!chatMessages.length || !advanced || loadingMoreRef.current || isLoadingMoreMessages || restoreScrollRef.current || searchInProgressRef.current) return; - if (isUserScrolledUp) setHasNewMessagesBelow(true); - else scrollToBottom(); - }, [chatMessages.length, finalMessageSize, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]); + if (!isFollowing) setHasNewMessagesBelow(true); + }, [chatMessages.length, finalMessageSize, isFollowing, isLoadingMoreMessages]); useEffect(() => { setHasNewMessagesBelow(false); priorContentRef.current = { count: 0, size: 0 };