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';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
|
||||||
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
|
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
|
||||||
import {
|
import {
|
||||||
getTeamTaskWorkflowColumn,
|
getTeamTaskWorkflowColumn,
|
||||||
isTeamTaskFinalForCompletionNotification,
|
isTeamTaskFinalForCompletionNotification,
|
||||||
|
|
@ -48,6 +45,19 @@ import {
|
||||||
mapSendMessageError,
|
mapSendMessageError,
|
||||||
shouldInvalidateCachedTeamDataForError,
|
shouldInvalidateCachedTeamDataForError,
|
||||||
} from '../team/teamErrorPolicies';
|
} 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 {
|
import {
|
||||||
areTeamLaunchParamsEqual,
|
areTeamLaunchParamsEqual,
|
||||||
buildLaunchParamsFromRuntimeRequest,
|
buildLaunchParamsFromRuntimeRequest,
|
||||||
|
|
@ -109,6 +119,12 @@ import {
|
||||||
hasTeamRefreshBurstDiagnostics,
|
hasTeamRefreshBurstDiagnostics,
|
||||||
noteTeamRefreshBurst,
|
noteTeamRefreshBurst,
|
||||||
} from '../team/teamRefreshBurstDiagnostics';
|
} from '../team/teamRefreshBurstDiagnostics';
|
||||||
|
import {
|
||||||
|
clearResolvedMemberSelectorCaches,
|
||||||
|
clearResolvedMemberSelectorCachesForTeam,
|
||||||
|
getResolvedMemberSelectorCacheSnapshotForTeam,
|
||||||
|
shouldPreserveSelectedTeamSnapshot,
|
||||||
|
} from '../team/teamResolvedMembers';
|
||||||
import {
|
import {
|
||||||
buildTeamScopedProgressTombstones,
|
buildTeamScopedProgressTombstones,
|
||||||
collectTeamScopedStateRemovals,
|
collectTeamScopedStateRemovals,
|
||||||
|
|
@ -139,11 +155,9 @@ import type {
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
MemberActivityMetaEntry,
|
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
NotificationTarget,
|
NotificationTarget,
|
||||||
ResolvedTeamMember,
|
|
||||||
RetryFailedOpenCodeSecondaryLanesResult,
|
RetryFailedOpenCodeSecondaryLanesResult,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
|
@ -154,7 +168,6 @@ import type {
|
||||||
TeamGetDataOptions,
|
TeamGetDataOptions,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamMemberActivityMeta,
|
TeamMemberActivityMeta,
|
||||||
TeamMemberSnapshot,
|
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
|
|
@ -173,6 +186,10 @@ export {
|
||||||
selectTeamMemberSnapshotsForName,
|
selectTeamMemberSnapshotsForName,
|
||||||
selectTeamTasksForName,
|
selectTeamTasksForName,
|
||||||
} from '../team/teamDataSelectors';
|
} from '../team/teamDataSelectors';
|
||||||
|
export {
|
||||||
|
getDefaultTeamGraphSlotAssignmentsForMembers,
|
||||||
|
isTeamGraphSlotPersistenceDisabled,
|
||||||
|
} from '../team/teamGraphLayout';
|
||||||
export type { TeamLaunchParams } from '../team/teamLaunchParams';
|
export type { TeamLaunchParams } from '../team/teamLaunchParams';
|
||||||
export type {
|
export type {
|
||||||
RefreshTeamMessagesHeadResult,
|
RefreshTeamMessagesHeadResult,
|
||||||
|
|
@ -187,9 +204,11 @@ export {
|
||||||
getActiveTeamPendingReplyWaits,
|
getActiveTeamPendingReplyWaits,
|
||||||
hasActiveTeamPendingReplyWait,
|
hasActiveTeamPendingReplyWait,
|
||||||
} from '../team/teamPendingReplyWaits';
|
} 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 logger = createLogger('teamSlice');
|
||||||
|
|
||||||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
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 {
|
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||||
return (
|
return (
|
||||||
hasFullTeamDataRequestForTeam(teamName) ||
|
hasFullTeamDataRequestForTeam(teamName) ||
|
||||||
|
|
@ -287,21 +295,13 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
clearAllMemberSpawnStatusesIpcBackoffs();
|
clearAllMemberSpawnStatusesIpcBackoffs();
|
||||||
clearAllTeamRefreshBurstDiagnostics();
|
clearAllTeamRefreshBurstDiagnostics();
|
||||||
clearAllMemberSpawnUiEqualLastWarns();
|
clearAllMemberSpawnUiEqualLastWarns();
|
||||||
resolvedMembersSelectorCache.clear();
|
clearResolvedMemberSelectorCaches();
|
||||||
resolvedMemberSelectorCache.clear();
|
|
||||||
clearTeamMessageSelectorCaches();
|
clearTeamMessageSelectorCaches();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTeamScopedSelectorCaches(teamName: string): void {
|
function clearTeamScopedSelectorCaches(teamName: string): void {
|
||||||
resolvedMembersSelectorCache.delete(teamName);
|
clearResolvedMemberSelectorCachesForTeam(teamName);
|
||||||
clearTeamMessageSelectorCachesForTeam(teamName);
|
clearTeamMessageSelectorCachesForTeam(teamName);
|
||||||
|
|
||||||
const teamScopedPrefix = `${teamName}:`;
|
|
||||||
for (const key of resolvedMemberSelectorCache.keys()) {
|
|
||||||
if (key.startsWith(teamScopedPrefix)) {
|
|
||||||
resolvedMemberSelectorCache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTeamScopedTransientState(teamName: string): void {
|
function clearTeamScopedTransientState(teamName: string): void {
|
||||||
|
|
@ -519,19 +519,15 @@ export function __getTeamScopedTransientStateForTests(teamName: string): {
|
||||||
hasTeamRefreshBurstDiagnostics: boolean;
|
hasTeamRefreshBurstDiagnostics: boolean;
|
||||||
hasMemberSpawnUiEqualLastWarn: boolean;
|
hasMemberSpawnUiEqualLastWarn: boolean;
|
||||||
} {
|
} {
|
||||||
const teamScopedPrefix = `${teamName}:`;
|
|
||||||
const messageSelectorCache = getTeamMessageSelectorCacheSnapshotForTeam(teamName);
|
const messageSelectorCache = getTeamMessageSelectorCacheSnapshotForTeam(teamName);
|
||||||
let resolvedMemberSelectorCount = 0;
|
const resolvedMemberSelectorCacheSnapshot =
|
||||||
|
getResolvedMemberSelectorCacheSnapshotForTeam(teamName);
|
||||||
for (const key of resolvedMemberSelectorCache.keys()) {
|
|
||||||
if (key.startsWith(teamScopedPrefix)) {
|
|
||||||
resolvedMemberSelectorCount += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
|
hasResolvedMembersSelector:
|
||||||
resolvedMemberSelectorCount,
|
resolvedMemberSelectorCacheSnapshot.hasResolvedMembersSelector,
|
||||||
|
resolvedMemberSelectorCount:
|
||||||
|
resolvedMemberSelectorCacheSnapshot.resolvedMemberSelectorCount,
|
||||||
hasMergedMessagesSelector: messageSelectorCache.hasMergedMessagesSelector,
|
hasMergedMessagesSelector: messageSelectorCache.hasMergedMessagesSelector,
|
||||||
memberMessagesSelectorCount: messageSelectorCache.memberMessagesSelectorCount,
|
memberMessagesSelectorCount: messageSelectorCache.memberMessagesSelectorCount,
|
||||||
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
|
hasPendingFreshTeamDataRefresh: pendingFreshTeamDataRefreshes.has(teamName),
|
||||||
|
|
@ -1237,682 +1233,6 @@ export interface PendingTeamSectionFocusState {
|
||||||
section: TeamSectionTarget;
|
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(
|
function isVisibleInActiveTeamSurface(
|
||||||
state: Pick<AppState, 'paneLayout'>,
|
state: Pick<AppState, 'paneLayout'>,
|
||||||
teamName: string | null | undefined
|
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