diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 21f381fa7..a75e3e260 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -1,6 +1,6 @@ import { IconPlus } from "@tabler/icons-react" import { useAtom } from "jotai" -import { type ChangeEvent, useEffect, useRef, useState } from "react" +import { type ChangeEvent, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -18,6 +18,7 @@ import { UserMessage } from "@/components/chat/user-message" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" +import { useChatAutoScroll } from "@/hooks/use-chat-auto-scroll" import { useChatModels } from "@/hooks/use-chat-models" import { useGateway } from "@/hooks/use-gateway" import { usePicoChat } from "@/hooks/use-pico-chat" @@ -108,10 +109,7 @@ function resolveChatInputDisabledReason({ export function ChatPage() { const { t } = useTranslation() - const scrollRef = useRef(null) const fileInputRef = useRef(null) - const [isAtBottom, setIsAtBottom] = useState(true) - const [hasScrolled, setHasScrolled] = useState(false) const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) const [showAssistantDetails, setShowAssistantDetails] = useAtom( @@ -177,25 +175,14 @@ export function ChatPage() { (appConfig?.agents as { defaults?: { streaming_enabled?: boolean } }) ?.defaults?.streaming_enabled ) !== false - - const syncScrollState = (element: HTMLDivElement) => { - const { clientHeight, scrollHeight, scrollTop } = element - setHasScrolled(scrollTop > 0) - setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) - } - - const handleScroll = (e: React.UIEvent) => { - syncScrollState(e.currentTarget) - } - - useEffect(() => { - if (scrollRef.current) { - if (isAtBottom) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight - } - syncScrollState(scrollRef.current) - } - }, [messages, isTyping, isAtBottom]) + const hasStreamingMessage = + streamingEnabled && + messages.some((message) => message.role === "assistant" && message.streaming) + const { scrollRef, hasScrolled, handleScroll, handleManualScrollIntent } = + useChatAutoScroll({ + deps: [messages, isTyping], + streaming: hasStreamingMessage, + }) const handleSend = () => { if ((!input.trim() && attachments.length === 0) || !canInput) return @@ -332,6 +319,8 @@ export function ChatPage() {
diff --git a/web/frontend/src/hooks/use-chat-auto-scroll.ts b/web/frontend/src/hooks/use-chat-auto-scroll.ts new file mode 100644 index 000000000..884d98004 --- /dev/null +++ b/web/frontend/src/hooks/use-chat-auto-scroll.ts @@ -0,0 +1,168 @@ +import { + type UIEvent, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react" + +const BOTTOM_FOLLOW_THRESHOLD = 32 + +function getBottomDistance(element: HTMLDivElement) { + return element.scrollHeight - element.scrollTop - element.clientHeight +} + +function getBottomScrollTop(element: HTMLDivElement) { + return Math.max(0, element.scrollHeight - element.clientHeight) +} + +function nextScrollStep(distance: number, streaming: boolean) { + const magnitude = Math.abs(distance) + const factor = streaming ? 0.34 : 0.22 + const min = streaming ? 12 : 10 + const max = streaming ? 72 : 56 + return Math.sign(distance) * Math.min(max, Math.max(min, magnitude * factor)) +} + +interface UseChatAutoScrollOptions { + deps: readonly unknown[] + streaming: boolean +} + +export function useChatAutoScroll({ + deps, + streaming, +}: UseChatAutoScrollOptions) { + const scrollRef = useRef(null) + const stickyRef = useRef(true) + const targetScrollTopRef = useRef(0) + const animationFrameRef = useRef(null) + const programmaticScrollRef = useRef(false) + + const [isAtBottom, setIsAtBottom] = useState(true) + const [hasScrolled, setHasScrolled] = useState(false) + + const cancelAnimation = useCallback(() => { + if (animationFrameRef.current !== null) { + window.cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = null + } + programmaticScrollRef.current = false + }, []) + + const syncStateFromElement = useCallback( + (element: HTMLDivElement, options?: { programmatic?: boolean }) => { + const distanceToBottom = getBottomDistance(element) + setHasScrolled(element.scrollTop > 0) + + if (options?.programmatic && stickyRef.current) { + setIsAtBottom(true) + return + } + + const nextIsAtBottom = distanceToBottom <= BOTTOM_FOLLOW_THRESHOLD + stickyRef.current = nextIsAtBottom + setIsAtBottom(nextIsAtBottom) + if (!nextIsAtBottom) { + cancelAnimation() + } + }, + [cancelAnimation], + ) + + const animateToBottom = useCallback(() => { + const element = scrollRef.current + if (!element || !stickyRef.current) { + return + } + + targetScrollTopRef.current = getBottomScrollTop(element) + + if (animationFrameRef.current !== null) { + return + } + + const step = () => { + const currentElement = scrollRef.current + if (!currentElement || !stickyRef.current) { + cancelAnimation() + return + } + + const target = Math.max( + targetScrollTopRef.current, + getBottomScrollTop(currentElement), + ) + targetScrollTopRef.current = target + + const distance = target - currentElement.scrollTop + if (Math.abs(distance) <= 1) { + currentElement.scrollTop = target + animationFrameRef.current = null + programmaticScrollRef.current = false + syncStateFromElement(currentElement, { programmatic: true }) + return + } + + programmaticScrollRef.current = true + currentElement.scrollTop += nextScrollStep(distance, streaming) + syncStateFromElement(currentElement, { programmatic: true }) + animationFrameRef.current = window.requestAnimationFrame(step) + } + + animationFrameRef.current = window.requestAnimationFrame(step) + }, [cancelAnimation, streaming, syncStateFromElement]) + + const handleScroll = useCallback( + (event: UIEvent) => { + const element = event.currentTarget + const isProgrammatic = programmaticScrollRef.current + if (!isProgrammatic) { + const nextIsAtBottom = + getBottomDistance(element) <= BOTTOM_FOLLOW_THRESHOLD + stickyRef.current = nextIsAtBottom + } + syncStateFromElement(element, { programmatic: isProgrammatic }) + }, + [syncStateFromElement], + ) + + const handleManualScrollIntent = useCallback(() => { + if (!stickyRef.current) { + return + } + stickyRef.current = false + setIsAtBottom(false) + cancelAnimation() + }, [cancelAnimation]) + + useLayoutEffect(() => { + const element = scrollRef.current + if (!element) { + return + } + + if (!hasScrolled && element.scrollHeight > 0) { + element.scrollTop = getBottomScrollTop(element) + stickyRef.current = true + setIsAtBottom(true) + setHasScrolled(element.scrollTop > 0) + return + } + + if (stickyRef.current) { + animateToBottom() + } + }, [animateToBottom, hasScrolled, ...deps]) + + useEffect(() => cancelAnimation, [cancelAnimation]) + + return { + scrollRef, + isAtBottom, + hasScrolled, + handleScroll, + handleManualScrollIntent, + } +}