Add chat scroll-to-bottom button

This commit is contained in:
SiYue-ZO 2026-04-19 15:02:54 +08:00 committed by SiYue
parent 52ec17eee5
commit cf5563c3ad
2 changed files with 49 additions and 7 deletions

View file

@ -1,4 +1,4 @@
import { IconPlus } from "@tabler/icons-react" import { IconArrowDown, IconPlus } from "@tabler/icons-react"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { type ChangeEvent, useRef, useState } from "react" import { type ChangeEvent, useRef, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@ -178,11 +178,17 @@ export function ChatPage() {
const hasStreamingMessage = const hasStreamingMessage =
streamingEnabled && streamingEnabled &&
messages.some((message) => message.role === "assistant" && message.streaming) messages.some((message) => message.role === "assistant" && message.streaming)
const { scrollRef, hasScrolled, handleScroll, handleManualScrollIntent } = const {
useChatAutoScroll({ scrollRef,
deps: [messages, isTyping], isAtBottom,
streaming: hasStreamingMessage, hasScrolled,
}) handleScroll,
handleManualScrollIntent,
scrollToBottom,
} = useChatAutoScroll({
deps: [messages, isTyping],
streaming: hasStreamingMessage,
})
const handleSend = () => { const handleSend = () => {
if ((!input.trim() && attachments.length === 0) || !canInput) return if ((!input.trim() && attachments.length === 0) || !canInput) return
@ -259,7 +265,7 @@ export function ChatPage() {
canInput && (Boolean(input.trim()) || attachments.length > 0) canInput && (Boolean(input.trim()) || attachments.length > 0)
return ( return (
<div className="bg-background/95 flex h-full flex-col"> <div className="bg-background/95 relative flex h-full flex-col">
<PageHeader <PageHeader
title={t("navigation.chat")} title={t("navigation.chat")}
className={`transition-shadow ${ className={`transition-shadow ${
@ -374,6 +380,18 @@ export function ChatPage() {
onChange={handleImageSelection} onChange={handleImageSelection}
/> />
{!isAtBottom && messages.length > 0 && (
<Button
type="button"
size="icon"
className="absolute right-6 bottom-28 z-20 rounded-full shadow-lg md:right-8"
aria-label="Scroll to bottom"
onClick={() => scrollToBottom()}
>
<IconArrowDown className="size-4" />
</Button>
)}
<ChatComposer <ChatComposer
input={input} input={input}
attachments={attachments} attachments={attachments}

View file

@ -137,6 +137,29 @@ export function useChatAutoScroll({
cancelAnimation() cancelAnimation()
}, [cancelAnimation]) }, [cancelAnimation])
const scrollToBottom = useCallback(
(options?: { immediate?: boolean }) => {
const element = scrollRef.current
if (!element) {
return
}
stickyRef.current = true
setIsAtBottom(true)
targetScrollTopRef.current = getBottomScrollTop(element)
if (options?.immediate) {
cancelAnimation()
element.scrollTop = targetScrollTopRef.current
syncStateFromElement(element, { programmatic: true })
return
}
animateToBottom()
},
[animateToBottom, cancelAnimation, syncStateFromElement],
)
useLayoutEffect(() => { useLayoutEffect(() => {
const element = scrollRef.current const element = scrollRef.current
if (!element) { if (!element) {
@ -164,5 +187,6 @@ export function useChatAutoScroll({
hasScrolled, hasScrolled,
handleScroll, handleScroll,
handleManualScrollIntent, handleManualScrollIntent,
scrollToBottom,
} }
} }