agent-ecosystem/src/renderer/components/team/members/MemberList.tsx
2026-04-16 22:52:56 +03:00

322 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
import { isLeadMember } from '@shared/utils/leadDetection';
import { MemberCard } from './MemberCard';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type {
LeadActivityState,
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamTaskWithKanban,
} from '@shared/types';
interface MemberListProps {
members: ResolvedTeamMember[];
memberTaskCounts?: Map<string, TaskStatusCounts>;
taskMap?: Map<string, TeamTaskWithKanban>;
pendingRepliesByMember?: Record<string, number>;
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
isLaunchSettling?: boolean;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
launchParams?: TeamLaunchParams;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
onOpenTask?: (taskId: string) => void;
}
function areResolvedMembersEquivalent(
left: readonly ResolvedTeamMember[],
right: readonly ResolvedTeamMember[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftMember = left[index];
const rightMember = right[index];
if (
leftMember.name !== rightMember.name ||
leftMember.status !== rightMember.status ||
leftMember.currentTaskId !== rightMember.currentTaskId ||
leftMember.taskCount !== rightMember.taskCount ||
leftMember.color !== rightMember.color ||
leftMember.agentType !== rightMember.agentType ||
leftMember.role !== rightMember.role ||
leftMember.workflow !== rightMember.workflow ||
leftMember.providerId !== rightMember.providerId ||
leftMember.model !== rightMember.model ||
leftMember.effort !== rightMember.effort ||
leftMember.cwd !== rightMember.cwd ||
leftMember.gitBranch !== rightMember.gitBranch ||
leftMember.removedAt !== rightMember.removedAt ||
leftMember.runtimeAdvisory?.kind !== rightMember.runtimeAdvisory?.kind ||
leftMember.runtimeAdvisory?.observedAt !== rightMember.runtimeAdvisory?.observedAt ||
leftMember.runtimeAdvisory?.retryUntil !== rightMember.runtimeAdvisory?.retryUntil ||
leftMember.runtimeAdvisory?.retryDelayMs !== rightMember.runtimeAdvisory?.retryDelayMs ||
leftMember.runtimeAdvisory?.reasonCode !== rightMember.runtimeAdvisory?.reasonCode ||
leftMember.runtimeAdvisory?.message !== rightMember.runtimeAdvisory?.message
) {
return false;
}
}
return true;
}
function areTaskStatusCountsMapsEquivalent(
left: Map<string, TaskStatusCounts> | undefined,
right: Map<string, TaskStatusCounts> | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.size !== right.size) return false;
for (const [key, leftCounts] of left) {
const rightCounts = right.get(key);
if (
leftCounts.pending !== rightCounts?.pending ||
leftCounts.inProgress !== rightCounts.inProgress ||
leftCounts.completed !== rightCounts.completed
) {
return false;
}
}
return true;
}
function areMemberTaskMapsEquivalent(
left: Map<string, TeamTaskWithKanban> | undefined,
right: Map<string, TeamTaskWithKanban> | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.size !== right.size) return false;
for (const [key, leftTask] of left) {
const rightTask = right.get(key);
if (
leftTask.id !== rightTask?.id ||
leftTask.displayId !== rightTask.displayId ||
leftTask.subject !== rightTask.subject ||
leftTask.owner !== rightTask.owner ||
leftTask.status !== rightTask.status ||
leftTask.reviewer !== rightTask.reviewer ||
leftTask.reviewState !== rightTask.reviewState ||
leftTask.kanbanColumn !== rightTask.kanbanColumn
) {
return false;
}
}
return true;
}
function arePendingRepliesEquivalent(
left: Record<string, number> | undefined,
right: Record<string, number> | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (left[key] !== right[key]) {
return false;
}
}
return true;
}
function areMemberSpawnStatusesEquivalent(
left: Map<string, MemberSpawnStatusEntry> | undefined,
right: Map<string, MemberSpawnStatusEntry> | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.size !== right.size) return false;
for (const [key, leftEntry] of left) {
const rightEntry = right.get(key);
if (
leftEntry.status !== rightEntry?.status ||
leftEntry.launchState !== rightEntry.launchState ||
leftEntry.error !== rightEntry.error ||
leftEntry.livenessSource !== rightEntry.livenessSource ||
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive
) {
return false;
}
}
return true;
}
function areLaunchParamsEquivalent(
left: TeamLaunchParams | undefined,
right: TeamLaunchParams | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
return (
left.providerId === right.providerId &&
left.model === right.model &&
left.effort === right.effort
);
}
function areMemberListPropsEqual(
prev: Readonly<MemberListProps>,
next: Readonly<MemberListProps>
): boolean {
return (
areResolvedMembersEquivalent(prev.members, next.members) &&
areTaskStatusCountsMapsEquivalent(prev.memberTaskCounts, next.memberTaskCounts) &&
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
prev.isLaunchSettling === next.isLaunchSettling &&
prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning &&
prev.leadActivity === next.leadActivity &&
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
);
}
export const MemberList = memo(function MemberList({
members,
memberTaskCounts,
taskMap,
pendingRepliesByMember,
memberSpawnStatuses,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
leadActivity,
launchParams,
onMemberClick,
onSendMessage,
onAssignTask,
onOpenTask,
}: MemberListProps): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [isWide, setIsWide] = useState(false);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
const entry = entries[0];
if (entry) {
setIsWide(entry.contentRect.width > 1000);
}
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(handleResize);
observer.observe(el);
return () => observer.disconnect();
}, [handleResize]);
const gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
const activeMembers = useMemo(
() =>
members
.filter((m) => !m.removedAt)
.sort((a, b) => {
if (isLeadMember(a)) return -1;
if (isLeadMember(b)) return 1;
return 0;
}),
[members]
);
const removedMembers = useMemo(() => members.filter((m) => m.removedAt), [members]);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const buildRuntimeSummary = useCallback(
(
member: ResolvedTeamMember,
spawnEntry: MemberSpawnStatusEntry | undefined
): string | undefined => {
return resolveMemberRuntimeSummary(member, launchParams, spawnEntry);
},
[launchParams]
);
if (members.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
Solo team lead only
</div>
);
}
// Pre-compute reviewer→task map to avoid O(n×m) scan per member
const reviewTaskByMember = useMemo(() => {
const result = new Map<string, TeamTaskWithKanban>();
if (!taskMap) return result;
for (const task of taskMap.values()) {
if (task.reviewer && (task.reviewState === 'review' || task.kanbanColumn === 'review')) {
result.set(task.reviewer, task);
}
}
return result;
}, [taskMap]);
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
const reviewTask =
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]);
const spawnEntry = memberSpawnStatuses?.get(member.name);
return (
<MemberCard
key={member.name}
member={member}
memberColor={colorMap.get(member.name) ?? 'blue'}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={isLeadMember(member) ? leadActivity : undefined}
currentTask={isRemoved ? null : currentTask}
reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
runtimeSummary={buildRuntimeSummary(member, isRemoved ? undefined : spawnEntry)}
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
isLaunchSettling={isRemoved ? false : isLaunchSettling}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask.id) : undefined}
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}
/>
);
};
return (
<div ref={containerRef} className="flex flex-col gap-1">
<div className={gridClass}>{activeMembers.map((member) => renderCard(member, false))}</div>
{removedMembers.length > 0 && (
<>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Removed ({removedMembers.length})
</div>
<div className={gridClass}>
{removedMembers.map((member) => renderCard(member, true))}
</div>
</>
)}
</div>
);
}, areMemberListPropsEqual);