diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index f3ac7e96..4e5ced93 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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; -type TeamGraphMemberSeedInput = Pick; -type TeamGraphConfigMemberSeedInput = Pick< - NonNullable[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(); - 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(); - 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(); - 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(); - 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(); - const buildSnapshot = ( - name: string, - source?: Omit, - 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>; - -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(); - - 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, teamName: string | null | undefined diff --git a/src/renderer/store/team/teamGraphLayout.ts b/src/renderer/store/team/teamGraphLayout.ts new file mode 100644 index 00000000..6f7329e8 --- /dev/null +++ b/src/renderer/store/team/teamGraphLayout.ts @@ -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; +export type TeamGraphMemberSeedInput = Pick; +export type TeamGraphConfigMemberSeedInput = Pick< + NonNullable[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(); + + 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; +} diff --git a/src/renderer/store/team/teamResolvedMembers.ts b/src/renderer/store/team/teamResolvedMembers.ts new file mode 100644 index 00000000..1f5b093f --- /dev/null +++ b/src/renderer/store/team/teamResolvedMembers.ts @@ -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; + teamByName?: Record; +} + +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(); + 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(); + 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(); + 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(); + 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(); + const buildSnapshot = ( + name: string, + source?: Omit, + 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; +} diff --git a/test/renderer/store/teamGraphLayout.test.ts b/test/renderer/store/teamGraphLayout.test.ts new file mode 100644 index 00000000..515bdd50 --- /dev/null +++ b/test/renderer/store/teamGraphLayout.test.ts @@ -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); + }); +}); diff --git a/test/renderer/store/teamResolvedMembers.test.ts b/test/renderer/store/teamResolvedMembers.test.ts new file mode 100644 index 00000000..6ea9f5ad --- /dev/null +++ b/test/renderer/store/teamResolvedMembers.test.ts @@ -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 { + return { + id: 'task-1', + subject: 'Task', + owner: 'alice', + status: 'in_progress', + ...overrides, + }; +} + +function createSnapshot(overrides: Partial = {}): 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); + }); +});