mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
refactor: replace React Query message cache with windowed message store
Introduces a new message-window-store module to manage message state with automatic windowing of visible and pending messages. This replaces manual React Query cache operations with a centralized, observable state system. close #39 Key changes: - New MessageWindowState tracks visible/pending messages with size limits - Automatic trimming of message windows (400 visible, 200 pending messages) - Pending message buffering when user scrolls away from bottom - Centralized status updates for optimistic messages - Thread component simplified with forwarded scroll and pending callbacks - Removes message count tracking from components This improves performance for chats with many messages and provides a cleaner separation of concerns between UI and state management.
This commit is contained in:
@@ -59,14 +59,18 @@ export function HappyThread(props: {
|
||||
disabled: boolean
|
||||
onRefresh: () => void
|
||||
onRetryMessage?: (localId: string) => void
|
||||
onFlushPending: () => void
|
||||
onAtBottomChange: (atBottom: boolean) => void
|
||||
isLoadingMessages: boolean
|
||||
messagesWarning: string | null
|
||||
hasMoreMessages: boolean
|
||||
isLoadingMoreMessages: boolean
|
||||
onLoadMore: () => Promise<unknown>
|
||||
pendingCount: number
|
||||
rawMessagesCount: number
|
||||
normalizedMessagesCount: number
|
||||
renderedMessagesCount: number
|
||||
messagesVersion: number
|
||||
forceScrollToken: number
|
||||
}) {
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const topSentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -75,22 +79,25 @@ export function HappyThread(props: {
|
||||
const prevLoadingMoreRef = useRef(false)
|
||||
const loadStartedRef = useRef(false)
|
||||
const isLoadingMoreRef = useRef(props.isLoadingMoreMessages)
|
||||
const atBottomRef = useRef(true)
|
||||
const onAtBottomChangeRef = useRef(props.onAtBottomChange)
|
||||
const onFlushPendingRef = useRef(props.onFlushPending)
|
||||
const forceScrollTokenRef = useRef(props.forceScrollToken)
|
||||
|
||||
// Smart scroll state: autoScroll enabled when user is near bottom
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true)
|
||||
const [newMessageCount, setNewMessageCount] = useState(0)
|
||||
const prevRenderedCountRef = useRef(props.renderedMessagesCount)
|
||||
const autoScrollEnabledRef = useRef(autoScrollEnabled)
|
||||
const newMessageCountRef = useRef(newMessageCount)
|
||||
const hasBootstrappedRef = useRef(false)
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
autoScrollEnabledRef.current = autoScrollEnabled
|
||||
}, [autoScrollEnabled])
|
||||
useEffect(() => {
|
||||
newMessageCountRef.current = newMessageCount
|
||||
}, [newMessageCount])
|
||||
onAtBottomChangeRef.current = props.onAtBottomChange
|
||||
}, [props.onAtBottomChange])
|
||||
useEffect(() => {
|
||||
onFlushPendingRef.current = props.onFlushPending
|
||||
}, [props.onFlushPending])
|
||||
|
||||
// Track scroll position to toggle autoScroll (stable listener using refs)
|
||||
useEffect(() => {
|
||||
@@ -105,9 +112,16 @@ export function HappyThread(props: {
|
||||
|
||||
if (isNearBottom) {
|
||||
if (!autoScrollEnabledRef.current) setAutoScrollEnabled(true)
|
||||
if (newMessageCountRef.current > 0) setNewMessageCount(0)
|
||||
} else {
|
||||
if (autoScrollEnabledRef.current) setAutoScrollEnabled(false)
|
||||
} else if (autoScrollEnabledRef.current) {
|
||||
setAutoScrollEnabled(false)
|
||||
}
|
||||
|
||||
if (isNearBottom !== atBottomRef.current) {
|
||||
atBottomRef.current = isNearBottom
|
||||
onAtBottomChangeRef.current(isNearBottom)
|
||||
if (isNearBottom) {
|
||||
onFlushPendingRef.current()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,43 +129,6 @@ export function HappyThread(props: {
|
||||
return () => viewport.removeEventListener('scroll', handleScroll)
|
||||
}, []) // Stable: no dependencies, reads from refs
|
||||
|
||||
// Track new messages when autoScroll is disabled
|
||||
const wasLoadingMoreRef = useRef(props.isLoadingMoreMessages)
|
||||
useEffect(() => {
|
||||
const prevCount = prevRenderedCountRef.current
|
||||
const currentCount = props.renderedMessagesCount
|
||||
const wasLoadingMore = wasLoadingMoreRef.current
|
||||
wasLoadingMoreRef.current = props.isLoadingMoreMessages
|
||||
|
||||
if (props.isLoadingMessages) {
|
||||
prevRenderedCountRef.current = currentCount
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasBootstrappedRef.current) {
|
||||
hasBootstrappedRef.current = true
|
||||
prevRenderedCountRef.current = currentCount
|
||||
return
|
||||
}
|
||||
|
||||
prevRenderedCountRef.current = currentCount
|
||||
|
||||
// Skip during loading states
|
||||
if (props.isLoadingMoreMessages) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if load-more just finished (older messages, not new ones)
|
||||
if (wasLoadingMore) {
|
||||
return
|
||||
}
|
||||
|
||||
const newCount = currentCount - prevCount
|
||||
if (newCount > 0 && !autoScrollEnabled) {
|
||||
setNewMessageCount((prev) => prev + newCount)
|
||||
}
|
||||
}, [props.renderedMessagesCount, props.isLoadingMoreMessages, props.isLoadingMessages, autoScrollEnabled])
|
||||
|
||||
// Scroll to bottom handler for the indicator button
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const viewport = viewportRef.current
|
||||
@@ -159,17 +136,29 @@ export function HappyThread(props: {
|
||||
viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
setAutoScrollEnabled(true)
|
||||
setNewMessageCount(0)
|
||||
if (!atBottomRef.current) {
|
||||
atBottomRef.current = true
|
||||
onAtBottomChangeRef.current(true)
|
||||
}
|
||||
onFlushPendingRef.current()
|
||||
}, [])
|
||||
|
||||
// Reset state when session changes
|
||||
useEffect(() => {
|
||||
setAutoScrollEnabled(true)
|
||||
setNewMessageCount(0)
|
||||
prevRenderedCountRef.current = 0
|
||||
hasBootstrappedRef.current = false
|
||||
atBottomRef.current = true
|
||||
onAtBottomChangeRef.current(true)
|
||||
forceScrollTokenRef.current = props.forceScrollToken
|
||||
}, [props.sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (forceScrollTokenRef.current === props.forceScrollToken) {
|
||||
return
|
||||
}
|
||||
forceScrollTokenRef.current = props.forceScrollToken
|
||||
scrollToBottom()
|
||||
}, [props.forceScrollToken, scrollToBottom])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (props.isLoadingMessages || !props.hasMoreMessages || props.isLoadingMoreMessages || loadLockRef.current) {
|
||||
return
|
||||
@@ -242,7 +231,7 @@ export function HappyThread(props: {
|
||||
viewport.scrollTop = pending.scrollTop + delta
|
||||
pendingScrollRef.current = null
|
||||
loadLockRef.current = false
|
||||
}, [props.rawMessagesCount])
|
||||
}, [props.messagesVersion])
|
||||
|
||||
useEffect(() => {
|
||||
isLoadingMoreRef.current = props.isLoadingMoreMessages
|
||||
@@ -320,7 +309,7 @@ export function HappyThread(props: {
|
||||
</div>
|
||||
</div>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<NewMessagesIndicator count={newMessageCount} onClick={scrollToBottom} />
|
||||
<NewMessagesIndicator count={props.pendingCount} onClick={scrollToBottom} />
|
||||
</ThreadPrimitive.Root>
|
||||
</HappyChatProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { AssistantRuntimeProvider } from '@assistant-ui/react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
@@ -24,10 +24,14 @@ export function SessionChat(props: {
|
||||
isLoadingMessages: boolean
|
||||
isLoadingMoreMessages: boolean
|
||||
isSending: boolean
|
||||
pendingCount: number
|
||||
messagesVersion: number
|
||||
onBack: () => void
|
||||
onRefresh: () => void
|
||||
onLoadMore: () => Promise<unknown>
|
||||
onSend: (text: string) => void
|
||||
onFlushPending: () => void
|
||||
onAtBottomChange: (atBottom: boolean) => void
|
||||
onRetryMessage?: (localId: string) => void
|
||||
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
|
||||
}) {
|
||||
@@ -36,6 +40,7 @@ export function SessionChat(props: {
|
||||
const controlsDisabled = !props.session.active
|
||||
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
|
||||
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
|
||||
const [forceScrollToken, setForceScrollToken] = useState(0)
|
||||
const agentFlavor = props.session.metadata?.flavor ?? null
|
||||
const { abortSession, switchSession, setPermissionMode, setModelMode } = useSessionActions(
|
||||
props.api,
|
||||
@@ -134,11 +139,16 @@ export function SessionChat(props: {
|
||||
})
|
||||
}, [navigate, props.session.id])
|
||||
|
||||
const handleSend = useCallback((text: string) => {
|
||||
props.onSend(text)
|
||||
setForceScrollToken((token) => token + 1)
|
||||
}, [props.onSend])
|
||||
|
||||
const runtime = useHappyRuntime({
|
||||
session: props.session,
|
||||
blocks: reconciled.blocks,
|
||||
isSending: props.isSending,
|
||||
onSendMessage: props.onSend,
|
||||
onSendMessage: handleSend,
|
||||
onAbort: handleAbort
|
||||
})
|
||||
|
||||
@@ -170,14 +180,18 @@ export function SessionChat(props: {
|
||||
disabled={controlsDisabled}
|
||||
onRefresh={props.onRefresh}
|
||||
onRetryMessage={props.onRetryMessage}
|
||||
onFlushPending={props.onFlushPending}
|
||||
onAtBottomChange={props.onAtBottomChange}
|
||||
isLoadingMessages={props.isLoadingMessages}
|
||||
messagesWarning={props.messagesWarning}
|
||||
hasMoreMessages={props.hasMoreMessages}
|
||||
isLoadingMoreMessages={props.isLoadingMoreMessages}
|
||||
onLoadMore={props.onLoadMore}
|
||||
pendingCount={props.pendingCount}
|
||||
rawMessagesCount={props.messages.length}
|
||||
normalizedMessagesCount={normalizedMessages.length}
|
||||
renderedMessagesCount={reconciled.blocks.length}
|
||||
messagesVersion={props.messagesVersion}
|
||||
forceScrollToken={forceScrollToken}
|
||||
/>
|
||||
|
||||
<HappyComposer
|
||||
|
||||
Reference in New Issue
Block a user