'use client'; import Link from 'next/link'; import { 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 '../shared/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 { OffsetEdge } from './offset-edge'; import { GraphSection } from './graph-section'; import { ProjectScopeControls } from '../shared/project-scope-controls'; import { WorkspaceHero } from '../shared/workspace-hero'; import { buildGraphModel } from '../../lib/graph'; import { buildPathWorkspace, type GraphHopDepth, analyzeBlockedChain, detectDependencyCycles, identifyTransitiveEdges, } from '../../lib/graph-view'; import { buildBlockedByTree } from '../../lib/kanban'; import { type BeadIssue } from '../../lib/types'; import type { ProjectScopeOption } from '../../lib/project-scope'; import { useBeadsSubscription } from '../../hooks/use-beads-subscription'; /** 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: initialIssues, projectRoot, projectScopeKey, projectScopeOptions, projectScopeMode, }: DependencyGraphPageProps) { const { issues, refresh: refreshIssues } = useBeadsSubscription(initialIssues, projectRoot); 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 heroTitle = activeTab === 'dependencies' ? 'Graph' : 'Tasks'; 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') .filter((issue) => (!hideClosed ? true : issue.status !== 'closed')) .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, hideClosed], ); const selectableEpics = useMemo( () => epics.filter((epic) => (!hideClosed ? true : epic.status !== 'closed' && epic.status !== 'tombstone')), [epics, hideClosed], ); // --- 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 (selectableEpics.length === 0) { if (selectedEpicId !== null) { setSelectedEpicId(null); } return; } const hasSelectedEpic = selectedEpicId ? selectableEpics.some((epic) => epic.id === selectedEpicId) : false; if (!hasSelectedEpic) { setSelectedEpicId(selectableEpics[0].id); } }, [selectableEpics, selectedEpicId]); useEffect(() => { if (requestedTab === 'tasks' || requestedTab === 'dependencies') { setActiveTab(requestedTab); } }, [requestedTab]); useEffect(() => { if (!requestedEpicId) return; if (!selectableEpics.some((epic) => epic.id === requestedEpicId)) return; setSelectedEpicId(requestedEpicId); }, [selectableEpics, 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(() => selectableEpics.find((epic) => epic.id === selectedEpicId) ?? null, [selectableEpics, 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 selectableEpics; }, [projectLevelTasks, selectableEpics, 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]); // --- Transitive edges (redundant blocks) --- const transitiveEdges = useMemo(() => identifyTransitiveEdges(graphModel), [graphModel]); // --- 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: focusId ? !chainNodeIds.has(issue.id) : false, blockerTooltipLines: externalBlockerNames.get(issue.id) ?? blockerTooltipMap.get(issue.id) ?? [], labels: issue.labels, }, position: { x: 0, y: 0 }, sourcePosition: Position.Right, targetPosition: Position.Left, type: 'flowNode', })); const visibleIds = new Set(baseNodes.map((node) => node.id)); // Use requestedTaskId from URL as the focus node for upstream/downstream highlighting. // `selectedId` is local state that tracks click selection for the drawer, // but it starts as null. The URL `task` param is what the user clicked in the graph // (set by handleNodeSelect -> router.push). We use requestedTaskId here // so that clicking a node - which updates the URL - also triggers edge color changes. const focusId = requestedTaskId; // --- Compute Upstream / Downstream Focus --- const upstreamIds = new Set(); const downstreamIds = new Set(); if (focusId && visibleIds.has(focusId)) { upstreamIds.add(focusId); downstreamIds.add(focusId); const outgoing = new Map(); const incoming = new Map(); for (const issue of issues) { for (const dep of issue.dependencies) { if (dep.type === 'blocks') { const blocker = dep.target; const blocked = issue.id; if (!outgoing.has(blocker)) outgoing.set(blocker, []); if (!incoming.has(blocked)) incoming.set(blocked, []); outgoing.get(blocker)!.push(blocked); incoming.get(blocked)!.push(blocker); } } } let queue = [focusId]; while (queue.length > 0) { const curr = queue.shift()!; for (const b of (incoming.get(curr) || [])) { if (!upstreamIds.has(b)) { upstreamIds.add(b); queue.push(b); } } } queue = [focusId]; while (queue.length > 0) { const curr = queue.shift()!; for (const b of (outgoing.get(curr) || [])) { if (!downstreamIds.has(b)) { downstreamIds.add(b); queue.push(b); } } } } 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; // 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 = `${dep.target}:blocks:${issue.id}`; const sourceId = dep.target; const targetId = issue.id; const isUpstreamOfFocus = focusId ? upstreamIds.has(sourceId) && upstreamIds.has(targetId) : false; const isDownstreamOfFocus = focusId ? downstreamIds.has(sourceId) && downstreamIds.has(targetId) : false; const isDirectlyFocused = focusId ? sourceId === focusId || targetId === focusId : false; let isUnrelated = false; if (focusId) { isUnrelated = !isUpstreamOfFocus && !isDownstreamOfFocus && !isDirectlyFocused; } const sourceNode = issues.find(i => i.id === sourceId); const sourceStatus = sourceNode?.status || 'open'; const isTransitive = transitiveEdges.has(edgeId); let stroke = '#3b82f6'; let strokeBg = 'rgba(59, 130, 246, 0.25)'; let dashArray: string | undefined = undefined; let opacity = 0.8; const isFocusedPath = isUpstreamOfFocus || isDownstreamOfFocus || isDirectlyFocused; const isAnimated = isFocusedPath || sourceStatus === 'in_progress'; // Base Status Colors if (sourceStatus === 'in_progress') { stroke = '#fbbf24'; // Bright Amber strokeBg = 'rgba(251, 191, 36, 0.25)'; } else if (sourceStatus === 'blocked') { stroke = '#f43f5e'; // Rose/Red for deep block strokeBg = 'rgba(244, 63, 94, 0.25)'; } else { stroke = '#3b82f6'; // Blue for open/ready strokeBg = 'rgba(59, 130, 246, 0.25)'; } // Selection Focus Overrides if (focusId) { if (isUnrelated) { stroke = '#1e293b'; // Super dim unrelated edges strokeBg = 'transparent'; opacity = 0.15; } else if (isUpstreamOfFocus || (isDirectlyFocused && targetId === focusId)) { stroke = '#f59e0b'; // Amber -- "I am blocking you" strokeBg = 'rgba(245, 158, 11, 0.35)'; opacity = 1; } else if (isDownstreamOfFocus || (isDirectlyFocused && sourceId === focusId)) { stroke = '#0ea5e9'; // Cyan -- "you are blocking me" strokeBg = 'rgba(14, 165, 233, 0.35)'; opacity = 1; } } // Transitive Styling if (isTransitive) { dashArray = '4 4'; if (!focusId || isUnrelated) { stroke = '#334155'; strokeBg = 'rgba(51, 65, 85, 0.3)'; opacity = 0.4; } else { opacity = 0.6; // Keep focused color but make dashed/transparent } } graphEdges.push({ id: edgeId, source: sourceId, target: targetId, className: isFocusedPath ? 'workflow-edge-selected' : 'workflow-edge-muted', animated: isAnimated, label: 'BLOCKS', labelStyle: { fill: isFocusedPath ? '#e2e8f0' : '#cbd5e1', fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', }, labelBgPadding: [6, 3], labelBgBorderRadius: 999, labelBgStyle: { fill: 'rgba(2, 6, 23, 0.92)', stroke: strokeBg, strokeWidth: 1, }, style: { stroke, strokeWidth: isFocusedPath ? 2.8 : 2.1, opacity, strokeDasharray: dashArray, }, markerEnd: { type: MarkerType.ArrowClosed, color: stroke, width: 14, height: 14 }, }); } } // --- Apply Offsets to Edge Data --- // Count how many edges share the same source and target, or just // group them by axis line to separate them visually. const edgeGroups = new Map(); for (const edge of graphEdges) { // Create a normalized key roughly defining the segment direction const key = [edge.source, edge.target].sort().join('-'); if (!edgeGroups.has(key)) edgeGroups.set(key, []); edgeGroups.get(key)!.push(edge); } // Assign offsets based on index in their shared group. for (const [unused_, groupEdges] of edgeGroups) { if (groupEdges.length <= 1) continue; const step = 8; // 8px offset per line const totalSpread = (groupEdges.length - 1) * step; let currentOffset = -(totalSpread / 2); for (const edge of groupEdges) { edge.data = { ...edge.data, offset: currentOffset }; currentOffset += step; } } return { nodes: layoutDagre(baseNodes, graphEdges), edges: graphEdges, }; }, [ transitiveEdges, hideClosed, issues, selectedEpicTasks, requestedTaskId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, externalBlockerNames, ]); const nodeTypes: NodeTypes = useMemo( () => ({ flowNode: GraphNodeCard as NodeTypes['flowNode'], }), [], ); const edgeTypes = useMemo( () => ({ offset: OffsetEdge, }), [] ); // --- 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 (
← Kanban )} scope={activeScope ? (

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

) : undefined} controls={( )} /> {/* 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 */} refreshIssues()} blockedTree={selectedIssue ? buildBlockedByTree(issues, selectedIssue.id) : undefined} outgoingBlocks={selectedId ? blocksDetailsMap.get(selectedId) ?? [] : []} onSelectBlockedIssue={handleTaskSelect} />
); }