/**
* GraphNodePopover — renders popover for graph nodes using project UI components.
* Lives in features/ (not in package) so it CAN import from @renderer/.
* Reuses agentAvatarUrl, status helpers, and UI primitives from the project.
*/
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
selectTeamDataForName,
} from '@renderer/store/slices/teamSlice';
import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { isTaskInReviewCycle, resolveTaskReviewer } from '../utils/taskGraphSemantics';
import { GraphTaskCard } from './GraphTaskCard';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { TeamTaskWithKanban } from '@shared/types';
// ─── Tool name/preview formatters ───────────────────────────────────────────
/** Clean up tool names: "mcp__agent-teams__task_create" → "Task Create" */
function formatToolName(raw: string): string {
// Strip MCP prefixes (mcp__serverName__toolName → toolName)
const parts = raw.split('__');
const name = parts[parts.length - 1] ?? raw;
// snake_case → Title Case
return name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Clean up tool preview: strip raw JSON, extract meaningful part */
function formatToolPreview(preview: string | undefined): string | undefined {
if (!preview) return undefined;
// If it looks like raw JSON object, try to extract a readable field
if (preview.startsWith('{') || preview.startsWith('[')) {
try {
const obj = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview);
// Common readable fields
return (
obj.subject ?? obj.name ?? obj.label ?? obj.file_path ?? obj.path ?? obj.query ?? undefined
);
} catch {
// Truncated JSON — extract first quoted value
const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview);
if (match) return match[1];
}
}
return preview.length > 50 ? preview.slice(0, 50) + '...' : preview;
}
interface GraphNodePopoverProps {
node: GraphNode;
teamName: string;
onClose: () => void;
onSendMessage?: (memberName: string) => void;
onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: (memberName: string) => void;
onCreateTask?: (owner: string) => void;
onStartTask?: (taskId: string) => void;
onCompleteTask?: (taskId: string) => void;
onApproveTask?: (taskId: string) => void;
onRequestReview?: (taskId: string) => void;
onRequestChanges?: (taskId: string) => void;
onCancelTask?: (taskId: string) => void;
onMoveBackToDone?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
}
export const GraphNodePopover = ({
node,
teamName,
onClose,
onSendMessage,
onOpenTaskDetail,
onOpenMemberProfile,
onCreateTask,
onStartTask,
onCompleteTask,
onApproveTask,
onRequestReview,
onRequestChanges,
onCancelTask,
onMoveBackToDone,
onDeleteTask,
}: GraphNodePopoverProps): React.JSX.Element => {
if (node.kind === 'member' || node.kind === 'lead') {
return (
);
}
if (node.kind === 'task') {
if (node.isOverflowStack || node.domainRef.kind === 'task_overflow') {
return (
);
}
return (
);
}
// Cross-team ghost node
if (node.kind === 'crossteam') {
const extTeamName =
node.domainRef.kind === 'crossteam' ? node.domainRef.externalTeamName : node.label;
return (
{'\u{2194}'}
{extTeamName}
External team
);
}
// Process
return (
{node.label}
{node.processCommand && (
$ {node.processCommand}
)}
{node.processRegisteredBy && (
Started by: {node.processRegisteredBy}
)}
{node.processRegisteredAt && (
At: {new Date(node.processRegisteredAt).toLocaleTimeString()}
)}
{node.exceptionLabel && (
{node.exceptionLabel}
)}
{node.processUrl && (
Open URL
)}
);
};
const OverflowPopoverContent = ({
node,
teamName,
onClose,
onOpenTaskDetail,
}: {
node: GraphNode;
teamName: string;
onClose: () => void;
onOpenTaskDetail?: (taskId: string) => void;
}): React.JSX.Element => {
const teamData = useStore((state) => selectTeamDataForName(state, teamName));
const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task]));
const hiddenTasks = (node.overflowTaskIds ?? [])
.map((taskId) => tasksById.get(taskId) ?? null)
.filter((task): task is TeamTaskWithKanban => task != null);
return (
Hidden tasks
{node.overflowCount ?? hiddenTasks.length}
{hiddenTasks.length === 0 ? (
No hidden tasks available.
) : (
hiddenTasks.map((task) => {
const reviewer = resolveTaskReviewer(task, teamData?.kanbanState.tasks[task.id]);
return (
);
})
)}
);
};
// ─── Member Popover ─────────────────────────────────────────────────────────
const MemberPopoverContent = ({
node,
onClose,
onSendMessage,
onOpenProfile,
onCreateTask,
onOpenTask,
}: {
node: GraphNode;
onClose: () => void;
onSendMessage?: (name: string) => void;
onOpenProfile?: (name: string) => void;
onCreateTask?: (owner: string) => void;
onOpenTask?: (taskId: string) => void;
}): React.JSX.Element => {
const memberName =
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
? node.domainRef.memberName
: 'team-lead';
const teamName =
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
? node.domainRef.teamName
: '';
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
useStore(
useShallow((state) => ({
teamData: teamName ? selectTeamDataForName(state, teamName) : null,
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
}))
);
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
const provisioningPresentation =
teamData && teamName
? buildTeamProvisioningPresentation({
progress,
members: teamData.members,
memberSpawnStatuses,
memberSpawnSnapshot,
})
: null;
const launchPresentation = member
? buildMemberLaunchPresentation({
member,
spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,
isTeamAlive: teamData?.isAlive,
isTeamProvisioning: provisioningPresentation?.isActive ?? false,
leadActivity: node.kind === 'lead' ? leadActivity : undefined,
})
: null;
const fallbackSpawnStatusLabel =
node.spawnStatus && node.spawnStatus !== 'online'
? node.spawnStatus === 'waiting' || node.spawnStatus === 'spawning'
? 'starting'
: node.spawnStatus
: null;
const statusLabel =
launchPresentation?.presenceLabel ??
fallbackSpawnStatusLabel ??
(node.state === 'active'
? 'active'
: node.state === 'idle'
? 'idle'
: node.state === 'terminated'
? 'offline'
: node.state === 'tool_calling'
? 'running tool'
: node.state);
const statusDotClass =
launchPresentation?.dotClass ??
(node.spawnStatus === 'spawning'
? 'bg-amber-400'
: node.spawnStatus === 'waiting'
? 'bg-zinc-400 animate-pulse'
: node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling'
? 'bg-emerald-400'
: node.state === 'idle'
? 'bg-zinc-400'
: node.state === 'error'
? 'bg-red-400'
: 'bg-zinc-600');
const showExceptionBadge = node.exceptionLabel && node.exceptionLabel !== statusLabel;
return (
{/* Header: avatar + name */}
{node.label.split(' · ')[0]}
{node.role && (
{node.role}
)}
{node.runtimeLabel && (
{node.runtimeLabel}
)}
{/* Status badges */}
{statusLabel}
{node.kind === 'lead' && (
Lead
)}
{(launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) &&
(launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) !== statusLabel && (
{launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel}
)}
{showExceptionBadge && (
{node.exceptionLabel}
)}
{/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */}
{/* Current task indicator — reuses same pattern as MemberCard */}
{node.currentTaskId && node.currentTaskSubject && (
working on
)}
{node.activeTool && (
{node.activeTool.state === 'running'
? 'Running tool'
: node.activeTool.state === 'error'
? 'Tool failed'
: 'Tool finished'}
{node.activeTool.preview
? `${node.activeTool.name}: ${node.activeTool.preview}`
: node.activeTool.name}
{node.activeTool.resultPreview && node.activeTool.state !== 'running' && (
{node.activeTool.resultPreview}
)}
)}
{node.recentTools && node.recentTools.length > 0 && (
Recent tools
{node.recentTools.slice(0, 5).map((tool) => {
const shortName = formatToolName(tool.name);
const shortPreview = formatToolPreview(tool.preview);
return (
{shortName}
{shortPreview && (
{shortPreview}
)}
);
})}
)}
{/* Actions */}
);
};