agent-ecosystem/src/renderer/components/team/members/MemberHoverCard.tsx

349 lines
13 KiB
TypeScript

import { memo } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card';
import {
getTeamColorSet,
getThemedBadge,
getThemedBorder,
getThemedText,
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
selectResolvedMemberForTeamName,
selectTeamIsAliveForName,
selectTeamMemberSnapshotsForName,
selectTeamTasksForName,
} from '@renderer/store/slices/teamSlice';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberLaunchPresentation,
displayMemberName,
shouldDisplayMemberCurrentTask,
} from '@renderer/utils/memberHelpers';
import {
buildMemberLaunchDiagnosticsPayload,
getMemberLaunchDiagnosticsErrorMessage,
hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError,
} from '@renderer/utils/memberLaunchDiagnostics';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { ExternalLink } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import { MemberPresenceDot } from './MemberPresenceDot';
import type { TeamTaskWithKanban } from '@shared/types';
interface MemberHoverCardProps {
/** The member name to look up */
name: string;
/** Color key for the member */
color?: string;
/** Owning team context for store lookups. */
teamName?: string;
/** Called when user clicks on the current task */
onOpenTask?: (task: TeamTaskWithKanban) => void;
children: React.ReactNode;
}
/**
* Wraps children in a HoverCard that shows member info on hover.
* Reads member data from the team snapshot + resolved member selectors.
* Falls back to a simple wrapper when member data is unavailable.
*/
export const MemberHoverCard = memo(function MemberHoverCard({
name,
color,
teamName,
onOpenTask,
children,
}: MemberHoverCardProps): React.JSX.Element {
const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
const {
member,
teamMembers,
tasks,
isTeamAlive,
progress,
memberSpawnSnapshot,
memberSpawnStatuses,
spawnEntry,
runtimeRunId,
runtimeEntries,
runtimeEntry,
leadActivity,
} = useStore(
useShallow((s) => ({
member: effectiveTeamName
? selectResolvedMemberForTeamName(s, effectiveTeamName, name)
: null,
teamMembers: effectiveTeamName ? selectTeamMemberSnapshotsForName(s, effectiveTeamName) : [],
tasks: effectiveTeamName ? selectTeamTasksForName(s, effectiveTeamName) : [],
isTeamAlive: effectiveTeamName ? selectTeamIsAliveForName(s, effectiveTeamName) : undefined,
progress: effectiveTeamName
? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
: null,
memberSpawnSnapshot: effectiveTeamName
? s.memberSpawnSnapshotsByTeam[effectiveTeamName]
: undefined,
memberSpawnStatuses: effectiveTeamName
? s.memberSpawnStatusesByTeam[effectiveTeamName]
: undefined,
spawnEntry: effectiveTeamName
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
: undefined,
runtimeRunId: effectiveTeamName
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.runId
: undefined,
runtimeEntries: effectiveTeamName
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members
: undefined,
runtimeEntry: effectiveTeamName
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members[name]
: undefined,
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
}))
);
const openMemberProfile = useStore((s) => s.openMemberProfile);
const avatarMap = buildMemberAvatarMap(teamMembers);
if (!member) {
return <>{children}</>;
}
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
members: teamMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
memberRuntimeEntries: runtimeEntries,
});
const isLaunchSettling =
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
const colors = getTeamColorSet(color ?? member.color ?? '');
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const currentTask =
isDisplayableCurrentTask(currentTaskCandidate) &&
shouldDisplayMemberCurrentTask({
member,
isTeamAlive,
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnEntry,
runtimeEntry,
})
? currentTaskCandidate
: null;
const presentationMember =
member.currentTaskId && !currentTask
? {
...member,
currentTaskId: null,
}
: member;
const launchPresentation = buildMemberLaunchPresentation({
member: presentationMember,
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
spawnAgentToolAccepted: spawnEntry?.agentToolAccepted,
spawnHardFailure: spawnEntry?.hardFailure,
spawnHardFailureReason: spawnEntry?.hardFailureReason,
spawnError: spawnEntry?.error,
spawnRuntimeDiagnostic: spawnEntry?.runtimeDiagnostic,
spawnLivenessKind: spawnEntry?.livenessKind,
spawnRuntimeDiagnosticSeverity: spawnEntry?.runtimeDiagnosticSeverity,
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
spawnUpdatedAt: spawnEntry?.updatedAt,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning: false,
leadActivity: isLeadMember(member) ? leadActivity : undefined,
});
const presenceLabel = launchPresentation.presenceLabel;
const launchVisualState = launchPresentation.launchVisualState;
const launchStatusLabel = launchPresentation.launchStatusLabel;
const dotClass = launchPresentation.dotClass;
const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel;
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone;
const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel
: launchVisualState === 'starting_stale' ||
launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;
const launchDiagnosticsPayload = buildMemberLaunchDiagnosticsPayload({
teamName: effectiveTeamName,
runId: runtimeRunId ?? memberSpawnSnapshot?.runId ?? progress?.runId,
memberName: member.name,
member,
spawnEntry,
runtimeEntry,
});
const launchErrorMessage = getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload);
const showCopyDiagnostics =
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const reviewTaskCandidate: TeamTaskWithKanban | null = tasks
? (tasks.find(
(task) =>
task.reviewer === member.name &&
task.id !== currentTask?.id &&
getTeamTaskWorkflowColumn(task) === 'review'
) ?? null)
: null;
const reviewTask =
reviewTaskCandidate &&
shouldDisplayMemberCurrentTask({
member,
isTeamAlive,
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnEntry,
runtimeEntry,
})
? reviewTaskCandidate
: null;
return (
<HoverCard openDelay={300} closeDelay={200}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent side="top" align="start" sideOffset={8}>
<div className="flex flex-col gap-2.5">
{/* Header: avatar + name + presence */}
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<img
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 64)}
alt={member.name}
className="size-10 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<MemberPresenceDot className={`size-3 ${dotClass}`} label={badgeLabel} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className="truncate text-sm font-semibold"
style={{ color: getThemedText(colors, isLight) }}
>
{displayMemberName(member.name)}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0 text-[10px] font-normal leading-tight"
title={runtimeAdvisoryTitle}
style={{
backgroundColor:
runtimeAdvisoryTone === 'error'
? 'rgba(239, 68, 68, 0.16)'
: getThemedBadge(colors, isLight),
color:
runtimeAdvisoryTone === 'error'
? 'rgb(252, 165, 165)'
: getThemedText(colors, isLight),
border:
runtimeAdvisoryTone === 'error'
? '1px solid rgba(248, 113, 113, 0.35)'
: `1px solid ${getThemedBorder(colors, isLight)}40`,
}}
>
{badgeLabel}
</Badge>
</div>
{roleLabel && (
<span className="text-xs text-[var(--color-text-muted)]">{roleLabel}</span>
)}
</div>
</div>
{/* Current task */}
{currentTask && (
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
<CurrentTaskIndicator
task={currentTask}
borderColor={colors.border}
maxSubjectLength={28}
activityLabel="working on"
onOpenTask={onOpenTask ? () => onOpenTask(currentTask) : undefined}
/>
</div>
)}
{/* Review task */}
{reviewTask && (
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
<CurrentTaskIndicator
task={reviewTask}
borderColor={colors.border}
maxSubjectLength={28}
activityLabel="reviewing"
onOpenTask={onOpenTask ? () => onOpenTask(reviewTask) : undefined}
/>
</div>
)}
{launchErrorMessage ? (
<div className="flex items-center gap-2 rounded border border-red-500/25 bg-red-500/10 px-2 py-1.5 text-xs text-red-300">
<span className="min-w-0 flex-1 truncate" title={launchErrorMessage}>
{launchErrorMessage}
</span>
{showCopyDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="h-auto shrink-0 rounded px-1.5 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
</div>
) : null}
<div className="flex gap-1.5">
<button
type="button"
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
openMemberProfile(member.name);
}}
>
<ExternalLink size={12} />
{t('members.actions.openProfile')}
</button>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
});