541 lines
16 KiB
TypeScript
541 lines
16 KiB
TypeScript
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;
|
|
}
|
|
>();
|
|
let activeRawTeammateNameKeysCache = new WeakMap<TeamViewSnapshot['members'], string[]>();
|
|
|
|
export function clearResolvedMemberSelectorCaches(): void {
|
|
resolvedMembersSelectorCache.clear();
|
|
resolvedMemberSelectorCache.clear();
|
|
activeRawTeammateNameKeysCache = new WeakMap<TeamViewSnapshot['members'], string[]>();
|
|
}
|
|
|
|
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 cached = activeRawTeammateNameKeysCache.get(snapshot.members);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
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);
|
|
}
|
|
const result = Array.from(names).sort((left, right) => left.localeCompare(right));
|
|
activeRawTeammateNameKeysCache.set(snapshot.members, result);
|
|
return result;
|
|
}
|
|
|
|
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;
|
|
}
|