feat(ui): add auto-scroll to bottom for chat messages

- Add useAutoScroll hook using ResizeObserver + MutationObserver
- Observes content children for size changes (streaming, images)
- Watches for new DOM nodes (new messages, history load)
- Respects user scroll position: no force-scroll when reading above
- Integrate in Chat component alongside existing useScrollFade

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-02 16:14:18 +08:00
parent 4d6d57187c
commit a1c28b3d04
2 changed files with 66 additions and 0 deletions

View file

@ -11,6 +11,7 @@ import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-fre
import { toast } from "@multica/ui/components/ui/sonner";
import { useHubStore, useDeviceId, useMessagesStore, useGatewayStore } from "@multica/store";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { cn } from "@multica/ui/lib/utils";
@ -54,6 +55,7 @@ export function Chat() {
const mainRef = useRef<HTMLElement>(null)
const fadeStyle = useScrollFade(mainRef)
useAutoScroll(mainRef)
return (
<div className="h-dvh flex flex-col overflow-hidden w-full">

View file

@ -0,0 +1,64 @@
import { type RefObject, useEffect, useRef } from "react"
/**
* Auto-scrolls a scroll container to the bottom when its inner content grows,
* as long as the user hasn't scrolled up to read older content.
*
* Observes child element size changes via ResizeObserver on all children,
* plus MutationObserver for added/removed nodes. Works for new messages,
* history loads, streaming updates, and image loads.
*/
export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
const stickRef = useRef(true)
useEffect(() => {
const el = ref.current
if (!el) return
const scrollToBottom = () => {
el.scrollTo({ top: el.scrollHeight })
}
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
stickRef.current = scrollHeight - scrollTop - clientHeight < 50
}
const onContentChange = () => {
if (stickRef.current) {
scrollToBottom()
}
}
// Watch child element resizes (content growth, image loads, streaming)
const ro = new ResizeObserver(onContentChange)
for (const child of el.children) {
ro.observe(child)
}
// Watch for added/removed child nodes (new messages rendered)
const mo = new MutationObserver((mutations) => {
// Also observe newly added elements
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof Element) {
ro.observe(node)
}
}
}
onContentChange()
})
mo.observe(el, { childList: true, subtree: true })
el.addEventListener("scroll", onScroll, { passive: true })
// Initial scroll to bottom
scrollToBottom()
return () => {
el.removeEventListener("scroll", onScroll)
ro.disconnect()
mo.disconnect()
}
}, [ref])
}