/** * GraphNodePopover — renders popover for graph nodes using project UI components. * This stays in the renderer slice instead of the reusable package because it * composes project-specific UI, selectors, and presentation helpers. */ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; import { isTaskInReviewCycle, resolveTaskReviewer } from '../../core/domain/taskGraphSemantics'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; import { useGraphMemberPopoverContext } from '../hooks/useGraphMemberPopoverContext'; 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 parsed: unknown = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const previewRecord = parsed as Record; const candidates = [ previewRecord.subject, previewRecord.name, previewRecord.label, previewRecord.file_path, previewRecord.path, previewRecord.query, ]; const firstText = candidates.find((value) => typeof value === 'string'); if (typeof firstText === 'string') { return firstText; } } } 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 } = useGraphActivityContext(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 } = useGraphMemberPopoverContext(teamName, memberName); 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' ? 'waiting to start' : node.spawnStatus === 'spawning' ? 'starting' : node.spawnStatus === 'error' ? 'failed' : node.spawnStatus : null; const statusLabel = launchPresentation?.launchStatusLabel ?? node.launchStatusLabel ?? 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 */}
{memberName}
{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} )}
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */} {/* 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 */}
); };