beadboard/src/components/graph/dependency-graph-page.tsx
2026-03-03 16:43:42 -08:00

1096 lines
42 KiB
TypeScript

'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<GraphNodeData>[], edges: Edge[]): Node<GraphNodeData>[] {
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<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [depth, setDepth] = useState<GraphHopDepth>(2);
const [hideClosed, setHideClosed] = useState(false);
const [showBlockingOnly, setShowBlockingOnly] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [activeTab, setActiveTab] = useState<WorkflowTab>('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<string, BeadIssue[]>();
// 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<string, number>();
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<string, BeadIssue>();
// 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<string, { blockedBy: number; blocks: number }>();
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<string>();
const ids = new Set<string>([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<string>();
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<string, string[]>();
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<string, BlockerDetail[]>();
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<string, string[]>();
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<string, BlockerDetail[]>();
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<GraphNodeData>[], 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<GraphNodeData>[] = 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<string>();
const downstreamIds = new Set<string>();
if (focusId && visibleIds.has(focusId)) {
upstreamIds.add(focusId);
downstreamIds.add(focusId);
const outgoing = new Map<string, string[]>();
const incoming = new Map<string, string[]>();
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<string, Edge[]>();
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 (
<main className="mx-auto max-w-[1880px] px-4 py-4 sm:px-6 sm:py-6 lg:px-10">
<WorkspaceHero
eyebrow="BeadBoard Workspace"
title={heroTitle}
description="Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance."
action={(
<Link
href={kanbanHref}
className="ui-text rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-[10px] font-bold text-text-body transition-all hover:bg-white/10 hover:border-white/20 sm:px-4 sm:text-xs"
>
&larr; Kanban
</Link>
)}
scope={activeScope ? (
<p className="ui-text text-xs text-text-muted/90">
Scope:{' '}
<span className="system-data rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] text-text-body">
{activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath}
</span>
</p>
) : undefined}
controls={(
<ProjectScopeControls
projectScopeKey={projectScopeKey}
projectScopeMode={projectScopeMode}
projectScopeOptions={projectScopeOptions}
/>
)}
/>
{/* Main content area */}
<section className="rounded-[2.5rem] border border-white/5 bg-[linear-gradient(180deg,rgba(255,255,255,0.015),rgba(255,255,255,0.005))] shadow-2xl backdrop-blur-sm overflow-hidden">
{/* Toolbar row: epic chips + tab switcher */}
<div className="flex flex-wrap items-center gap-4 border-b border-white/5 px-6 py-4 bg-white/[0.02]">
{/* Epic chip strip - shows titles, not just IDs */}
<div className="flex-1 min-w-0">
<EpicChipStrip
epics={selectableEpics}
selectedEpicId={selectedEpicId}
beadCounts={beadCounts}
onSelect={setSelectedEpicId}
/>
</div>
{/* Right side: filter toggle + stats + mobile toggle */}
<div className="flex items-center gap-3">
{/* Filters toggle - hides power-user controls behind a button */}
<button
type="button"
onClick={() => setShowFilters((current) => !current)}
className={`rounded-xl border px-3 py-1.5 text-xs font-bold transition-all ${showFilters
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
: 'border-white/10 bg-white/5 text-text-muted hover:bg-white/10'
}`}
>
Filters {showFilters ? '▴' : '▾'}
</button>
{/* Mobile panel toggle */}
<div className="md:hidden">
<button
type="button"
onClick={() => setMobilePanel((current) => (current === 'overview' ? 'flow' : 'overview'))}
className="rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 py-1.5 text-xs font-bold text-sky-300 transition-all hover:bg-sky-400/20"
>
{mobilePanel === 'overview' ? 'Switch to Graph' : 'Back to Selection'}
</button>
</div>
</div>
</div>
{/* Collapsible filters row - tab-aware: different filters per tab */}
{showFilters ? (
<div className="flex flex-wrap items-center gap-4 border-b border-white/5 px-6 py-3 bg-white/[0.01]">
{/* Shared filter: Hide closed */}
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
<input type="checkbox" className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500" checked={hideClosed} onChange={(event) => setHideClosed(event.target.checked)} />
Hide closed
</label>
{/* Tasks-specific filters */}
{activeTab === 'tasks' ? (
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
<input type="checkbox" className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500" checked={sortReadyFirst} onChange={(event) => setSortReadyFirst(event.target.checked)} />
Ready first
</label>
) : null}
{/* Dependencies-specific filters */}
{activeTab === 'dependencies' ? (
<>
<div className="flex items-center gap-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-text-muted" htmlFor="depth-select">
Hop Depth
</label>
<select
id="depth-select"
className="ui-field ui-select rounded-xl px-3 py-1.5 text-xs font-medium ring-sky-400/20 focus:ring-2 outline-none transition-all"
value={String(depth)}
onChange={(event) => {
const value = event.target.value;
setDepth(value === 'full' ? 'full' : (Number(value) as 1 | 2));
}}
>
{DEPTH_OPTIONS.map((option) => (
<option className="ui-option" key={String(option)} value={String(option)}>
{option === 'full' ? 'Infinite' : `${option} hop${option === 1 ? '' : 's'}`}
</option>
))}
</select>
</div>
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
<input type="checkbox" className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500" checked={showBlockingOnly} onChange={(event) => setShowBlockingOnly(event.target.checked)} />
Blocking path only
</label>
</>
) : null}
</div>
) : null}
{/* Tab switcher row + selected epic context */}
<div className="hidden md:flex items-center justify-between border-b border-white/5 px-6 py-3 bg-white/[0.01]">
<WorkflowTabs activeTab={activeTab} onTabChange={setActiveTab} />
{selectedEpic ? (
<div className="flex items-center gap-3 text-xs">
<span className="font-mono text-[10px] text-text-muted/50">{selectedEpic.id}</span>
<span className="font-medium text-text-body truncate max-w-[20rem]">{selectedEpic.title}</span>
{!selectedEpicHasChildren && projectLevelTasks.length > 0 ? (
<span className="rounded-md bg-sky-400/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-sky-300/90">
project tasks fallback
</span>
) : null}
<span className="rounded-md bg-white/5 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-text-muted/60">
{selectedEpicTasks.length} tasks
</span>
</div>
) : null}
</div>
{/* ====== MOBILE LAYOUT ====== */}
{/* Mobile: overview panel (epic selection + task cards + dep flow) */}
<div className={`${mobilePanel === 'overview' ? 'flex' : 'hidden'} flex-col gap-6 p-6 md:hidden`}>
<section className="space-y-6 rounded-3xl bg-[rgba(14,20,33,0.88)] p-6 ring-1 ring-white/10 backdrop-blur-xl">
{/* Epic selector as horizontal scroll */}
<div>
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">1) Select Epic</h2>
<div className="mt-4">
<EpicChipStrip
epics={selectableEpics}
selectedEpicId={selectedEpicId}
beadCounts={beadCounts}
onSelect={setSelectedEpicId}
/>
</div>
</div>
{/* Selected epic info */}
<section className="rounded-2xl bg-white/5 p-5 ring-1 ring-white/5">
<h3 className="text-base font-bold text-text-strong">{selectedEpic?.title ?? 'No epic selected'}</h3>
<p className="mt-1 text-xs font-medium text-text-muted/80">
{selectedEpicTasks.length} tasks &bull; <span className="uppercase">{selectedEpic?.status ?? 'unknown'}</span>
</p>
</section>
{/* Task cards */}
<section className="rounded-2xl bg-white/[0.02] p-5 ring-1 ring-white/5">
<h3 className="mb-4 text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">2) Pick Task</h3>
<div className="max-h-[30vh] overflow-y-auto overscroll-contain custom-scrollbar">
<TaskCardGrid
tasks={sortedEpicTasks}
selectedId={selectedId}
blockerDetailsMap={blockerDetailsMap}
blocksDetailsMap={blocksDetailsMap}
actionableIds={actionableNodeIds}
onSelect={handleTaskSelect}
/>
</div>
</section>
</section>
</div>
{/* ====== DESKTOP LAYOUT ====== */}
{/* Desktop: Tasks tab content - use conditional rendering, not Tailwind dynamic classes */}
{activeTab === 'tasks' ? (
<div className="hidden md:block p-6">
<TaskCardGrid
tasks={sortedEpicTasks}
selectedId={selectedId}
blockerDetailsMap={blockerDetailsMap}
blocksDetailsMap={blocksDetailsMap}
actionableIds={actionableNodeIds}
onSelect={handleTaskSelect}
/>
</div>
) : null}
{/* Desktop: Dependencies tab content (graph only, no flow strip) */}
{activeTab === 'dependencies' ? (
<div className="hidden md:flex min-h-0 flex-col p-6">
{/* Dependency Flow Strip - above graph */}
<div className="mb-6">
<DependencyFlowStrip
workspace={workspace}
selectedId={selectedId}
signalById={signalById}
onSelect={setSelectedId}
/>
</div>
<ReactFlowProvider>
<GraphSection
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
onNodeClick={handleFlowNodeClick}
blockerAnalysis={blockerAnalysis}
hideClosed={hideClosed}
/>
</ReactFlowProvider>
</div>
) : null}
{/* Mobile: graph panel */}
<section className={`${mobilePanel === 'flow' ? 'flex' : 'hidden'} min-h-0 flex-col border-t border-white/10 bg-[rgba(8,12,20,0.9)] p-6 backdrop-blur-xl md:hidden`}>
<ReactFlowProvider>
<GraphSection
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
onNodeClick={handleFlowNodeClick}
blockerAnalysis={blockerAnalysis}
hideClosed={hideClosed}
/>
</ReactFlowProvider>
</section>
</section>
{/* Task details drawer - slides in from right on task selection */}
<TaskDetailsDrawer
issue={selectedIssue}
open={drawerOpen}
onClose={handleDrawerClose}
projectRoot={projectRoot}
editable={projectScopeMode === 'single'}
onIssueUpdated={() => refreshIssues()}
blockedTree={selectedIssue ? buildBlockedByTree(issues, selectedIssue.id) : undefined}
outgoingBlocks={selectedId ? blocksDetailsMap.get(selectedId) ?? [] : []}
onSelectBlockedIssue={handleTaskSelect}
/>
</main>
);
}