refactor(team): extract resolved members and graph layout
This commit is contained in:
parent
ab76e5424d
commit
8946bc668d
5 changed files with 1130 additions and 715 deletions
|
|
@ -13,14 +13,11 @@ import {
|
|||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
|
||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskFinalForCompletionNotification,
|
||||
|
|
@ -48,6 +45,19 @@ import {
|
|||
mapSendMessageError,
|
||||
shouldInvalidateCachedTeamDataForError,
|
||||
} from '../team/teamErrorPolicies';
|
||||
import {
|
||||
areTeamGraphSlotAssignmentsEqual,
|
||||
DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS,
|
||||
GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
migrateStableSlotAssignmentsForMembers,
|
||||
normalizeTeamGraphGridOwnerOrder,
|
||||
pruneTeamGraphSlotAssignmentsForVisibleOwners,
|
||||
seedStableSlotAssignmentsForMembers,
|
||||
type TeamGraphConfigMemberSeedInput,
|
||||
type TeamGraphLayoutSessionState,
|
||||
type TeamGraphMemberSeedInput,
|
||||
type TeamGraphSlotAssignments,
|
||||
} from '../team/teamGraphLayout';
|
||||
import {
|
||||
areTeamLaunchParamsEqual,
|
||||
buildLaunchParamsFromRuntimeRequest,
|
||||
|
|
@ -109,6 +119,12 @@ import {
|
|||
hasTeamRefreshBurstDiagnostics,
|
||||
noteTeamRefreshBurst,
|
||||
} from '../team/teamRefreshBurstDiagnostics';
|
||||
import {
|
||||
clearResolvedMemberSelectorCaches,
|
||||
clearResolvedMemberSelectorCachesForTeam,
|
||||
getResolvedMemberSelectorCacheSnapshotForTeam,
|
||||
shouldPreserveSelectedTeamSnapshot,
|
||||
} from '../team/teamResolvedMembers';
|
||||
import {
|
||||
buildTeamScopedProgressTombstones,
|
||||
collectTeamScopedStateRemovals,
|
||||
|
|
@ -139,11 +155,9 @@ import type {
|
|||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
MemberActivityMetaEntry,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
NotificationTarget,
|
||||
ResolvedTeamMember,
|
||||
RetryFailedOpenCodeSecondaryLanesResult,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
|
|
@ -154,7 +168,6 @@ import type {
|
|||
TeamGetDataOptions,
|
||||
TeamLaunchRequest,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMemberSnapshot,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
|
|
@ -173,6 +186,10 @@ export {
|
|||
selectTeamMemberSnapshotsForName,
|
||||
selectTeamTasksForName,
|
||||
} from '../team/teamDataSelectors';
|
||||
export {
|
||||
getDefaultTeamGraphSlotAssignmentsForMembers,
|
||||
isTeamGraphSlotPersistenceDisabled,
|
||||
} from '../team/teamGraphLayout';
|
||||
export type { TeamLaunchParams } from '../team/teamLaunchParams';
|
||||
export type {
|
||||
RefreshTeamMessagesHeadResult,
|
||||
|
|
@ -187,9 +204,11 @@ export {
|
|||
getActiveTeamPendingReplyWaits,
|
||||
hasActiveTeamPendingReplyWait,
|
||||
} from '../team/teamPendingReplyWaits';
|
||||
export {
|
||||
selectResolvedMemberForTeamName,
|
||||
selectResolvedMembersForTeamName,
|
||||
} from '../team/teamResolvedMembers';
|
||||
|
||||
const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
|
||||
const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
|
||||
const logger = createLogger('teamSlice');
|
||||
|
||||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -242,17 +261,6 @@ function clearTeamDataRequestsForTeam(teamName: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
||||
type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
||||
type TeamGraphConfigMemberSeedInput = Pick<
|
||||
NonNullable<TeamViewSnapshot['config']['members']>[number],
|
||||
'name' | 'agentId' | 'removedAt'
|
||||
>;
|
||||
interface TeamGraphLayoutSessionState {
|
||||
mode: 'default' | 'manual';
|
||||
signature: string | null;
|
||||
}
|
||||
|
||||
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||
return (
|
||||
hasFullTeamDataRequestForTeam(teamName) ||
|
||||
|
|
@ -287,21 +295,13 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
|||
clearAllMemberSpawnStatusesIpcBackoffs();
|
||||
clearAllTeamRefreshBurstDiagnostics();
|
||||
clearAllMemberSpawnUiEqualLastWarns();
|
||||
resolvedMembersSelectorCache.clear();
|
||||
resolvedMemberSelectorCache.clear();
|
||||
clearResolvedMemberSelectorCaches();
|
||||
clearTeamMessageSelectorCaches();
|
||||
}
|
||||
|
||||
function clearTeamScopedSelectorCaches(teamName: string): void {
|
||||
resolvedMembersSelectorCache.delete(teamName);
|
||||
clearResolvedMemberSelectorCachesForTeam(teamName);
|
||||
clearTeamMessageSelectorCachesForTeam(teamName);
|
||||
|
||||
const teamScopedPrefix = `${teamName}:`;
|
||||
for (const key of resolvedMemberSelectorCache.keys()) {
|
||||
if (key.startsWith(teamScopedPrefix)) {
|
||||
resolvedMemberSelectorCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearTeamScopedTransientState(teamName: string): void {
|
||||
|
|
@ -519,19 +519,15 @@ export function __getTeamScopedTransientStateForTests(teamName: string): {
|
|||
hasTeamRefreshBurstDiagnostics: boolean;
|
||||
hasMemberSpawnUiEqualLastWarn: boolean;
|
||||
} {
|
||||
const teamScopedPrefix = `${teamName}:`;
|
||||
const messageSelectorCache = getTeamMessageSelectorCacheSnapshotForTeam(teamName);
|
||||
let resolvedMemberSelectorCount = 0;
|
||||
|
||||
for (const key of resolvedMemberSelectorCache.keys()) {
|
||||
if (key.startsWith(teamScopedPrefix)) {
|
||||
resolvedMemberSelectorCount += 1;
|
||||
}
|
||||
}
|
||||
const resolvedMemberSelectorCacheSnapshot =
|
||||
getResolvedMemberSelectorCacheSnapshotForTeam(teamName);
|
||||
|
||||
return {
|
||||
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
|
||||
resolvedMemberSelectorCount,
|
||||
hasResolvedMembersSelector:
|
||||
resolvedMemberSelectorCacheSnapshot.hasResolvedMembersSelector,
|
||||
resolvedMemberSelectorCount:
|
||||
resolvedMemberSelectorCacheSnapshot.resolvedMemberSelectorCount,
|
||||
hasMergedMessagesSelector: messageSelectorCache.hasMergedMessagesSelector,
|
||||
memberMessagesSelectorCount: messageSelectorCache.memberMessagesSelectorCount,
|
||||
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
|
||||
|
|
@ -1237,682 +1233,6 @@ export interface PendingTeamSectionFocusState {
|
|||
section: TeamSectionTarget;
|
||||
}
|
||||
|
||||
const resolvedMembersSelectorCache = new Map<
|
||||
string,
|
||||
{
|
||||
snapshotRef: TeamViewSnapshot['members'];
|
||||
configMembersRef: TeamViewSnapshot['config']['members'] | undefined;
|
||||
summaryRef: TeamSummary | undefined;
|
||||
tasksRef: TeamViewSnapshot['tasks'] | undefined;
|
||||
metaMembersRef: TeamMemberActivityMeta['members'] | undefined;
|
||||
result: ResolvedTeamMember[];
|
||||
}
|
||||
>();
|
||||
const resolvedMemberSelectorCache = new Map<
|
||||
string,
|
||||
{
|
||||
snapshotMemberRef: TeamMemberSnapshot | undefined;
|
||||
metaEntryRef: MemberActivityMetaEntry | undefined;
|
||||
result: ResolvedTeamMember | null;
|
||||
}
|
||||
>();
|
||||
function resolveMemberStatus(
|
||||
snapshot: TeamMemberSnapshot,
|
||||
activity: MemberActivityMetaEntry | undefined
|
||||
): ResolvedTeamMember['status'] {
|
||||
if (activity?.latestAuthoredMessageSignalsTermination) {
|
||||
return 'terminated';
|
||||
}
|
||||
|
||||
if (!activity?.lastAuthoredMessageAt) {
|
||||
return snapshot.currentTaskId ? 'active' : 'idle';
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt);
|
||||
if (Number.isNaN(ageMs)) {
|
||||
return 'unknown';
|
||||
}
|
||||
if (ageMs < 5 * 60 * 1000) {
|
||||
return 'active';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function buildResolvedMembers(
|
||||
snapshots: readonly TeamMemberSnapshot[],
|
||||
meta: TeamMemberActivityMeta | undefined
|
||||
): ResolvedTeamMember[] {
|
||||
return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name]));
|
||||
}
|
||||
|
||||
function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean {
|
||||
return (
|
||||
task.status === 'in_progress' &&
|
||||
getTeamTaskWorkflowColumn(task) !== 'review' &&
|
||||
!isTeamTaskFinalForCompletionNotification(task)
|
||||
);
|
||||
}
|
||||
|
||||
function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] {
|
||||
const configMembers = snapshot.config.members ?? [];
|
||||
const hasConfiguredTeammate = configMembers.some((member) => {
|
||||
const name = member.name?.trim();
|
||||
return Boolean(name) && !member.removedAt && !isLeadMember(member);
|
||||
});
|
||||
if (!hasConfiguredTeammate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const fallbackMembers: TeamMemberSnapshot[] = [];
|
||||
for (const member of configMembers) {
|
||||
const name = member.name?.trim();
|
||||
if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (seenNames.has(key)) continue;
|
||||
seenNames.add(key);
|
||||
|
||||
const ownedTasks = snapshot.tasks.filter((task) => task.owner === name);
|
||||
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
|
||||
fallbackMembers.push({
|
||||
name,
|
||||
agentId: member.agentId,
|
||||
currentTaskId: currentTask?.id ?? null,
|
||||
taskCount: ownedTasks.length,
|
||||
color: member.color ?? getMemberColorByName(name),
|
||||
agentType: member.agentType,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
isolation: member.isolation,
|
||||
providerId: member.providerId,
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
mcpPolicy: member.mcpPolicy,
|
||||
selectedFastMode: member.fastMode,
|
||||
cwd: member.cwd,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return fallbackMembers;
|
||||
}
|
||||
|
||||
function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] {
|
||||
if (!snapshot) {
|
||||
return [];
|
||||
}
|
||||
const names = new Set<string>();
|
||||
for (const member of snapshot.members) {
|
||||
const name = member.name.trim();
|
||||
const key = name.toLowerCase();
|
||||
if (!name || key === 'user' || member.removedAt || isLeadMember(member)) {
|
||||
continue;
|
||||
}
|
||||
names.add(key);
|
||||
}
|
||||
return Array.from(names).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
|
||||
return getActiveRawTeammateNameKeys(snapshot).length > 0;
|
||||
}
|
||||
|
||||
function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
|
||||
return Boolean(snapshot?.members.some((member) => member.removedAt));
|
||||
}
|
||||
|
||||
function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
|
||||
return Boolean(
|
||||
snapshot?.config.members?.some((member) => {
|
||||
const name = member.name?.trim();
|
||||
return Boolean(name) && !member.removedAt && !isLeadMember(member);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryFallbackMemberSource {
|
||||
name: string;
|
||||
agentId?: string;
|
||||
role?: string;
|
||||
color?: string;
|
||||
mcpPolicy?: TeamMemberSnapshot['mcpPolicy'];
|
||||
}
|
||||
|
||||
function normalizeSummaryTeammateName(
|
||||
name: string | undefined | null,
|
||||
leadName?: string
|
||||
): string | null {
|
||||
const trimmed = name?.trim();
|
||||
const normalizedName = trimmed?.toLowerCase();
|
||||
const normalizedLeadName = leadName?.trim().toLowerCase();
|
||||
if (
|
||||
!trimmed ||
|
||||
normalizedName === 'user' ||
|
||||
isLeadMember({ name: trimmed }) ||
|
||||
(normalizedLeadName && normalizedName === normalizedLeadName)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
|
||||
const seenNames = new Set<string>();
|
||||
const sources: SummaryFallbackMemberSource[] = [];
|
||||
for (const member of summary.members ?? []) {
|
||||
const name = normalizeSummaryTeammateName(member.name, summary.leadName);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seenNames.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenNames.add(key);
|
||||
sources.push({
|
||||
name,
|
||||
agentId: member.agentId,
|
||||
role: member.role,
|
||||
color: member.color,
|
||||
mcpPolicy: member.mcpPolicy,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean {
|
||||
return (
|
||||
summary.partialLaunchFailure === true ||
|
||||
summary.teamLaunchState === 'partial_failure' ||
|
||||
summary.teamLaunchState === 'partial_pending' ||
|
||||
summary.teamLaunchState === 'partial_skipped'
|
||||
);
|
||||
}
|
||||
|
||||
function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
|
||||
if (!shouldUseSummaryLaunchTeammateSources(summary)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const sources: SummaryFallbackMemberSource[] = [];
|
||||
for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) {
|
||||
const name = normalizeSummaryTeammateName(rawName, summary.leadName);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seenNames.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenNames.add(key);
|
||||
sources.push({ name });
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] {
|
||||
return getSummaryLaunchTeammateSources(summary)
|
||||
.map((member) => member.name.toLowerCase())
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function getSummaryTeammateNameKeys(summary: TeamSummary): string[] {
|
||||
const rosterNames = getSummaryRosterTeammateSources(summary)
|
||||
.map((member) => member.name.toLowerCase())
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
if (rosterNames.length > 0) {
|
||||
return rosterNames;
|
||||
}
|
||||
|
||||
const launchNames = getSummaryLaunchTeammateNameKeys(summary);
|
||||
const expectedCount = summary.expectedMemberCount ?? summary.memberCount;
|
||||
if (expectedCount > 0 && launchNames.length === expectedCount) {
|
||||
return launchNames;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
|
||||
return getSummaryRosterTeammateSources(summary);
|
||||
}
|
||||
|
||||
function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((name, index) => name === right[index]);
|
||||
}
|
||||
|
||||
function summaryConfirmsActiveTeammateRoster(
|
||||
current: TeamViewSnapshot,
|
||||
summary: TeamSummary
|
||||
): boolean {
|
||||
if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentNames = getActiveRawTeammateNameKeys(current);
|
||||
const summaryNames = getSummaryTeammateNameKeys(summary);
|
||||
if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return areNameKeyListsEqual(summaryNames, currentNames);
|
||||
}
|
||||
|
||||
function buildSummaryFallbackMemberSnapshots(
|
||||
snapshot: TeamViewSnapshot,
|
||||
summary: TeamSummary | undefined
|
||||
): TeamMemberSnapshot[] {
|
||||
if (!summary) {
|
||||
return [];
|
||||
}
|
||||
const summaryMembers = getSummaryFallbackTeammateSources(summary);
|
||||
if (summaryMembers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const buildSnapshot = (
|
||||
name: string,
|
||||
source?: Omit<SummaryFallbackMemberSource, 'name'>,
|
||||
lead = false
|
||||
): TeamMemberSnapshot | null => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seenNames.has(key)) return null;
|
||||
seenNames.add(key);
|
||||
|
||||
const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed);
|
||||
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
|
||||
return {
|
||||
name: trimmed,
|
||||
agentId: source?.agentId,
|
||||
currentTaskId: currentTask?.id ?? null,
|
||||
taskCount: ownedTasks.length,
|
||||
color: source?.color ?? getMemberColorByName(trimmed),
|
||||
agentType: lead ? 'team-lead' : undefined,
|
||||
role: source?.role ?? (lead ? 'Team Lead' : undefined),
|
||||
mcpPolicy: source?.mcpPolicy,
|
||||
};
|
||||
};
|
||||
|
||||
const teammates = summaryMembers.flatMap((member) => {
|
||||
const item = buildSnapshot(member.name, member);
|
||||
return item ? [item] : [];
|
||||
});
|
||||
if (teammates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member));
|
||||
if (existingLead) {
|
||||
return [existingLead, ...teammates];
|
||||
}
|
||||
|
||||
const configuredLead = snapshot.config.members?.find(
|
||||
(member) => !member.removedAt && isLeadMember(member)
|
||||
);
|
||||
const leadName = configuredLead?.name?.trim() || summary.leadName?.trim();
|
||||
const lead = leadName
|
||||
? buildSnapshot(
|
||||
leadName,
|
||||
{
|
||||
agentId: configuredLead?.agentId,
|
||||
role: configuredLead?.role,
|
||||
color: configuredLead?.color ?? summary.leadColor,
|
||||
},
|
||||
true
|
||||
)
|
||||
: null;
|
||||
|
||||
return lead ? [lead, ...teammates] : teammates;
|
||||
}
|
||||
|
||||
function getResolvableMemberSnapshots(
|
||||
snapshot: TeamViewSnapshot,
|
||||
summary?: TeamSummary
|
||||
): readonly TeamMemberSnapshot[] {
|
||||
if (
|
||||
snapshot.members.length > 0 &&
|
||||
(hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot))
|
||||
) {
|
||||
return snapshot.members;
|
||||
}
|
||||
|
||||
const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot);
|
||||
if (configFallbackMembers.length > 0) {
|
||||
return configFallbackMembers;
|
||||
}
|
||||
|
||||
const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary);
|
||||
if (summaryFallbackMembers.length > 0) {
|
||||
return summaryFallbackMembers;
|
||||
}
|
||||
|
||||
return snapshot.members;
|
||||
}
|
||||
|
||||
function shouldPreserveSelectedTeamSnapshot(
|
||||
current: TeamViewSnapshot | null,
|
||||
baseline: TeamViewSnapshot | null | undefined,
|
||||
incoming: TeamViewSnapshot,
|
||||
summary: TeamSummary | undefined
|
||||
): boolean {
|
||||
if (!current || !hasActiveRawTeammateRoster(current)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
hasActiveRawTeammateRoster(incoming) ||
|
||||
hasRemovedRawMemberRoster(incoming) ||
|
||||
hasConfigTeammateRoster(incoming)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const currentNames = getActiveRawTeammateNameKeys(current);
|
||||
if (
|
||||
current !== baseline &&
|
||||
!areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (summary) {
|
||||
return summaryConfirmsActiveTeammateRoster(current, summary);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildResolvedMember(
|
||||
snapshot: TeamMemberSnapshot,
|
||||
activity: MemberActivityMetaEntry | undefined
|
||||
): ResolvedTeamMember {
|
||||
return {
|
||||
...snapshot,
|
||||
status: resolveMemberStatus(snapshot, activity),
|
||||
messageCount: activity?.messageCountExact ?? 0,
|
||||
lastActiveAt: activity?.lastAuthoredMessageAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type ResolvedMemberSelectorState = Pick<
|
||||
TeamSlice,
|
||||
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
|
||||
> &
|
||||
Partial<Pick<TeamSlice, 'teamByName'>>;
|
||||
|
||||
function migrateStableSlotAssignmentsForMembers(
|
||||
assignments: TeamGraphSlotAssignments | undefined,
|
||||
members: readonly TeamGraphMemberSeedInput[]
|
||||
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
|
||||
let changed = false;
|
||||
|
||||
for (const member of members) {
|
||||
const fallbackKey = member.name.trim();
|
||||
const stableOwnerId = getStableTeamOwnerId(member);
|
||||
const fallbackAssignment = nextAssignments[fallbackKey];
|
||||
const stableAssignment = nextAssignments[stableOwnerId];
|
||||
|
||||
if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) {
|
||||
nextAssignments[stableOwnerId] = fallbackAssignment;
|
||||
delete nextAssignments[fallbackKey];
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) {
|
||||
delete nextAssignments[fallbackKey];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { assignments: nextAssignments, changed };
|
||||
}
|
||||
|
||||
export function selectResolvedMembersForTeamName(
|
||||
state: ResolvedMemberSelectorState,
|
||||
teamName: string | null | undefined
|
||||
): ResolvedTeamMember[] {
|
||||
const snapshot = selectTeamDataForName(state, teamName);
|
||||
if (!snapshot || !teamName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const meta = state.memberActivityMetaByTeam[teamName];
|
||||
const metaMembers = meta?.members;
|
||||
const shouldUseMemberFallback =
|
||||
snapshot.members.length === 0 ||
|
||||
(!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot));
|
||||
const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined;
|
||||
const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined;
|
||||
const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined;
|
||||
const cached = resolvedMembersSelectorCache.get(teamName);
|
||||
if (
|
||||
cached?.snapshotRef === snapshot.members &&
|
||||
cached.configMembersRef === configMembersRef &&
|
||||
cached.summaryRef === summaryRef &&
|
||||
cached.tasksRef === tasksRef &&
|
||||
cached.metaMembersRef === metaMembers
|
||||
) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta);
|
||||
resolvedMembersSelectorCache.set(teamName, {
|
||||
snapshotRef: snapshot.members,
|
||||
configMembersRef,
|
||||
summaryRef,
|
||||
tasksRef,
|
||||
metaMembersRef: metaMembers,
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function selectResolvedMemberForTeamName(
|
||||
state: ResolvedMemberSelectorState,
|
||||
teamName: string | null | undefined,
|
||||
memberName: string | null | undefined
|
||||
): ResolvedTeamMember | null {
|
||||
const snapshot = selectTeamDataForName(state, teamName);
|
||||
if (!snapshot || !teamName || !memberName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find(
|
||||
(member) => member.name === memberName
|
||||
);
|
||||
if (!snapshotMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName];
|
||||
const cacheKey = `${teamName}:${memberName}`;
|
||||
const cached = resolvedMemberSelectorCache.get(cacheKey);
|
||||
if (cached?.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const result = buildResolvedMember(snapshotMember, metaEntry);
|
||||
resolvedMemberSelectorCache.set(cacheKey, {
|
||||
snapshotMemberRef: snapshotMember,
|
||||
metaEntryRef: metaEntry,
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function seedStableSlotAssignmentsForMembers(
|
||||
assignments: TeamGraphSlotAssignments,
|
||||
members: readonly TeamGraphMemberSeedInput[],
|
||||
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
|
||||
if (
|
||||
defaultSeed.orderedVisibleOwnerIds.length === 0 ||
|
||||
Object.keys(defaultSeed.assignments).length === 0
|
||||
) {
|
||||
return { assignments, changed: false };
|
||||
}
|
||||
|
||||
const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds;
|
||||
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
|
||||
(stableOwnerId) => assignments[stableOwnerId] != null
|
||||
);
|
||||
if (hasAnyVisibleAssignments) {
|
||||
return { assignments, changed: false };
|
||||
}
|
||||
|
||||
const nextAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||
visibleStableOwnerIds.forEach((stableOwnerId) => {
|
||||
nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!;
|
||||
});
|
||||
|
||||
return { assignments: nextAssignments, changed: true };
|
||||
}
|
||||
|
||||
function areTeamGraphSlotAssignmentsEqual(
|
||||
left: TeamGraphSlotAssignments | undefined,
|
||||
right: TeamGraphSlotAssignments | undefined
|
||||
): boolean {
|
||||
const leftEntries = Object.entries(left ?? {});
|
||||
const rightEntries = Object.entries(right ?? {});
|
||||
if (leftEntries.length !== rightEntries.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [stableOwnerId, leftAssignment] of leftEntries) {
|
||||
const rightAssignment = right?.[stableOwnerId];
|
||||
if (
|
||||
rightAssignment?.ringIndex !== leftAssignment.ringIndex ||
|
||||
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
assignments: TeamGraphSlotAssignments | undefined,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): TeamGraphSlotAssignments {
|
||||
if (visibleOwnerIds.length === 0 || !assignments) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalizedAssignments: TeamGraphSlotAssignments = {};
|
||||
for (const stableOwnerId of visibleOwnerIds) {
|
||||
const assignment = assignments[stableOwnerId];
|
||||
if (!assignment) {
|
||||
continue;
|
||||
}
|
||||
normalizedAssignments[stableOwnerId] = assignment;
|
||||
}
|
||||
return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds);
|
||||
}
|
||||
|
||||
function normalizeLegacySixRowOrbitAssignments(
|
||||
assignments: TeamGraphSlotAssignments,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): TeamGraphSlotAssignments {
|
||||
if (visibleOwnerIds.length !== 6) {
|
||||
return assignments;
|
||||
}
|
||||
|
||||
const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => {
|
||||
const assignment = assignments[stableOwnerId];
|
||||
return assignment ? [assignment] : [];
|
||||
});
|
||||
const hasLegacyTwoRowBottomMarker = visibleAssignments.some(
|
||||
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
|
||||
);
|
||||
let changed = false;
|
||||
const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||
|
||||
for (const stableOwnerId of visibleOwnerIds) {
|
||||
const assignment = normalizedAssignments[stableOwnerId];
|
||||
if (!assignment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
hasLegacyTwoRowBottomMarker &&
|
||||
assignment.ringIndex === 1 &&
|
||||
assignment.sectorIndex >= 0 &&
|
||||
assignment.sectorIndex < 3
|
||||
) {
|
||||
normalizedAssignments[stableOwnerId] = {
|
||||
ringIndex: 2,
|
||||
sectorIndex: assignment.sectorIndex,
|
||||
};
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) {
|
||||
normalizedAssignments[stableOwnerId] = {
|
||||
ringIndex: 2,
|
||||
sectorIndex: assignment.sectorIndex - 3,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? normalizedAssignments : assignments;
|
||||
}
|
||||
|
||||
function pruneTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
assignments: TeamGraphSlotAssignments | undefined,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): TeamGraphSlotAssignments | undefined {
|
||||
const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
assignments,
|
||||
visibleOwnerIds
|
||||
);
|
||||
return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined;
|
||||
}
|
||||
|
||||
function normalizeTeamGraphGridOwnerOrder(
|
||||
order: readonly string[] | undefined,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): string[] {
|
||||
const visibleOwnerIdSet = new Set(visibleOwnerIds);
|
||||
const normalizedOrder: string[] = [];
|
||||
const seenOwnerIds = new Set<string>();
|
||||
|
||||
for (const stableOwnerId of order ?? []) {
|
||||
if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
normalizedOrder.push(stableOwnerId);
|
||||
seenOwnerIds.add(stableOwnerId);
|
||||
}
|
||||
|
||||
for (const stableOwnerId of visibleOwnerIds) {
|
||||
if (seenOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
normalizedOrder.push(stableOwnerId);
|
||||
seenOwnerIds.add(stableOwnerId);
|
||||
}
|
||||
|
||||
return normalizedOrder;
|
||||
}
|
||||
|
||||
export function getDefaultTeamGraphSlotAssignmentsForMembers(
|
||||
members: readonly TeamGraphMemberSeedInput[],
|
||||
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||
): TeamGraphSlotAssignments {
|
||||
return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments;
|
||||
}
|
||||
|
||||
export function isTeamGraphSlotPersistenceDisabled(): boolean {
|
||||
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
|
||||
}
|
||||
|
||||
function isVisibleInActiveTeamSurface(
|
||||
state: Pick<AppState, 'paneLayout'>,
|
||||
teamName: string | null | undefined
|
||||
|
|
|
|||
219
src/renderer/store/team/teamGraphLayout.ts
Normal file
219
src/renderer/store/team/teamGraphLayout.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
|
||||
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||
import type { TeamMemberSnapshot, TeamViewSnapshot } from '@shared/types';
|
||||
|
||||
export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
|
||||
export const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
|
||||
|
||||
export type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
||||
export type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
||||
export type TeamGraphConfigMemberSeedInput = Pick<
|
||||
NonNullable<TeamViewSnapshot['config']['members']>[number],
|
||||
'name' | 'agentId' | 'removedAt'
|
||||
>;
|
||||
|
||||
export interface TeamGraphLayoutSessionState {
|
||||
mode: 'default' | 'manual';
|
||||
signature: string | null;
|
||||
}
|
||||
|
||||
export function migrateStableSlotAssignmentsForMembers(
|
||||
assignments: TeamGraphSlotAssignments | undefined,
|
||||
members: readonly TeamGraphMemberSeedInput[]
|
||||
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
|
||||
let changed = false;
|
||||
|
||||
for (const member of members) {
|
||||
const fallbackKey = member.name.trim();
|
||||
const stableOwnerId = getStableTeamOwnerId(member);
|
||||
const fallbackAssignment = nextAssignments[fallbackKey];
|
||||
const stableAssignment = nextAssignments[stableOwnerId];
|
||||
|
||||
if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) {
|
||||
nextAssignments[stableOwnerId] = fallbackAssignment;
|
||||
delete nextAssignments[fallbackKey];
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) {
|
||||
delete nextAssignments[fallbackKey];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { assignments: nextAssignments, changed };
|
||||
}
|
||||
|
||||
export function seedStableSlotAssignmentsForMembers(
|
||||
assignments: TeamGraphSlotAssignments,
|
||||
members: readonly TeamGraphMemberSeedInput[],
|
||||
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
|
||||
if (
|
||||
defaultSeed.orderedVisibleOwnerIds.length === 0 ||
|
||||
Object.keys(defaultSeed.assignments).length === 0
|
||||
) {
|
||||
return { assignments, changed: false };
|
||||
}
|
||||
|
||||
const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds;
|
||||
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
|
||||
(stableOwnerId) => assignments[stableOwnerId] != null
|
||||
);
|
||||
if (hasAnyVisibleAssignments) {
|
||||
return { assignments, changed: false };
|
||||
}
|
||||
|
||||
const nextAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||
visibleStableOwnerIds.forEach((stableOwnerId) => {
|
||||
nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!;
|
||||
});
|
||||
|
||||
return { assignments: nextAssignments, changed: true };
|
||||
}
|
||||
|
||||
export function areTeamGraphSlotAssignmentsEqual(
|
||||
left: TeamGraphSlotAssignments | undefined,
|
||||
right: TeamGraphSlotAssignments | undefined
|
||||
): boolean {
|
||||
const leftEntries = Object.entries(left ?? {});
|
||||
const rightEntries = Object.entries(right ?? {});
|
||||
if (leftEntries.length !== rightEntries.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [stableOwnerId, leftAssignment] of leftEntries) {
|
||||
const rightAssignment = right?.[stableOwnerId];
|
||||
if (
|
||||
rightAssignment?.ringIndex !== leftAssignment.ringIndex ||
|
||||
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
assignments: TeamGraphSlotAssignments | undefined,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): TeamGraphSlotAssignments {
|
||||
if (visibleOwnerIds.length === 0 || !assignments) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalizedAssignments: TeamGraphSlotAssignments = {};
|
||||
for (const stableOwnerId of visibleOwnerIds) {
|
||||
const assignment = assignments[stableOwnerId];
|
||||
if (!assignment) {
|
||||
continue;
|
||||
}
|
||||
normalizedAssignments[stableOwnerId] = assignment;
|
||||
}
|
||||
return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds);
|
||||
}
|
||||
|
||||
export function normalizeLegacySixRowOrbitAssignments(
|
||||
assignments: TeamGraphSlotAssignments,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): TeamGraphSlotAssignments {
|
||||
if (visibleOwnerIds.length !== 6) {
|
||||
return assignments;
|
||||
}
|
||||
|
||||
const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => {
|
||||
const assignment = assignments[stableOwnerId];
|
||||
return assignment ? [assignment] : [];
|
||||
});
|
||||
const hasLegacyTwoRowBottomMarker = visibleAssignments.some(
|
||||
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
|
||||
);
|
||||
let changed = false;
|
||||
const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||
|
||||
for (const stableOwnerId of visibleOwnerIds) {
|
||||
const assignment = normalizedAssignments[stableOwnerId];
|
||||
if (!assignment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
hasLegacyTwoRowBottomMarker &&
|
||||
assignment.ringIndex === 1 &&
|
||||
assignment.sectorIndex >= 0 &&
|
||||
assignment.sectorIndex < 3
|
||||
) {
|
||||
normalizedAssignments[stableOwnerId] = {
|
||||
ringIndex: 2,
|
||||
sectorIndex: assignment.sectorIndex,
|
||||
};
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) {
|
||||
normalizedAssignments[stableOwnerId] = {
|
||||
ringIndex: 2,
|
||||
sectorIndex: assignment.sectorIndex - 3,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? normalizedAssignments : assignments;
|
||||
}
|
||||
|
||||
export function pruneTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
assignments: TeamGraphSlotAssignments | undefined,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): TeamGraphSlotAssignments | undefined {
|
||||
const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
assignments,
|
||||
visibleOwnerIds
|
||||
);
|
||||
return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined;
|
||||
}
|
||||
|
||||
export function normalizeTeamGraphGridOwnerOrder(
|
||||
order: readonly string[] | undefined,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): string[] {
|
||||
const visibleOwnerIdSet = new Set(visibleOwnerIds);
|
||||
const normalizedOrder: string[] = [];
|
||||
const seenOwnerIds = new Set<string>();
|
||||
|
||||
for (const stableOwnerId of order ?? []) {
|
||||
if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
normalizedOrder.push(stableOwnerId);
|
||||
seenOwnerIds.add(stableOwnerId);
|
||||
}
|
||||
|
||||
for (const stableOwnerId of visibleOwnerIds) {
|
||||
if (seenOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
normalizedOrder.push(stableOwnerId);
|
||||
seenOwnerIds.add(stableOwnerId);
|
||||
}
|
||||
|
||||
return normalizedOrder;
|
||||
}
|
||||
|
||||
export function getDefaultTeamGraphSlotAssignmentsForMembers(
|
||||
members: readonly TeamGraphMemberSeedInput[],
|
||||
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||
): TeamGraphSlotAssignments {
|
||||
return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments;
|
||||
}
|
||||
|
||||
export function isTeamGraphSlotPersistenceDisabled(): boolean {
|
||||
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
|
||||
}
|
||||
533
src/renderer/store/team/teamResolvedMembers.ts
Normal file
533
src/renderer/store/team/teamResolvedMembers.ts
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskFinalForCompletionNotification,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
import { selectTeamDataForName, type TeamDataSelectorState } from './teamDataSelectors';
|
||||
|
||||
import type {
|
||||
MemberActivityMetaEntry,
|
||||
ResolvedTeamMember,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMemberSnapshot,
|
||||
TeamSummary,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface ResolvedMemberSelectorState extends TeamDataSelectorState {
|
||||
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
|
||||
teamByName?: Record<string, TeamSummary>;
|
||||
}
|
||||
|
||||
export interface ResolvedMemberSelectorCacheSnapshot {
|
||||
hasResolvedMembersSelector: boolean;
|
||||
resolvedMemberSelectorCount: number;
|
||||
}
|
||||
|
||||
const resolvedMembersSelectorCache = new Map<
|
||||
string,
|
||||
{
|
||||
snapshotRef: TeamViewSnapshot['members'];
|
||||
configMembersRef: TeamViewSnapshot['config']['members'] | undefined;
|
||||
summaryRef: TeamSummary | undefined;
|
||||
tasksRef: TeamViewSnapshot['tasks'] | undefined;
|
||||
metaMembersRef: TeamMemberActivityMeta['members'] | undefined;
|
||||
result: ResolvedTeamMember[];
|
||||
}
|
||||
>();
|
||||
const resolvedMemberSelectorCache = new Map<
|
||||
string,
|
||||
{
|
||||
snapshotMemberRef: TeamMemberSnapshot | undefined;
|
||||
metaEntryRef: MemberActivityMetaEntry | undefined;
|
||||
result: ResolvedTeamMember | null;
|
||||
}
|
||||
>();
|
||||
|
||||
export function clearResolvedMemberSelectorCaches(): void {
|
||||
resolvedMembersSelectorCache.clear();
|
||||
resolvedMemberSelectorCache.clear();
|
||||
}
|
||||
|
||||
export function clearResolvedMemberSelectorCachesForTeam(teamName: string): void {
|
||||
resolvedMembersSelectorCache.delete(teamName);
|
||||
|
||||
const teamScopedPrefix = `${teamName}:`;
|
||||
for (const key of resolvedMemberSelectorCache.keys()) {
|
||||
if (key.startsWith(teamScopedPrefix)) {
|
||||
resolvedMemberSelectorCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getResolvedMemberSelectorCacheSnapshotForTeam(
|
||||
teamName: string
|
||||
): ResolvedMemberSelectorCacheSnapshot {
|
||||
const teamScopedPrefix = `${teamName}:`;
|
||||
let resolvedMemberSelectorCount = 0;
|
||||
|
||||
for (const key of resolvedMemberSelectorCache.keys()) {
|
||||
if (key.startsWith(teamScopedPrefix)) {
|
||||
resolvedMemberSelectorCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
|
||||
resolvedMemberSelectorCount,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMemberStatus(
|
||||
snapshot: TeamMemberSnapshot,
|
||||
activity: MemberActivityMetaEntry | undefined
|
||||
): ResolvedTeamMember['status'] {
|
||||
if (activity?.latestAuthoredMessageSignalsTermination) {
|
||||
return 'terminated';
|
||||
}
|
||||
|
||||
if (!activity?.lastAuthoredMessageAt) {
|
||||
return snapshot.currentTaskId ? 'active' : 'idle';
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt);
|
||||
if (Number.isNaN(ageMs)) {
|
||||
return 'unknown';
|
||||
}
|
||||
if (ageMs < 5 * 60 * 1000) {
|
||||
return 'active';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function buildResolvedMembers(
|
||||
snapshots: readonly TeamMemberSnapshot[],
|
||||
meta: TeamMemberActivityMeta | undefined
|
||||
): ResolvedTeamMember[] {
|
||||
return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name]));
|
||||
}
|
||||
|
||||
function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean {
|
||||
return (
|
||||
task.status === 'in_progress' &&
|
||||
getTeamTaskWorkflowColumn(task) !== 'review' &&
|
||||
!isTeamTaskFinalForCompletionNotification(task)
|
||||
);
|
||||
}
|
||||
|
||||
function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] {
|
||||
const configMembers = snapshot.config.members ?? [];
|
||||
const hasConfiguredTeammate = configMembers.some((member) => {
|
||||
const name = member.name?.trim();
|
||||
return Boolean(name) && !member.removedAt && !isLeadMember(member);
|
||||
});
|
||||
if (!hasConfiguredTeammate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const fallbackMembers: TeamMemberSnapshot[] = [];
|
||||
for (const member of configMembers) {
|
||||
const name = member.name?.trim();
|
||||
if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (seenNames.has(key)) continue;
|
||||
seenNames.add(key);
|
||||
|
||||
const ownedTasks = snapshot.tasks.filter((task) => task.owner === name);
|
||||
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
|
||||
fallbackMembers.push({
|
||||
name,
|
||||
agentId: member.agentId,
|
||||
currentTaskId: currentTask?.id ?? null,
|
||||
taskCount: ownedTasks.length,
|
||||
color: member.color ?? getMemberColorByName(name),
|
||||
agentType: member.agentType,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
isolation: member.isolation,
|
||||
providerId: member.providerId,
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
mcpPolicy: member.mcpPolicy,
|
||||
selectedFastMode: member.fastMode,
|
||||
cwd: member.cwd,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return fallbackMembers;
|
||||
}
|
||||
|
||||
function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] {
|
||||
if (!snapshot) {
|
||||
return [];
|
||||
}
|
||||
const names = new Set<string>();
|
||||
for (const member of snapshot.members) {
|
||||
const name = member.name.trim();
|
||||
const key = name.toLowerCase();
|
||||
if (!name || key === 'user' || member.removedAt || isLeadMember(member)) {
|
||||
continue;
|
||||
}
|
||||
names.add(key);
|
||||
}
|
||||
return Array.from(names).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
|
||||
return getActiveRawTeammateNameKeys(snapshot).length > 0;
|
||||
}
|
||||
|
||||
function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
|
||||
return Boolean(snapshot?.members.some((member) => member.removedAt));
|
||||
}
|
||||
|
||||
function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
|
||||
return Boolean(
|
||||
snapshot?.config.members?.some((member) => {
|
||||
const name = member.name?.trim();
|
||||
return Boolean(name) && !member.removedAt && !isLeadMember(member);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryFallbackMemberSource {
|
||||
name: string;
|
||||
agentId?: string;
|
||||
role?: string;
|
||||
color?: string;
|
||||
mcpPolicy?: TeamMemberSnapshot['mcpPolicy'];
|
||||
}
|
||||
|
||||
function normalizeSummaryTeammateName(
|
||||
name: string | undefined | null,
|
||||
leadName?: string
|
||||
): string | null {
|
||||
const trimmed = name?.trim();
|
||||
const normalizedName = trimmed?.toLowerCase();
|
||||
const normalizedLeadName = leadName?.trim().toLowerCase();
|
||||
if (
|
||||
!trimmed ||
|
||||
normalizedName === 'user' ||
|
||||
isLeadMember({ name: trimmed }) ||
|
||||
(normalizedLeadName && normalizedName === normalizedLeadName)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
|
||||
const seenNames = new Set<string>();
|
||||
const sources: SummaryFallbackMemberSource[] = [];
|
||||
for (const member of summary.members ?? []) {
|
||||
const name = normalizeSummaryTeammateName(member.name, summary.leadName);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seenNames.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenNames.add(key);
|
||||
sources.push({
|
||||
name,
|
||||
agentId: member.agentId,
|
||||
role: member.role,
|
||||
color: member.color,
|
||||
mcpPolicy: member.mcpPolicy,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean {
|
||||
return (
|
||||
summary.partialLaunchFailure === true ||
|
||||
summary.teamLaunchState === 'partial_failure' ||
|
||||
summary.teamLaunchState === 'partial_pending' ||
|
||||
summary.teamLaunchState === 'partial_skipped'
|
||||
);
|
||||
}
|
||||
|
||||
function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
|
||||
if (!shouldUseSummaryLaunchTeammateSources(summary)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const sources: SummaryFallbackMemberSource[] = [];
|
||||
for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) {
|
||||
const name = normalizeSummaryTeammateName(rawName, summary.leadName);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seenNames.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenNames.add(key);
|
||||
sources.push({ name });
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] {
|
||||
return getSummaryLaunchTeammateSources(summary)
|
||||
.map((member) => member.name.toLowerCase())
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function getSummaryTeammateNameKeys(summary: TeamSummary): string[] {
|
||||
const rosterNames = getSummaryRosterTeammateSources(summary)
|
||||
.map((member) => member.name.toLowerCase())
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
if (rosterNames.length > 0) {
|
||||
return rosterNames;
|
||||
}
|
||||
|
||||
const launchNames = getSummaryLaunchTeammateNameKeys(summary);
|
||||
const expectedCount = summary.expectedMemberCount ?? summary.memberCount;
|
||||
if (expectedCount > 0 && launchNames.length === expectedCount) {
|
||||
return launchNames;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
|
||||
return getSummaryRosterTeammateSources(summary);
|
||||
}
|
||||
|
||||
function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((name, index) => name === right[index]);
|
||||
}
|
||||
|
||||
function summaryConfirmsActiveTeammateRoster(
|
||||
current: TeamViewSnapshot,
|
||||
summary: TeamSummary
|
||||
): boolean {
|
||||
if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentNames = getActiveRawTeammateNameKeys(current);
|
||||
const summaryNames = getSummaryTeammateNameKeys(summary);
|
||||
if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return areNameKeyListsEqual(summaryNames, currentNames);
|
||||
}
|
||||
|
||||
function buildSummaryFallbackMemberSnapshots(
|
||||
snapshot: TeamViewSnapshot,
|
||||
summary: TeamSummary | undefined
|
||||
): TeamMemberSnapshot[] {
|
||||
if (!summary) {
|
||||
return [];
|
||||
}
|
||||
const summaryMembers = getSummaryFallbackTeammateSources(summary);
|
||||
if (summaryMembers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const buildSnapshot = (
|
||||
name: string,
|
||||
source?: Omit<SummaryFallbackMemberSource, 'name'>,
|
||||
lead = false
|
||||
): TeamMemberSnapshot | null => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seenNames.has(key)) return null;
|
||||
seenNames.add(key);
|
||||
|
||||
const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed);
|
||||
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
|
||||
return {
|
||||
name: trimmed,
|
||||
agentId: source?.agentId,
|
||||
currentTaskId: currentTask?.id ?? null,
|
||||
taskCount: ownedTasks.length,
|
||||
color: source?.color ?? getMemberColorByName(trimmed),
|
||||
agentType: lead ? 'team-lead' : undefined,
|
||||
role: source?.role ?? (lead ? 'Team Lead' : undefined),
|
||||
mcpPolicy: source?.mcpPolicy,
|
||||
};
|
||||
};
|
||||
|
||||
const teammates = summaryMembers.flatMap((member) => {
|
||||
const item = buildSnapshot(member.name, member);
|
||||
return item ? [item] : [];
|
||||
});
|
||||
if (teammates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member));
|
||||
if (existingLead) {
|
||||
return [existingLead, ...teammates];
|
||||
}
|
||||
|
||||
const configuredLead = snapshot.config.members?.find(
|
||||
(member) => !member.removedAt && isLeadMember(member)
|
||||
);
|
||||
const leadName = configuredLead?.name?.trim() || summary.leadName?.trim();
|
||||
const lead = leadName
|
||||
? buildSnapshot(
|
||||
leadName,
|
||||
{
|
||||
agentId: configuredLead?.agentId,
|
||||
role: configuredLead?.role,
|
||||
color: configuredLead?.color ?? summary.leadColor,
|
||||
},
|
||||
true
|
||||
)
|
||||
: null;
|
||||
|
||||
return lead ? [lead, ...teammates] : teammates;
|
||||
}
|
||||
|
||||
function getResolvableMemberSnapshots(
|
||||
snapshot: TeamViewSnapshot,
|
||||
summary?: TeamSummary
|
||||
): readonly TeamMemberSnapshot[] {
|
||||
if (
|
||||
snapshot.members.length > 0 &&
|
||||
(hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot))
|
||||
) {
|
||||
return snapshot.members;
|
||||
}
|
||||
|
||||
const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot);
|
||||
if (configFallbackMembers.length > 0) {
|
||||
return configFallbackMembers;
|
||||
}
|
||||
|
||||
const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary);
|
||||
if (summaryFallbackMembers.length > 0) {
|
||||
return summaryFallbackMembers;
|
||||
}
|
||||
|
||||
return snapshot.members;
|
||||
}
|
||||
|
||||
export function shouldPreserveSelectedTeamSnapshot(
|
||||
current: TeamViewSnapshot | null,
|
||||
baseline: TeamViewSnapshot | null | undefined,
|
||||
incoming: TeamViewSnapshot,
|
||||
summary: TeamSummary | undefined
|
||||
): boolean {
|
||||
if (!current || !hasActiveRawTeammateRoster(current)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
hasActiveRawTeammateRoster(incoming) ||
|
||||
hasRemovedRawMemberRoster(incoming) ||
|
||||
hasConfigTeammateRoster(incoming)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const currentNames = getActiveRawTeammateNameKeys(current);
|
||||
if (
|
||||
current !== baseline &&
|
||||
!areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (summary) {
|
||||
return summaryConfirmsActiveTeammateRoster(current, summary);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildResolvedMember(
|
||||
snapshot: TeamMemberSnapshot,
|
||||
activity: MemberActivityMetaEntry | undefined
|
||||
): ResolvedTeamMember {
|
||||
return {
|
||||
...snapshot,
|
||||
status: resolveMemberStatus(snapshot, activity),
|
||||
messageCount: activity?.messageCountExact ?? 0,
|
||||
lastActiveAt: activity?.lastAuthoredMessageAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectResolvedMembersForTeamName(
|
||||
state: ResolvedMemberSelectorState,
|
||||
teamName: string | null | undefined
|
||||
): ResolvedTeamMember[] {
|
||||
const snapshot = selectTeamDataForName(state, teamName);
|
||||
if (!snapshot || !teamName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const meta = state.memberActivityMetaByTeam[teamName];
|
||||
const metaMembers = meta?.members;
|
||||
const shouldUseMemberFallback =
|
||||
snapshot.members.length === 0 ||
|
||||
(!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot));
|
||||
const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined;
|
||||
const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined;
|
||||
const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined;
|
||||
const cached = resolvedMembersSelectorCache.get(teamName);
|
||||
if (
|
||||
cached?.snapshotRef === snapshot.members &&
|
||||
cached.configMembersRef === configMembersRef &&
|
||||
cached.summaryRef === summaryRef &&
|
||||
cached.tasksRef === tasksRef &&
|
||||
cached.metaMembersRef === metaMembers
|
||||
) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta);
|
||||
resolvedMembersSelectorCache.set(teamName, {
|
||||
snapshotRef: snapshot.members,
|
||||
configMembersRef,
|
||||
summaryRef,
|
||||
tasksRef,
|
||||
metaMembersRef: metaMembers,
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function selectResolvedMemberForTeamName(
|
||||
state: ResolvedMemberSelectorState,
|
||||
teamName: string | null | undefined,
|
||||
memberName: string | null | undefined
|
||||
): ResolvedTeamMember | null {
|
||||
const snapshot = selectTeamDataForName(state, teamName);
|
||||
if (!snapshot || !teamName || !memberName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find(
|
||||
(member) => member.name === memberName
|
||||
);
|
||||
if (!snapshotMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName];
|
||||
const cacheKey = `${teamName}:${memberName}`;
|
||||
const cached = resolvedMemberSelectorCache.get(cacheKey);
|
||||
if (cached?.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const result = buildResolvedMember(snapshotMember, metaEntry);
|
||||
resolvedMemberSelectorCache.set(cacheKey, {
|
||||
snapshotMemberRef: snapshotMember,
|
||||
metaEntryRef: metaEntry,
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
133
test/renderer/store/teamGraphLayout.test.ts
Normal file
133
test/renderer/store/teamGraphLayout.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
areTeamGraphSlotAssignmentsEqual,
|
||||
getDefaultTeamGraphSlotAssignmentsForMembers,
|
||||
isTeamGraphSlotPersistenceDisabled,
|
||||
migrateStableSlotAssignmentsForMembers,
|
||||
normalizeLegacySixRowOrbitAssignments,
|
||||
normalizeTeamGraphGridOwnerOrder,
|
||||
normalizeTeamGraphSlotAssignmentsForVisibleOwners,
|
||||
pruneTeamGraphSlotAssignmentsForVisibleOwners,
|
||||
seedStableSlotAssignmentsForMembers,
|
||||
} from '../../../src/renderer/store/team/teamGraphLayout';
|
||||
|
||||
describe('teamGraphLayout', () => {
|
||||
it('migrates legacy name-keyed assignments to stable owner ids', () => {
|
||||
const migrated = migrateStableSlotAssignmentsForMembers(
|
||||
{
|
||||
alice: { ringIndex: 0, sectorIndex: 1 },
|
||||
},
|
||||
[{ name: 'alice', agentId: 'agent-a' }]
|
||||
);
|
||||
|
||||
expect(migrated.changed).toBe(true);
|
||||
expect(migrated.assignments).toEqual({
|
||||
'agent-a': { ringIndex: 0, sectorIndex: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops stale name-keyed assignments when stable assignments already exist', () => {
|
||||
const migrated = migrateStableSlotAssignmentsForMembers(
|
||||
{
|
||||
alice: { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-a': { ringIndex: 0, sectorIndex: 2 },
|
||||
},
|
||||
[{ name: 'alice', agentId: 'agent-a' }]
|
||||
);
|
||||
|
||||
expect(migrated.changed).toBe(true);
|
||||
expect(migrated.assignments).toEqual({
|
||||
'agent-a': { ringIndex: 0, sectorIndex: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds default assignments only when no visible owner has a persisted assignment', () => {
|
||||
const seeded = seedStableSlotAssignmentsForMembers(
|
||||
{ unrelated: { ringIndex: 4, sectorIndex: 0 } },
|
||||
[
|
||||
{ name: 'alice', agentId: 'agent-a' },
|
||||
{ name: 'bob', agentId: 'agent-b' },
|
||||
]
|
||||
);
|
||||
|
||||
expect(seeded.changed).toBe(true);
|
||||
expect(Object.keys(seeded.assignments)).toEqual(['unrelated', 'agent-a', 'agent-b']);
|
||||
expect(seeded.assignments['agent-a']).toEqual({ ringIndex: 0, sectorIndex: 0 });
|
||||
expect(seeded.assignments['agent-b']).toEqual({ ringIndex: 0, sectorIndex: 1 });
|
||||
|
||||
const preserved = seedStableSlotAssignmentsForMembers(seeded.assignments, [
|
||||
{ name: 'alice', agentId: 'agent-a' },
|
||||
{ name: 'bob', agentId: 'agent-b' },
|
||||
]);
|
||||
expect(preserved.changed).toBe(false);
|
||||
expect(preserved.assignments).toBe(seeded.assignments);
|
||||
});
|
||||
|
||||
it('normalizes six-owner legacy two-row orbit assignments', () => {
|
||||
const ownerIds = ['a', 'b', 'c', 'd', 'e', 'f'];
|
||||
const normalized = normalizeLegacySixRowOrbitAssignments(
|
||||
{
|
||||
a: { ringIndex: 0, sectorIndex: 0 },
|
||||
b: { ringIndex: 0, sectorIndex: 4 },
|
||||
c: { ringIndex: 1, sectorIndex: 2 },
|
||||
d: { ringIndex: 1, sectorIndex: 0 },
|
||||
},
|
||||
ownerIds
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
a: { ringIndex: 0, sectorIndex: 0 },
|
||||
b: { ringIndex: 2, sectorIndex: 1 },
|
||||
c: { ringIndex: 2, sectorIndex: 2 },
|
||||
d: { ringIndex: 2, sectorIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes and prunes assignments to visible owners', () => {
|
||||
const normalized = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
{
|
||||
a: { ringIndex: 0, sectorIndex: 0 },
|
||||
hidden: { ringIndex: 4, sectorIndex: 4 },
|
||||
},
|
||||
['a']
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({ a: { ringIndex: 0, sectorIndex: 0 } });
|
||||
expect(pruneTeamGraphSlotAssignmentsForVisibleOwners({ hidden: { ringIndex: 4, sectorIndex: 4 } }, ['a']))
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes grid owner order by filtering stale and duplicate ids then appending missing ids', () => {
|
||||
expect(normalizeTeamGraphGridOwnerOrder(['b', 'stale', 'b'], ['a', 'b', 'c'])).toEqual([
|
||||
'b',
|
||||
'a',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('compares assignments by owner id and slot coordinates', () => {
|
||||
expect(
|
||||
areTeamGraphSlotAssignmentsEqual(
|
||||
{ a: { ringIndex: 0, sectorIndex: 0 } },
|
||||
{ a: { ringIndex: 0, sectorIndex: 0 } }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
areTeamGraphSlotAssignmentsEqual(
|
||||
{ a: { ringIndex: 0, sectorIndex: 0 } },
|
||||
{ a: { ringIndex: 0, sectorIndex: 1 } }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes default assignment and persistence guardrail helpers', () => {
|
||||
expect(
|
||||
getDefaultTeamGraphSlotAssignmentsForMembers([
|
||||
{ name: 'team-lead', agentId: 'lead-agent' },
|
||||
{ name: 'alice', agentId: 'agent-a' },
|
||||
])
|
||||
).toEqual({ 'agent-a': { ringIndex: 0, sectorIndex: 0 } });
|
||||
expect(isTeamGraphSlotPersistenceDisabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
210
test/renderer/store/teamResolvedMembers.test.ts
Normal file
210
test/renderer/store/teamResolvedMembers.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
clearResolvedMemberSelectorCaches,
|
||||
getResolvedMemberSelectorCacheSnapshotForTeam,
|
||||
selectResolvedMemberForTeamName,
|
||||
selectResolvedMembersForTeamName,
|
||||
shouldPreserveSelectedTeamSnapshot,
|
||||
} from '../../../src/renderer/store/team/teamResolvedMembers';
|
||||
|
||||
import type {
|
||||
TeamMemberActivityMeta,
|
||||
TeamMemberSnapshot,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamViewSnapshot,
|
||||
} from '../../../src/shared/types';
|
||||
|
||||
function createTask(overrides: Partial<TeamTask> = {}): TeamTask {
|
||||
return {
|
||||
id: 'task-1',
|
||||
subject: 'Task',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSnapshot(overrides: Partial<TeamViewSnapshot> = {}): TeamViewSnapshot {
|
||||
return {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
...overrides,
|
||||
} as TeamViewSnapshot;
|
||||
}
|
||||
|
||||
function createState(
|
||||
snapshot: TeamViewSnapshot,
|
||||
options: {
|
||||
summary?: TeamSummary;
|
||||
meta?: TeamMemberActivityMeta;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
selectedTeamName: snapshot.teamName,
|
||||
selectedTeamData: snapshot,
|
||||
teamDataCacheByName: { [snapshot.teamName]: snapshot },
|
||||
memberActivityMetaByTeam: options.meta ? { [snapshot.teamName]: options.meta } : {},
|
||||
teamByName: options.summary ? { [snapshot.teamName]: options.summary } : {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('teamResolvedMembers', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-17T12:00:00.000Z'));
|
||||
clearResolvedMemberSelectorCaches();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
clearResolvedMemberSelectorCaches();
|
||||
});
|
||||
|
||||
it('builds config fallback members when runtime snapshots are empty', () => {
|
||||
const snapshot = createSnapshot({
|
||||
config: {
|
||||
name: 'My Team',
|
||||
members: [
|
||||
{ name: 'team-lead', role: 'Lead' },
|
||||
{ name: 'alice', agentId: 'agent-a', role: 'Engineer' },
|
||||
{ name: 'Alice', agentId: 'duplicate' },
|
||||
],
|
||||
},
|
||||
tasks: [
|
||||
createTask({ id: 'task-active', owner: 'alice', status: 'in_progress' }),
|
||||
createTask({ id: 'task-done', owner: 'alice', status: 'completed' }),
|
||||
],
|
||||
});
|
||||
|
||||
const members = selectResolvedMembersForTeamName(createState(snapshot), 'my-team');
|
||||
|
||||
expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice']);
|
||||
expect(members[1]).toMatchObject({
|
||||
name: 'alice',
|
||||
agentId: 'agent-a',
|
||||
currentTaskId: 'task-active',
|
||||
taskCount: 2,
|
||||
role: 'Engineer',
|
||||
status: 'active',
|
||||
messageCount: 0,
|
||||
lastActiveAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds summary fallback members with a lead when config and runtime snapshots are empty', () => {
|
||||
const snapshot = createSnapshot();
|
||||
const summary = {
|
||||
teamName: 'my-team',
|
||||
displayName: 'My Team',
|
||||
memberCount: 2,
|
||||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
leadName: 'lead-one',
|
||||
leadColor: '#fff',
|
||||
members: [
|
||||
{ name: 'lead-one', role: 'Lead' },
|
||||
{ name: 'bob', agentId: 'agent-b', role: 'Reviewer', color: '#123456' },
|
||||
{ name: 'Bob', agentId: 'duplicate' },
|
||||
],
|
||||
} as TeamSummary;
|
||||
|
||||
const members = selectResolvedMembersForTeamName(createState(snapshot, { summary }), 'my-team');
|
||||
|
||||
expect(members.map((member) => member.name)).toEqual(['lead-one', 'bob']);
|
||||
expect(members[0]).toMatchObject({ agentType: 'team-lead', role: 'Team Lead' });
|
||||
expect(members[1]).toMatchObject({
|
||||
agentId: 'agent-b',
|
||||
role: 'Reviewer',
|
||||
color: '#123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('memoizes selector results until resolved-member cache is cleared', () => {
|
||||
const snapshot = createSnapshot({
|
||||
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
|
||||
});
|
||||
const state = createState(snapshot);
|
||||
|
||||
const firstMembers = selectResolvedMembersForTeamName(state, 'my-team');
|
||||
const secondMembers = selectResolvedMembersForTeamName(state, 'my-team');
|
||||
const firstAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice');
|
||||
const secondAlice = selectResolvedMemberForTeamName(state, 'my-team', 'alice');
|
||||
|
||||
expect(secondMembers).toBe(firstMembers);
|
||||
expect(secondAlice).toBe(firstAlice);
|
||||
expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({
|
||||
hasResolvedMembersSelector: true,
|
||||
resolvedMemberSelectorCount: 1,
|
||||
});
|
||||
|
||||
clearResolvedMemberSelectorCaches();
|
||||
|
||||
expect(selectResolvedMembersForTeamName(state, 'my-team')).not.toBe(firstMembers);
|
||||
expect(getResolvedMemberSelectorCacheSnapshotForTeam('my-team')).toEqual({
|
||||
hasResolvedMembersSelector: true,
|
||||
resolvedMemberSelectorCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives activity status from member activity metadata', () => {
|
||||
const snapshot = createSnapshot({
|
||||
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
|
||||
});
|
||||
const meta = {
|
||||
teamName: 'my-team',
|
||||
feedRevision: 'rev-1',
|
||||
computedAt: '2026-04-17T12:00:00.000Z',
|
||||
members: {
|
||||
alice: {
|
||||
memberName: 'alice',
|
||||
lastAuthoredMessageAt: '2026-04-17T11:57:00.000Z',
|
||||
messageCountExact: 3,
|
||||
latestAuthoredMessageSignalsTermination: false,
|
||||
},
|
||||
},
|
||||
} as TeamMemberActivityMeta;
|
||||
|
||||
expect(selectResolvedMemberForTeamName(createState(snapshot, { meta }), 'my-team', 'alice'))
|
||||
.toMatchObject({
|
||||
status: 'active',
|
||||
messageCount: 3,
|
||||
lastActiveAt: '2026-04-17T11:57:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the selected snapshot when an incoming empty snapshot is confirmed by summary', () => {
|
||||
const current = createSnapshot({
|
||||
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
|
||||
});
|
||||
const incoming = createSnapshot({ members: [], config: { name: 'My Team' } });
|
||||
const summary = {
|
||||
teamName: 'my-team',
|
||||
displayName: 'My Team',
|
||||
memberCount: 1,
|
||||
expectedMemberCount: 1,
|
||||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
members: [{ name: 'alice' }],
|
||||
} as TeamSummary;
|
||||
|
||||
expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, summary)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not preserve the selected snapshot when incoming data has a config roster', () => {
|
||||
const current = createSnapshot({
|
||||
members: [{ name: 'alice', currentTaskId: null, taskCount: 0 } as TeamMemberSnapshot],
|
||||
});
|
||||
const incoming = createSnapshot({
|
||||
members: [],
|
||||
config: { name: 'My Team', members: [{ name: 'bob' }] },
|
||||
});
|
||||
|
||||
expect(shouldPreserveSelectedTeamSnapshot(current, current, incoming, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue