scroll sticky can exit + fix re render issue
This commit is contained in:
parent
b2825ac1f6
commit
ea520b433b
@ -40,14 +40,25 @@ export const useMessages = create<MessageState>()((set, get) => ({
|
|||||||
assistant: selectedAssistant,
|
assistant: selectedAssistant,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
messages: {
|
||||||
|
...state.messages,
|
||||||
|
[message.thread_id]: [
|
||||||
|
...(state.messages[message.thread_id] || []),
|
||||||
|
newMessage,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
getServiceHub().messages().createMessage(newMessage).then((createdMessage) => {
|
getServiceHub().messages().createMessage(newMessage).then((createdMessage) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: {
|
messages: {
|
||||||
...state.messages,
|
...state.messages,
|
||||||
[message.thread_id]: [
|
[message.thread_id]:
|
||||||
...(state.messages[message.thread_id] || []),
|
state.messages[message.thread_id]?.map((existing) =>
|
||||||
createdMessage,
|
existing.id === newMessage.id ? createdMessage : existing
|
||||||
],
|
) ?? [createdMessage],
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@ -20,13 +20,16 @@ export const useThreadScrolling = (
|
|||||||
const [hasScrollbar, setHasScrollbar] = useState(false)
|
const [hasScrollbar, setHasScrollbar] = useState(false)
|
||||||
const lastScrollTopRef = useRef(0)
|
const lastScrollTopRef = useRef(0)
|
||||||
const lastAssistantMessageRef = useRef<HTMLElement | null>(null)
|
const lastAssistantMessageRef = useRef<HTMLElement | null>(null)
|
||||||
|
const userForcedUnfollowRef = useRef(false)
|
||||||
|
const stickyStreamingActiveRef = useRef(false)
|
||||||
|
const stickyReleaseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const wasAtBottomRef = useRef(true)
|
||||||
const threadScrollBehavior = useInterfaceSettings(
|
const threadScrollBehavior = useInterfaceSettings(
|
||||||
(state) => state.threadScrollBehavior
|
(state) => state.threadScrollBehavior
|
||||||
)
|
)
|
||||||
const isFlowScroll = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.FLOW
|
const isFlowScroll = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.FLOW
|
||||||
const isStickyScroll = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.STICKY
|
const isStickyScroll = threadScrollBehavior === THREAD_SCROLL_BEHAVIOR.STICKY
|
||||||
const [isStickyScrollLocked, setIsStickyScrollLocked] = useState(false)
|
const [isStickyScrollFollowing, setIsStickyScrollFollowing] = useState(true)
|
||||||
const stickyScrollStreamingRef = useRef(false)
|
|
||||||
|
|
||||||
const messageCount = useMessages((state) => state.messages[threadId]?.length ?? 0)
|
const messageCount = useMessages((state) => state.messages[threadId]?.length ?? 0)
|
||||||
const lastMessageRole = useMessages((state) => {
|
const lastMessageRole = useMessages((state) => {
|
||||||
@ -53,33 +56,91 @@ export const useThreadScrolling = (
|
|||||||
|
|
||||||
|
|
||||||
const showScrollToBottomBtn =
|
const showScrollToBottomBtn =
|
||||||
!isAtBottom && hasScrollbar && !(isStickyScroll && isStickyScrollLocked)
|
!isAtBottom && hasScrollbar && (!isStickyScroll || !isStickyScrollFollowing)
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = false) => {
|
const clearStickyReleaseTimeout = useCallback(() => {
|
||||||
if (scrollContainerRef.current) {
|
if (stickyReleaseTimeoutRef.current) {
|
||||||
scrollContainerRef.current.scrollTo({
|
clearTimeout(stickyReleaseTimeoutRef.current)
|
||||||
top: scrollContainerRef.current.scrollHeight,
|
stickyReleaseTimeoutRef.current = null
|
||||||
...(smooth ? { behavior: 'smooth' } : {}),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [scrollContainerRef])
|
}, [])
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(
|
||||||
|
(smooth = false) => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTo({
|
||||||
|
top: scrollContainerRef.current.scrollHeight,
|
||||||
|
...(smooth ? { behavior: 'smooth' } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
const handleScroll = useCallback((e: Event) => {
|
if (isStickyScroll) {
|
||||||
const target = e.target as HTMLDivElement
|
clearStickyReleaseTimeout()
|
||||||
const { scrollTop, scrollHeight, clientHeight } = target
|
userForcedUnfollowRef.current = false
|
||||||
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
setIsStickyScrollFollowing(true)
|
||||||
const hasScroll = scrollHeight > clientHeight
|
}
|
||||||
|
|
||||||
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
|
|
||||||
if (streamingContent && !isBottom) {
|
|
||||||
userIntendedPositionRef.current = scrollTop
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
setIsAtBottom(isBottom)
|
[clearStickyReleaseTimeout, isStickyScroll, scrollContainerRef]
|
||||||
setHasScrollbar(hasScroll)
|
)
|
||||||
lastScrollTopRef.current = scrollTop
|
|
||||||
}, [streamingContent])
|
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
(e: Event) => {
|
||||||
|
const target = e.target as HTMLDivElement
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = target
|
||||||
|
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
||||||
|
const hasScroll = scrollHeight > clientHeight
|
||||||
|
const previousScrollTop = lastScrollTopRef.current
|
||||||
|
const delta = scrollTop - previousScrollTop
|
||||||
|
const wasAtBottom = wasAtBottomRef.current
|
||||||
|
|
||||||
|
if (Math.abs(delta) > 10) {
|
||||||
|
if (streamingContent && !isBottom) {
|
||||||
|
userIntendedPositionRef.current = scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStickyScroll) {
|
||||||
|
if (!isBottom && delta < 0) {
|
||||||
|
clearStickyReleaseTimeout()
|
||||||
|
userForcedUnfollowRef.current = true
|
||||||
|
setIsStickyScrollFollowing((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStickyScroll) {
|
||||||
|
if (!isBottom && delta < -1) {
|
||||||
|
clearStickyReleaseTimeout()
|
||||||
|
userForcedUnfollowRef.current = true
|
||||||
|
setIsStickyScrollFollowing((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else if (isBottom && (!wasAtBottom || !isStickyScrollFollowing)) {
|
||||||
|
clearStickyReleaseTimeout()
|
||||||
|
userForcedUnfollowRef.current = false
|
||||||
|
setIsStickyScrollFollowing((prev) => {
|
||||||
|
if (prev) return prev
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAtBottom(isBottom)
|
||||||
|
setHasScrollbar(hasScroll)
|
||||||
|
lastScrollTopRef.current = scrollTop
|
||||||
|
wasAtBottomRef.current = isBottom
|
||||||
|
},
|
||||||
|
[
|
||||||
|
clearStickyReleaseTimeout,
|
||||||
|
isStickyScroll,
|
||||||
|
isStickyScrollFollowing,
|
||||||
|
streamingContent,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollContainer = scrollContainerRef.current
|
const scrollContainer = scrollContainerRef.current
|
||||||
@ -113,43 +174,6 @@ export const useThreadScrolling = (
|
|||||||
}
|
}
|
||||||
}, [checkScrollState, scrollToBottom])
|
}, [checkScrollState, scrollToBottom])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isStickyScroll) {
|
|
||||||
if (stickyScrollStreamingRef.current) {
|
|
||||||
stickyScrollStreamingRef.current = false
|
|
||||||
}
|
|
||||||
if (isStickyScrollLocked) {
|
|
||||||
setIsStickyScrollLocked(false)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCurrentThreadStreaming =
|
|
||||||
!!streamingContent && streamingContent.thread_id === threadId
|
|
||||||
|
|
||||||
if (isCurrentThreadStreaming && !stickyScrollStreamingRef.current) {
|
|
||||||
stickyScrollStreamingRef.current = true
|
|
||||||
setIsStickyScrollLocked(true)
|
|
||||||
} else if (!isCurrentThreadStreaming && stickyScrollStreamingRef.current) {
|
|
||||||
stickyScrollStreamingRef.current = false
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
scrollToBottom(false)
|
|
||||||
checkScrollState()
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsStickyScrollLocked(false)
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
checkScrollState,
|
|
||||||
isStickyScroll,
|
|
||||||
isStickyScrollLocked,
|
|
||||||
scrollToBottom,
|
|
||||||
streamingContent,
|
|
||||||
threadId,
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
const prevCountRef = useRef(messageCount)
|
const prevCountRef = useRef(messageCount)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFlowScroll) {
|
if (!isFlowScroll) {
|
||||||
@ -260,23 +284,67 @@ export const useThreadScrolling = (
|
|||||||
originalPaddingRef.current = 0
|
originalPaddingRef.current = 0
|
||||||
}, [isFlowScroll])
|
}, [isFlowScroll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStickyScroll) {
|
||||||
|
stickyStreamingActiveRef.current = false
|
||||||
|
clearStickyReleaseTimeout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentThreadStreaming =
|
||||||
|
!!streamingContent && streamingContent.thread_id === threadId
|
||||||
|
|
||||||
|
if (isCurrentThreadStreaming) {
|
||||||
|
if (!stickyStreamingActiveRef.current) {
|
||||||
|
stickyStreamingActiveRef.current = true
|
||||||
|
}
|
||||||
|
clearStickyReleaseTimeout()
|
||||||
|
if (!userForcedUnfollowRef.current) {
|
||||||
|
setIsStickyScrollFollowing((prev) => {
|
||||||
|
if (prev) return prev
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (stickyStreamingActiveRef.current) {
|
||||||
|
stickyStreamingActiveRef.current = false
|
||||||
|
clearStickyReleaseTimeout()
|
||||||
|
if (isStickyScrollFollowing) {
|
||||||
|
stickyReleaseTimeoutRef.current = setTimeout(() => {
|
||||||
|
stickyReleaseTimeoutRef.current = null
|
||||||
|
setIsStickyScrollFollowing((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clearStickyReleaseTimeout,
|
||||||
|
isStickyScroll,
|
||||||
|
isStickyScrollFollowing,
|
||||||
|
streamingContent,
|
||||||
|
threadId,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isStickyScroll) return
|
if (!isStickyScroll) return
|
||||||
|
setIsStickyScrollFollowing(true)
|
||||||
scrollToBottom(false)
|
scrollToBottom(false)
|
||||||
}, [isStickyScroll, scrollToBottom, threadId])
|
}, [isStickyScroll, scrollToBottom, threadId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isStickyScroll) return
|
if (!isStickyScroll) return
|
||||||
if (isStickyScrollLocked) return
|
if (!isStickyScrollFollowing) return
|
||||||
if (messageCount === 0) return
|
if (messageCount === 0) return
|
||||||
scrollToBottom(true)
|
scrollToBottom(true)
|
||||||
}, [isStickyScroll, isStickyScrollLocked, messageCount, scrollToBottom])
|
}, [isStickyScroll, isStickyScrollFollowing, messageCount, scrollToBottom])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isStickyScroll) return
|
if (!isStickyScroll) return
|
||||||
|
if (!isStickyScrollFollowing) return
|
||||||
if (streamingContent?.thread_id !== threadId) return
|
if (streamingContent?.thread_id !== threadId) return
|
||||||
scrollToBottom(false)
|
scrollToBottom(false)
|
||||||
}, [isStickyScroll, scrollToBottom, streamingContent, threadId])
|
}, [isStickyScroll, isStickyScrollFollowing, scrollToBottom, streamingContent, threadId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
userIntendedPositionRef.current = null
|
userIntendedPositionRef.current = null
|
||||||
@ -286,12 +354,22 @@ export const useThreadScrolling = (
|
|||||||
prevCountRef.current = messageCount
|
prevCountRef.current = messageCount
|
||||||
scrollToBottom(false)
|
scrollToBottom(false)
|
||||||
checkScrollState()
|
checkScrollState()
|
||||||
stickyScrollStreamingRef.current = false
|
setIsStickyScrollFollowing(true)
|
||||||
setIsStickyScrollLocked(false)
|
clearStickyReleaseTimeout()
|
||||||
|
stickyStreamingActiveRef.current = false
|
||||||
|
userForcedUnfollowRef.current = false
|
||||||
|
wasAtBottomRef.current = true
|
||||||
// Only reset when switching threads; keep deps limited intentionally.
|
// Only reset when switching threads; keep deps limited intentionally.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [threadId])
|
}, [threadId])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clearStickyReleaseTimeout()
|
||||||
|
},
|
||||||
|
[clearStickyReleaseTimeout]
|
||||||
|
)
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
showScrollToBottomBtn,
|
showScrollToBottomBtn,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user