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,
|
canDisplayTaskChangesForOptions,
|
||||||
type TaskChangeRequestOptions,
|
type TaskChangeRequestOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
|
||||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
|
|
@ -30,9 +29,24 @@ import {
|
||||||
isTeamTaskNeedsFixActionable,
|
isTeamTaskNeedsFixActionable,
|
||||||
} from '@shared/utils/teamTaskState';
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
|
import {
|
||||||
|
areInboxMessageArraysEquivalent,
|
||||||
|
clearTeamMessageSelectorCaches,
|
||||||
|
clearTeamMessageSelectorCachesForTeam,
|
||||||
|
extractRetainedCanonicalOlderTail,
|
||||||
|
getCanonicalHeadSlice,
|
||||||
|
getTeamMessagesCacheEntry,
|
||||||
|
getTeamMessageSelectorCacheSnapshotForTeam,
|
||||||
|
pruneOptimisticMessages,
|
||||||
|
upsertOptimisticTeamMessage,
|
||||||
|
} from '../team/teamMessagesCache';
|
||||||
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
||||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
RefreshTeamMessagesHeadResult,
|
||||||
|
TeamMessagesCacheEntry,
|
||||||
|
} from '../team/teamMessagesCache';
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||||
import type { AppConfig } from '@renderer/types/data';
|
import type { AppConfig } from '@renderer/types/data';
|
||||||
|
|
@ -81,6 +95,12 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { StateCreator } from 'zustand';
|
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 GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
|
||||||
const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
|
const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
|
||||||
const logger = createLogger('teamSlice');
|
const logger = createLogger('teamSlice');
|
||||||
|
|
@ -240,13 +260,12 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
memberSpawnUiEqualLastWarnAtByTeam.clear();
|
memberSpawnUiEqualLastWarnAtByTeam.clear();
|
||||||
resolvedMembersSelectorCache.clear();
|
resolvedMembersSelectorCache.clear();
|
||||||
resolvedMemberSelectorCache.clear();
|
resolvedMemberSelectorCache.clear();
|
||||||
mergedMessagesSelectorCache.clear();
|
clearTeamMessageSelectorCaches();
|
||||||
memberMessagesSelectorCache.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTeamScopedSelectorCaches(teamName: string): void {
|
function clearTeamScopedSelectorCaches(teamName: string): void {
|
||||||
resolvedMembersSelectorCache.delete(teamName);
|
resolvedMembersSelectorCache.delete(teamName);
|
||||||
mergedMessagesSelectorCache.delete(teamName);
|
clearTeamMessageSelectorCachesForTeam(teamName);
|
||||||
|
|
||||||
const teamScopedPrefix = `${teamName}:`;
|
const teamScopedPrefix = `${teamName}:`;
|
||||||
for (const key of resolvedMemberSelectorCache.keys()) {
|
for (const key of resolvedMemberSelectorCache.keys()) {
|
||||||
|
|
@ -254,11 +273,6 @@ function clearTeamScopedSelectorCaches(teamName: string): void {
|
||||||
resolvedMemberSelectorCache.delete(key);
|
resolvedMemberSelectorCache.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const key of memberMessagesSelectorCache.keys()) {
|
|
||||||
if (key.startsWith(teamScopedPrefix)) {
|
|
||||||
memberMessagesSelectorCache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTeamScopedTransientState(teamName: string): void {
|
function clearTeamScopedTransientState(teamName: string): void {
|
||||||
|
|
@ -649,25 +663,20 @@ export function __getTeamScopedTransientStateForTests(teamName: string): {
|
||||||
hasMemberSpawnUiEqualLastWarn: boolean;
|
hasMemberSpawnUiEqualLastWarn: boolean;
|
||||||
} {
|
} {
|
||||||
const teamScopedPrefix = `${teamName}:`;
|
const teamScopedPrefix = `${teamName}:`;
|
||||||
|
const messageSelectorCache = getTeamMessageSelectorCacheSnapshotForTeam(teamName);
|
||||||
let resolvedMemberSelectorCount = 0;
|
let resolvedMemberSelectorCount = 0;
|
||||||
let memberMessagesSelectorCount = 0;
|
|
||||||
|
|
||||||
for (const key of resolvedMemberSelectorCache.keys()) {
|
for (const key of resolvedMemberSelectorCache.keys()) {
|
||||||
if (key.startsWith(teamScopedPrefix)) {
|
if (key.startsWith(teamScopedPrefix)) {
|
||||||
resolvedMemberSelectorCount += 1;
|
resolvedMemberSelectorCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const key of memberMessagesSelectorCache.keys()) {
|
|
||||||
if (key.startsWith(teamScopedPrefix)) {
|
|
||||||
memberMessagesSelectorCount += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
|
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
|
||||||
resolvedMemberSelectorCount,
|
resolvedMemberSelectorCount,
|
||||||
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
|
hasMergedMessagesSelector: messageSelectorCache.hasMergedMessagesSelector,
|
||||||
memberMessagesSelectorCount,
|
memberMessagesSelectorCount: messageSelectorCache.memberMessagesSelectorCount,
|
||||||
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
|
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
|
||||||
hasQueuedFullTeamDataRefreshAfterThin: queuedFullTeamDataRefreshesAfterThin.has(teamName),
|
hasQueuedFullTeamDataRefreshAfterThin: queuedFullTeamDataRefreshesAfterThin.has(teamName),
|
||||||
hasPostPaintTeamEnrichmentTimer: postPaintTeamEnrichmentTimers.has(teamName),
|
hasPostPaintTeamEnrichmentTimer: postPaintTeamEnrichmentTimers.has(teamName),
|
||||||
|
|
@ -1082,153 +1091,6 @@ function areTeamAgentRuntimeSnapshotsEqual(
|
||||||
return true;
|
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 {
|
function clearPendingReplyRefreshTimer(teamName: string): void {
|
||||||
const existingTimer = pendingTeamPendingReplyRefreshTimers.get(teamName);
|
const existingTimer = pendingTeamPendingReplyRefreshTimers.get(teamName);
|
||||||
if (existingTimer == null) {
|
if (existingTimer == null) {
|
||||||
|
|
@ -1266,50 +1128,6 @@ function setPendingReplyRefreshEnabled(
|
||||||
return true;
|
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(
|
async function refreshTaskChangePresenceForUpdatedTask(
|
||||||
getState: () => AppState,
|
getState: () => AppState,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -1945,23 +1763,8 @@ const resolvedMemberSelectorCache = new Map<
|
||||||
result: ResolvedTeamMember | null;
|
result: ResolvedTeamMember | null;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
const mergedMessagesSelectorCache = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
canonicalRef: InboxMessage[];
|
|
||||||
optimisticRef: InboxMessage[];
|
|
||||||
result: InboxMessage[];
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = [];
|
const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = [];
|
||||||
const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = [];
|
const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = [];
|
||||||
const memberMessagesSelectorCache = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
messagesRef: InboxMessage[];
|
|
||||||
result: InboxMessage[];
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
function resolveMemberStatus(
|
function resolveMemberStatus(
|
||||||
snapshot: TeamMemberSnapshot,
|
snapshot: TeamMemberSnapshot,
|
||||||
|
|
@ -2534,58 +2337,6 @@ export function selectTeamIsAliveForName(
|
||||||
return selectTeamDataForName(state, teamName)?.isAlive;
|
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(
|
function isMemberActivityMetaStale(
|
||||||
state: Pick<TeamSlice, 'memberActivityMetaByTeam' | 'teamMessagesByName'>,
|
state: Pick<TeamSlice, 'memberActivityMetaByTeam' | 'teamMessagesByName'>,
|
||||||
teamName: string
|
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