mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
refactor(sync): replace message reloads with incremental tail sync
This commit is contained in:
+3
-3
@@ -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(() => {
|
||||
|
||||
@@ -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>>()
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
+732
-974
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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user