refactor(team): extract message cache layer
This commit is contained in:
parent
a8e7f1ccd5
commit
8589391ccf
3 changed files with 503 additions and 275 deletions
|
|
@ -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<TeamSlice, 'teamMessagesByName'>,
|
||||
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<TeamSlice, 'teamMessagesByName'>,
|
||||
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<TeamSlice, 'teamMessagesByName'>,
|
||||
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<TeamSlice, 'memberActivityMetaByTeam' | 'teamMessagesByName'>,
|
||||
teamName: string
|
||||
|
|
|
|||
291
src/renderer/store/team/teamMessagesCache.ts
Normal file
291
src/renderer/store/team/teamMessagesCache.ts
Normal file
|
|
@ -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<string, TeamMessagesCacheEntry>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
186
test/renderer/store/teamMessagesCache.test.ts
Normal file
186
test/renderer/store/teamMessagesCache.test.ts
Normal file
|
|
@ -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<InboxMessage> & { 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> = {}): 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue