mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
Closes #3183. Tabs render under `<Activity mode="visible|hidden">`, which keeps React state but drops DOM scrollTop when the subtree leaves layout. Switching to another tab and back sent users to the top of long discussions. `useTabScrollRestore` records the scrollTop of every element marked with `data-tab-scroll-root` while the tab is visible (capture-phase scroll listener) and restores them in a useLayoutEffect on the next visible transition, before paint. Saved offsets are dropped when the tab's path changes so intra-tab navigation lands at scroll=0 instead of inheriting the previous route's position. Mark scroll containers in views with `data-tab-scroll-root` (issue detail + chat message list ship with the marker; other views can adopt the convention as needed). `useAutoScroll` previously called `scrollToBottom()` on every effect mount, which would have overwritten the restored offset every time a chat tab cycled back to visible. Guard it with a once-per-instance ref. Co-authored-by: multica-agent <github@multica.ai>
81 lines
2.4 KiB
TypeScript
81 lines
2.4 KiB
TypeScript
import { type RefObject, useEffect, useRef, useCallback } 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.
|
|
*
|
|
* Returns a `lockRef` that can be set to `true` to temporarily suppress
|
|
* auto-scroll (e.g. during history prepend operations).
|
|
*/
|
|
export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
|
|
const stickRef = useRef(true)
|
|
const lockRef = useRef(false)
|
|
// Re-running the initial scroll-to-bottom on every effect mount would
|
|
// overwrite the scroll position any time React tears the effect down and
|
|
// brings it back — e.g. when the host tab cycles through `<Activity
|
|
// mode="hidden">` and back. We want the jump only on a real first mount.
|
|
const didInitialScrollRef = useRef(false)
|
|
|
|
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 (lockRef.current) return
|
|
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 })
|
|
|
|
if (!didInitialScrollRef.current) {
|
|
didInitialScrollRef.current = true
|
|
scrollToBottom()
|
|
}
|
|
|
|
return () => {
|
|
el.removeEventListener("scroll", onScroll)
|
|
ro.disconnect()
|
|
mo.disconnect()
|
|
}
|
|
}, [ref])
|
|
|
|
/** Temporarily suppress auto-scroll during prepend operations */
|
|
const suppressAutoScroll = useCallback(() => {
|
|
lockRef.current = true
|
|
return () => { lockRef.current = false }
|
|
}, [])
|
|
|
|
return { suppressAutoScroll }
|
|
}
|