'use client'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkerType, Position, ReactFlowProvider, type Edge, type Node, type NodeMouseHandler, type NodeTypes, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import dagre from 'dagre'; import { EpicChipStrip } from './epic-chip-strip'; import { WorkflowTabs, type WorkflowTab } from './workflow-tabs'; import { TaskCardGrid, type BlockerDetail } from './task-card-grid'; import { TaskDetailsDrawer } from './task-details-drawer'; import { DependencyFlowStrip } from './dependency-flow-strip'; import { GraphNodeCard, type GraphNodeData } from './graph-node-card'; import { GraphSection } from './graph-section'; import { ProjectScopeControls } from '../shared/project-scope-controls'; import { buildGraphModel, type GraphNode } from '../../lib/graph'; import { buildPathWorkspace, type GraphHopDepth, analyzeBlockedChain, detectDependencyCycles, } from '../../lib/graph-view'; import { buildBlockedByTree, type BlockedTreeNode } from '../../lib/kanban'; import { type BeadIssue } from '../../lib/types'; import type { ProjectScopeOption } from '../../lib/project-scope'; /** Props for the DependencyGraphPage component. */ interface DependencyGraphPageProps { /** All issues in the project. */ issues: BeadIssue[]; /** The project root key for graph model construction. */ projectRoot: string; /** URL scope key (local or registry key). */ projectScopeKey: string; /** Available scope options for context rendering. */ projectScopeOptions: ProjectScopeOption[]; /** Scope mode selection from URL (single/aggregate). */ projectScopeMode: 'single' | 'aggregate'; } /** Available hop depth values for the depth selector. */ const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full']; /** * Positions nodes using the Dagre graph layout engine. * This respects dependency direction (Left-to-Right) and creates a true flowchart. */ function layoutDagre(nodes: Node[], edges: Edge[]): Node[] { const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); // Set layout direction: 'LR' = Left-to-Right (Blocker -> Blocked) dagreGraph.setGraph({ rankdir: 'LR' }); // Node dimensions (must match Card dimensions + some padding?) // Card is ~280x120? // We can be precise or approximate. const nodeWidth = 320; const nodeHeight = 150; for (const node of nodes) { dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); } for (const edge of edges) { dagreGraph.setEdge(edge.source, edge.target); } dagre.layout(dagreGraph); // Apply positions back to nodes // Dagre gives center coordinates (x, y). ReactFlow expects top-left? // ReactFlow handles position as top-left by default. // Wait, Dagre node `x,y` is the CENTER of the node? // Let's check docs or common knowledge. Yes, Dagre usually returns center. // ReactFlow nodes position is Top-Left. return nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id); return { ...node, position: { x: nodeWithPosition.x - nodeWidth / 2, y: nodeWithPosition.y - nodeHeight / 2, }, }; }); } /** * Main Workflow Explorer page component. * Provides a tabbed interface for browsing tasks and visualizing dependencies. * * Layout structure: * - Header: title + navigation * - Toolbar: hop depth, filters, epic chips, tab switcher * - Tasks tab: responsive card grid + details drawer * - Dependencies tab: flow strip + ReactFlow graph */ export function DependencyGraphPage({ issues, projectRoot, projectScopeKey, projectScopeOptions, projectScopeMode, }: DependencyGraphPageProps) { const router = useRouter(); const searchParams = useSearchParams(); // --- State --- const [selectedEpicId, setSelectedEpicId] = useState(null); const [selectedId, setSelectedId] = useState(null); const [depth, setDepth] = useState(2); const [hideClosed, setHideClosed] = useState(false); const [showBlockingOnly, setShowBlockingOnly] = useState(false); const [showFilters, setShowFilters] = useState(false); const [activeTab, setActiveTab] = useState('tasks'); const [drawerOpen, setDrawerOpen] = useState(false); // Task-specific: sort ready (actionable) tasks to the top const [sortReadyFirst, setSortReadyFirst] = useState(true); // Mobile panel toggle (preserved for mobile responsiveness) const [mobilePanel, setMobilePanel] = useState<'overview' | 'flow'>('overview'); const requestedEpicId = searchParams.get('epic'); const requestedTaskId = searchParams.get('task'); const requestedTab = searchParams.get('tab'); const kanbanHref = useMemo(() => { const params = new URLSearchParams(); if (projectScopeMode !== 'single') { params.set('mode', projectScopeMode); } if (projectScopeKey !== 'local') { params.set('project', projectScopeKey); } const query = params.toString(); return query ? `/?${query}` : '/'; }, [projectScopeKey, projectScopeMode]); const activeScope = useMemo( () => projectScopeOptions.find((option) => option.key === projectScopeKey) ?? projectScopeOptions[0] ?? null, [projectScopeKey, projectScopeOptions], ); // --- Derived data: epics --- const epics = useMemo( () => issues .filter((issue) => issue.issue_type === 'epic') .sort((a, b) => { // Push closed epics to the end if (a.status === 'closed' && b.status !== 'closed') return 1; if (b.status === 'closed' && a.status !== 'closed') return -1; return a.id.localeCompare(b.id); }), [issues], ); // --- Derived data: tasks grouped by parent epic --- const tasksByEpic = useMemo(() => { const map = new Map(); // Initialize empty arrays for each epic for (const epic of epics) { map.set(epic.id, []); } // Assign each non-epic issue to its parent epic for (const issue of issues) { if (issue.issue_type === 'epic') continue; const parentDep = issue.dependencies.find((dep) => dep.type === 'parent'); const candidateEpicId = parentDep?.target ?? (issue.id.includes('.') ? issue.id.split('.')[0] : null); if (candidateEpicId && map.has(candidateEpicId)) { map.get(candidateEpicId)?.push(issue); } } // Sort tasks within each epic: filter by closed status, then by priority for (const [epicId, children] of map.entries()) { map.set( epicId, children .filter((x) => (!hideClosed ? true : x.status !== 'closed')) .sort((a, b) => { const priorityDiff = a.priority - b.priority; if (priorityDiff !== 0) return priorityDiff; return a.id.localeCompare(b.id); }), ); } return map; }, [epics, hideClosed, issues]); const beadCounts = useMemo(() => { const counts = new Map(); for (const epic of epics) { counts.set(epic.id, tasksByEpic.get(epic.id)?.length ?? 0); } return counts; }, [epics, tasksByEpic]); // --- Derived: Map task ID to its Epic (for easy lookup) --- const epicByTaskId = useMemo(() => { const map = new Map(); // Iterate tasksByEpic map for (const [epicId, tasks] of tasksByEpic.entries()) { const epic = epics.find((e) => e.id === epicId); if (!epic) continue; for (const t of tasks) { map.set(t.id, epic); } } return map; }, [epics, tasksByEpic]); // --- Auto-select first epic if none selected --- useEffect(() => { if (epics.length === 0) { if (selectedEpicId !== null) { setSelectedEpicId(null); } return; } const hasSelectedEpic = selectedEpicId ? epics.some((epic) => epic.id === selectedEpicId) : false; if (!hasSelectedEpic) { setSelectedEpicId(epics[0].id); } }, [epics, selectedEpicId]); useEffect(() => { if (requestedTab === 'tasks' || requestedTab === 'dependencies') { setActiveTab(requestedTab); } }, [requestedTab]); useEffect(() => { if (!requestedEpicId) return; if (!epics.some((epic) => epic.id === requestedEpicId)) return; setSelectedEpicId(requestedEpicId); }, [epics, requestedEpicId]); useEffect(() => { if (!requestedTaskId) { return; } if (!issues.some((issue) => issue.id === requestedTaskId)) { return; } setSelectedId(requestedTaskId); }, [issues, requestedTaskId]); // If project scope changes and the selected task no longer exists, reset selection. useEffect(() => { if (!selectedId) { return; } if (!issues.some((issue) => issue.id === selectedId)) { setSelectedId(null); } }, [issues, selectedId]); // --- Derived: selected epic and its tasks --- const selectedEpic = useMemo(() => epics.find((epic) => epic.id === selectedEpicId) ?? null, [epics, selectedEpicId]); const projectLevelTasks = useMemo( () => issues .filter((issue) => issue.issue_type !== 'epic') .filter((issue) => (!hideClosed ? true : issue.status !== 'closed')) .sort((a, b) => { const priorityDiff = a.priority - b.priority; if (priorityDiff !== 0) { return priorityDiff; } return a.id.localeCompare(b.id); }), [hideClosed, issues], ); const selectedEpicTasks = useMemo(() => { const epicChildren = selectedEpic ? tasksByEpic.get(selectedEpic.id) ?? [] : []; if (epicChildren.length > 0) { return epicChildren; } // Fallback: some projects have tasks but weak/missing parent links. // Keep the page usable by showing project-level tasks instead of a blank view. if (projectLevelTasks.length > 0) { return projectLevelTasks; } // Last-resort fallback: if there are only epics, render epics as selectable items. return epics.filter((epic) => (!hideClosed ? true : epic.status !== 'closed')); }, [epics, hideClosed, projectLevelTasks, selectedEpic, tasksByEpic]); const selectedEpicHasChildren = useMemo(() => { if (selectedEpic) { return (tasksByEpic.get(selectedEpic.id) ?? []).length > 0; } return false; }, [selectedEpic, tasksByEpic]); // --- Auto-select best task when epic changes --- useEffect(() => { // Keep current selection if it remains visible in the current scope. if (selectedId && selectedEpicTasks.some((task) => task.id === selectedId)) { return; } const best = selectedEpicTasks.find((task) => task.status !== 'closed') ?? selectedEpicTasks[0] ?? null; if (best?.id !== selectedId) { setSelectedId(best?.id ?? null); } }, [selectedEpic, selectedEpicTasks, selectedId]); // --- Graph model --- const graphModel = useMemo(() => buildGraphModel(issues, { projectKey: projectRoot }), [issues, projectRoot]); // --- Signal map: blocker/blocks counts per issue --- const signalById = useMemo(() => { const map = new Map(); for (const issue of issues) { const adjacency = graphModel.adjacency[issue.id]; map.set(issue.id, { blockedBy: adjacency?.incoming.length ?? 0, blocks: adjacency?.outgoing.length ?? 0, }); } return map; }, [graphModel.adjacency, issues]); // --- Blocker chain analysis for selected node --- const blockerAnalysis = useMemo(() => { if (!selectedId) return null; return analyzeBlockedChain(graphModel, { focusId: selectedId }); }, [graphModel, selectedId]); // --- Cycle detection across the entire graph --- const cycleAnalysis = useMemo(() => { return detectDependencyCycles(graphModel); }, [graphModel]); const cycleNodeIdSet = useMemo(() => new Set(cycleAnalysis.cycleNodeIds), [cycleAnalysis]); // --- Path workspace: blockers and dependents for the selected node --- const workspace = useMemo( () => buildPathWorkspace(graphModel, { focusId: selectedId, depth, hideClosed, }), [depth, graphModel, hideClosed, selectedId], ); // --- Currently selected issue object --- const selectedIssue = useMemo(() => issues.find((issue) => issue.id === selectedId) ?? null, [issues, selectedId]); // --- Compute which node IDs are in the selected dependency chain (for dimming) --- const chainNodeIds = useMemo(() => { if (!selectedId || !blockerAnalysis) return new Set(); const ids = new Set([selectedId, ...blockerAnalysis.blockerNodeIds]); // Also include dependents for (const node of workspace.dependents.flat()) { ids.add(node.id); } return ids; }, [selectedId, blockerAnalysis, workspace.dependents]); // --- Compute actionable (unblocked) status for each node --- const actionableNodeIds = useMemo(() => { const ids = new Set(); for (const issue of issues) { if (issue.status === 'closed') continue; const adjacency = graphModel.adjacency[issue.id]; if (!adjacency) continue; // A node is actionable if none of its incoming "blocks" edges come from non-closed nodes const hasOpenBlocker = adjacency.incoming.some((edge) => { if (edge.type !== 'blocks') return false; const sourceNode = issues.find((i) => i.id === edge.source); return sourceNode ? sourceNode.status !== 'closed' : false; }); if (!hasOpenBlocker) { ids.add(issue.id); } } return ids; }, [graphModel.adjacency, issues]); // --- Sorted epic tasks: optionally sort ready/actionable tasks first --- const sortedEpicTasks = useMemo(() => { if (!sortReadyFirst) return selectedEpicTasks; // Partition: ready (actionable + open) first, then in-progress, then blocked, then closed return [...selectedEpicTasks].sort((a, b) => { const aReady = actionableNodeIds.has(a.id) && a.status !== 'closed'; const bReady = actionableNodeIds.has(b.id) && b.status !== 'closed'; // Ready tasks bubble to the top if (aReady && !bReady) return -1; if (!aReady && bReady) return 1; // Within same readiness group, keep original priority order return 0; }); }, [selectedEpicTasks, actionableNodeIds, sortReadyFirst]); // --- Build blocker tooltip data per node --- const blockerTooltipMap = useMemo(() => { const map = new Map(); for (const issue of issues) { const adjacency = graphModel.adjacency[issue.id]; if (!adjacency) continue; const lines: string[] = []; for (const edge of adjacency.incoming) { if (edge.type !== 'blocks') continue; const source = issues.find((i) => i.id === edge.source); if (source && source.status !== 'closed') { lines.push(`${source.id} (${source.status}) - "${source.title}"`); } } map.set(issue.id, lines); } return map; }, [graphModel.adjacency, issues]); // --- Detailed blocker info for task cards --- const blockerDetailsMap = useMemo(() => { const map = new Map(); for (const task of selectedEpicTasks) { const adjacency = graphModel.adjacency[task.id]; if (!adjacency) continue; const details: BlockerDetail[] = []; for (const edge of adjacency.incoming) { if (edge.type !== 'blocks') continue; const source = issues.find((i) => i.id === edge.source); if (source && source.status !== 'closed') { const sourceEpic = epicByTaskId.get(source.id); details.push({ id: source.id, title: source.title, status: source.status, priority: source.priority, epicTitle: sourceEpic?.title, }); } } if (details.length > 0) map.set(task.id, details); } return map; }, [graphModel.adjacency, issues, selectedEpicTasks, epicByTaskId]); // --- External blocker names for each task (shown inline on nodes) --- const externalBlockerNames = useMemo(() => { const epicTaskIds = new Set(selectedEpicTasks.map((t) => t.id)); const map = new Map(); for (const task of selectedEpicTasks) { const adjacency = graphModel.adjacency[task.id]; if (!adjacency) continue; const externalNames: string[] = []; for (const edge of adjacency.incoming) { if (edge.type !== 'blocks') continue; // Only include blockers from OUTSIDE this epic if (!epicTaskIds.has(edge.source) && edge.source !== selectedEpicId) { const source = issues.find((i) => i.id === edge.source); if (source && source.status !== 'closed') { externalNames.push(`${source.id}: ${source.title}`); } } } if (externalNames.length > 0) map.set(task.id, externalNames); } return map; }, [graphModel.adjacency, issues, selectedEpicId, selectedEpicTasks]); // --- Detailed downstream blocking info for task cards --- const blocksDetailsMap = useMemo(() => { const map = new Map(); for (const task of selectedEpicTasks) { const adjacency = graphModel.adjacency[task.id]; if (!adjacency) continue; const details: BlockerDetail[] = []; for (const edge of adjacency.outgoing) { if (edge.type !== 'blocks') continue; const target = issues.find((i) => i.id === edge.target); if (target && target.status !== 'closed') { const targetEpic = epicByTaskId.get(target.id); details.push({ id: target.id, title: target.title, status: target.status, priority: target.priority, epicTitle: targetEpic?.title, }); } } if (details.length > 0) map.set(task.id, details); } return map; }, [graphModel.adjacency, issues, selectedEpicTasks, epicByTaskId]); // --- ReactFlow model: ONLY this epic's tasks in status lanes --- const flowModel = useMemo(() => { if (selectedEpicTasks.length === 0) { return { nodes: [] as Node[], edges: [] as Edge[] }; } // SCOPED: Only the epic's own child tasks (no cross-epic workspace nodes) const visibleTasks = selectedEpicTasks .filter((issue) => (!hideClosed ? true : issue.status !== 'closed')); // Build ReactFlow nodes with our custom GraphNodeData const baseNodes: Node[] = visibleTasks .map((issue) => ({ id: issue.id, data: { title: issue.title, kind: 'issue' as const, status: issue.status, priority: issue.priority, blockedBy: signalById.get(issue.id)?.blockedBy ?? 0, blocks: signalById.get(issue.id)?.blocks ?? 0, isActionable: actionableNodeIds.has(issue.id), isCycleNode: cycleNodeIdSet.has(issue.id), isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false, blockerTooltipLines: externalBlockerNames.get(issue.id) ?? blockerTooltipMap.get(issue.id) ?? [], }, position: { x: 0, y: 0 }, sourcePosition: Position.Right, targetPosition: Position.Left, type: 'flowNode', })); const visibleIds = new Set(baseNodes.map((node) => node.id)); const graphEdges: Edge[] = []; // Search ALL issues for blocking edges between visible nodes. // Dependencies may be stored on issues outside visibleTasks but // still connect two nodes that are both visible in the graph. for (const issue of issues) { for (const dep of issue.dependencies) { // Both endpoints must be visible in the graph if (!visibleIds.has(issue.id) && !visibleIds.has(dep.target)) continue; if (!visibleIds.has(issue.id) || !visibleIds.has(dep.target)) continue; // Only show blocking edges (skip parent, relates_to, etc.) if (dep.type !== 'blocks') continue; // Avoid self-loops if (issue.id === dep.target) continue; const edgeId = `${issue.id}:blocks:${dep.target}`; const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false; graphEdges.push({ id: edgeId, source: issue.id, target: dep.target, className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted', animated: linkedToSelection, style: { stroke: linkedToSelection ? '#7dd3fc' : '#fbbf24', strokeWidth: linkedToSelection ? 2.5 : 1.8, opacity: linkedToSelection ? 1 : 0.55, }, markerEnd: { type: MarkerType.ArrowClosed, color: linkedToSelection ? '#7dd3fc' : '#fbbf24', width: 14, height: 14 }, }); } } return { nodes: layoutDagre(baseNodes, graphEdges), edges: graphEdges, }; }, [ hideClosed, issues, selectedEpicTasks, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, externalBlockerNames, ]); const nodeTypes: NodeTypes = useMemo( () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any flowNode: GraphNodeCard as any, }), [], ); // --- Handle node click in the graph (also opens detail drawer) --- const handleFlowNodeClick: NodeMouseHandler = useCallback((_, node) => { setSelectedId(node.id); setDrawerOpen(true); }, []); // --- Default edge rendering options --- const defaultEdgeOptions = useMemo( () => ({ type: 'smoothstep' as const, zIndex: 40, interactionWidth: 24, }), [], ); // --- Handle task selection (opens drawer on Tasks tab) --- // If the target is in another epic or IS an epic, switch to that epic first. const handleTaskSelect = useCallback((id: string, shouldOpenDrawer = true) => { // 1. If task is already visible in current epic view, just select it if (selectedEpicTasks.some((t) => t.id === id)) { setSelectedId(id); if (shouldOpenDrawer) setDrawerOpen(true); return; } // 2. If the target IS an epic itself, switch to that epic const targetIsEpic = epics.some((e) => e.id === id); if (targetIsEpic) { setSelectedEpicId(id); // Select the epic itself so the drawer shows its details setSelectedId(id); if (shouldOpenDrawer) setDrawerOpen(true); return; } // 3. Target is a task in another epic -- find which epic owns it const targetIssue = issues.find((i) => i.id === id); if (targetIssue) { // Determine parent epic: explicit parent dependency, or convention (id prefix before first dot) const parentDep = targetIssue.dependencies.find((dep) => dep.type === 'parent'); const epicId = parentDep?.target ?? (targetIssue.id.includes('.') ? targetIssue.id.split('.')[0] : null); if (epicId && epicId !== selectedEpicId) { const epicExists = epics.some((e) => e.id === epicId); if (epicExists) { // If the target is closed and we are hiding closed tasks, unhide so we can see it if (targetIssue.status === 'closed' && hideClosed) { setHideClosed(false); } setSelectedEpicId(epicId); setSelectedId(id); if (shouldOpenDrawer) setDrawerOpen(true); return; } } } // 4. Fallback: select the id directly (might be orphan) setSelectedId(id); if (shouldOpenDrawer) setDrawerOpen(true); }, [selectedEpicTasks, selectedEpicId, issues, epics, hideClosed]); // --- Handle drawer close --- const handleDrawerClose = useCallback(() => { setDrawerOpen(false); }, []); return (
{/* Page header */}

BeadBoard Workspace

Workflow Explorer

← Kanban

Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance.

{activeScope ? (

Scope:{' '} {activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath}

) : null}
{/* Main content area */}
{/* Toolbar row: epic chips + tab switcher */}
{/* Epic chip strip - shows titles, not just IDs */}
{/* Right side: filter toggle + stats + mobile toggle */}
{/* Filters toggle - hides power-user controls behind a button */} {/* Mobile panel toggle */}
{/* Collapsible filters row - tab-aware: different filters per tab */} {showFilters ? (
{/* Shared filter: Hide closed */} {/* Tasks-specific filters */} {activeTab === 'tasks' ? ( ) : null} {/* Dependencies-specific filters */} {activeTab === 'dependencies' ? ( <>
) : null}
) : null} {/* Tab switcher row + selected epic context */}
{selectedEpic ? (
{selectedEpic.id} {selectedEpic.title} {!selectedEpicHasChildren && projectLevelTasks.length > 0 ? ( project tasks fallback ) : null} {selectedEpicTasks.length} tasks
) : null}
{/* ====== MOBILE LAYOUT ====== */} {/* Mobile: overview panel (epic selection + task cards + dep flow) */}
{/* Epic selector as horizontal scroll */}

1) Select Epic

{/* Selected epic info */}

{selectedEpic?.title ?? 'No epic selected'}

{selectedEpicTasks.length} tasks • {selectedEpic?.status ?? 'unknown'}

{/* Task cards */}

2) Pick Task

{/* ====== DESKTOP LAYOUT ====== */} {/* Desktop: Tasks tab content - use conditional rendering, not Tailwind dynamic classes */} {activeTab === 'tasks' ? (
) : null} {/* Desktop: Dependencies tab content (graph only, no flow strip) */} {activeTab === 'dependencies' ? (
{/* Dependency Flow Strip - above graph */}
) : null} {/* Mobile: graph panel */}
{/* Task details drawer - slides in from right on task selection */} router.refresh()} blockedTree={selectedIssue ? buildBlockedByTree(issues, selectedIssue.id) : undefined} outgoingBlocks={selectedId ? blocksDetailsMap.get(selectedId) ?? [] : []} onSelectBlockedIssue={handleTaskSelect} />
); }