agent-ecosystem/src/renderer/hooks/useAutoScrollBottom.ts
matt e0e399ec31 refactor(ProjectScanner): enhance file detail retrieval and session metadata handling
- Updated the ProjectScanner class to utilize a new asynchronous method for resolving file details, improving accuracy in file metadata retrieval.
- Introduced birthtimeMs to session file information, ensuring comprehensive metadata is available for session management.
- Adjusted session creation logic to account for the new birthtimeMs, enhancing the integrity of session timestamps.
- Improved the handling of auto-scroll behavior in ChatHistory and useAutoScrollBottom hook, allowing for smoother user experience during content updates.

This commit enhances the ProjectScanner's efficiency and improves user experience in chat history management.
2026-02-13 14:31:11 +09:00

270 lines
7.8 KiB
TypeScript

import { useCallback, useEffect, useRef } from 'react';
/**
* Options for the auto-scroll hook.
*/
interface UseAutoScrollBottomOptions {
/**
* Threshold in pixels from bottom to consider "at bottom".
* Default: 100px (generous threshold for better UX)
*/
threshold?: number;
/**
* Smooth scroll duration in milliseconds.
* Default: 300ms
*/
smoothDuration?: number;
/**
* Whether auto-scroll is enabled.
* Default: true
*/
enabled?: boolean;
/**
* Scroll behavior used for automatic follow when content updates.
* Default: 'smooth'
*/
autoBehavior?: ScrollBehavior;
/**
* Whether auto-scroll is temporarily disabled (e.g., during navigation).
* Unlike enabled, this is for transient disabling during specific operations.
* Default: false
*/
disabled?: boolean;
/**
* Optional external scroll container ref. If provided, the hook will use this
* ref instead of creating its own. Useful when the ref needs to be shared
* with other hooks (e.g., navigation coordinator).
*/
externalRef?: React.RefObject<HTMLDivElement>;
/**
* When this value changes, reset isAtBottom state to true.
* Use for tab/session changes to ensure new content scrolls to bottom.
*/
resetKey?: string | null;
}
/**
* Return type for the auto-scroll hook.
*/
interface UseAutoScrollBottomReturn {
/**
* Ref to attach to the scroll container element.
*/
scrollContainerRef: React.RefObject<HTMLDivElement>;
/**
* Get whether the user is currently at the bottom of the scroll container.
* Returns a function to avoid accessing ref.current during render.
*/
getIsAtBottom: () => boolean;
/**
* Manually scroll to bottom with smooth animation.
*/
scrollToBottom: (behavior?: ScrollBehavior) => void;
/**
* Check and update the isAtBottom state.
* Call this after content changes if needed.
*/
checkIsAtBottom: () => boolean;
}
export function isNearBottom(
scrollTop: number,
scrollHeight: number,
clientHeight: number,
threshold: number
): boolean {
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
return distanceFromBottom <= threshold;
}
/**
* Custom hook for managing auto-scroll-to-bottom behavior in chat-like interfaces.
*
* Features:
* - Tracks whether user is at the bottom of the scroll container
* - Automatically scrolls to bottom when content changes (if user was at bottom)
* - Smooth scrolling animation
* - Respects user's scroll position (doesn't force scroll if user scrolled up)
*
* @param dependencies - Array of dependencies that trigger scroll check (e.g., conversation items)
* @param options - Configuration options
* @returns Scroll management utilities
*
* @example
* ```tsx
* const { scrollContainerRef, isAtBottom, scrollToBottom } = useAutoScrollBottom(
* [conversation?.items.length],
* { threshold: 100 }
* );
*
* return (
* <div ref={scrollContainerRef} className="overflow-y-auto">
* {items.map(renderItem)}
* </div>
* );
* ```
*/
export function useAutoScrollBottom(
dependencies: unknown[],
options: UseAutoScrollBottomOptions = {}
): UseAutoScrollBottomReturn {
const {
threshold = 100,
smoothDuration = 300,
enabled = true,
autoBehavior = 'smooth',
disabled = false,
externalRef,
resetKey,
} = options;
// Use external ref if provided, otherwise create our own
const internalRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = externalRef ?? internalRef;
const isAtBottomRef = useRef(true); // Start assuming at bottom
const wasAtBottomBeforeUpdateRef = useRef(true);
const isScrollingRef = useRef(false);
// Track disabled state in ref for checking inside RAF callbacks
const disabledRef = useRef(disabled);
// Track resetKey to detect changes
const prevResetKeyRef = useRef(resetKey);
/**
* Check if the scroll container is at the bottom.
*/
const checkIsAtBottom = useCallback((): boolean => {
const container = scrollContainerRef.current;
if (!container) return true;
const { scrollTop, scrollHeight, clientHeight } = container;
return isNearBottom(scrollTop, scrollHeight, clientHeight, threshold);
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders
}, [threshold]);
/**
* Scroll to bottom with smooth animation.
*/
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
const container = scrollContainerRef.current;
if (!container) return;
// Prevent scroll event handler from updating isAtBottom during programmatic scroll
isScrollingRef.current = true;
const targetScrollTop = container.scrollHeight - container.clientHeight;
if (behavior === 'smooth') {
// Use native smooth scrolling
container.scrollTo({
top: targetScrollTop,
behavior: 'smooth',
});
// Reset flag after animation completes
setTimeout(() => {
isScrollingRef.current = false;
isAtBottomRef.current = true;
}, smoothDuration);
} else {
container.scrollTop = targetScrollTop;
isScrollingRef.current = false;
isAtBottomRef.current = true;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders
[smoothDuration]
);
/**
* Handle scroll events to track isAtBottom state.
*/
const handleScroll = useCallback(() => {
// Ignore scroll events during programmatic scrolling
if (isScrollingRef.current) return;
isAtBottomRef.current = checkIsAtBottom();
}, [checkIsAtBottom]);
/**
* Set up scroll event listener.
*/
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
container.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container.removeEventListener('scroll', handleScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders
}, [handleScroll]);
/**
* Before content updates, remember if we were at bottom.
*/
useEffect(() => {
wasAtBottomBeforeUpdateRef.current = isAtBottomRef.current;
});
// Keep disabledRef in sync with disabled prop
useEffect(() => {
disabledRef.current = disabled;
}, [disabled]);
// Reset isAtBottom state when resetKey changes (e.g., tab/session switch)
// This ensures new content will auto-scroll to bottom
useEffect(() => {
if (resetKey !== prevResetKeyRef.current) {
isAtBottomRef.current = true;
wasAtBottomBeforeUpdateRef.current = true;
prevResetKeyRef.current = resetKey;
}
}, [resetKey]);
/**
* After content updates (dependencies change), scroll to bottom if we were at bottom.
*/
useEffect(() => {
// Skip if disabled (e.g., during navigation) or not enabled
if (!enabled || disabled) return;
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
// Re-check disabled state inside RAF - it might have changed between effect and callback
// This prevents auto-scroll from firing if navigation started after the effect ran
if (disabledRef.current) return;
// Only auto-scroll if user was at bottom before the update
if (wasAtBottomBeforeUpdateRef.current) {
scrollToBottom(autoBehavior);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design
}, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]);
/**
* Getter function for isAtBottom to avoid accessing ref.current during render.
*/
const getIsAtBottom = useCallback((): boolean => {
return isAtBottomRef.current;
}, []);
return {
scrollContainerRef,
getIsAtBottom,
scrollToBottom,
checkIsAtBottom,
};
}