feat: scrolling behaves like chatgpt with padding (#6598)
* scroll like chatgpt with padding * minor refactor
This commit is contained in:
parent
580bdc511a
commit
b422970369
@ -18,7 +18,7 @@ const ScrollToBottom = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const appMainViewBgColor = useAppearance((state) => state.appMainViewBgColor)
|
const appMainViewBgColor = useAppearance((state) => state.appMainViewBgColor)
|
||||||
const { showScrollToBottomBtn, scrollToBottom, setIsUserScrolling } =
|
const { showScrollToBottomBtn, scrollToBottom } =
|
||||||
useThreadScrolling(threadId, scrollContainerRef)
|
useThreadScrolling(threadId, scrollContainerRef)
|
||||||
const { messages } = useMessages(
|
const { messages } = useMessages(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
@ -50,7 +50,6 @@ const ScrollToBottom = ({
|
|||||||
className="bg-main-view-fg/10 px-2 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
|
className="bg-main-view-fg/10 px-2 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
scrollToBottom(true)
|
scrollToBottom(true)
|
||||||
setIsUserScrolling(false)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-xs">{t('scrollToBottom')}</p>
|
<p className="text-xs">{t('scrollToBottom')}</p>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useAppState } from './useAppState'
|
import { useAppState } from './useAppState'
|
||||||
import { useMessages } from './useMessages'
|
import { useMessages } from './useMessages'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
|
||||||
import debounce from 'lodash.debounce'
|
const VIEWPORT_PADDING = 40 // Offset from viewport bottom for user message positioning
|
||||||
|
const MAX_DOM_RETRY_ATTEMPTS = 3 // Maximum attempts to find DOM elements before giving up
|
||||||
|
|
||||||
export const useThreadScrolling = (
|
export const useThreadScrolling = (
|
||||||
threadId: string,
|
threadId: string,
|
||||||
@ -10,18 +11,36 @@ export const useThreadScrolling = (
|
|||||||
) => {
|
) => {
|
||||||
const streamingContent = useAppState((state) => state.streamingContent)
|
const streamingContent = useAppState((state) => state.streamingContent)
|
||||||
const isFirstRender = useRef(true)
|
const isFirstRender = useRef(true)
|
||||||
const { messages } = useMessages(
|
|
||||||
useShallow((state) => ({
|
|
||||||
messages: state.messages[threadId],
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
const wasStreamingRef = useRef(false)
|
const wasStreamingRef = useRef(false)
|
||||||
const userIntendedPositionRef = useRef<number | null>(null)
|
const userIntendedPositionRef = useRef<number | null>(null)
|
||||||
const [isUserScrolling, setIsUserScrolling] = useState(false)
|
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||||
const [hasScrollbar, setHasScrollbar] = useState(false)
|
const [hasScrollbar, setHasScrollbar] = useState(false)
|
||||||
const lastScrollTopRef = useRef(0)
|
const lastScrollTopRef = useRef(0)
|
||||||
const messagesCount = useMemo(() => messages?.length ?? 0, [messages])
|
|
||||||
|
const messageCount = useMessages((state) => state.messages[threadId]?.length ?? 0)
|
||||||
|
const lastMessageRole = useMessages((state) => {
|
||||||
|
const msgs = state.messages[threadId]
|
||||||
|
return msgs && msgs.length > 0 ? msgs[msgs.length - 1].role : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const [paddingHeight, setPaddingHeightInternal] = useState(0)
|
||||||
|
const setPaddingHeight = setPaddingHeightInternal
|
||||||
|
const originalPaddingRef = useRef(0)
|
||||||
|
|
||||||
|
const getDOMElements = useCallback(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
if (!scrollContainer) return null
|
||||||
|
|
||||||
|
const userMessages = scrollContainer.querySelectorAll('[data-message-author-role="user"]')
|
||||||
|
const assistantMessages = scrollContainer.querySelectorAll('[data-message-author-role="assistant"]')
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollContainer,
|
||||||
|
lastUserMessage: userMessages[userMessages.length - 1] as HTMLElement,
|
||||||
|
lastAssistantMessage: assistantMessages[assistantMessages.length - 1] as HTMLElement,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
const showScrollToBottomBtn = !isAtBottom && hasScrollbar
|
const showScrollToBottomBtn = !isAtBottom && hasScrollbar
|
||||||
|
|
||||||
@ -32,20 +51,16 @@ export const useThreadScrolling = (
|
|||||||
...(smooth ? { behavior: 'smooth' } : {}),
|
...(smooth ? { behavior: 'smooth' } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [scrollContainerRef])
|
||||||
|
|
||||||
|
|
||||||
const handleScroll = useCallback((e: Event) => {
|
const handleScroll = useCallback((e: Event) => {
|
||||||
const target = e.target as HTMLDivElement
|
const target = e.target as HTMLDivElement
|
||||||
const { scrollTop, scrollHeight, clientHeight } = target
|
const { scrollTop, scrollHeight, clientHeight } = target
|
||||||
// Use a small tolerance to better detect when we're at the bottom
|
|
||||||
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
||||||
const hasScroll = scrollHeight > clientHeight
|
const hasScroll = scrollHeight > clientHeight
|
||||||
|
|
||||||
// Detect if this is a user-initiated scroll
|
|
||||||
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
|
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
|
||||||
setIsUserScrolling(!isBottom)
|
|
||||||
|
|
||||||
// If user scrolls during streaming and moves away from bottom, record their intended position
|
|
||||||
if (streamingContent && !isBottom) {
|
if (streamingContent && !isBottom) {
|
||||||
userIntendedPositionRef.current = scrollTop
|
userIntendedPositionRef.current = scrollTop
|
||||||
}
|
}
|
||||||
@ -76,117 +91,103 @@ export const useThreadScrolling = (
|
|||||||
setHasScrollbar(hasScroll)
|
setHasScrollbar(hasScroll)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Single useEffect for all auto-scrolling logic
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Track streaming state changes
|
if (!scrollContainerRef.current) return
|
||||||
const isCurrentlyStreaming = !!streamingContent
|
|
||||||
const justFinishedStreaming =
|
|
||||||
wasStreamingRef.current && !isCurrentlyStreaming
|
|
||||||
wasStreamingRef.current = isCurrentlyStreaming
|
|
||||||
|
|
||||||
// If streaming just finished and user had an intended position, restore it
|
|
||||||
if (justFinishedStreaming && userIntendedPositionRef.current !== null) {
|
|
||||||
// Small delay to ensure DOM has updated
|
|
||||||
setTimeout(() => {
|
|
||||||
if (
|
|
||||||
scrollContainerRef.current &&
|
|
||||||
userIntendedPositionRef.current !== null
|
|
||||||
) {
|
|
||||||
scrollContainerRef.current.scrollTo({
|
|
||||||
top: userIntendedPositionRef.current,
|
|
||||||
behavior: 'smooth',
|
|
||||||
})
|
|
||||||
userIntendedPositionRef.current = null
|
|
||||||
setIsUserScrolling(false)
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Clear intended position when streaming starts fresh
|
|
||||||
if (isCurrentlyStreaming && !wasStreamingRef.current) {
|
|
||||||
userIntendedPositionRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only auto-scroll when the user is not actively scrolling
|
|
||||||
// AND either at the bottom OR there's streaming content
|
|
||||||
if (!isUserScrolling && (streamingContent || isAtBottom) && messagesCount) {
|
|
||||||
// Use non-smooth scrolling for auto-scroll to prevent jank
|
|
||||||
scrollToBottom(false)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [streamingContent, isUserScrolling, messagesCount])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (streamingContent) {
|
|
||||||
const interval = setInterval(checkScrollState, 100)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}
|
|
||||||
}, [streamingContent, checkScrollState])
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when component mounts or thread content changes
|
|
||||||
useEffect(() => {
|
|
||||||
const scrollContainer = scrollContainerRef.current
|
|
||||||
if (!scrollContainer) return
|
|
||||||
|
|
||||||
// Always scroll to bottom on first render or when thread changes
|
|
||||||
if (isFirstRender.current) {
|
if (isFirstRender.current) {
|
||||||
isFirstRender.current = false
|
isFirstRender.current = false
|
||||||
scrollToBottom()
|
|
||||||
setIsAtBottom(true)
|
|
||||||
setIsUserScrolling(false)
|
|
||||||
userIntendedPositionRef.current = null
|
userIntendedPositionRef.current = null
|
||||||
wasStreamingRef.current = false
|
wasStreamingRef.current = false
|
||||||
|
scrollToBottom(false)
|
||||||
checkScrollState()
|
checkScrollState()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}, [checkScrollState, scrollToBottom])
|
}, [checkScrollState, scrollToBottom])
|
||||||
|
|
||||||
const handleDOMScroll = (e: Event) => {
|
|
||||||
const target = e.target as HTMLDivElement
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = target
|
|
||||||
// Use a small tolerance to better detect when we're at the bottom
|
|
||||||
const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10
|
|
||||||
const hasScroll = scrollHeight > clientHeight
|
|
||||||
|
|
||||||
// Detect if this is a user-initiated scroll
|
const prevCountRef = useRef(messageCount)
|
||||||
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
|
useEffect(() => {
|
||||||
setIsUserScrolling(!isBottom)
|
const prevCount = prevCountRef.current
|
||||||
|
const becameLonger = messageCount > prevCount
|
||||||
|
const isUserMessage = lastMessageRole === 'user'
|
||||||
|
|
||||||
// If user scrolls during streaming and moves away from bottom, record their intended position
|
if (becameLonger && messageCount > 0 && isUserMessage) {
|
||||||
if (streamingContent && !isBottom) {
|
const calculatePadding = () => {
|
||||||
userIntendedPositionRef.current = scrollTop
|
const elements = getDOMElements()
|
||||||
|
if (!elements?.lastUserMessage) return
|
||||||
|
|
||||||
|
const viewableHeight = elements.scrollContainer.clientHeight
|
||||||
|
const userMessageHeight = elements.lastUserMessage.offsetHeight
|
||||||
|
const calculatedPadding = Math.max(0, viewableHeight - VIEWPORT_PADDING - userMessageHeight)
|
||||||
|
|
||||||
|
setPaddingHeight(calculatedPadding)
|
||||||
|
originalPaddingRef.current = calculatedPadding
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
elements.scrollContainer.scrollTo({
|
||||||
|
top: elements.scrollContainer.scrollHeight,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let retryCount = 0
|
||||||
|
|
||||||
|
const tryCalculatePadding = () => {
|
||||||
|
if (getDOMElements()?.lastUserMessage) {
|
||||||
|
calculatePadding()
|
||||||
|
} else if (retryCount < MAX_DOM_RETRY_ATTEMPTS) {
|
||||||
|
retryCount++
|
||||||
|
requestAnimationFrame(tryCalculatePadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(tryCalculatePadding)
|
||||||
}
|
}
|
||||||
setIsAtBottom(isBottom)
|
|
||||||
setHasScrollbar(hasScroll)
|
prevCountRef.current = messageCount
|
||||||
lastScrollTopRef.current = scrollTop
|
}, [messageCount, lastMessageRole])
|
||||||
}
|
|
||||||
// Use a shorter debounce time for more responsive scrolling
|
|
||||||
const debouncedScroll = debounce(handleDOMScroll)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const chatHistoryElement = scrollContainerRef.current
|
const previouslyStreaming = wasStreamingRef.current
|
||||||
if (chatHistoryElement) {
|
const currentlyStreaming = !!streamingContent && streamingContent.thread_id === threadId
|
||||||
chatHistoryElement.addEventListener('scroll', debouncedScroll)
|
|
||||||
return () =>
|
const streamingEnded = previouslyStreaming && !currentlyStreaming
|
||||||
chatHistoryElement.removeEventListener('scroll', debouncedScroll)
|
const hasPaddingToAdjust = originalPaddingRef.current > 0
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
if (streamingEnded && hasPaddingToAdjust) {
|
||||||
}, [])
|
requestAnimationFrame(() => {
|
||||||
|
const elements = getDOMElements()
|
||||||
|
if (!elements?.lastAssistantMessage || !elements?.lastUserMessage) return
|
||||||
|
|
||||||
|
const userRect = elements.lastUserMessage.getBoundingClientRect()
|
||||||
|
const assistantRect = elements.lastAssistantMessage.getBoundingClientRect()
|
||||||
|
const actualSpacing = assistantRect.top - userRect.bottom
|
||||||
|
const totalAssistantHeight = elements.lastAssistantMessage.offsetHeight + actualSpacing
|
||||||
|
const newPadding = Math.max(0, originalPaddingRef.current - totalAssistantHeight)
|
||||||
|
|
||||||
|
setPaddingHeight(newPadding)
|
||||||
|
originalPaddingRef.current = newPadding
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wasStreamingRef.current = currentlyStreaming
|
||||||
|
}, [streamingContent, threadId])
|
||||||
|
|
||||||
// Reset scroll state when thread changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isFirstRender.current = true
|
|
||||||
scrollToBottom()
|
|
||||||
setIsAtBottom(true)
|
|
||||||
setIsUserScrolling(false)
|
|
||||||
userIntendedPositionRef.current = null
|
userIntendedPositionRef.current = null
|
||||||
wasStreamingRef.current = false
|
wasStreamingRef.current = false
|
||||||
|
setPaddingHeight(0)
|
||||||
|
originalPaddingRef.current = 0
|
||||||
|
prevCountRef.current = messageCount
|
||||||
|
scrollToBottom(false)
|
||||||
checkScrollState()
|
checkScrollState()
|
||||||
}, [threadId, checkScrollState, scrollToBottom])
|
}, [threadId])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({ showScrollToBottomBtn, scrollToBottom, setIsUserScrolling }),
|
() => ({
|
||||||
[showScrollToBottomBtn, scrollToBottom, setIsUserScrolling]
|
showScrollToBottomBtn,
|
||||||
|
scrollToBottom,
|
||||||
|
paddingHeight
|
||||||
|
}),
|
||||||
|
[showScrollToBottomBtn, scrollToBottom, paddingHeight]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { PlatformFeatures } from '@/lib/platform/const'
|
|||||||
import { PlatformFeature } from '@/lib/platform/types'
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
import ScrollToBottom from '@/containers/ScrollToBottom'
|
import ScrollToBottom from '@/containers/ScrollToBottom'
|
||||||
import { PromptProgress } from '@/components/PromptProgress'
|
import { PromptProgress } from '@/components/PromptProgress'
|
||||||
|
import { useThreadScrolling } from '@/hooks/useThreadScrolling'
|
||||||
|
|
||||||
// as route.threadsDetail
|
// as route.threadsDetail
|
||||||
export const Route = createFileRoute('/threads/$threadId')({
|
export const Route = createFileRoute('/threads/$threadId')({
|
||||||
@ -48,6 +49,9 @@ function ThreadDetail() {
|
|||||||
const thread = useThreads(useShallow((state) => state.threads[threadId]))
|
const thread = useThreads(useShallow((state) => state.threads[threadId]))
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Get padding height for ChatGPT-style message positioning
|
||||||
|
const { paddingHeight } = useThreadScrolling(threadId, scrollContainerRef)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentThreadId(threadId)
|
setCurrentThreadId(threadId)
|
||||||
const assistant = assistants.find(
|
const assistant = assistants.find(
|
||||||
@ -186,6 +190,12 @@ function ThreadDetail() {
|
|||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
data-test-id="thread-content-text"
|
data-test-id="thread-content-text"
|
||||||
/>
|
/>
|
||||||
|
{/* Persistent padding element for ChatGPT-style message positioning */}
|
||||||
|
<div
|
||||||
|
style={{ height: paddingHeight }}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
data-testid="chat-padding"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user