349 lines
13 KiB
TypeScript
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>
|
|
);
|
|
});
|