Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/components/chat/hooks/useChatFollowScroll.dom.bun.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const follow = useChatFollowScroll({ scrollContainerRef: containerRef, enabled: true });

return (
<>
<div
ref={(node) => {
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' }}
>
<div data-testid="content" style={{ height }} />
</div>
<button onClick={() => setHeight((value) => value + 100)} type="button">grow</button>
<button onClick={follow.scrollToBottom} type="button">bottom</button>
<output data-testid="following">{String(follow.isFollowing)}</output>
</>
);
}

function setup() {
const view = render(<Harness />);
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;
});
94 changes: 94 additions & 0 deletions src/components/chat/hooks/useChatFollowScroll.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>;
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<boolean>) => {
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 };
}
46 changes: 18 additions & 28 deletions src/components/chat/hooks/useChatSessionState.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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<Record<string, unknown> | null>(null);
const [sessionState, setSessionState] = useState<Record<string, unknown> | null>(null);
Expand Down Expand Up @@ -127,7 +127,6 @@ export function useChatSessionState({
const topRequestLockRef = useRef(false);
const nearTopRef = useRef(false);
const restoreScrollRef = useRef<ScrollSnapshot | null>(null);
const scrollBeforeRenderRef = useRef<ScrollSnapshot>({ height: 0, top: 0 });
const ignoredOffsetRef = useRef(0);
const overlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const finishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -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<Dispatch<SetStateAction<boolean>>>((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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -492,32 +499,15 @@ 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(() => {
const before = priorContentRef.current;
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 };
Expand Down