mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -117,9 +117,7 @@ function SessionPage() {
|
||||
isSending={isSending}
|
||||
onBack={goBack}
|
||||
onRefresh={refreshSelectedSession}
|
||||
onLoadMore={() => {
|
||||
void loadMoreMessages()
|
||||
}}
|
||||
onLoadMore={loadMoreMessages}
|
||||
onSend={sendMessage}
|
||||
onRetryMessage={retryMessage}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user