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>
)
+17 -3
View File
@@ -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