Refactor chat auto-scroll behavior

This commit is contained in:
SiYue-ZO 2026-04-19 14:55:03 +08:00 committed by SiYue
parent d380f0b0ba
commit 52ec17eee5
2 changed files with 180 additions and 23 deletions

View file

@ -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<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
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<HTMLDivElement>) => {
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() {
<div
ref={scrollRef}
onScroll={handleScroll}
onWheelCapture={handleManualScrollIntent}
onTouchStart={handleManualScrollIntent}
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] md:px-8 lg:px-24 xl:px-48"
>
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">

View file

@ -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<HTMLDivElement>(null)
const stickyRef = useRef(true)
const targetScrollTopRef = useRef(0)
const animationFrameRef = useRef<number | null>(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<HTMLDivElement>) => {
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,
}
}