1096 lines
42 KiB
TypeScript
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"
|
|
>
|
|
← 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 • <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>
|
|
);
|
|
}
|