import { useMemo } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; import type { TeamTaskWithKanban } from '@shared/types'; function isTaskNode(node: GraphNode | undefined): node is GraphNode & { domainRef: Extract; } { return node?.kind === 'task' && node.domainRef.kind === 'task'; } function isOverflowNode( node: GraphNode | undefined ): node is GraphNode & { isOverflowStack: true } { return Boolean(node?.kind === 'task' && node.isOverflowStack); } function describeNode(node: GraphNode | undefined, fallback: string): string { if (!node) return fallback; if (isOverflowNode(node)) { return node.overflowCount && node.overflowCount > 1 ? `${node.overflowCount} hidden tasks` : 'Hidden task stack'; } if (isTaskNode(node)) { return `${node.displayId ?? node.label} - ${node.sublabel ?? 'Task'}`; } return node.label; } function getActionLabel(node: GraphNode | undefined, role: 'blocker' | 'blocked'): string | null { if (!node) return null; if (isOverflowNode(node)) { return role === 'blocker' ? 'Open blocker stack' : 'Open blocked stack'; } if (isTaskNode(node)) { return role === 'blocker' ? 'Open blocker task' : 'Open blocked task'; } return null; } export interface GraphBlockingEdgePopoverProps { teamName: string; edge: GraphEdge; sourceNode: GraphNode | undefined; targetNode: GraphNode | undefined; onClose: () => void; onSelectNode: (nodeId: string) => void; onOpenTaskDetail?: (taskId: string) => void; } export const GraphBlockingEdgePopover = ({ teamName, edge, sourceNode, targetNode, onClose, onSelectNode, onOpenTaskDetail, }: GraphBlockingEdgePopoverProps): React.JSX.Element => { const { teamData } = useGraphActivityContext(teamName); const tasksById = useMemo( () => new Map((teamData?.tasks ?? []).map((task) => [task.id, task] as const)), [teamData?.tasks] ); const relationCount = edge.aggregateCount ?? 1; const sourceLabel = describeNode(sourceNode, edge.source); const targetLabel = describeNode(targetNode, edge.target); const sourceActionLabel = getActionLabel(sourceNode, 'blocker'); const targetActionLabel = getActionLabel(targetNode, 'blocked'); const sourceHiddenTasks = resolveEdgeTaskPreview(sourceNode, edge.sourceTaskIds, tasksById); const targetHiddenTasks = resolveEdgeTaskPreview(targetNode, edge.targetTaskIds, tasksById); const openSource = (): void => { if (isTaskNode(sourceNode)) { onOpenTaskDetail?.(sourceNode.domainRef.taskId); onClose(); return; } if (sourceNode) { onSelectNode(sourceNode.id); } }; const openTarget = (): void => { if (isTaskNode(targetNode)) { onOpenTaskDetail?.(targetNode.domainRef.taskId); onClose(); return; } if (targetNode) { onSelectNode(targetNode.id); } }; return (
Blocking Dependency
{relationCount > 1 && ( {relationCount} links )}
{sourceLabel}
{sourceHiddenTasks.length > 0 && ( )}
blocks
{targetLabel}
{targetHiddenTasks.length > 0 && ( )}
{sourceActionLabel && ( )} {targetActionLabel && ( )}
); }; function resolveEdgeTaskPreview( node: GraphNode | undefined, edgeTaskIds: string[] | undefined, tasksById: ReadonlyMap ): TeamTaskWithKanban[] { if (!node || !isOverflowNode(node)) { return []; } const candidateIds = edgeTaskIds && edgeTaskIds.length > 0 ? edgeTaskIds : (node.overflowTaskIds ?? []); return candidateIds .map((taskId) => tasksById.get(taskId) ?? null) .filter((task): task is TeamTaskWithKanban => task != null) .slice(0, 4); } const HiddenTaskPreview = ({ title, tasks, onOpenTaskDetail, onClose, }: { title: string; tasks: TeamTaskWithKanban[]; onOpenTaskDetail?: (taskId: string) => void; onClose: () => void; }): React.JSX.Element => { return (
{title}
{tasks.map((task) => ( ))}
); };