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:
parent
4d6d57187c
commit
a1c28b3d04
2 changed files with 66 additions and 0 deletions
|
|
@ -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">
|
||||
|
|
|
|||
64
packages/ui/src/hooks/use-auto-scroll.ts
Normal file
64
packages/ui/src/hooks/use-auto-scroll.ts
Normal 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])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue