From a1c28b3d043151d7197ee18621cff536e575387c Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:14:18 +0800 Subject: [PATCH] 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 --- packages/ui/src/components/chat.tsx | 2 + packages/ui/src/hooks/use-auto-scroll.ts | 64 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 packages/ui/src/hooks/use-auto-scroll.ts diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx index f7fa8910..53476365 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -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(null) const fadeStyle = useScrollFade(mainRef) + useAutoScroll(mainRef) return (
diff --git a/packages/ui/src/hooks/use-auto-scroll.ts b/packages/ui/src/hooks/use-auto-scroll.ts new file mode 100644 index 00000000..8090631b --- /dev/null +++ b/packages/ui/src/hooks/use-auto-scroll.ts @@ -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) { + 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]) +}