diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 12537c80..4fc94234 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -11,7 +11,6 @@ import { canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; -import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; @@ -30,9 +29,24 @@ import { isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; +import { + areInboxMessageArraysEquivalent, + clearTeamMessageSelectorCaches, + clearTeamMessageSelectorCachesForTeam, + extractRetainedCanonicalOlderTail, + getCanonicalHeadSlice, + getTeamMessagesCacheEntry, + getTeamMessageSelectorCacheSnapshotForTeam, + pruneOptimisticMessages, + upsertOptimisticTeamMessage, +} from '../team/teamMessagesCache'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; +import type { + RefreshTeamMessagesHeadResult, + TeamMessagesCacheEntry, +} from '../team/teamMessagesCache'; import type { AppState } from '../types'; import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; import type { AppConfig } from '@renderer/types/data'; @@ -81,6 +95,12 @@ import type { } from '@shared/types'; import type { StateCreator } from 'zustand'; +export type { + RefreshTeamMessagesHeadResult, + TeamMessagesCacheEntry, +} from '../team/teamMessagesCache'; +export { selectMemberMessagesForTeamMember, selectTeamMessages } from '../team/teamMessagesCache'; + const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; const logger = createLogger('teamSlice'); @@ -240,13 +260,12 @@ export function __resetTeamSliceModuleStateForTests(): void { memberSpawnUiEqualLastWarnAtByTeam.clear(); resolvedMembersSelectorCache.clear(); resolvedMemberSelectorCache.clear(); - mergedMessagesSelectorCache.clear(); - memberMessagesSelectorCache.clear(); + clearTeamMessageSelectorCaches(); } function clearTeamScopedSelectorCaches(teamName: string): void { resolvedMembersSelectorCache.delete(teamName); - mergedMessagesSelectorCache.delete(teamName); + clearTeamMessageSelectorCachesForTeam(teamName); const teamScopedPrefix = `${teamName}:`; for (const key of resolvedMemberSelectorCache.keys()) { @@ -254,11 +273,6 @@ function clearTeamScopedSelectorCaches(teamName: string): void { resolvedMemberSelectorCache.delete(key); } } - for (const key of memberMessagesSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - memberMessagesSelectorCache.delete(key); - } - } } function clearTeamScopedTransientState(teamName: string): void { @@ -649,25 +663,20 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { hasMemberSpawnUiEqualLastWarn: boolean; } { const teamScopedPrefix = `${teamName}:`; + const messageSelectorCache = getTeamMessageSelectorCacheSnapshotForTeam(teamName); let resolvedMemberSelectorCount = 0; - let memberMessagesSelectorCount = 0; for (const key of resolvedMemberSelectorCache.keys()) { if (key.startsWith(teamScopedPrefix)) { resolvedMemberSelectorCount += 1; } } - for (const key of memberMessagesSelectorCache.keys()) { - if (key.startsWith(teamScopedPrefix)) { - memberMessagesSelectorCount += 1; - } - } return { hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName), resolvedMemberSelectorCount, - hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName), - memberMessagesSelectorCount, + hasMergedMessagesSelector: messageSelectorCache.hasMergedMessagesSelector, + memberMessagesSelectorCount: messageSelectorCache.memberMessagesSelectorCount, hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName), hasQueuedFullTeamDataRefreshAfterThin: queuedFullTeamDataRefreshesAfterThin.has(teamName), hasPostPaintTeamEnrichmentTimer: postPaintTeamEnrichmentTimers.has(teamName), @@ -1082,153 +1091,6 @@ function areTeamAgentRuntimeSnapshotsEqual( return true; } -function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number { - const aTime = Date.parse(a.timestamp); - const bTime = Date.parse(b.timestamp); - const aValid = Number.isFinite(aTime); - const bValid = Number.isFinite(bTime); - if (aValid && bValid && aTime !== bTime) { - return aTime - bTime; - } - if (aValid !== bValid) { - return aValid ? -1 : 1; - } - const aId = typeof a.messageId === 'string' ? a.messageId : ''; - const bId = typeof b.messageId === 'string' ? b.messageId : ''; - return aId.localeCompare(bId); -} - -export interface TeamMessagesCacheEntry { - canonicalMessages: InboxMessage[]; - optimisticMessages: InboxMessage[]; - feedRevision: string | null; - nextCursor: string | null; - hasMore: boolean; - lastFetchedAt: number | null; - loadingHead: boolean; - loadingOlder: boolean; - headHydrated: boolean; -} - -export interface RefreshTeamMessagesHeadResult { - feedChanged: boolean; - headChanged: boolean; - feedRevision: string | null; -} - -const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = { - canonicalMessages: [], - optimisticMessages: [], - feedRevision: null, - nextCursor: null, - hasMore: false, - lastFetchedAt: null, - loadingHead: false, - loadingOlder: false, - headHydrated: false, -}; - -function createEmptyTeamMessagesCacheEntry(): TeamMessagesCacheEntry { - return { - canonicalMessages: [], - optimisticMessages: [], - feedRevision: null, - nextCursor: null, - hasMore: false, - lastFetchedAt: null, - loadingHead: false, - loadingOlder: false, - headHydrated: false, - }; -} - -function getTeamMessagesCacheEntry( - state: Pick, - teamName: string -): TeamMessagesCacheEntry { - return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY; -} - -function upsertOptimisticTeamMessage( - entry: TeamMessagesCacheEntry, - message: InboxMessage -): TeamMessagesCacheEntry { - const nextOptimistic = [...entry.optimisticMessages]; - const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; - if (messageId.length > 0) { - const existingIndex = nextOptimistic.findIndex( - (candidate) => - typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId - ); - if (existingIndex >= 0) { - nextOptimistic[existingIndex] = { - ...nextOptimistic[existingIndex], - ...message, - }; - } else { - nextOptimistic.push(message); - } - } else { - nextOptimistic.push(message); - } - nextOptimistic.sort(compareInboxMessagesByTimestamp); - return { - ...entry, - optimisticMessages: nextOptimistic, - }; -} - -function areInboxMessageArraysEquivalent( - left: readonly InboxMessage[], - right: readonly InboxMessage[] -): boolean { - if (left === right) return true; - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - const leftItem = left[index]; - const rightItem = right[index]; - if ( - leftItem.messageId !== rightItem.messageId || - leftItem.timestamp !== rightItem.timestamp || - leftItem.from !== rightItem.from || - leftItem.to !== rightItem.to || - leftItem.text !== rightItem.text || - leftItem.summary !== rightItem.summary || - leftItem.read !== rightItem.read || - leftItem.actionMode !== rightItem.actionMode || - leftItem.commentId !== rightItem.commentId || - leftItem.relayOfMessageId !== rightItem.relayOfMessageId || - leftItem.source !== rightItem.source || - leftItem.leadSessionId !== rightItem.leadSessionId || - leftItem.messageKind !== rightItem.messageKind || - JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null) - ) { - return false; - } - } - return true; -} - -function pruneOptimisticMessages( - optimistic: readonly InboxMessage[], - canonical: readonly InboxMessage[] -): InboxMessage[] { - if (optimistic.length === 0) { - return []; - } - - const canonicalIds = new Set( - canonical - .map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : '')) - .filter((messageId) => messageId.length > 0) - ); - - return optimistic.filter((message) => { - const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; - return !messageId || !canonicalIds.has(messageId); - }); -} - function clearPendingReplyRefreshTimer(teamName: string): void { const existingTimer = pendingTeamPendingReplyRefreshTimers.get(teamName); if (existingTimer == null) { @@ -1266,50 +1128,6 @@ function setPendingReplyRefreshEnabled( return true; } -function getCanonicalHeadSlice( - canonicalMessages: readonly InboxMessage[], - headLength: number -): readonly InboxMessage[] { - if (headLength <= 0) { - return []; - } - return canonicalMessages.slice(0, headLength); -} - -function extractRetainedCanonicalOlderTail( - canonicalMessages: readonly InboxMessage[], - freshHeadMessages: readonly InboxMessage[] -): InboxMessage[] | null { - if (canonicalMessages.length === 0) { - return []; - } - if (freshHeadMessages.length === 0) { - return null; - } - - const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message))); - let hasMessagesOutsideFreshHead = false; - for (const message of canonicalMessages) { - if (!freshHeadKeys.has(toMessageKey(message))) { - hasMessagesOutsideFreshHead = true; - break; - } - } - if (!hasMessagesOutsideFreshHead) { - return []; - } - - const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]); - const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey); - if (anchorIndex < 0) { - return null; - } - - return canonicalMessages - .slice(anchorIndex + 1) - .filter((message) => !freshHeadKeys.has(toMessageKey(message))); -} - async function refreshTaskChangePresenceForUpdatedTask( getState: () => AppState, teamName: string, @@ -1945,23 +1763,8 @@ const resolvedMemberSelectorCache = new Map< result: ResolvedTeamMember | null; } >(); -const mergedMessagesSelectorCache = new Map< - string, - { - canonicalRef: InboxMessage[]; - optimisticRef: InboxMessage[]; - result: InboxMessage[]; - } ->(); const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = []; const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; -const memberMessagesSelectorCache = new Map< - string, - { - messagesRef: InboxMessage[]; - result: InboxMessage[]; - } ->(); function resolveMemberStatus( snapshot: TeamMemberSnapshot, @@ -2534,58 +2337,6 @@ export function selectTeamIsAliveForName( return selectTeamDataForName(state, teamName)?.isAlive; } -export function selectTeamMessages( - state: Pick, - teamName: string | null | undefined -): InboxMessage[] { - if (!teamName) { - return []; - } - - const entry = getTeamMessagesCacheEntry(state, teamName); - const cached = mergedMessagesSelectorCache.get(teamName); - if ( - cached?.canonicalRef === entry.canonicalMessages && - cached.optimisticRef === entry.optimisticMessages - ) { - return cached.result; - } - - const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages); - mergedMessagesSelectorCache.set(teamName, { - canonicalRef: entry.canonicalMessages, - optimisticRef: entry.optimisticMessages, - result, - }); - return result; -} - -export function selectMemberMessagesForTeamMember( - state: Pick, - teamName: string | null | undefined, - memberName: string | null | undefined -): InboxMessage[] { - if (!teamName || !memberName) { - return []; - } - - const messages = selectTeamMessages(state, teamName); - const cacheKey = `${teamName}:${memberName}`; - const cached = memberMessagesSelectorCache.get(cacheKey); - if (cached?.messagesRef === messages) { - return cached.result; - } - - const result = messages.filter( - (message) => message.from === memberName || message.to === memberName - ); - memberMessagesSelectorCache.set(cacheKey, { - messagesRef: messages, - result, - }); - return result; -} - function isMemberActivityMetaStale( state: Pick, teamName: string diff --git a/src/renderer/store/team/teamMessagesCache.ts b/src/renderer/store/team/teamMessagesCache.ts new file mode 100644 index 00000000..b5900d82 --- /dev/null +++ b/src/renderer/store/team/teamMessagesCache.ts @@ -0,0 +1,291 @@ +import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; + +import type { InboxMessage } from '@shared/types'; + +export interface TeamMessagesCacheEntry { + canonicalMessages: InboxMessage[]; + optimisticMessages: InboxMessage[]; + feedRevision: string | null; + nextCursor: string | null; + hasMore: boolean; + lastFetchedAt: number | null; + loadingHead: boolean; + loadingOlder: boolean; + headHydrated: boolean; +} + +export interface RefreshTeamMessagesHeadResult { + feedChanged: boolean; + headChanged: boolean; + feedRevision: string | null; +} + +export interface TeamMessagesCacheState { + teamMessagesByName: Record; +} + +export interface TeamMessageSelectorCacheSnapshot { + hasMergedMessagesSelector: boolean; + memberMessagesSelectorCount: number; +} + +export const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: false, +}; + +const mergedMessagesSelectorCache = new Map< + string, + { + canonicalRef: readonly InboxMessage[]; + optimisticRef: readonly InboxMessage[]; + result: InboxMessage[]; + } +>(); +const memberMessagesSelectorCache = new Map< + string, + { + messagesRef: readonly InboxMessage[]; + result: InboxMessage[]; + } +>(); + +export function clearTeamMessageSelectorCaches(): void { + mergedMessagesSelectorCache.clear(); + memberMessagesSelectorCache.clear(); +} + +export function clearTeamMessageSelectorCachesForTeam(teamName: string): void { + mergedMessagesSelectorCache.delete(teamName); + + const teamScopedPrefix = `${teamName}:`; + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCache.delete(key); + } + } +} + +export function getTeamMessageSelectorCacheSnapshotForTeam( + teamName: string +): TeamMessageSelectorCacheSnapshot { + const teamScopedPrefix = `${teamName}:`; + let memberMessagesSelectorCount = 0; + for (const key of memberMessagesSelectorCache.keys()) { + if (key.startsWith(teamScopedPrefix)) { + memberMessagesSelectorCount += 1; + } + } + + return { + hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName), + memberMessagesSelectorCount, + }; +} + +export function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number { + const aTime = Date.parse(a.timestamp); + const bTime = Date.parse(b.timestamp); + const aValid = Number.isFinite(aTime); + const bValid = Number.isFinite(bTime); + if (aValid && bValid && aTime !== bTime) { + return aTime - bTime; + } + if (aValid !== bValid) { + return aValid ? -1 : 1; + } + const aId = typeof a.messageId === 'string' ? a.messageId : ''; + const bId = typeof b.messageId === 'string' ? b.messageId : ''; + return aId.localeCompare(bId); +} + +export function getTeamMessagesCacheEntry( + state: TeamMessagesCacheState, + teamName: string +): TeamMessagesCacheEntry { + return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY; +} + +export function upsertOptimisticTeamMessage( + entry: TeamMessagesCacheEntry, + message: InboxMessage +): TeamMessagesCacheEntry { + const nextOptimistic = [...entry.optimisticMessages]; + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + const existingIndex = nextOptimistic.findIndex( + (candidate) => + typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId + ); + if (existingIndex >= 0) { + nextOptimistic[existingIndex] = { + ...nextOptimistic[existingIndex], + ...message, + }; + } else { + nextOptimistic.push(message); + } + } else { + nextOptimistic.push(message); + } + nextOptimistic.sort(compareInboxMessagesByTimestamp); + return { + ...entry, + optimisticMessages: nextOptimistic, + }; +} + +export function areInboxMessageArraysEquivalent( + left: readonly InboxMessage[], + right: readonly InboxMessage[] +): boolean { + if (left === right) return true; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftItem = left[index]; + const rightItem = right[index]; + if ( + leftItem.messageId !== rightItem.messageId || + leftItem.timestamp !== rightItem.timestamp || + leftItem.from !== rightItem.from || + leftItem.to !== rightItem.to || + leftItem.text !== rightItem.text || + leftItem.summary !== rightItem.summary || + leftItem.read !== rightItem.read || + leftItem.actionMode !== rightItem.actionMode || + leftItem.commentId !== rightItem.commentId || + leftItem.relayOfMessageId !== rightItem.relayOfMessageId || + leftItem.source !== rightItem.source || + leftItem.leadSessionId !== rightItem.leadSessionId || + leftItem.messageKind !== rightItem.messageKind || + JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null) + ) { + return false; + } + } + return true; +} + +export function pruneOptimisticMessages( + optimistic: readonly InboxMessage[], + canonical: readonly InboxMessage[] +): InboxMessage[] { + if (optimistic.length === 0) { + return []; + } + + const canonicalIds = new Set( + canonical + .map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : '')) + .filter((messageId) => messageId.length > 0) + ); + + return optimistic.filter((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + return !messageId || !canonicalIds.has(messageId); + }); +} + +export function getCanonicalHeadSlice( + canonicalMessages: readonly InboxMessage[], + headLength: number +): readonly InboxMessage[] { + if (headLength <= 0) { + return []; + } + return canonicalMessages.slice(0, headLength); +} + +export function extractRetainedCanonicalOlderTail( + canonicalMessages: readonly InboxMessage[], + freshHeadMessages: readonly InboxMessage[] +): InboxMessage[] | null { + if (canonicalMessages.length === 0) { + return []; + } + if (freshHeadMessages.length === 0) { + return null; + } + + const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message))); + let hasMessagesOutsideFreshHead = false; + for (const message of canonicalMessages) { + if (!freshHeadKeys.has(toMessageKey(message))) { + hasMessagesOutsideFreshHead = true; + break; + } + } + if (!hasMessagesOutsideFreshHead) { + return []; + } + + const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]); + const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey); + if (anchorIndex < 0) { + return null; + } + + return canonicalMessages + .slice(anchorIndex + 1) + .filter((message) => !freshHeadKeys.has(toMessageKey(message))); +} + +export function selectTeamMessages( + state: TeamMessagesCacheState, + teamName: string | null | undefined +): InboxMessage[] { + if (!teamName) { + return []; + } + + const entry = getTeamMessagesCacheEntry(state, teamName); + const cached = mergedMessagesSelectorCache.get(teamName); + if ( + cached?.canonicalRef === entry.canonicalMessages && + cached.optimisticRef === entry.optimisticMessages + ) { + return cached.result; + } + + const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages); + mergedMessagesSelectorCache.set(teamName, { + canonicalRef: entry.canonicalMessages, + optimisticRef: entry.optimisticMessages, + result, + }); + return result; +} + +export function selectMemberMessagesForTeamMember( + state: TeamMessagesCacheState, + teamName: string | null | undefined, + memberName: string | null | undefined +): InboxMessage[] { + if (!teamName || !memberName) { + return []; + } + + const messages = selectTeamMessages(state, teamName); + const cacheKey = `${teamName}:${memberName}`; + const cached = memberMessagesSelectorCache.get(cacheKey); + if (cached?.messagesRef === messages) { + return cached.result; + } + + const result = messages.filter( + (message) => message.from === memberName || message.to === memberName + ); + memberMessagesSelectorCache.set(cacheKey, { + messagesRef: messages, + result, + }); + return result; +} diff --git a/test/renderer/store/teamMessagesCache.test.ts b/test/renderer/store/teamMessagesCache.test.ts new file mode 100644 index 00000000..74f11d82 --- /dev/null +++ b/test/renderer/store/teamMessagesCache.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + areInboxMessageArraysEquivalent, + clearTeamMessageSelectorCaches, + clearTeamMessageSelectorCachesForTeam, + EMPTY_TEAM_MESSAGES_CACHE_ENTRY, + extractRetainedCanonicalOlderTail, + getCanonicalHeadSlice, + getTeamMessagesCacheEntry, + getTeamMessageSelectorCacheSnapshotForTeam, + pruneOptimisticMessages, + selectMemberMessagesForTeamMember, + selectTeamMessages, + type TeamMessagesCacheEntry, + type TeamMessagesCacheState, + upsertOptimisticTeamMessage, +} from '../../../src/renderer/store/team/teamMessagesCache'; + +import type { InboxMessage } from '../../../src/shared/types'; + +afterEach(() => { + clearTeamMessageSelectorCaches(); +}); + +function createMessage(overrides: Partial & { messageId: string }): InboxMessage { + return { + from: 'lead', + to: 'alice', + text: overrides.messageId, + timestamp: '2026-03-12T10:00:00.000Z', + read: false, + ...overrides, + }; +} + +function createEntry(overrides: Partial = {}): TeamMessagesCacheEntry { + return { + ...EMPTY_TEAM_MESSAGES_CACHE_ENTRY, + ...overrides, + }; +} + +describe('teamMessagesCache', () => { + it('returns the immutable empty entry when a team has no cached messages', () => { + const state: TeamMessagesCacheState = { teamMessagesByName: {} }; + + expect(getTeamMessagesCacheEntry(state, 'missing-team')).toBe(EMPTY_TEAM_MESSAGES_CACHE_ENTRY); + }); + + it('upserts optimistic messages by durable id and keeps deterministic timestamp order', () => { + const first = upsertOptimisticTeamMessage( + createEntry(), + createMessage({ + messageId: 'msg-new', + timestamp: '2026-03-12T10:00:03.000Z', + text: 'draft', + }) + ); + const second = upsertOptimisticTeamMessage( + first, + createMessage({ + messageId: 'msg-old', + timestamp: '2026-03-12T10:00:01.000Z', + }) + ); + const replaced = upsertOptimisticTeamMessage( + second, + createMessage({ + messageId: 'msg-new', + timestamp: '2026-03-12T10:00:03.000Z', + text: 'sent', + }) + ); + + expect(replaced.optimisticMessages.map((message) => message.messageId)).toEqual([ + 'msg-old', + 'msg-new', + ]); + expect(replaced.optimisticMessages[1].text).toBe('sent'); + }); + + it('compares semantic message arrays and prunes optimistic rows confirmed by canonical data', () => { + const canonical = [ + createMessage({ messageId: 'msg-1', text: 'confirmed' }), + createMessage({ messageId: 'msg-2' }), + ]; + const equivalentCanonical = [ + createMessage({ messageId: 'msg-1', text: 'confirmed' }), + createMessage({ messageId: 'msg-2' }), + ]; + const optimistic = [ + createMessage({ messageId: 'msg-1', text: 'draft that arrived' }), + createMessage({ messageId: 'msg-local', text: 'still local' }), + ]; + + expect(areInboxMessageArraysEquivalent(canonical, equivalentCanonical)).toBe(true); + expect( + areInboxMessageArraysEquivalent(canonical, [ + createMessage({ messageId: 'msg-1', text: 'changed' }), + createMessage({ messageId: 'msg-2' }), + ]) + ).toBe(false); + expect(pruneOptimisticMessages(optimistic, canonical).map((message) => message.messageId)).toEqual( + ['msg-local'] + ); + }); + + it('retains already-loaded older tail only when the fresh head anchors into canonical data', () => { + const canonical = [ + createMessage({ messageId: 'msg-4', timestamp: '2026-03-12T10:00:04.000Z' }), + createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }), + createMessage({ messageId: 'msg-2', timestamp: '2026-03-12T10:00:02.000Z' }), + createMessage({ messageId: 'msg-1', timestamp: '2026-03-12T10:00:01.000Z' }), + ]; + const freshHead = [ + createMessage({ messageId: 'msg-5', timestamp: '2026-03-12T10:00:05.000Z' }), + createMessage({ messageId: 'msg-3', timestamp: '2026-03-12T10:00:03.000Z' }), + ]; + + expect(getCanonicalHeadSlice(canonical, 2).map((message) => message.messageId)).toEqual([ + 'msg-4', + 'msg-3', + ]); + expect( + extractRetainedCanonicalOlderTail(canonical, freshHead)?.map((message) => message.messageId) + ).toEqual(['msg-2', 'msg-1']); + expect( + extractRetainedCanonicalOlderTail(canonical, [createMessage({ messageId: 'disjoint' })]) + ).toBeNull(); + }); + + it('memoizes merged and member-scoped selectors and clears team-scoped caches', () => { + const state: TeamMessagesCacheState = { + teamMessagesByName: { + 'my-team': createEntry({ + canonicalMessages: [ + createMessage({ + messageId: 'msg-1', + to: 'alice', + timestamp: '2026-03-12T10:00:01.000Z', + }), + createMessage({ + messageId: 'msg-2', + to: 'bob', + timestamp: '2026-03-12T10:00:02.000Z', + }), + ], + optimisticMessages: [ + createMessage({ + messageId: 'msg-3', + from: 'alice', + to: 'lead', + timestamp: '2026-03-12T10:00:03.000Z', + }), + ], + }), + }, + }; + + const firstTeamMessages = selectTeamMessages(state, 'my-team'); + const secondTeamMessages = selectTeamMessages(state, 'my-team'); + const firstAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice'); + const secondAliceMessages = selectMemberMessagesForTeamMember(state, 'my-team', 'alice'); + + expect(firstTeamMessages).toBe(secondTeamMessages); + expect(firstAliceMessages).toBe(secondAliceMessages); + expect(firstTeamMessages.map((message) => message.messageId)).toEqual([ + 'msg-3', + 'msg-2', + 'msg-1', + ]); + expect(firstAliceMessages.map((message) => message.messageId)).toEqual(['msg-3', 'msg-1']); + expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasMergedMessagesSelector: true, + memberMessagesSelectorCount: 1, + }); + + clearTeamMessageSelectorCachesForTeam('my-team'); + + expect(getTeamMessageSelectorCacheSnapshotForTeam('my-team')).toEqual({ + hasMergedMessagesSelector: false, + memberMessagesSelectorCount: 0, + }); + }); +});