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:
weishu
2026-01-07 16:59:12 +08:00
parent 70242041d5
commit 0f29ee182c
10 changed files with 556 additions and 174 deletions
@@ -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>
)