From ddbfde57cb601954a03dd4b4fd7f9835042e692c Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 7 Jan 2026 18:10:23 +0800 Subject: [PATCH] refactor: track visible pending messages separately from total count Introduces visibility tracking for pending messages to distinguish between total pending messages and those that are actually visible to the user. This allows the overflow warning to trigger only when visible messages are dropped, not just any messages. Includes caching of visibility checks for pending messages to improve performance. --- web/src/lib/message-window-store.ts | 126 +++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 12 deletions(-) diff --git a/web/src/lib/message-window-store.ts b/web/src/lib/message-window-store.ts index 59be8434..2f8e83ac 100644 --- a/web/src/lib/message-window-store.ts +++ b/web/src/lib/message-window-store.ts @@ -1,5 +1,6 @@ import type { ApiClient } from '@/api/client' import type { DecryptedMessage, MessageStatus } from '@/types/api' +import { normalizeDecryptedMessage } from '@/chat/normalize' import { mergeMessages } from '@/lib/messages' export type MessageWindowState = { @@ -24,10 +25,66 @@ const PENDING_OVERFLOW_WARNING = 'New messages arrived while you were away. Scro type InternalState = MessageWindowState & { pendingOverflowCount: number + pendingVisibleCount: number + pendingOverflowVisibleCount: number +} + +type PendingVisibilityCacheEntry = { + source: DecryptedMessage + visible: boolean } const states = new Map() const listeners = new Map void>>() +const pendingVisibilityCacheBySession = new Map>() + +function getPendingVisibilityCache(sessionId: string): Map { + const existing = pendingVisibilityCacheBySession.get(sessionId) + if (existing) { + return existing + } + const created = new Map() + pendingVisibilityCacheBySession.set(sessionId, created) + return created +} + +function clearPendingVisibilityCache(sessionId: string): void { + pendingVisibilityCacheBySession.delete(sessionId) +} + +function isVisiblePendingMessage(sessionId: string, message: DecryptedMessage): boolean { + const cache = getPendingVisibilityCache(sessionId) + const cached = cache.get(message.id) + if (cached && cached.source === message) { + return cached.visible + } + const visible = normalizeDecryptedMessage(message) !== null + cache.set(message.id, { source: message, visible }) + return visible +} + +function countVisiblePendingMessages(sessionId: string, messages: DecryptedMessage[]): number { + let count = 0 + for (const message of messages) { + if (isVisiblePendingMessage(sessionId, message)) { + count += 1 + } + } + return count +} + +function syncPendingVisibilityCache(sessionId: string, pending: DecryptedMessage[]): void { + const cache = pendingVisibilityCacheBySession.get(sessionId) + if (!cache) { + return + } + const keep = new Set(pending.map((message) => message.id)) + for (const id of cache.keys()) { + if (!keep.has(id)) { + cache.delete(id) + } + } +} function createState(sessionId: string): InternalState { return { @@ -35,6 +92,8 @@ function createState(sessionId: string): InternalState { messages: [], pending: [], pendingCount: 0, + pendingVisibleCount: 0, + pendingOverflowVisibleCount: 0, hasMore: false, oldestSeq: null, newestSeq: null, @@ -101,6 +160,8 @@ function buildState( messages?: DecryptedMessage[] pending?: DecryptedMessage[] pendingOverflowCount?: number + pendingVisibleCount?: number + pendingOverflowVisibleCount?: number hasMore?: boolean isLoading?: boolean isLoadingMore?: boolean @@ -111,7 +172,16 @@ function buildState( const messages = updates.messages ?? prev.messages const pending = updates.pending ?? prev.pending const pendingOverflowCount = updates.pendingOverflowCount ?? prev.pendingOverflowCount - const pendingCount = pending.length + pendingOverflowCount + const pendingOverflowVisibleCount = updates.pendingOverflowVisibleCount ?? prev.pendingOverflowVisibleCount + let pendingVisibleCount = updates.pendingVisibleCount ?? prev.pendingVisibleCount + const pendingChanged = pending !== prev.pending + if (pendingChanged && updates.pendingVisibleCount === undefined) { + pendingVisibleCount = countVisiblePendingMessages(prev.sessionId, pending) + } + if (pendingChanged) { + syncPendingVisibilityCache(prev.sessionId, pending) + } + const pendingCount = pendingVisibleCount + pendingOverflowVisibleCount const { oldestSeq, newestSeq } = deriveSeqBounds(messages) const messagesVersion = messages === prev.messages ? prev.messagesVersion : prev.messagesVersion + 1 @@ -120,6 +190,8 @@ function buildState( messages, pending, pendingOverflowCount, + pendingVisibleCount, + pendingOverflowVisibleCount, pendingCount, oldestSeq, newestSeq, @@ -142,12 +214,18 @@ function trimVisible(messages: DecryptedMessage[], mode: 'append' | 'prepend'): return messages.slice(messages.length - VISIBLE_WINDOW_SIZE) } -function trimPending(messages: DecryptedMessage[]): { pending: DecryptedMessage[]; dropped: number } { +function trimPending( + sessionId: string, + messages: DecryptedMessage[] +): { pending: DecryptedMessage[]; dropped: number; droppedVisible: number } { if (messages.length <= PENDING_WINDOW_SIZE) { - return { pending: messages, dropped: 0 } + return { pending: messages, dropped: 0, droppedVisible: 0 } } - const dropped = messages.length - PENDING_WINDOW_SIZE - return { pending: messages.slice(messages.length - PENDING_WINDOW_SIZE), dropped } + const cutoff = messages.length - PENDING_WINDOW_SIZE + const droppedMessages = messages.slice(0, cutoff) + const pending = messages.slice(cutoff) + const droppedVisible = countVisiblePendingMessages(sessionId, droppedMessages) + return { pending, dropped: droppedMessages.length, droppedVisible } } function filterPendingAgainstVisible(pending: DecryptedMessage[], visible: DecryptedMessage[]): DecryptedMessage[] { @@ -165,16 +243,30 @@ function isOptimisticMessage(message: DecryptedMessage): boolean { function mergeIntoPending( prev: InternalState, incoming: DecryptedMessage[] -): { pending: DecryptedMessage[]; pendingOverflowCount: number; warning: string | null } { +): { + pending: DecryptedMessage[] + pendingVisibleCount: number + pendingOverflowCount: number + pendingOverflowVisibleCount: number + warning: string | null +} { if (incoming.length === 0) { - return { pending: prev.pending, pendingOverflowCount: prev.pendingOverflowCount, warning: prev.warning } + return { + pending: prev.pending, + pendingVisibleCount: prev.pendingVisibleCount, + pendingOverflowCount: prev.pendingOverflowCount, + pendingOverflowVisibleCount: prev.pendingOverflowVisibleCount, + warning: prev.warning + } } const mergedPending = mergeMessages(prev.pending, incoming) const filtered = filterPendingAgainstVisible(mergedPending, prev.messages) - const { pending, dropped } = trimPending(filtered) + const { pending, dropped, droppedVisible } = trimPending(prev.sessionId, filtered) + const pendingVisibleCount = countVisiblePendingMessages(prev.sessionId, pending) const pendingOverflowCount = prev.pendingOverflowCount + dropped - const warning = dropped > 0 && !prev.warning ? PENDING_OVERFLOW_WARNING : prev.warning - return { pending, pendingOverflowCount, warning } + const pendingOverflowVisibleCount = prev.pendingOverflowVisibleCount + droppedVisible + const warning = droppedVisible > 0 && !prev.warning ? PENDING_OVERFLOW_WARNING : prev.warning + return { pending, pendingVisibleCount, pendingOverflowCount, pendingOverflowVisibleCount, warning } } export function getMessageWindowState(sessionId: string): MessageWindowState { @@ -192,11 +284,13 @@ export function subscribeMessageWindow(sessionId: string, listener: () => void): if (current.size === 0) { listeners.delete(sessionId) states.delete(sessionId) + clearPendingVisibilityCache(sessionId) } } } export function clearMessageWindow(sessionId: string): void { + clearPendingVisibilityCache(sessionId) if (!states.has(sessionId)) { return } @@ -220,6 +314,8 @@ export async function fetchLatestMessages(api: ApiClient, sessionId: string): Pr messages: trimmed, pending: [], pendingOverflowCount: 0, + pendingVisibleCount: 0, + pendingOverflowVisibleCount: 0, hasMore: response.page.hasMore, isLoading: false, warning: null, @@ -228,7 +324,9 @@ export async function fetchLatestMessages(api: ApiClient, sessionId: string): Pr const pendingResult = mergeIntoPending(prev, response.messages) return buildState(prev, { pending: pendingResult.pending, + pendingVisibleCount: pendingResult.pendingVisibleCount, pendingOverflowCount: pendingResult.pendingOverflowCount, + pendingOverflowVisibleCount: pendingResult.pendingOverflowVisibleCount, isLoading: false, warning: pendingResult.warning, }) @@ -280,7 +378,9 @@ export function ingestIncomingMessages(sessionId: string, incoming: DecryptedMes const pendingResult = mergeIntoPending(prev, incoming) return buildState(prev, { pending: pendingResult.pending, + pendingVisibleCount: pendingResult.pendingVisibleCount, pendingOverflowCount: pendingResult.pendingOverflowCount, + pendingOverflowVisibleCount: pendingResult.pendingOverflowVisibleCount, warning: pendingResult.warning, }) }) @@ -288,10 +388,10 @@ export function ingestIncomingMessages(sessionId: string, incoming: DecryptedMes export function flushPendingMessages(sessionId: string): boolean { const current = getState(sessionId) - if (current.pending.length === 0 && current.pendingOverflowCount === 0) { + if (current.pending.length === 0 && current.pendingOverflowVisibleCount === 0) { return false } - const needsRefresh = current.pendingOverflowCount > 0 + const needsRefresh = current.pendingOverflowVisibleCount > 0 updateState(sessionId, (prev) => { const merged = mergeMessages(prev.messages, prev.pending) const trimmed = trimVisible(merged, 'append') @@ -299,6 +399,8 @@ export function flushPendingMessages(sessionId: string): boolean { messages: trimmed, pending: [], pendingOverflowCount: 0, + pendingVisibleCount: 0, + pendingOverflowVisibleCount: 0, warning: needsRefresh ? (prev.warning ?? PENDING_OVERFLOW_WARNING) : prev.warning, }) })