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
+3 -3
View File
@@ -15,7 +15,7 @@ import { useViewportHeight } from '@/hooks/useViewportHeight'
import { useVisibilityReporter } from '@/hooks/useVisibilityReporter'
import { queryKeys } from '@/lib/query-keys'
import { AppContextProvider } from '@/lib/app-context'
import { clearMessageWindow, fetchLatestMessages } from '@/lib/message-window-store'
import { clearMessageWindow, syncTailMessages } from '@/lib/message-window-store'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useTranslation } from '@/lib/use-translation'
import { VoiceProvider } from '@/lib/voice-context'
@@ -229,7 +229,7 @@ function AppInner() {
queryClient.invalidateQueries({ queryKey: ['session'] })
]
const refreshMessages = (selectedSessionId && api)
? fetchLatestMessages(api, selectedSessionId)
? syncTailMessages(api, selectedSessionId)
: Promise.resolve()
Promise.all([...invalidations, refreshMessages])
.catch((error) => {
@@ -259,7 +259,7 @@ function AppInner() {
return
}
clearMessageWindow(event.sessionId)
void fetchLatestMessages(api, event.sessionId)
void syncTailMessages(api, event.sessionId)
}, [api, selectedSessionId])
const handleSessionSseConnect = useCallback(() => {
+20
View File
@@ -283,6 +283,11 @@ export class ApiClient {
options: {
beforeSeq?: number | null
beforeAt?: number | null
afterSeq?: number | null
afterAt?: number | null
untilSeq?: number | null
untilAt?: number | null
epoch?: number | null
limit?: number
}
): Promise<MessagesResponse> {
@@ -293,6 +298,21 @@ export class ApiClient {
if (options.beforeSeq !== undefined && options.beforeSeq !== null) {
params.set('beforeSeq', `${options.beforeSeq}`)
}
if (options.afterAt !== undefined && options.afterAt !== null) {
params.set('afterAt', `${options.afterAt}`)
}
if (options.afterSeq !== undefined && options.afterSeq !== null) {
params.set('afterSeq', `${options.afterSeq}`)
}
if (options.untilAt !== undefined && options.untilAt !== null) {
params.set('untilAt', `${options.untilAt}`)
}
if (options.untilSeq !== undefined && options.untilSeq !== null) {
params.set('untilSeq', `${options.untilSeq}`)
}
if (options.epoch !== undefined && options.epoch !== null) {
params.set('epoch', `${options.epoch}`)
}
if (options.limit !== undefined && options.limit !== null) {
params.set('limit', `${options.limit}`)
}
@@ -5,10 +5,13 @@ import { I18nProvider } from '@/lib/i18n-context'
import {
ConversationOutlinePanel,
captureScrollAnchor,
getHistoryCoverageRetryDelay,
getScrollIntent,
loadOlderUntilViewportCovered,
locateOutlineTargetMessage,
prependMissingUserSnapshot,
restoreScrollAnchor,
shouldLoadOlderForViewport,
shouldCancelInitialScrollSettling,
} from '@/components/AssistantChat/HappyThread'
import type { ConversationOutlineItem } from '@/chat/outline'
@@ -230,6 +233,89 @@ describe('scroll anchor helpers', () => {
})
})
describe('viewport-driven history coverage', () => {
it('recognizes an underfilled viewport and a top sentinel inside the preload margin', () => {
expect(shouldLoadOlderForViewport({
scrollHeight: 300,
clientHeight: 500,
viewportTop: 100,
sentinelTop: 100,
sentinelBottom: 101
})).toBe(true)
expect(shouldLoadOlderForViewport({
scrollHeight: 1_000,
clientHeight: 500,
viewportTop: 100,
sentinelTop: -200,
sentinelBottom: -199
})).toBe(false)
})
it('loads raw pages until rendered DOM grows by roughly one viewport', async () => {
let scrollHeight = 100
const growthByPage = [0, 250, 300]
const loadOlderPage = vi.fn(async () => {
scrollHeight += growthByPage.shift() ?? 0
return true
})
const loaded = await loadOlderUntilViewportCovered({
hasMoreMessages: () => true,
needsCoverage: () => true,
getScrollHeight: () => scrollHeight,
getClientHeight: () => 500,
loadOlderPage,
waitForRender: async () => {}
})
expect(loaded).toBe(3)
expect(loadOlderPage).toHaveBeenCalledTimes(3)
expect(scrollHeight).toBe(650)
})
it('stops when history is exhausted even if raw pages produced no DOM growth', async () => {
let remainingPages = 2
const loadOlderPage = vi.fn(async () => {
remainingPages -= 1
return true
})
const loaded = await loadOlderUntilViewportCovered({
hasMoreMessages: () => remainingPages > 0,
needsCoverage: () => true,
getScrollHeight: () => 100,
getClientHeight: () => 500,
loadOlderPage,
waitForRender: async () => {}
})
expect(loaded).toBe(2)
expect(loadOlderPage).toHaveBeenCalledTimes(2)
})
it('caps one gesture when pages keep producing no rendered output', async () => {
const loadOlderPage = vi.fn(async () => true)
const loaded = await loadOlderUntilViewportCovered({
hasMoreMessages: () => true,
needsCoverage: () => true,
getScrollHeight: () => 100,
getClientHeight: () => 500,
loadOlderPage,
waitForRender: async () => {},
maxPages: 4
})
expect(loaded).toBe(4)
expect(loadOlderPage).toHaveBeenCalledTimes(4)
})
it('defers an intersection signal until the initial scroll-settling deadline', () => {
expect(getHistoryCoverageRetryDelay(2_800, 1_000)).toBe(1_816)
expect(getHistoryCoverageRetryDelay(900, 1_000)).toBe(16)
})
})
describe('outline target loading', () => {
it('loads older messages through the scroll-preserving wrapper until the target appears', async () => {
const loadOlderPreservingScroll = vi.fn<() => Promise<boolean>>()
+236 -80
View File
@@ -83,6 +83,8 @@ const AUTO_SCROLL_RESUME_THRESHOLD_PX = 120
const MANUAL_SCROLL_EPSILON_PX = 1
const INITIAL_SCROLL_SETTLE_MS = 1800
const INITIAL_SCROLL_SETTLE_DELAYS_MS = [0, 16, 50, 120, 250, 500, 900, 1400, 1800] as const
const HISTORY_PRELOAD_MARGIN_PX = 200
const HISTORY_COVERAGE_PAGE_CAP = 8
type ScrollIntent = {
distanceFromBottom: number
@@ -156,6 +158,63 @@ export async function locateOutlineTargetMessage(options: LocateOutlineTargetOpt
return target
}
export function shouldLoadOlderForViewport(params: {
scrollHeight: number
clientHeight: number
viewportTop: number
sentinelTop: number
sentinelBottom: number
preloadMarginPx?: number
}): boolean {
const preloadMarginPx = params.preloadMarginPx ?? HISTORY_PRELOAD_MARGIN_PX
if (params.scrollHeight <= params.clientHeight + 1) {
return true
}
return params.sentinelBottom >= params.viewportTop - preloadMarginPx
&& params.sentinelTop <= params.viewportTop + preloadMarginPx
}
export async function loadOlderUntilViewportCovered(options: {
hasMoreMessages: () => boolean
needsCoverage: () => boolean
getScrollHeight: () => number
getClientHeight: () => number
loadOlderPage: () => Promise<boolean>
waitForRender: () => Promise<void>
forceFirstPage?: boolean
maxPages?: number
}): Promise<number> {
if (!options.hasMoreMessages()) {
return 0
}
if (!options.forceFirstPage && !options.needsCoverage()) {
return 0
}
const baselineHeight = options.getScrollHeight()
const targetGrowth = Math.max(1, options.getClientHeight())
const maxPages = options.maxPages ?? HISTORY_COVERAGE_PAGE_CAP
let loadedPages = 0
while (loadedPages < maxPages && options.hasMoreMessages()) {
if (loadedPages > 0 && options.getScrollHeight() - baselineHeight >= targetGrowth) {
break
}
const loaded = await options.loadOlderPage()
if (!loaded) {
break
}
loadedPages += 1
await options.waitForRender()
}
return loadedPages
}
export function getHistoryCoverageRetryDelay(deadline: number, now: number): number {
return Math.max(0, deadline - now) + 16
}
function NewMessagesIndicator(props: { count: number; onClick: () => void }) {
const { t } = useTranslation()
if (props.count === 0) {
@@ -341,17 +400,17 @@ export function HappyThread(props: {
disabled: boolean
onRefresh: () => void
onRetryMessage?: (localId: string) => void
onFlushPending: () => void
onAtBottomChange: (atBottom: boolean) => void
isLoadingMessages: boolean
onViewModeChange: (mode: 'tail' | 'history') => void
isSyncingTail: boolean
messagesWarning: string | null
hasMoreMessages: boolean
isLoadingMoreMessages: boolean
onLoadMore: () => Promise<unknown>
pendingCount: number
onLoadMore: () => Promise<boolean>
unseenCount: number
rawMessagesCount: number
normalizedMessagesCount: number
messagesVersion: number
historyVersion: number
forceScrollToken: number
outlineOpen: boolean
outlineItems: readonly ConversationOutlineItem[]
@@ -367,20 +426,24 @@ export function HappyThread(props: {
const topSentinelRef = useRef<HTMLDivElement | null>(null)
const loadLockRef = useRef(false)
const pendingScrollRef = useRef<PendingScrollRestore | null>(null)
const prevLoadingMoreRef = useRef(false)
const loadStartedRef = useRef(false)
const isLoadingMoreRef = useRef(props.isLoadingMoreMessages)
const hasMoreMessagesRef = useRef(props.hasMoreMessages)
const isLoadingMessagesRef = useRef(props.isLoadingMessages)
const isSyncingTailRef = useRef(props.isSyncingTail)
const messagesVersionRef = useRef(props.messagesVersion)
const historyVersionRef = useRef(props.historyVersion)
const onLoadMoreRef = useRef(props.onLoadMore)
const handleLoadMoreRef = useRef<() => void>(() => {})
const pendingLoadPromiseRef = useRef<Promise<boolean> | null>(null)
const pendingLoadResolveRef = useRef<((value: boolean) => void) | null>(null)
const pendingLoadBaselineRef = useRef<{ messagesVersion: number; hasMoreMessages: boolean } | null>(null)
const pendingLoadBaselineRef = useRef<{
messagesVersion: number
historyVersion: number
hasMoreMessages: boolean
} | null>(null)
const coveragePromiseRef = useRef<Promise<boolean> | null>(null)
const coverageRetryTimerRef = useRef<number | null>(null)
const atBottomRef = useRef(true)
const onAtBottomChangeRef = useRef(props.onAtBottomChange)
const onFlushPendingRef = useRef(props.onFlushPending)
const onViewModeChangeRef = useRef(props.onViewModeChange)
const forceScrollTokenRef = useRef(props.forceScrollToken)
const lastScrollTopRef = useRef(0)
const sessionIdRef = useRef(props.sessionId)
@@ -391,20 +454,20 @@ export function HappyThread(props: {
// Smart scroll state: enabled only while the user is intentionally at the bottom.
const autoScrollEnabledRef = useRef(true)
useEffect(() => {
onAtBottomChangeRef.current = props.onAtBottomChange
}, [props.onAtBottomChange])
useEffect(() => {
onFlushPendingRef.current = props.onFlushPending
}, [props.onFlushPending])
onViewModeChangeRef.current = props.onViewModeChange
}, [props.onViewModeChange])
useEffect(() => {
hasMoreMessagesRef.current = props.hasMoreMessages
}, [props.hasMoreMessages])
useEffect(() => {
isLoadingMessagesRef.current = props.isLoadingMessages
}, [props.isLoadingMessages])
isSyncingTailRef.current = props.isSyncingTail
}, [props.isSyncingTail])
useEffect(() => {
messagesVersionRef.current = props.messagesVersion
}, [props.messagesVersion])
useEffect(() => {
historyVersionRef.current = props.historyVersion
}, [props.historyVersion])
useEffect(() => {
onLoadMoreRef.current = props.onLoadMore
}, [props.onLoadMore])
@@ -424,6 +487,22 @@ export function HappyThread(props: {
initialScrollTimersRef.current = []
}, [])
const clearCoverageRetryTimer = useCallback(() => {
if (coverageRetryTimerRef.current !== null) {
window.clearTimeout(coverageRetryTimerRef.current)
coverageRetryTimerRef.current = null
}
}, [])
const waitForRenderedFrame = useCallback((): Promise<void> => {
return new Promise((resolve) => {
const schedule = typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame.bind(window)
: (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0)
schedule(() => schedule(() => resolve()))
})
}, [])
const settlePendingLoad = useCallback((result: boolean) => {
const resolve = pendingLoadResolveRef.current
const baseline = pendingLoadBaselineRef.current
@@ -439,6 +518,7 @@ export function HappyThread(props: {
}
resolve(
messagesVersionRef.current !== baseline.messagesVersion
|| historyVersionRef.current !== baseline.historyVersion
|| hasMoreMessagesRef.current !== baseline.hasMoreMessages
)
}, [])
@@ -462,10 +542,7 @@ export function HappyThread(props: {
return
}
atBottomRef.current = atBottom
onAtBottomChangeRef.current(atBottom)
if (atBottom) {
onFlushPendingRef.current()
}
onViewModeChangeRef.current(atBottom ? 'tail' : 'history')
}
const handleScroll = () => {
@@ -525,9 +602,8 @@ export function HappyThread(props: {
autoScrollEnabledRef.current = true
if (!atBottomRef.current) {
atBottomRef.current = true
onAtBottomChangeRef.current(true)
onViewModeChangeRef.current('tail')
}
onFlushPendingRef.current()
}, [])
// Reset state when session changes
@@ -535,25 +611,22 @@ export function HappyThread(props: {
autoScrollEnabledRef.current = true
lastScrollTopRef.current = viewportRef.current?.scrollTop ?? 0
atBottomRef.current = true
onAtBottomChangeRef.current(true)
// Re-entry forces the thread to the bottom, so release anything the
// non-at-bottom cold load parked in pending — otherwise new messages
// stay invisible until the user manually scrolls to bottom.
onFlushPendingRef.current()
onViewModeChangeRef.current('tail')
forceScrollTokenRef.current = props.forceScrollToken
pendingScrollRef.current = null
loadLockRef.current = false
loadStartedRef.current = false
coveragePromiseRef.current = null
initialScrollSessionRef.current = null
initialScrollDeadlineRef.current = 0
clearInitialScrollTimers()
clearCoverageRetryTimer()
settlePendingLoad(false)
}, [props.sessionId, clearInitialScrollTimers, settlePendingLoad])
}, [props.sessionId, clearInitialScrollTimers, clearCoverageRetryTimer, settlePendingLoad])
useLayoutEffect(() => {
if (
initialScrollSessionRef.current === props.sessionId
|| props.isLoadingMessages
|| props.isSyncingTail
|| props.rawMessagesCount === 0
|| pendingScrollRef.current
) {
@@ -563,8 +636,7 @@ export function HappyThread(props: {
initialScrollSessionRef.current = props.sessionId
autoScrollEnabledRef.current = true
atBottomRef.current = true
onAtBottomChangeRef.current(true)
onFlushPendingRef.current()
onViewModeChangeRef.current('tail')
scrollToBottomInstant()
initialScrollDeadlineRef.current = Date.now() + INITIAL_SCROLL_SETTLE_MS
@@ -581,7 +653,7 @@ export function HappyThread(props: {
}, delay))
}, [
props.sessionId,
props.isLoadingMessages,
props.isSyncingTail,
props.rawMessagesCount,
props.messagesVersion,
scrollToBottomInstant,
@@ -591,9 +663,10 @@ export function HappyThread(props: {
useEffect(() => {
return () => {
clearInitialScrollTimers()
clearCoverageRetryTimer()
settlePendingLoad(false)
}
}, [clearInitialScrollTimers, settlePendingLoad])
}, [clearInitialScrollTimers, clearCoverageRetryTimer, settlePendingLoad])
useEffect(() => {
if (forceScrollTokenRef.current === props.forceScrollToken) {
@@ -609,7 +682,7 @@ export function HappyThread(props: {
}
if (
isInitialScrollSettling()
|| isLoadingMessagesRef.current
|| isSyncingTailRef.current
|| !hasMoreMessagesRef.current
|| isLoadingMoreRef.current
|| loadLockRef.current
@@ -627,46 +700,95 @@ export function HappyThread(props: {
}
autoScrollEnabledRef.current = false
loadLockRef.current = true
loadStartedRef.current = false
pendingLoadBaselineRef.current = {
messagesVersion: messagesVersionRef.current,
historyVersion: historyVersionRef.current,
hasMoreMessages: hasMoreMessagesRef.current
}
const loadPromise = new Promise<boolean>((resolve) => {
pendingLoadResolveRef.current = resolve
})
pendingLoadPromiseRef.current = loadPromise
try {
void onLoadMoreRef.current().catch((error) => {
void (async () => {
try {
const loaded = await onLoadMoreRef.current()
if (loaded) {
return
}
pendingScrollRef.current = null
loadLockRef.current = false
settlePendingLoad(false)
} catch (error) {
pendingScrollRef.current = null
loadLockRef.current = false
settlePendingLoad(false)
console.error('Failed to load older messages:', error)
}).finally(() => {
if (!loadStartedRef.current && !isLoadingMoreRef.current) {
if (pendingScrollRef.current) {
pendingScrollRef.current = null
loadLockRef.current = false
}
settlePendingLoad(true)
}
})
} catch (error) {
pendingScrollRef.current = null
loadLockRef.current = false
settlePendingLoad(false)
console.error('Failed to load older messages:', error)
}
}
})()
return loadPromise
}, [isInitialScrollSettling, settlePendingLoad])
const needsViewportCoverage = useCallback((): boolean => {
const viewport = viewportRef.current
const sentinel = topSentinelRef.current
if (!viewport || !sentinel) {
return false
}
const viewportRect = viewport.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
return shouldLoadOlderForViewport({
scrollHeight: viewport.scrollHeight,
clientHeight: viewport.clientHeight,
viewportTop: viewportRect.top,
sentinelTop: sentinelRect.top,
sentinelBottom: sentinelRect.bottom
})
}, [])
const loadOlderWithCoverage = useCallback((forceFirstPage = false): Promise<boolean> => {
if (coveragePromiseRef.current) {
return coveragePromiseRef.current
}
const viewport = viewportRef.current
if (!viewport) {
return Promise.resolve(false)
}
const run = loadOlderUntilViewportCovered({
hasMoreMessages: () => hasMoreMessagesRef.current,
needsCoverage: needsViewportCoverage,
getScrollHeight: () => viewport.scrollHeight,
getClientHeight: () => viewport.clientHeight,
loadOlderPage: loadOlderPreservingScroll,
waitForRender: waitForRenderedFrame,
forceFirstPage
}).then((loadedPages) => loadedPages > 0)
let tracked: Promise<boolean>
tracked = run.finally(() => {
if (coveragePromiseRef.current === tracked) {
coveragePromiseRef.current = null
}
})
coveragePromiseRef.current = tracked
return tracked
}, [loadOlderPreservingScroll, needsViewportCoverage, waitForRenderedFrame])
const scheduleCoverageAfterSettling = useCallback(() => {
clearCoverageRetryTimer()
const delay = getHistoryCoverageRetryDelay(initialScrollDeadlineRef.current, Date.now())
coverageRetryTimerRef.current = window.setTimeout(() => {
coverageRetryTimerRef.current = null
void loadOlderWithCoverage(false)
}, delay)
}, [clearCoverageRetryTimer, loadOlderWithCoverage])
const loadOlderFromUserAction = useCallback((): Promise<boolean> => {
// Initial settling protects the automatic top sentinel from racing the
// first scroll-to-bottom pass. It must not swallow an explicit click.
initialScrollDeadlineRef.current = 0
clearInitialScrollTimers()
return loadOlderPreservingScroll()
}, [clearInitialScrollTimers, loadOlderPreservingScroll])
clearCoverageRetryTimer()
return loadOlderWithCoverage(true)
}, [clearInitialScrollTimers, clearCoverageRetryTimer, loadOlderWithCoverage])
const handleOutlineSelect = useCallback(async (item: ConversationOutlineItem) => {
const target = await locateOutlineTargetMessage({
@@ -685,14 +807,14 @@ export function HappyThread(props: {
useEffect(() => {
handleLoadMoreRef.current = () => {
void loadOlderPreservingScroll()
void loadOlderWithCoverage(false)
}
}, [loadOlderPreservingScroll])
}, [loadOlderWithCoverage])
useEffect(() => {
const sentinel = topSentinelRef.current
const viewport = viewportRef.current
if (!sentinel || !viewport || !props.hasMoreMessages || props.isLoadingMessages) {
if (!sentinel || !viewport || !props.hasMoreMessages || props.isSyncingTail) {
return
}
if (typeof IntersectionObserver === 'undefined') {
@@ -704,6 +826,7 @@ export function HappyThread(props: {
for (const entry of entries) {
if (entry.isIntersecting) {
if (isInitialScrollSettling()) {
scheduleCoverageAfterSettling()
continue
}
handleLoadMoreRef.current()
@@ -712,13 +835,36 @@ export function HappyThread(props: {
},
{
root: viewport,
rootMargin: '200px 0px 0px 0px'
rootMargin: `${HISTORY_PRELOAD_MARGIN_PX}px 0px 0px 0px`
}
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [props.hasMoreMessages, props.isLoadingMessages, isInitialScrollSettling])
}, [
props.hasMoreMessages,
props.isSyncingTail,
isInitialScrollSettling,
scheduleCoverageAfterSettling
])
useEffect(() => {
if (!props.hasMoreMessages || props.isSyncingTail) {
clearCoverageRetryTimer()
return
}
if (isInitialScrollSettling() && needsViewportCoverage()) {
scheduleCoverageAfterSettling()
}
}, [
props.hasMoreMessages,
props.isSyncingTail,
props.messagesVersion,
isInitialScrollSettling,
needsViewportCoverage,
scheduleCoverageAfterSettling,
clearCoverageRetryTimer
])
useEffect(() => {
const content = contentRef.current
@@ -737,10 +883,22 @@ export function HappyThread(props: {
) {
scrollToBottomInstant()
}
if (
hasMoreMessagesRef.current
&& !isInitialScrollSettling()
&& needsViewportCoverage()
) {
void loadOlderWithCoverage(false)
}
})
observer.observe(content)
return () => observer.disconnect()
}, [scrollToBottomInstant])
}, [
scrollToBottomInstant,
isInitialScrollSettling,
needsViewportCoverage,
loadOlderWithCoverage
])
useLayoutEffect(() => {
const pending = pendingScrollRef.current
@@ -763,24 +921,13 @@ export function HappyThread(props: {
if (atBottomRef.current && autoScrollEnabledRef.current) {
scrollToBottomInstant()
}
}, [props.messagesVersion, scrollToBottomInstant, settlePendingLoad])
}, [props.messagesVersion, props.historyVersion, scrollToBottomInstant, settlePendingLoad])
useEffect(() => {
isLoadingMoreRef.current = props.isLoadingMoreMessages
if (props.isLoadingMoreMessages) {
loadStartedRef.current = true
}
if (prevLoadingMoreRef.current && !props.isLoadingMoreMessages) {
if (pendingScrollRef.current) {
pendingScrollRef.current = null
loadLockRef.current = false
}
settlePendingLoad(true)
}
prevLoadingMoreRef.current = props.isLoadingMoreMessages
}, [props.isLoadingMoreMessages, settlePendingLoad])
}, [props.isLoadingMoreMessages])
const showSkeleton = props.isLoadingMessages && props.rawMessagesCount === 0 && props.pendingCount === 0
const showSkeleton = props.isSyncingTail && props.rawMessagesCount === 0
const handleShareTurn = useCallback((
messageTarget: HTMLElement | string | null,
clientY?: number,
@@ -863,6 +1010,15 @@ export function HappyThread(props: {
loadOlderMessagesPreservingScroll: loadOlderFromUserAction
}}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col relative">
{props.isSyncingTail && props.rawMessagesCount > 0 ? (
<div
role="status"
className="pointer-events-none absolute right-3 top-3 z-20 flex items-center gap-1.5 rounded-full border border-[var(--app-border)] bg-[var(--app-bg)]/90 px-2.5 py-1 text-xs text-[var(--app-hint)] shadow-sm backdrop-blur"
>
<Spinner size="sm" label={null} className="text-current" />
<span>{t('misc.loadingMessages')}</span>
</div>
) : null}
<ThreadPrimitive.Viewport
asChild
autoScroll={false}
@@ -883,7 +1039,7 @@ export function HappyThread(props: {
</div>
) : null}
{props.hasMoreMessages && !props.isLoadingMessages ? (
{props.hasMoreMessages && !props.isSyncingTail ? (
<div className="py-1 mb-2">
<div className="mx-auto w-fit">
<Button
@@ -892,7 +1048,7 @@ export function HappyThread(props: {
onClick={() => {
void loadOlderFromUserAction()
}}
disabled={props.isLoadingMoreMessages || props.isLoadingMessages}
disabled={props.isLoadingMoreMessages || props.isSyncingTail}
aria-busy={props.isLoadingMoreMessages}
className="gap-1.5 text-xs opacity-80 hover:opacity-100"
>
@@ -925,7 +1081,7 @@ export function HappyThread(props: {
</div>
</div>
</ThreadPrimitive.Viewport>
<NewMessagesIndicator count={props.pendingCount} onClick={scrollToBottom} />
<NewMessagesIndicator count={props.unseenCount} onClick={scrollToBottom} />
{props.outlineOpen ? (
<>
<button
@@ -71,8 +71,7 @@ function useQueuedMessages(sessionId: string): DecryptedMessage[] {
// useSyncExternalStore guarantees a stable reference when the snapshot is
// unchanged, so [state] as the dependency avoids unnecessary re-sorts.
return useMemo(() => {
const allMessages = [...state.messages, ...state.pending]
return sortQueuedMessages(allMessages.filter(isQueuedForInvocation))
return sortQueuedMessages(state.messages.filter(isQueuedForInvocation))
}, [state])
}
+4 -6
View File
@@ -313,16 +313,14 @@ describe('buildGoalStateMessages', () => {
.toEqual(['local-immediate'])
})
it('includes pending messages that are outside the visible timeline window', () => {
it('uses every canonical message even when the thread hides queued rows', () => {
const now = 1_700_000_000_000
const visible = [
userMessage({ id: 'visible', createdAt: now - 10 })
]
const pending = [
const messages = [
userMessage({ id: 'visible', createdAt: now - 10 }),
userMessage({ id: 'pending', createdAt: now })
]
expect(buildGoalStateMessages(visible, pending).map((message) => message.id))
expect(buildGoalStateMessages(messages).map((message) => message.id))
.toEqual(['visible', 'pending'])
})
+14 -20
View File
@@ -19,7 +19,7 @@ import { reduceChatBlocks } from '@/chat/reducer'
import { reconcileChatBlocks } from '@/chat/reconcile'
import { buildConversationOutline } from '@/chat/outline'
import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups'
import { isQueuedForInvocation, mergeMessages } from '@/lib/messages'
import { isQueuedForInvocation } from '@/lib/messages'
import { inactiveSessionCanResume } from '@/lib/sessionResume'
import {
getCodexModelReasoningEfforts,
@@ -357,14 +357,9 @@ export function ScratchlistDrawerHost(props: {
}
export function buildGoalStateMessages(
messages: DecryptedMessage[],
pendingMessages: DecryptedMessage[] = []
messages: DecryptedMessage[]
): DecryptedMessage[] {
const eligibleMessages = messages.filter((message) => !isUninvokedScheduledMessage(message))
const eligiblePendingMessages = pendingMessages.filter((message) => !isUninvokedScheduledMessage(message))
return eligiblePendingMessages.length > 0
? mergeMessages(eligibleMessages, eligiblePendingMessages)
: eligibleMessages
return messages.filter((message) => !isUninvokedScheduledMessage(message))
}
function hasAbortableAgentRun(blocks: readonly ChatBlock[]): boolean {
@@ -390,24 +385,23 @@ type SessionChatProps = {
cursorChatOnDisk?: boolean
reopenDisabledReason?: string
messages: DecryptedMessage[]
pendingMessages?: DecryptedMessage[]
messagesWarning: string | null
hasMoreMessages: boolean
isLoadingMessages: boolean
isSyncingTail: boolean
isLoadingMoreMessages: boolean
isSending: boolean
pendingCount: number
unseenCount: number
messagesVersion: number
historyVersion: number
onBack: () => void
onRefresh: () => void
onLoadMore: () => Promise<unknown>
onLoadMore: () => Promise<boolean>
// Resolves true when the send was accepted by the underlying mutation, false when
// pre-mutation guards (no-api / no-session / pending) rejected the call OR async
// inactive-session resume failed. Composer state that should only be cleared on
// actual send (pendingSchedule) must await this — see handleSend below.
onSend: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise<boolean>
onFlushPending: () => void
onAtBottomChange: (atBottom: boolean) => void
onViewModeChange: (mode: 'tail' | 'history') => void
onRetryMessage?: (localId: string) => void
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
availableSlashCommands?: readonly SlashCommand[]
@@ -920,8 +914,8 @@ function SessionChatInner(props: SessionChatProps) {
}, [visibleMessages])
const goalStateSourceMessages = useMemo(
() => buildGoalStateMessages(props.messages, props.pendingMessages ?? []),
[props.messages, props.pendingMessages]
() => buildGoalStateMessages(props.messages),
[props.messages]
)
const normalizedGoalStateMessages: NormalizedMessage[] = useMemo(() => {
@@ -1262,17 +1256,17 @@ function SessionChatInner(props: SessionChatProps) {
disabled={sessionInactive}
onRefresh={props.onRefresh}
onRetryMessage={props.onRetryMessage}
onFlushPending={props.onFlushPending}
onAtBottomChange={props.onAtBottomChange}
isLoadingMessages={props.isLoadingMessages}
onViewModeChange={props.onViewModeChange}
isSyncingTail={props.isSyncingTail}
messagesWarning={props.messagesWarning}
hasMoreMessages={props.hasMoreMessages}
isLoadingMoreMessages={props.isLoadingMoreMessages}
onLoadMore={props.onLoadMore}
pendingCount={props.pendingCount}
unseenCount={props.unseenCount}
rawMessagesCount={visibleMessages.length}
normalizedMessagesCount={normalizedMessages.length}
messagesVersion={props.messagesVersion}
historyVersion={props.historyVersion}
forceScrollToken={forceScrollToken}
outlineOpen={outlineOpen}
outlineItems={outlineItems}
@@ -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,
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,21 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ApiClient } from '@/api/client'
vi.mock('./message-window-store', () => ({
fetchLatestMessages: vi.fn(),
getQueuedReconcileCandidateLocalIds: vi.fn(),
markMessagesConsumed: vi.fn(),
reconcileQueuedLocalIds: vi.fn(),
syncTailMessages: vi.fn(),
}))
import {
fetchLatestMessages,
getQueuedReconcileCandidateLocalIds,
markMessagesConsumed,
reconcileQueuedLocalIds,
syncTailMessages,
} from './message-window-store'
import { reconcileQueuedStateAfterConnect } from './queued-state-reconciliation'
const mockFetchLatestMessages = vi.mocked(fetchLatestMessages)
const mockSyncTailMessages = vi.mocked(syncTailMessages)
const mockGetCandidates = vi.mocked(getQueuedReconcileCandidateLocalIds)
const mockMarkMessagesConsumed = vi.mocked(markMessagesConsumed)
const mockReconcileQueuedLocalIds = vi.mocked(reconcileQueuedLocalIds)
@@ -33,13 +33,13 @@ function createMockApi(
describe('reconcileQueuedStateAfterConnect', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchLatestMessages.mockResolvedValue(undefined)
mockSyncTailMessages.mockResolvedValue(undefined)
mockGetCandidates.mockReturnValue([])
})
it('waits for the latest messages before snapshotting and querying queued state', async () => {
let resolveRefresh: (() => void) | undefined
mockFetchLatestMessages.mockImplementationOnce(
mockSyncTailMessages.mockImplementationOnce(
() => new Promise<void>((resolve) => {
resolveRefresh = resolve
})
@@ -63,6 +63,11 @@ describe('reconcileQueuedStateAfterConnect', () => {
await reconciliation
expect(mockGetCandidates).toHaveBeenCalledWith('session-A')
expect(mockSyncTailMessages).toHaveBeenCalledWith(
expect.anything(),
'session-A',
{ ensureAfterCurrent: true }
)
expect(getQueuedState).toHaveBeenCalledWith('session-A', ['local-1'])
})
+2 -2
View File
@@ -1,9 +1,9 @@
import type { ApiClient } from '@/api/client'
import {
fetchLatestMessages,
getQueuedReconcileCandidateLocalIds,
markMessagesConsumed,
reconcileQueuedLocalIds,
syncTailMessages,
} from './message-window-store'
const QUEUED_STATE_BATCH_SIZE = 1000
@@ -12,7 +12,7 @@ export async function reconcileQueuedStateAfterConnect(
api: ApiClient,
sessionId: string
): Promise<void> {
await fetchLatestMessages(api, sessionId)
await syncTailMessages(api, sessionId, { ensureAfterCurrent: true })
const candidateLocalIds = getQueuedReconcileCandidateLocalIds(sessionId)
if (candidateLocalIds.length === 0) {
return
+10 -12
View File
@@ -39,7 +39,7 @@ import { ApiError } from '@/api/client'
import { queryKeys } from '@/lib/query-keys'
import { useToast } from '@/lib/toast-context'
import { useTranslation } from '@/lib/use-translation'
import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message-window-store'
import { seedMessageWindowFromSession, syncTailMessages } from '@/lib/message-window-store'
import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend'
import { inactiveSessionCanResume } from '@/lib/sessionResume'
import { markSessionSeen } from '@/lib/sessionLastSeen'
@@ -708,17 +708,16 @@ function SessionPage() {
} = useCursorChatStoreStatus({ api, session })
const {
messages,
pendingMessages,
warning: messagesWarning,
isLoading: messagesLoading,
isSyncingTail: messagesSyncingTail,
isLoadingMore: messagesLoadingMore,
hasMore: messagesHasMore,
loadMore: loadMoreMessages,
refetch: refetchMessages,
pendingCount,
unseenCount,
messagesVersion,
flushPending,
setAtBottom,
historyVersion,
setViewMode,
} = useMessages(api, sessionId)
// Tracks the most recent send the hub rejected (4xx/5xx/network), keyed
@@ -911,7 +910,7 @@ function SessionPage() {
queryKey: queryKeys.session(resolvedSessionId),
queryFn: () => api.getSession(resolvedSessionId),
}),
fetchLatestMessages(api, resolvedSessionId),
syncTailMessages(api, resolvedSessionId),
])
} catch {
}
@@ -1027,20 +1026,19 @@ function SessionPage() {
cursorChatOnDisk={cursorChatStoreStatus?.onDisk}
reopenDisabledReason={cursorReopenDisabledReason}
messages={messages}
pendingMessages={pendingMessages}
messagesWarning={messagesWarning}
hasMoreMessages={messagesHasMore}
isLoadingMessages={messagesLoading}
isSyncingTail={messagesSyncingTail}
isLoadingMoreMessages={messagesLoadingMore}
isSending={isSending}
pendingCount={pendingCount}
unseenCount={unseenCount}
messagesVersion={messagesVersion}
historyVersion={historyVersion}
onBack={goBack}
onRefresh={refreshSelectedSession}
onLoadMore={loadMoreMessages}
onSend={sendMessage}
onFlushPending={flushPending}
onAtBottomChange={setAtBottom}
onViewModeChange={setViewMode}
onRetryMessage={retryMessage}
autocompleteSuggestions={getAutocompleteSuggestions}
availableSlashCommands={slashCommands}