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
+20 -50
View File
@@ -1,8 +1,12 @@
import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query'
import { useMutation } from '@tanstack/react-query'
import type { ApiClient } from '@/api/client'
import type { DecryptedMessage, MessagesResponse } from '@/types/api'
import { makeClientSideId, upsertMessagesInCache } from '@/lib/messages'
import { queryKeys } from '@/lib/query-keys'
import type { DecryptedMessage } from '@/types/api'
import { makeClientSideId } from '@/lib/messages'
import {
appendOptimisticMessage,
getMessageWindowState,
updateMessageStatus,
} from '@/lib/message-window-store'
import { usePlatform } from '@/hooks/usePlatform'
type SendMessageInput = {
@@ -12,36 +16,16 @@ type SendMessageInput = {
createdAt: number
}
function updateMessageStatus(
data: InfiniteData<MessagesResponse> | undefined,
localId: string,
status: DecryptedMessage['status'],
): InfiniteData<MessagesResponse> | undefined {
if (!data) return data
const pages = data.pages.map((page) => ({
...page,
messages: page.messages.map((message) =>
message.localId === localId
? { ...message, status }
: message
),
}))
return {
...data,
pages,
}
}
function findMessageByLocalId(
data: InfiniteData<MessagesResponse> | undefined,
sessionId: string,
localId: string,
): DecryptedMessage | null {
if (!data) return null
for (const page of data.pages) {
const match = page.messages.find((message) => message.localId === localId)
if (match) return match
const state = getMessageWindowState(sessionId)
for (const message of state.messages) {
if (message.localId === localId) return message
}
for (const message of state.pending) {
if (message.localId === localId) return message
}
return null
}
@@ -51,7 +35,6 @@ export function useSendMessage(api: ApiClient | null, sessionId: string | null):
retryMessage: (localId: string) => void
isSending: boolean
} {
const queryClient = useQueryClient()
const { haptic } = usePlatform()
const mutation = useMutation({
@@ -72,23 +55,14 @@ export function useSendMessage(api: ApiClient | null, sessionId: string | null):
originalText: input.text,
}
queryClient.setQueryData<InfiniteData<MessagesResponse>>(
queryKeys.messages(input.sessionId),
(data) => upsertMessagesInCache(data, [optimisticMessage]),
)
appendOptimisticMessage(input.sessionId, optimisticMessage)
},
onSuccess: (_, input) => {
queryClient.setQueryData<InfiniteData<MessagesResponse>>(
queryKeys.messages(input.sessionId),
(data) => updateMessageStatus(data, input.localId, 'sent'),
)
updateMessageStatus(input.sessionId, input.localId, 'sent')
haptic.notification('success')
},
onError: (_, input) => {
queryClient.setQueryData<InfiniteData<MessagesResponse>>(
queryKeys.messages(input.sessionId),
(data) => updateMessageStatus(data, input.localId, 'failed'),
)
updateMessageStatus(input.sessionId, input.localId, 'failed')
haptic.notification('error')
},
})
@@ -109,14 +83,10 @@ export function useSendMessage(api: ApiClient | null, sessionId: string | null):
if (!api || !sessionId) return
if (mutation.isPending) return
const data = queryClient.getQueryData<InfiniteData<MessagesResponse>>(queryKeys.messages(sessionId))
const message = findMessageByLocalId(data, localId)
const message = findMessageByLocalId(sessionId, localId)
if (!message?.originalText) return
queryClient.setQueryData<InfiniteData<MessagesResponse>>(
queryKeys.messages(sessionId),
(current) => updateMessageStatus(current, localId, 'sending'),
)
updateMessageStatus(sessionId, localId, 'sending')
mutation.mutate({
sessionId,
+2 -1
View File
@@ -3,6 +3,7 @@ import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'
import type { ApiClient } from '@/api/client'
import type { ModelMode, PermissionMode } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
import { clearMessageWindow } from '@/lib/message-window-store'
export function useSessionActions(
api: ApiClient | null,
@@ -100,7 +101,7 @@ export function useSessionActions(
onSuccess: async () => {
if (!sessionId) return
queryClient.removeQueries({ queryKey: queryKeys.session(sessionId) })
queryClient.removeQueries({ queryKey: queryKeys.messages(sessionId) })
clearMessageWindow(sessionId)
await queryClient.invalidateQueries({ queryKey: queryKeys.sessions })
},
})