Refactor chat auto-scroll behavior
This commit is contained in:
parent
d380f0b0ba
commit
52ec17eee5
2 changed files with 180 additions and 23 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { IconPlus } from "@tabler/icons-react"
|
import { IconPlus } from "@tabler/icons-react"
|
||||||
import { useAtom } from "jotai"
|
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 { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { UserMessage } from "@/components/chat/user-message"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { useChatAutoScroll } from "@/hooks/use-chat-auto-scroll"
|
||||||
import { useChatModels } from "@/hooks/use-chat-models"
|
import { useChatModels } from "@/hooks/use-chat-models"
|
||||||
import { useGateway } from "@/hooks/use-gateway"
|
import { useGateway } from "@/hooks/use-gateway"
|
||||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||||
|
|
@ -108,10 +109,7 @@ function resolveChatInputDisabledReason({
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
|
||||||
const [hasScrolled, setHasScrolled] = useState(false)
|
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
||||||
const [showAssistantDetails, setShowAssistantDetails] = useAtom(
|
const [showAssistantDetails, setShowAssistantDetails] = useAtom(
|
||||||
|
|
@ -177,25 +175,14 @@ export function ChatPage() {
|
||||||
(appConfig?.agents as { defaults?: { streaming_enabled?: boolean } })
|
(appConfig?.agents as { defaults?: { streaming_enabled?: boolean } })
|
||||||
?.defaults?.streaming_enabled
|
?.defaults?.streaming_enabled
|
||||||
) !== false
|
) !== false
|
||||||
|
const hasStreamingMessage =
|
||||||
const syncScrollState = (element: HTMLDivElement) => {
|
streamingEnabled &&
|
||||||
const { clientHeight, scrollHeight, scrollTop } = element
|
messages.some((message) => message.role === "assistant" && message.streaming)
|
||||||
setHasScrolled(scrollTop > 0)
|
const { scrollRef, hasScrolled, handleScroll, handleManualScrollIntent } =
|
||||||
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
|
useChatAutoScroll({
|
||||||
}
|
deps: [messages, isTyping],
|
||||||
|
streaming: hasStreamingMessage,
|
||||||
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 handleSend = () => {
|
const handleSend = () => {
|
||||||
if ((!input.trim() && attachments.length === 0) || !canInput) return
|
if ((!input.trim() && attachments.length === 0) || !canInput) return
|
||||||
|
|
@ -332,6 +319,8 @@ export function ChatPage() {
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={handleScroll}
|
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"
|
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">
|
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
|
||||||
|
|
|
||||||
168
web/frontend/src/hooks/use-chat-auto-scroll.ts
Normal file
168
web/frontend/src/hooks/use-chat-auto-scroll.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue