refactor(sync): replace message reloads with incremental tail sync

This commit is contained in:
weishu
2026-07-28 12:20:53 +08:00
parent 2235b924a7
commit faf70c64dd
28 changed files with 2927 additions and 2077 deletions
@@ -7,7 +7,7 @@ import { ApiError, type ApiClient } from '@/api/client'
vi.mock('@/lib/message-window-store', () => ({
appendOptimisticMessage: vi.fn(),
getMessageWindowState: vi.fn(() => ({ messages: [], pending: [] })),
getMessageWindowState: vi.fn(() => ({ messages: [] })),
updateMessageStatus: vi.fn(),
removeOptimisticMessage: vi.fn(),
}))
@@ -452,8 +452,7 @@ describe('useSendMessage', () => {
originalText: 'photo + text',
}
stateMock.mockReturnValue({
messages: [failedAttachmentMessage],
pending: []
messages: [failedAttachmentMessage]
} as unknown as ReturnType<typeof getMessageWindowState>)
const { result } = renderHook(
@@ -683,8 +682,7 @@ describe('useSendMessage', () => {
const { getMessageWindowState } = await import('@/lib/message-window-store')
vi.mocked(getMessageWindowState).mockReturnValueOnce({
messages: [],
pending: [{
messages: [{
id: 'local-retry-1',
seq: null,
localId: 'local-retry-1',
@@ -102,9 +102,6 @@ function findMessageByLocalId(
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
}
+38 -45
View File
@@ -1,104 +1,97 @@
import { useCallback, useEffect, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useLayoutEffect, useSyncExternalStore } from 'react'
import type { ApiClient } from '@/api/client'
import type { DecryptedMessage } from '@/types/api'
import {
fetchLatestMessages,
activateMessageWindow,
fetchOlderMessages,
flushPendingMessages,
getMessageWindowState,
setAtBottom as setMessageWindowAtBottom,
setMessageViewMode,
subscribeMessageWindow,
syncTailMessages,
type MessageViewMode,
type MessageWindowState,
} from '@/lib/message-window-store'
export const EMPTY_STATE: MessageWindowState = {
sessionId: 'unknown',
messages: [],
pending: [],
pendingCount: 0,
hasMore: false,
oldestSeq: null,
newestSeq: null,
isLoading: false,
epoch: null,
isSyncingTail: false,
isLoadingMore: false,
warning: null,
atBottom: true,
viewMode: 'tail',
unseenCount: 0,
messagesVersion: 0,
historyVersion: 0,
}
export function useMessages(api: ApiClient | null, sessionId: string | null): {
messages: DecryptedMessage[]
pendingMessages: DecryptedMessage[]
warning: string | null
isLoading: boolean
isSyncingTail: boolean
isLoadingMore: boolean
hasMore: boolean
pendingCount: number
unseenCount: number
messagesVersion: number
loadMore: () => Promise<unknown>
refetch: () => Promise<unknown>
flushPending: () => Promise<void>
setAtBottom: (atBottom: boolean) => void
historyVersion: number
loadMore: () => Promise<boolean>
refetch: () => Promise<void>
setViewMode: (mode: MessageViewMode) => void
} {
const state = useSyncExternalStore(
useCallback((listener) => {
if (!sessionId) {
return () => {}
}
if (!sessionId) return () => {}
return subscribeMessageWindow(sessionId, listener)
}, [sessionId]),
useCallback(() => {
if (!sessionId) {
return EMPTY_STATE
}
return getMessageWindowState(sessionId)
}, [sessionId]),
useCallback(() => sessionId ? getMessageWindowState(sessionId) : EMPTY_STATE, [sessionId]),
() => EMPTY_STATE
)
useLayoutEffect(() => {
if (sessionId) {
activateMessageWindow(sessionId)
}
}, [sessionId])
useEffect(() => {
if (!api || !sessionId) {
return
if (api && sessionId) {
void syncTailMessages(api, sessionId)
}
void fetchLatestMessages(api, sessionId)
}, [api, sessionId])
const loadMore = useCallback(async () => {
if (!api || !sessionId) return
if (!state.hasMore || state.isLoadingMore) return
await fetchOlderMessages(api, sessionId)
if (!api || !sessionId || !state.hasMore || state.isLoadingMore) return false
return await fetchOlderMessages(api, sessionId)
}, [api, sessionId, state.hasMore, state.isLoadingMore])
const refetch = useCallback(async () => {
if (!api || !sessionId) return
await fetchLatestMessages(api, sessionId)
await syncTailMessages(api, sessionId, { ensureAfterCurrent: true })
}, [api, sessionId])
const flushPending = useCallback(async () => {
const setViewMode = useCallback((mode: MessageViewMode) => {
if (!sessionId) return
const needsRefresh = flushPendingMessages(sessionId)
if (needsRefresh && api) {
await fetchLatestMessages(api, sessionId)
const previousMode = getMessageWindowState(sessionId).viewMode
setMessageViewMode(sessionId, mode)
if (mode === 'tail' && previousMode !== 'tail' && api) {
void syncTailMessages(api, sessionId, { ensureAfterCurrent: true })
}
}, [api, sessionId])
const setAtBottom = useCallback((atBottom: boolean) => {
if (!sessionId) return
setMessageWindowAtBottom(sessionId, atBottom)
}, [sessionId])
return {
messages: state.messages,
pendingMessages: state.pending,
warning: state.warning,
isLoading: state.isLoading,
isSyncingTail: state.isSyncingTail,
isLoadingMore: state.isLoadingMore,
hasMore: state.hasMore,
pendingCount: state.pendingCount,
unseenCount: state.unseenCount,
messagesVersion: state.messagesVersion,
historyVersion: state.historyVersion,
loadMore,
refetch,
flushPending,
setAtBottom,
setViewMode,
}
}