refactor(web): move message loading header into HappyThread with infinite scroll

Move the message loading header UI and infinite scroll logic from SessionChat into HappyThread component. This improves component isolation and adds IntersectionObserver-based automatic loading with proper scroll position preservation and error handling.
This commit is contained in:
weishu
2025-12-22 21:45:34 +08:00
parent 999ee64852
commit aef3ed367b
3 changed files with 151 additions and 60 deletions
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { ThreadPrimitive } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
@@ -6,6 +6,7 @@ import { HappyChatProvider } from '@/components/AssistantChat/context'
import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage'
import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage'
import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMessage'
import { Button } from '@/components/ui/button'
const THREAD_MESSAGE_COMPONENTS = {
UserMessage: HappyUserMessage,
@@ -20,8 +21,108 @@ export function HappyThread(props: {
disabled: boolean
onRefresh: () => void
onRetryMessage?: (localId: string) => void
header?: ReactNode
isLoadingMessages: boolean
messagesWarning: string | null
hasMoreMessages: boolean
isLoadingMoreMessages: boolean
onLoadMore: () => Promise<unknown>
rawMessagesCount: number
normalizedMessagesCount: number
}) {
const viewportRef = useRef<HTMLDivElement | null>(null)
const topSentinelRef = useRef<HTMLDivElement | null>(null)
const loadLockRef = useRef(false)
const pendingScrollRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null)
const prevLoadingMoreRef = useRef(false)
const loadStartedRef = useRef(false)
const isLoadingMoreRef = useRef(props.isLoadingMoreMessages)
const handleLoadMore = useCallback(() => {
if (props.isLoadingMessages || !props.hasMoreMessages || props.isLoadingMoreMessages || loadLockRef.current) {
return
}
const viewport = viewportRef.current
if (!viewport) {
return
}
pendingScrollRef.current = {
scrollTop: viewport.scrollTop,
scrollHeight: viewport.scrollHeight
}
loadLockRef.current = true
loadStartedRef.current = false
let loadPromise: Promise<unknown>
try {
loadPromise = props.onLoadMore()
} catch (error) {
pendingScrollRef.current = null
loadLockRef.current = false
throw error
}
void loadPromise.catch((error) => {
pendingScrollRef.current = null
loadLockRef.current = false
console.error('Failed to load older messages:', error)
}).finally(() => {
if (!loadStartedRef.current && !isLoadingMoreRef.current && pendingScrollRef.current) {
pendingScrollRef.current = null
loadLockRef.current = false
}
})
}, [props.hasMoreMessages, props.isLoadingMoreMessages, props.isLoadingMessages, props.onLoadMore])
useEffect(() => {
const sentinel = topSentinelRef.current
const viewport = viewportRef.current
if (!sentinel || !viewport || !props.hasMoreMessages || props.isLoadingMessages) {
return
}
if (typeof IntersectionObserver === 'undefined') {
return
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
handleLoadMore()
}
}
},
{
root: viewport,
rootMargin: '200px 0px 0px 0px'
}
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [handleLoadMore, props.hasMoreMessages, props.isLoadingMessages])
useLayoutEffect(() => {
const pending = pendingScrollRef.current
const viewport = viewportRef.current
if (!pending || !viewport) {
return
}
const delta = viewport.scrollHeight - pending.scrollHeight
viewport.scrollTop = pending.scrollTop + delta
pendingScrollRef.current = null
loadLockRef.current = false
}, [props.rawMessagesCount])
useEffect(() => {
isLoadingMoreRef.current = props.isLoadingMoreMessages
if (props.isLoadingMoreMessages) {
loadStartedRef.current = true
}
if (prevLoadingMoreRef.current && !props.isLoadingMoreMessages && pendingScrollRef.current) {
pendingScrollRef.current = null
loadLockRef.current = false
}
prevLoadingMoreRef.current = props.isLoadingMoreMessages
}, [props.isLoadingMoreMessages])
return (
<HappyChatProvider value={{
api: props.api,
@@ -32,11 +133,45 @@ export function HappyThread(props: {
onRetryMessage: props.onRetryMessage
}}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
<ThreadPrimitive.Viewport className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden" autoScroll>
<div className="mx-auto w-full max-w-[720px] min-w-0 p-3">
{props.header}
<div className="flex flex-col gap-3">
<ThreadPrimitive.Messages components={THREAD_MESSAGE_COMPONENTS} />
<ThreadPrimitive.Viewport asChild autoScroll>
<div ref={viewportRef} className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="mx-auto w-full max-w-[720px] min-w-0 p-3">
<div ref={topSentinelRef} className="h-px w-full" aria-hidden="true" />
{props.isLoadingMessages ? (
<div className="text-sm text-[var(--app-hint)]">
Loading...
</div>
) : (
<>
{props.messagesWarning ? (
<div className="mb-3 rounded-md bg-amber-500/10 p-2 text-xs">
{props.messagesWarning}
</div>
) : null}
{props.hasMoreMessages && !props.isLoadingMessages ? (
<div className="mb-3">
<Button
variant="secondary"
size="sm"
onClick={handleLoadMore}
disabled={props.isLoadingMoreMessages || props.isLoadingMessages}
>
{props.isLoadingMoreMessages ? 'Loading...' : 'Load older'}
</Button>
</div>
) : null}
{import.meta.env.DEV && props.normalizedMessagesCount === 0 && props.rawMessagesCount > 0 ? (
<div className="mb-2 rounded-md bg-amber-500/10 p-2 text-xs">
Message normalization returned 0 items for {props.rawMessagesCount} messages (see `web/src/chat/normalize.ts`).
</div>
) : null}
</>
)}
<div className="flex flex-col gap-3">
<ThreadPrimitive.Messages components={THREAD_MESSAGE_COMPONENTS} />
</div>
</div>
</div>
</ThreadPrimitive.Viewport>
+8 -50
View File
@@ -7,7 +7,6 @@ import type { ChatBlock, NormalizedMessage } from '@/chat/types'
import { normalizeDecryptedMessage } from '@/chat/normalize'
import { reduceChatBlocks } from '@/chat/reducer'
import { reconcileChatBlocks } from '@/chat/reconcile'
import { Button } from '@/components/ui/button'
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
import { HappyThread } from '@/components/AssistantChat/HappyThread'
import { useHappyRuntime } from '@/lib/assistant-runtime'
@@ -26,7 +25,7 @@ export function SessionChat(props: {
isSending: boolean
onBack: () => void
onRefresh: () => void
onLoadMore: () => void
onLoadMore: () => Promise<unknown>
onSend: (text: string) => void
onRetryMessage?: (localId: string) => void
}) {
@@ -129,53 +128,6 @@ export function SessionChat(props: {
onAbort: handleAbort
})
const threadHeader = useMemo(() => {
if (props.isLoadingMessages) {
return (
<div className="text-sm text-[var(--app-hint)]">
Loading
</div>
)
}
return (
<>
{props.messagesWarning ? (
<div className="mb-3 rounded-md bg-amber-500/10 p-2 text-xs">
{props.messagesWarning}
</div>
) : null}
{props.hasMoreMessages ? (
<div className="mb-3">
<Button
variant="secondary"
size="sm"
onClick={props.onLoadMore}
disabled={props.isLoadingMoreMessages}
>
{props.isLoadingMoreMessages ? 'Loading…' : 'Load older'}
</Button>
</div>
) : null}
{import.meta.env.DEV && normalizedMessages.length === 0 && props.messages.length > 0 ? (
<div className="mb-2 rounded-md bg-amber-500/10 p-2 text-xs">
Message normalization returned 0 items for {props.messages.length} messages (see `web/src/chat/normalize.ts`).
</div>
) : null}
</>
)
}, [
props.isLoadingMessages,
props.messagesWarning,
props.hasMoreMessages,
props.isLoadingMoreMessages,
props.onLoadMore,
props.messages.length,
normalizedMessages.length
])
return (
<div className="flex h-full flex-col">
<SessionHeader
@@ -200,7 +152,13 @@ export function SessionChat(props: {
disabled={controlsDisabled}
onRefresh={props.onRefresh}
onRetryMessage={props.onRetryMessage}
header={threadHeader}
isLoadingMessages={props.isLoadingMessages}
messagesWarning={props.messagesWarning}
hasMoreMessages={props.hasMoreMessages}
isLoadingMoreMessages={props.isLoadingMoreMessages}
onLoadMore={props.onLoadMore}
rawMessagesCount={props.messages.length}
normalizedMessagesCount={normalizedMessages.length}
/>
<HappyComposer
+1 -3
View File
@@ -117,9 +117,7 @@ function SessionPage() {
isSending={isSending}
onBack={goBack}
onRefresh={refreshSelectedSession}
onLoadMore={() => {
void loadMoreMessages()
}}
onLoadMore={loadMoreMessages}
onSend={sendMessage}
onRetryMessage={retryMessage}
/>